commit 74992e893f98e6bbd1323d318282ae8b42f5b343 Author: A Date: Thu Jun 4 01:37:39 2026 +0800 Initial open source release diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7ece1dd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Go build artifacts +/bin/ +*.exe +*.test +*.out +coverage.* + +# 本地环境 / 密钥(server RSA private key 必须持久化,但禁止入库) +*.local +*.pem +*.key +/secrets/ +/data/* +!/data/langpack/ +!/data/langpack/** +logs/ +.gocache/ +.tdesktop-e2e/ + +# IDE +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..1431d0b1 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# telesrv + +`telesrv` is a Telegram-like MTProto server written in Go. It uses +[`github.com/gotd/td`](https://github.com/gotd/td) v0.144.0 / Layer 225 as the TL +and MTProto base, and its first compatibility target is a pinned Telegram +Desktop build. + +`telesrv` is an independent, unofficial project. It is not affiliated with, +endorsed by, or sponsored by Telegram or the official Telegram team. + +[中文 README](README.zh-CN.md) + +![Telegram Desktop Alice/Bob connected to telesrv](docs/assets/tdesktop-dual-session.png) + +## Status + +This project is useful for local protocol research and Telegram Desktop +compatibility work. It is not a production Telegram replacement. + +Implemented main paths include MTProto key exchange, login with a development +code, users/contacts/dialogs, private messages, supergroups/channels, update +difference recovery, local media/files, profile/channel photos, stickers, +reactions, and presence. Large-scale public channels, multi-DC/file-DC/CDN, +Bot API, payments, stories, Premium business logic, production abuse controls, +and production object storage are intentionally out of scope for now. + +## Contributing + +Contributions are very welcome. The most helpful areas right now are Telegram +Desktop compatibility reports, reproducible RPC traces, focused bug fixes, +tests for online/offline update behavior, performance work on already +implemented paths, and documentation that makes local setup easier. + +Please keep changes scoped and compatibility-driven. If a change affects +Telegram Desktop behavior, include the client version/commit, the RPC path you +tested, and whether server logs stayed free of `NOT_IMPLEMENTED`, `Unhandled +RPC`, `bad_msg`, panic, or internal errors. + +## 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 +``` + +## Run telesrv + +Requirements: + +- Go 1.25 or newer +- Docker Desktop or Docker Engine with Compose +- OpenSSL, if you want to build a matching Telegram Desktop client + +Start PostgreSQL and Redis: + +```powershell +docker compose -f deploy/docker-compose.yml up -d +``` + +Build and run the server: + +```powershell +go build -o bin/telesrv.exe ./cmd/telesrv +.\bin\telesrv.exe +``` + +On first start, `telesrv` creates `data/server_rsa.pem`, applies all database +migrations, seeds bundled language packs, and listens on `0.0.0.0:2398`. + +Useful development 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_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 | + +The optional sticker seed directory is skipped when it does not exist. + +## Build Telegram Desktop For telesrv + +The stock Telegram Desktop binary will not connect to `telesrv`: it trusts +Telegram's production DC list and RSA keys. Build your own patched client. + +Target baseline: + +- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b` +- TL layer: 225 +- Local DC: `127.0.0.1:2398`, DC id `2` + +Clone and pin Telegram Desktop: + +```powershell +git clone --recursive https://github.com/telegramdesktop/tdesktop.git +cd tdesktop +git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b +git submodule update --init --recursive +``` + +Build prerequisites and exact platform instructions are maintained upstream: + +- Windows: `docs/building-win.md` +- macOS: `docs/building-mac.md` +- Linux: `docs/building-linux.md` + +For Windows x64, the pinned upstream instructions currently boil down to: + +```powershell +Telegram\build\prepare\win.bat +cd Telegram +configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH +``` + +Then open `out\Telegram.slnx` in Visual Studio and build the `Telegram` +project. The debug binary is written to `out\Debug\Telegram.exe`. + +## Patch Telegram Desktop + +After `telesrv` has generated `data/server_rsa.pem`, export the matching public +key: + +```powershell +openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub +``` + +Patch Telegram Desktop file +`Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp`: + +1. Replace built-in production and test DC lists with local DC 2: + +```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 }, +}; +``` + +2. Replace both `kPublicRSAKeys` and `kTestPublicRSAKeys` with the contents of + `data/server_rsa.pub`. +3. In `DcOptions::constructFromBuiltIn()`, add `Flag::f_tcpo_only` to the IPv4 + and IPv6 built-in DC flags. + +Keep this client patch minimal: DC endpoints, RSA key, and TCP-only flags only. +Do not mix UI changes into the protocol patch. + +## Run Two Local Desktop Clients + +Use separate TDesktop working directories so Alice and Bob do not share `tdata`: + +```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") +``` + +Log in with two different phone numbers. In local development, the login code is +`12345` unless you changed `TELESRV_DEV_AUTH_CODE`. + +If the client keeps reconnecting, check these first: + +- `telesrv` is listening on port `2398`. +- `data/server_rsa.pub` was copied into both RSA key arrays in TDesktop. +- `TELESRV_ADVERTISE_IP` matches the address reachable from the client. +- TDesktop was built from the pinned Layer 225 baseline or re-audited for a new + layer. + +## Documentation + +- [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) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..d076637b --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,182 @@ +# telesrv + +`telesrv` 是一个用 Go 编写的 Telegram-like MTProto server。它以 +[`github.com/gotd/td`](https://github.com/gotd/td) v0.144.0 / Layer 225 作为 +TL 与 MTProto 基础,第一兼容目标是固定基线的 Telegram Desktop。 + +`telesrv` 是独立的非官方项目,与 Telegram 官方及其团队没有关联,也未获得其背书或赞助。 + +[English README](README.md) + +![Telegram Desktop Alice/Bob connected to telesrv](docs/assets/tdesktop-dual-session.png) + +## 当前状态 + +本项目适合本地协议研究、Telegram Desktop 兼容性验证和自建 MTProto server 实验。它不是生产级 Telegram 替代品。 + +当前已覆盖的主路径包括 MTProto key exchange、开发验证码登录、users/contacts/dialogs、私聊消息、超级群/频道、updates difference 恢复、本地 media/files、用户/频道头像、stickers、reactions 和 presence。 + +暂不默认覆盖大规模公开频道、多 DC / 文件 DC / CDN、Bot API、payments、stories、Premium 商业逻辑、生产风控、生产对象存储等能力。 + +## 欢迎贡献 + +欢迎大家参与贡献。现在最有价值的方向包括 Telegram Desktop 兼容性报告、可复现 RPC trace、聚焦的小 bug fix、在线/离线 updates 行为测试、已实现路径的性能优化,以及让本地启动更顺滑的文档改进。 + +请尽量保持改动范围清晰,并围绕兼容性目标展开。如果改动会影响 Telegram Desktop 可见行为,请在 PR 或说明里写清客户端版本/commit、验证过的 RPC 路径,以及 server 日志是否没有新增 `NOT_IMPLEMENTED`、`Unhandled RPC`、`bad_msg`、panic 或 internal error。 + +## 仓库结构 + +```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/ 兼容性记录与模块设计文档 +``` + +## 运行 telesrv + +依赖: + +- Go 1.25 或更新版本 +- Docker Desktop 或带 Compose 的 Docker Engine +- OpenSSL,如果要编译匹配的 Telegram Desktop 客户端 + +启动 PostgreSQL 和 Redis: + +```powershell +docker compose -f deploy/docker-compose.yml up -d +``` + +编译并启动 server: + +```powershell +go build -o bin/telesrv.exe ./cmd/telesrv +.\bin\telesrv.exe +``` + +第一次启动时,`telesrv` 会创建 `data/server_rsa.pem`,自动执行所有数据库 migrations,导入内置语言包,并监听 `0.0.0.0:2398`。 + +常用开发环境变量: + +| 变量 | 默认值 | 说明 | +|---|---:|---| +| `TELESRV_LISTEN` | `0.0.0.0:2398` | MTProto 监听地址 | +| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 写入 `help.getConfig` 的客户端连接 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 导出种子目录 | + +如果 sticker seed 目录不存在,启动时会自动跳过。 + +## 编译连接 telesrv 的 Telegram Desktop + +官方 Telegram Desktop 二进制不能直接连接 `telesrv`,因为它信任的是 Telegram 官方 DC 列表和 RSA keys。你需要编译一个最小 patch 过的客户端。 + +目标基线: + +- Telegram Desktop commit:`9caf32dffc90ddd9bb08ad5777b865f729fa167b` +- TL layer:225 +- 本地 DC:`127.0.0.1:2398`,DC id `2` + +克隆并固定 Telegram Desktop: + +```powershell +git clone --recursive https://github.com/telegramdesktop/tdesktop.git +cd tdesktop +git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b +git submodule update --init --recursive +``` + +编译依赖和各平台完整说明以 Telegram Desktop 上游文档为准: + +- Windows:`docs/building-win.md` +- macOS:`docs/building-mac.md` +- Linux:`docs/building-linux.md` + +Windows x64 下,固定基线的主要步骤是: + +```powershell +Telegram\build\prepare\win.bat +cd Telegram +configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH +``` + +然后用 Visual Studio 打开 `out\Telegram.slnx`,构建 `Telegram` project。Debug 二进制会生成在 `out\Debug\Telegram.exe`。 + +## Patch Telegram Desktop + +等 `telesrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥: + +```powershell +openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub +``` + +修改 Telegram Desktop 文件: + +```text +Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp +``` + +1. 把内置 production/test DC 列表替换为本地 DC 2: + +```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 }, +}; +``` + +2. 把 `kPublicRSAKeys` 和 `kTestPublicRSAKeys` 都替换为 `data/server_rsa.pub` 的内容。 +3. 在 `DcOptions::constructFromBuiltIn()` 中给 IPv4 与 IPv6 built-in DC flags 加上 `Flag::f_tcpo_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; +``` + +客户端 patch 应保持最小:只改 DC endpoint、RSA public key 和 TCP-only flags,不要把 UI 改动混入协议兼容 patch。 + +## 启动两个本地 Desktop 客户端 + +用不同的 TDesktop working directory,避免 Alice 和 Bob 共用同一个 `tdata`: + +```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") +``` + +用两个不同手机号登录。本地开发默认验证码是 `12345`,除非你修改了 `TELESRV_DEV_AUTH_CODE`。 + +如果客户端一直重连,优先检查: + +- `telesrv` 是否正在监听 `2398`。 +- `data/server_rsa.pub` 是否同时复制到了 TDesktop 的两个 RSA key 数组。 +- `TELESRV_ADVERTISE_IP` 是否是客户端可访问的地址。 +- TDesktop 是否基于固定 Layer 225 基线构建,或者你已经重新审计了新 layer。 + +## 文档 + +- [兼容矩阵](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) diff --git a/cmd/telesrv/exe_integration_test.go b/cmd/telesrv/exe_integration_test.go new file mode 100644 index 00000000..44cd425b --- /dev/null +++ b/cmd/telesrv/exe_integration_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + tdcrypto "github.com/gotd/td/crypto" + "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/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/mtprotoedge" +) + +func TestExecutablePrivateMessageRoundTrip(t *testing.T) { + exe := os.Getenv("TELESRV_TEST_EXE") + if exe == "" { + t.Skip("set TELESRV_TEST_EXE to run executable-level MTProto roundtrip") + } + repoRoot := filepath.Clean(filepath.Join("..", "..")) + if !filepath.IsAbs(exe) { + exe = filepath.Join(repoRoot, exe) + } + exeAbs, err := filepath.Abs(exe) + if err != nil { + t.Fatalf("resolve executable path: %v", err) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve listen port: %v", err) + } + addr := ln.Addr().String() + _ = ln.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, exeAbs) + cmd.Dir = repoRoot + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "TELESRV_LISTEN="+addr, + "TELESRV_ADVERTISE_IP=127.0.0.1", + ) + if err := cmd.Start(); err != nil { + t.Fatalf("start executable: %v", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + processDone := false + t.Cleanup(func() { + if processDone { + return + } + select { + case <-done: + default: + _ = cmd.Process.Kill() + <-done + } + }) + select { + case err := <-done: + processDone = true + t.Fatalf("executable exited before client test: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) + case <-time.After(4 * time.Second): + case <-ctx.Done(): + t.Fatalf("wait executable startup: %v\nstdout:\n%s\nstderr:\n%s", ctx.Err(), stdout.String(), stderr.String()) + } + + rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(filepath.Join(repoRoot, "data", "server_rsa.pem")) + if err != nil { + t.Fatalf("load rsa key: %v", err) + } + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + var port int + if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { + t.Fatalf("parse port %q: %v", portStr, err) + } + + newClient := func(storage *session.StorageMemory) *telegram.Client { + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: noSecretObfuscatedResolver{addr: addr, dc: 2}, + DCList: dcs.List{Options: []tg.DCOption{{ID: 2, IPAddress: host, Port: port, Static: true}}}, + Logger: zaptest.NewLogger(t).Named("exe-client"), + 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 + } + } + signUp := func(storage *session.StorageMemory, phone, firstName string) tg.User { + t.Helper() + client := newClient(storage) + var out tg.User + 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 + signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + PhoneCode: "12345", + }) + if err != nil { + return err + } + if authz, ok := signInRes.(*tg.AuthAuthorization); ok { + out = *(authz.User.(*tg.User)) + return nil + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + FirstName: firstName, + }) + if err != nil { + return err + } + authz := res.(*tg.AuthAuthorization) + out = *(authz.User.(*tg.User)) + return nil + }); err != nil { + t.Fatalf("sign up %s: %v\nstdout:\n%s\nstderr:\n%s", firstName, err, stdout.String(), stderr.String()) + } + return out + } + sendAndAssertHistory := func(storage *session.StorageMemory, to tg.User, body string, randomID int64, wantOut bool) { + t.Helper() + client := newClient(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Message: body, + RandomID: randomID, + }); err != nil { + return err + } + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("history = %T %+v, want messages", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != body || msg.Out != wantOut { + t.Fatalf("latest message = %#v, want out=%v text=%q", msgs[0], wantOut, body) + } + return nil + }); err != nil { + t.Fatalf("send %q: %v\nstdout:\n%s\nstderr:\n%s", body, err, stdout.String(), stderr.String()) + } + } + readLatest := func(storage *session.StorageMemory, from tg.User, body string, wantOut bool) { + t.Helper() + client := newClient(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: from.ID, AccessHash: from.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("history = %T %+v, want messages", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != body || msg.Out != wantOut { + t.Fatalf("latest message = %#v, want out=%v text=%q", msgs[0], wantOut, body) + } + return nil + }); err != nil { + t.Fatalf("read latest %q: %v\nstdout:\n%s\nstderr:\n%s", body, err, stdout.String(), stderr.String()) + } + } + + suffix := time.Now().UnixNano() % 100000000 + storageA := &session.StorageMemory{} + storageB := &session.StorageMemory{} + userA := signUp(storageA, fmt.Sprintf("+1555%08d", suffix), "ExeAlice") + userB := signUp(storageB, fmt.Sprintf("+1556%08d", suffix), "ExeBob") + + sendAndAssertHistory(storageA, userB, "exe hello bob", time.Now().UnixNano(), true) + readLatest(storageB, userA, "exe hello bob", false) + sendAndAssertHistory(storageB, userA, "exe hi alice", time.Now().UnixNano()+1, true) + readLatest(storageA, userB, "exe hi alice", false) +} + +type noSecretObfuscatedResolver struct { + addr string + dc int +} + +func (r noSecretObfuscatedResolver) Primary(ctx context.Context, _ int, _ dcs.List) (transport.Conn, error) { + return r.connect(ctx) +} + +func (r noSecretObfuscatedResolver) MediaOnly(ctx context.Context, _ int, _ dcs.List) (transport.Conn, error) { + return r.connect(ctx) +} + +func (r noSecretObfuscatedResolver) CDN(ctx context.Context, _ int, _ dcs.List) (transport.Conn, error) { + return r.connect(ctx) +} + +func (r noSecretObfuscatedResolver) connect(ctx context.Context) (_ transport.Conn, rerr error) { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", r.addr) + if err != nil { + return nil, err + } + defer func() { + if rerr != nil { + _ = conn.Close() + } + }() + + obfsConn := obfuscator.Obfuscated2(tdcrypto.DefaultRand(), conn) + if err := obfsConn.Handshake(codec.IntermediateClientStart, r.dc, mtproxy.Secret{}); err != nil { + return nil, err + } + proto := transport.NewProtocol(func() transport.Codec { + return codec.NoHeader{Codec: codec.Intermediate{}} + }) + return proto.Handshake(obfsConn) +} diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go new file mode 100644 index 00000000..c28e9651 --- /dev/null +++ b/cmd/telesrv/main.go @@ -0,0 +1,212 @@ +// Command telesrv 是基于 gotd/td 的 Telegram-like server(第一兼容目标:Telegram Desktop)。 +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "strconv" + "syscall" + + "go.uber.org/zap" + + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/tg" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + channelapp "telesrv/internal/app/channels" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + filesapp "telesrv/internal/app/files" + "telesrv/internal/app/help" + "telesrv/internal/app/langpack" + "telesrv/internal/app/maintenance" + messageapp "telesrv/internal/app/messages" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/config" + "telesrv/internal/mtprotoedge" + "telesrv/internal/rpc" + "telesrv/internal/store/postgres" + "telesrv/internal/store/redisstore" +) + +func main() { + logger, err := zap.NewDevelopment() + if err != nil { + fmt.Fprintln(os.Stderr, "init logger:", err) + os.Exit(1) + } + defer func() { _ = logger.Sync() }() + + if err := run(logger); err != nil { + logger.Error("telesrv 退出", zap.Error(err)) + os.Exit(1) + } +} + +func run(logger *zap.Logger) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + rsaKey, err := mtprotoedge.LoadOrGenerateRSAKey(cfg.RSAKeyPath) + if err != nil { + return fmt.Errorf("server rsa key: %w", err) + } + fingerprint := exchange.PrivateKey{RSA: rsaKey}.Fingerprint() + + _, portStr, err := net.SplitHostPort(cfg.ListenAddr) + if err != nil { + return fmt.Errorf("parse listen addr %q: %w", cfg.ListenAddr, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("parse listen port %q: %w", portStr, err) + } + + // tg.Layer 来自 gotd/td v0.144.0(应为 225),需与目标 TDesktop 基线对齐。 + logger.Info("telesrv 启动", + zap.String("listen", cfg.ListenAddr), + zap.Int("dc", cfg.DC), + zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)), + zap.Int("tl_layer", tg.Layer), + zap.String("rsa_key", cfg.RSAKeyPath), + zap.Int64("rsa_fingerprint", fingerprint), + ) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // 持久化依赖:先迁移 schema,再建立连接。auth_key 落 PostgreSQL、session 落 Redis。 + // 依赖由 deploy/docker-compose.yml 启动;连不上则启动失败(开发期须先 docker compose up)。 + if err := postgres.Migrate(cfg.PostgresDSN); err != nil { + return fmt.Errorf("postgres migrate: %w", err) + } + pool, err := postgres.Open(ctx, cfg.PostgresDSN, + postgres.WithMaxConns(cfg.PostgresMaxConns), + postgres.WithMinConns(cfg.PostgresMinConns), + ) + if err != nil { + return fmt.Errorf("connect postgres: %w", err) + } + defer pool.Close() + + rdb, err := redisstore.Open(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB) + if err != nil { + return fmt.Errorf("connect redis: %w", err) + } + defer func() { _ = rdb.Close() }() + logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr)) + + ln, err := net.Listen("tcp", cfg.ListenAddr) + if err != nil { + return fmt.Errorf("listen %q: %w", cfg.ListenAddr, err) + } + + authKeyStore := postgres.NewAuthKeyStore(pool) + userStore := postgres.NewUserStore(pool) + authzStore := postgres.NewAuthorizationStore(pool) + updateStateStore := postgres.NewUpdateStateStore(pool) + updateEventStore := postgres.NewUpdateEventStore(pool) + dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout)) + ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore) + boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) + channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) + channelPtsAllocator := redisstore.NewChannelPtsAllocator(rdb, postgres.NewChannelPtsCounterSource(pool)) + channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) + contactStore := postgres.NewContactStore(pool) + dialogStore := postgres.NewDialogStore(pool) + messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator)) + channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator)) + mediaStore := postgres.NewMediaStore(pool) + blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir) + if err != nil { + return fmt.Errorf("init blob backend: %w", err) + } + filesService := filesapp.NewService(mediaStore, blobBackend, cfg.DC) + if stats, err := filesService.SeedMedia(ctx, cfg.StickerSeedDir, cfg.StickerSeedMaxSets); err != nil { + return fmt.Errorf("seed media: %w", err) + } else if !stats.Skipped { + logger.Info("媒体种子导入完成", + zap.String("dir", cfg.StickerSeedDir), + zap.Int("reactions", stats.Reactions), + zap.Int("sticker_sets", stats.StickerSets), + zap.Int("documents", stats.Documents), + zap.Int("blobs", stats.Blobs), + ) + } + if stats, err := filesService.WarmCaches(ctx); err != nil { + logger.Warn("媒体资源缓存预热失败", zap.Error(err)) + } else if stats.StickerSets > 0 || stats.Documents > 0 || stats.Blobs > 0 { + logger.Info("媒体资源缓存预热完成", + zap.Int("sticker_sets", stats.StickerSets), + zap.Int("documents", stats.Documents), + zap.Int("blobs", stats.Blobs), + ) + } + langPackStore := postgres.NewLangPackStore(pool) + passwordStore := postgres.NewPasswordStore(pool) + helpStore := postgres.NewHelpStore(pool) + tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool) + sessionStore := redisstore.NewSessionStore(rdb, redisstore.DefaultSessionTTL) + codeStore := redisstore.NewCodeStore(rdb) + rateLimiter := redisstore.NewRateLimiter(rdb) + activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions")) + go maintenance.NewRetentionWorker(dispatchOutboxStore, logger.Named("maintenance").Named("retention"), + cfg.UpdateEventRetention, + cfg.RetentionInterval, + cfg.RetentionBatch, + ).Run(ctx) + go rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, activeSessions, logger.Named("rpc").Named("outbox"), + rpc.WithOutboxWorkers(cfg.OutboxWorkers), + rpc.WithOutboxBatch(cfg.OutboxBatch), + rpc.WithOutboxInterval(cfg.OutboxInterval), + rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout), + ).Run(ctx) + langPackService := langpack.NewService(langPackStore) + if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil { + return fmt.Errorf("seed langpack: %w", err) + } else if seeded > 0 { + logger.Info("语言包种子导入完成", zap.String("dir", cfg.LangPackSeedDir), zap.Int("strings", seeded)) + } + + router := rpc.New(rpc.Config{ + DC: cfg.DC, + IP: cfg.AdvertiseIP, + Port: port, + OutboundPushTimeout: cfg.OutboundPushTimeout, + }, rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)), + Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore, users.WithPhotoProvider(mediaStore)), + Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)), + Contacts: contacts.NewService(contactStore, userStore), + Dialogs: dialogs.NewService(dialogStore, channelStore), + Messages: messageapp.NewService(messageStore, dialogStore), + Channels: channelapp.NewService(channelStore), + Files: filesService, + LangPack: langPackService, + Sessions: activeSessions, + Limiter: rateLimiter, + }, logger.Named("rpc"), clock.System) + activeSessions.SetLifecycleObserver(router) + + srv := mtprotoedge.New(mtprotoedge.Options{ + Logger: logger.Named("mtprotoedge"), + DC: cfg.DC, + RSAKey: rsaKey, + RPC: router, + AuthKeys: authKeyStore, + Sessions: sessionStore, + ActiveSessions: activeSessions, + ObfuscatedTCP: true, + }) + return srv.Serve(ctx, ln) +} diff --git a/data/langpack/tdesktop/tdesktop_en_v12000000.strings b/data/langpack/tdesktop/tdesktop_en_v12000000.strings new file mode 100644 index 00000000..5a919815 --- /dev/null +++ b/data/langpack/tdesktop/tdesktop_en_v12000000.strings @@ -0,0 +1,9104 @@ +/* +This file is part of Tidings Desktop, +the official desktop application for the Tidings messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +"lng_language_name" = "English"; +"lng_switch_to_this" = "Continue in English"; + +"lng_menu_contacts" = "Contacts"; +"lng_menu_calls" = "Calls"; +"lng_menu_settings" = "Settings"; +"lng_menu_about" = "About"; +"lng_menu_update" = "Update"; +"lng_menu_night_mode" = "Night Mode"; +"lng_menu_add_account" = "Add Account"; +"lng_menu_activate" = "Switch to this account"; +"lng_menu_set_status" = "Set Emoji Status"; +"lng_menu_change_status" = "Change Emoji Status"; +"lng_menu_my_profile" = "My Profile"; +"lng_menu_my_stories" = "My Stories"; +"lng_menu_my_groups" = "My Groups"; +"lng_menu_my_channels" = "My Channels"; + +"lng_disable_notifications_from_tray" = "Disable notifications"; +"lng_enable_notifications_from_tray" = "Enable notifications"; +"lng_open_from_tray" = "Open Tidings"; +"lng_minimize_to_tray" = "Minimize to Tray"; +"lng_quit_from_tray" = "Quit Tidings"; +"lng_tray_icon_text" = "Tidings is still running here,\nyou can change this in Settings.\nIf it disappears from the tray,\nyou can drag it back from the hidden icons."; + +// For lng_month_year or plain month name. +"lng_month1" = "January"; +"lng_month2" = "February"; +"lng_month3" = "March"; +"lng_month4" = "April"; +"lng_month5" = "May"; +"lng_month6" = "June"; +"lng_month7" = "July"; +"lng_month8" = "August"; +"lng_month9" = "September"; +"lng_month10" = "October"; +"lng_month11" = "November"; +"lng_month12" = "December"; + +// For lng_month_day and lng_month_day_year. +"lng_month_day1" = "January"; +"lng_month_day2" = "February"; +"lng_month_day3" = "March"; +"lng_month_day4" = "April"; +"lng_month_day5" = "May"; +"lng_month_day6" = "June"; +"lng_month_day7" = "July"; +"lng_month_day8" = "August"; +"lng_month_day9" = "September"; +"lng_month_day10" = "October"; +"lng_month_day11" = "November"; +"lng_month_day12" = "December"; + +"lng_month1_small" = "Jan"; +"lng_month2_small" = "Feb"; +"lng_month3_small" = "Mar"; +"lng_month4_small" = "Apr"; +"lng_month5_small" = "May"; +"lng_month6_small" = "Jun"; +"lng_month7_small" = "Jul"; +"lng_month8_small" = "Aug"; +"lng_month9_small" = "Sep"; +"lng_month10_small" = "Oct"; +"lng_month11_small" = "Nov"; +"lng_month12_small" = "Dec"; + +"lng_weekday1" = "Mon"; +"lng_weekday2" = "Tue"; +"lng_weekday3" = "Wed"; +"lng_weekday4" = "Thu"; +"lng_weekday5" = "Fri"; +"lng_weekday6" = "Sat"; +"lng_weekday7" = "Sun"; + +"lng_month_day" = "{month} {day}"; +"lng_month_day_year" = "{month} {day}, {year}"; +"lng_month_year" = "{month} {year}"; + +"lng_calendar_select_days" = "Select days"; +"lng_calendar_start_tip" = "Press and hold to jump to the start."; +"lng_calendar_end_tip" = "Press and hold to jump to the end."; +"lng_calendar_days#one" = "{count} day"; +"lng_calendar_days#other" = "{count} days"; + +"lng_seconds#one" = "{count} second"; +"lng_seconds#other" = "{count} seconds"; +"lng_minutes#one" = "{count} minute"; +"lng_minutes#other" = "{count} minutes"; +"lng_hours#one" = "{count} hour"; +"lng_hours#other" = "{count} hours"; +"lng_days#one" = "{count} day"; +"lng_days#other" = "{count} days"; +"lng_weeks#one" = "{count} week"; +"lng_weeks#other" = "{count} weeks"; +"lng_months#one" = "{count} month"; +"lng_months#other" = "{count} months"; +"lng_years#one" = "{count} year"; +"lng_years#other" = "{count} years"; + +"lng_seconds_tiny#one" = "{count}s"; +"lng_seconds_tiny#other" = "{count}s"; +"lng_minutes_tiny#one" = "{count}m"; +"lng_minutes_tiny#other" = "{count}m"; +"lng_hours_tiny#one" = "{count}h"; +"lng_hours_tiny#other" = "{count}h"; +"lng_days_tiny#one" = "{count}d"; +"lng_days_tiny#other" = "{count}d"; +"lng_weeks_tiny#one" = "{count}w"; +"lng_weeks_tiny#other" = "{count}w"; +"lng_months_tiny#one" = "{count}m"; +"lng_months_tiny#other" = "{count}m"; +"lng_years_tiny#one" = "{count}y"; +"lng_years_tiny#other" = "{count}y"; + +"lng_box_ok" = "OK"; +"lng_box_done" = "Done"; +"lng_box_yes" = "Yes"; +"lng_box_no" = "No"; + +"lng_cancel" = "Cancel"; +"lng_continue" = "Continue"; +"lng_close" = "Close"; +"lng_connecting" = "Connecting..."; +"lng_reconnecting#one" = "Reconnect in {count} s..."; +"lng_reconnecting#other" = "Reconnect in {count} s..."; +"lng_reconnecting_try_now" = "Try now"; + +"lng_code_block_header_copy" = "copy"; + +"lng_status_service_notifications" = "service notifications"; +"lng_status_support" = "support"; +"lng_status_bot" = "bot"; +"lng_status_bot_reads_all" = "has access to messages"; +"lng_status_bot_not_reads_all" = "has no access to messages"; +"lng_status_offline" = "last seen a long time ago"; +"lng_status_recently" = "last seen recently"; +"lng_status_last_week" = "last seen within a week"; +"lng_status_last_month" = "last seen within a month"; +"lng_status_lastseen_now" = "last seen just now"; +"lng_status_lastseen_when" = "when?"; +"lng_status_lastseen_minutes#one" = "last seen {count} minute ago"; +"lng_status_lastseen_minutes#other" = "last seen {count} minutes ago"; +"lng_status_lastseen_hours#one" = "last seen {count} hour ago"; +"lng_status_lastseen_hours#other" = "last seen {count} hours ago"; +"lng_status_lastseen_today" = "last seen today at {time}"; +"lng_status_lastseen_yesterday" = "last seen yesterday at {time}"; +"lng_status_lastseen_date" = "last seen {date}"; +"lng_status_lastseen_date_time" = "last seen {date} at {time}"; +"lng_status_online" = "online"; +"lng_status_connecting" = "connecting..."; + +"lng_chat_status_unaccessible" = "group is inaccessible"; +"lng_chat_status_members#one" = "{count} member"; +"lng_chat_status_members#other" = "{count} members"; +"lng_chat_status_online#one" = "{count} online"; +"lng_chat_status_online#other" = "{count} online"; +"lng_chat_status_members_online" = "{members_count}, {online_count}"; +"lng_chat_status_subscribers#one" = "{count} subscriber"; +"lng_chat_status_subscribers#other" = "{count} subscribers"; + +"lng_channel_status" = "channel"; +"lng_group_status" = "group"; +"lng_scam_badge" = "SCAM"; +"lng_fake_badge" = "FAKE"; + +"lng_remember" = "Remember this choice"; + +"lng_lastseen_show_title" = "Show Your Last Seen"; +"lng_lastseen_show_about" = "To see **{user}'s** Last Seen time, either start\nshowing your own Last Seen time..."; +"lng_lastseen_show_button" = "Show My Last Seen"; +"lng_lastseen_or" = "or"; +"lng_lastseen_premium_title" = "Upgrade to Premium"; +"lng_lastseen_premium_about" = "Subscribing will let you see **{user}'s** Last Seen\nstatus without showing yours."; +"lng_lastseen_premium_button" = "Subscribe to Tidings Premium"; +"lng_lastseen_shown_toast" = "Your last seen time is now visible."; + +"lng_readtime_show_title" = "Show Your Read Date"; +"lng_readtime_show_about" = "To see when **{user}** read the message,\neither start showing your own read time..."; +"lng_readtime_show_button" = "Show My Read Time"; +"lng_readtime_or" = "or"; +"lng_readtime_premium_title" = "Upgrade to Premium"; +"lng_readtime_premium_about" = "Subscription will let you see **{user}'s** read time\nwithout showing yours."; +"lng_readtime_premium_button" = "Subscribe to Tidings Premium"; +"lng_readtime_shown_toast" = "Your read times are now visible."; + +"lng_channels_limit_title" = "Too Many Communities"; +"lng_channels_limit1#one" = "You are a member of **{count}** group or channel."; +"lng_channels_limit1#other" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit2#one" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** group or channel."; +"lng_channels_limit2#other" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2_final" = "Please leave some before joining a new one."; +"lng_channels_leave_title" = "Least active communities"; +"lng_channels_leave_status" = "{type}, inactive {time}"; +"lng_channels_leave#one" = "Leave {count} community"; +"lng_channels_leave#other" = "Leave {count} communities"; +"lng_channels_leave_done" = "You've left the selected communities."; + +"lng_links_limit_title" = "Too Many Public Links"; +"lng_links_limit1#one" = "You have reserved **{count}** public link."; +"lng_links_limit1#other" = "You have reserved **{count}** public links."; +"lng_links_limit2#one" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public link."; +"lng_links_limit2#other" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2_final" = "Try revoking the link from an older group or channel"; +"lng_links_revoke_title" = "Your public communities"; + +"lng_filter_chats_limit_title" = "Limit Reached"; +"lng_filter_chats_limit1#one" = "Sorry, you can't add more than **{count}** chat to a folder."; +"lng_filter_chats_limit1#other" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_exlude_limit1#one" = "Sorry, you can't exclude more than **{count}** chat from a folder."; +"lng_filter_chats_exlude_limit1#other" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_limit2#one" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#other" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; + +"lng_filters_limit_title" = "Limit Reached"; +"lng_filters_limit1#one" = "You have reached the limit of **{count}** folder."; +"lng_filters_limit1#other" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit2#one" = "You can double the limit to **{count}** folder by subscribing to **Tidings Premium**."; +"lng_filters_limit2#other" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; + +"lng_filter_pin_limit_title" = "Limit Reached"; +"lng_filter_pin_limit1#one" = "Sorry, you can't pin more than **{count}** chat to the top."; +"lng_filter_pin_limit1#other" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit2#one" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chat."; +"lng_filter_pin_limit2#other" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; + +"lng_forum_pin_limit#one" = "Sorry, you can't pin more than **{count}** topic to the top."; +"lng_forum_pin_limit#other" = "Sorry, you can't pin more than **{count}** topics to the top."; + +"lng_fave_sticker_limit_title#one" = "Limit of {count} Sticker Reached"; +"lng_fave_sticker_limit_title#other" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_more#one" = "An older sticker was replaced with this one.\nYou can {link} to {count} sticker."; +"lng_fave_sticker_limit_more#other" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_link" = "increase the limit"; + +"lng_saved_gif_limit_title#one" = "Limit of {count} GIF Reached"; +"lng_saved_gif_limit_title#other" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_more#one" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIF."; +"lng_saved_gif_limit_more#other" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_link" = "increase the limit"; + +"lng_caption_limit_title" = "Limit Reached"; +"lng_caption_limit1#one" = "Sorry, you can't use more than **{count}** character in media captions."; +"lng_caption_limit1#other" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit2#one" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** character."; +"lng_caption_limit2#other" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit_reached#one" = "You've reached the media caption limit. Please make the caption shorter by {count} character."; +"lng_caption_limit_reached#other" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_move_up" = "Move Caption Up"; +"lng_caption_move_down" = "Move Caption Down"; + +"lng_file_size_limit_title" = "File Too Large"; +"lng_file_size_limit#one" = "{count} GB"; +"lng_file_size_limit#other" = "{count} GB"; +"lng_file_size_limit1" = "The document can't be sent, because it is larger than {size}."; +"lng_file_size_limit2" = "You can double this limit to {size} per document by subscribing to **Tidings Premium**."; + +"lng_filter_links_limit_title" = "Limit Reached"; +"lng_filter_links_limit1#one" = "Sorry, you can't create more than **{count}** invite link."; +"lng_filter_links_limit1#other" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit2#one" = "You can increase the limit to **{count}** link by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#other" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; + +"lng_filter_shared_limit_title" = "Limit Reached"; +"lng_filter_shared_limit1#one" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#other" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit2#one" = "You can increase the limit to **{count}** folder by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#other" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; + +"lng_limits_increase" = "Increase Limit"; + +"lng_sticker_premium_text" = "This set contains premium stickers like this one."; +"lng_sticker_premium_view" = "View"; +"lng_animated_emoji_text" = "Subscribe to **Tidings Premium** to unlock this emoji."; +"lng_animated_emoji_saved" = "Try sending these emoji in **Saved Messages** for free to test."; +"lng_animated_emoji_saved_open" = "Open"; + +"lng_flood_error" = "Too many tries. Please try again later."; +"lng_edit_error" = "You can't edit this message."; +"lng_error_phone_flood" = "Sorry, you have deleted and re-created your account too many times recently. Please wait for a few days before signing up again."; +"lng_error_start_minimized_passcoded" = "You have set a local passcode, so Tidings Desktop can't be launched minimized; it will ask you to enter your passcode before it can start working."; +"lng_error_public_groups_denied" = "Unfortunately, you are banned from participating in public groups.\n{more_info}"; +"lng_error_cant_add_member" = "Sorry, you can't add the bot to this group. Ask a group admin to do it."; +"lng_error_cant_add_bot" = "Sorry, this bot can't be added to groups."; +"lng_error_cant_add_admin_invite" = "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."; +"lng_error_you_blocked_user" = "Sorry, you can't add this user or bot to groups because you've blocked them. Please unblock to proceed."; +"lng_error_add_admin_not_member" = "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."; +"lng_error_user_admin_invalid" = "You can't ban this user because they are an admin in this group and you are not allowed to demote them."; +"lng_error_channel_bots_too_much" = "Sorry, this channel has too many bots."; +"lng_error_group_bots_too_much" = "There are too many bots in this group. Please remove some of the bots you're not using first."; +"lng_error_cant_add_admin_unban" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; +"lng_error_cant_ban_admin" = "You can't ban this user because they are an admin in this group and you are not allowed to demote them."; +"lng_error_cant_reply_other" = "This message can't be replied in another chat."; +"lng_error_admin_limit" = "Sorry, you've reached the maximum number of admins for this group."; +"lng_error_admin_limit_channel" = "Sorry, you've reached the maximum number of admins for this channel."; +"lng_error_post_link_invalid" = "Unfortunately, you can't access this message. You aren't a member of the chat where it was posted."; +"lng_error_noforwards_group" = "Sorry, forwarding from this group is disabled by admins."; +"lng_error_noforwards_channel" = "Sorry, forwarding from this channel is disabled by admins."; +"lng_error_nocopy_group" = "Sorry, copying from this group is disabled by admins."; +"lng_error_nocopy_channel" = "Sorry, copying from this channel is disabled by admins."; +"lng_error_nocopy_story" = "Sorry, the creator of this story disabled copying."; +"lng_error_schedule_limit" = "Sorry, you can't schedule more than 100 messages."; +"lng_sure_add_admin_invite" = "This user is not a member of this group. Add them to the group and promote them to admin?"; +"lng_sure_add_admin_invite_channel" = "This user is not a subscriber of this channel. Add them to the channel and promote them to admin?"; +"lng_sure_add_admin_unremove" = "This user is currently restricted or removed. Are you sure you want to promote them?"; +"lng_sure_ban_admin" = "This user is an admin. Are you sure you want to go ahead and restrict them?"; +"lng_sure_enable_socks" = "Are you sure you want to enable this proxy?\n\nServer: {server}\nPort: {port}\n\nYou can change your proxy server later in Settings > Advanced > Connection Type."; +"lng_sure_enable" = "Enable"; +"lng_proxy_box_title" = "Enable proxy"; +"lng_proxy_box_server" = "Server"; +"lng_proxy_box_port" = "Port"; +"lng_proxy_box_secret" = "Secret"; +"lng_proxy_box_status" = "Status"; +"lng_proxy_box_username" = "Username"; +"lng_proxy_box_password" = "Password"; +"lng_proxy_invalid" = "The proxy link is invalid."; +"lng_proxy_unsupported" = "Your Tidings Desktop version doesn't support this proxy type or the proxy link is invalid. Please update Tidings Desktop to the latest version."; + +"lng_edit_deleted" = "This message was deleted"; +"lng_edit_limit_reached#one" = "You've reached the message text limit. Please make the text shorter by {count} character."; +"lng_edit_limit_reached#other" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_message" = "Edit message"; +"lng_edit_message_text" = "New message..."; +"lng_deleted" = "Deleted Account"; +"lng_deleted_message" = "Deleted message"; +"lng_deleted_story" = "Deleted story"; +"lng_pinned_message" = "Pinned message"; +"lng_pinned_previous" = "Previous message"; +"lng_pinned_unpin_sure" = "Would you like to unpin this message?"; +"lng_pinned_pin_sure" = "Would you like to pin this message?"; +"lng_pinned_pin_sure_group" = "Pin this message in the group?"; +"lng_pinned_pin_old_sure" = "Do you want to pin an older message while leaving a more recent one pinned?"; +"lng_pinned_pin" = "Pin"; +"lng_pinned_unpin" = "Unpin"; +"lng_pinned_notify" = "Notify all members"; +"lng_pinned_also_for_other" = "Also pin for {user}"; +"lng_pinned_messages_title#one" = "{count} pinned message"; +"lng_pinned_messages_title#other" = "{count} pinned messages"; +"lng_pinned_hide_all" = "Don't show pinned messages"; +"lng_pinned_unpin_all#one" = "Unpin {count} message"; +"lng_pinned_unpin_all#other" = "Unpin all {count} messages"; +"lng_pinned_unpin_all_sure" = "Do you want to unpin all messages?"; +"lng_pinned_hide_all_sure" = "Do you want to hide the pinned message bar? It will stay hidden until a new message is pinned."; +"lng_pinned_hide_all_hide" = "Hide"; + +"lng_edit_media_album_error" = "This file cannot be saved as a part of an album."; +"lng_edit_media_invalid_file" = "Sorry, no way to use this file."; +"lng_edit_photo_editor_hint" = "Left-click on the photo to edit."; +"lng_edit_caption_attach" = "Sorry, you can't attach new media while editing a message."; +"lng_edit_caption_voice" = "Sorry, you can't edit messages when you have an unsent voice message."; + +"lng_intro_about" = "Welcome to the official Tidings Desktop app.\nIt's fast and secure."; +"lng_start_msgs" = "Start Messaging"; + +"lng_intro_next" = "Next"; +"lng_intro_finish" = "Sign Up"; +"lng_intro_submit" = "Submit"; + +"lng_photo_caption" = "Caption"; +"lng_photos_comment" = "Comment"; + +"lng_intro_qr_title" = "Scan From Mobile Tidings"; +"lng_intro_qr_step1" = "Open Tidings on your phone"; +"lng_intro_qr_step2" = "Go to Settings > Devices > Link Desktop Device"; +"lng_intro_qr_step3" = "Scan this image to Log In"; +"lng_intro_qr_skip" = "Or log in using your phone number"; + +"lng_intro_fragment_title" = "Enter code"; +"lng_intro_fragment_about" = "Get the code for {phone_number} in the Anonymous Numbers section on Fragment."; +"lng_intro_fragment_button" = "Open Fragment"; + +"lng_phone_title" = "Your Phone Number"; +"lng_phone_desc" = "Please confirm your country code\nand enter your phone number."; +"lng_phone_to_qr" = "Quick log in using QR code"; +"lng_country_code" = "Country Code"; +"lng_bad_country_code" = "Invalid Country Code"; +"lng_country_ph" = "Search"; +"lng_country_none" = "Country not found"; +"lng_country_select" = "Select Country"; + +"lng_code_ph" = "Code"; +"lng_settings_chat_quick_action_select" = "Double click to select all"; +"lng_code_desc" = "We've sent an activation code to your phone.\nPlease enter it below."; +"lng_code_from_telegram" = "A code was sent **via Tidings** to your other\ndevices, if you have any connected."; +"lng_code_no_telegram" = "Send code via SMS"; +"lng_code_call" = "Tidings will call you in {minutes}:{seconds}"; +"lng_code_calling" = "Requesting a call from Tidings..."; +"lng_code_called" = "Tidings dialed your number"; + +"lng_bad_phone" = "Invalid phone number. Please try again."; +"lng_bad_code" = "You have entered an invalid code."; +"lng_bad_name" = "Enter your first and last name."; +"lng_bad_photo" = "Sorry, Tidings can't process that type of image."; + +"lng_signin_title" = "Cloud password check"; +"lng_signin_desc" = "Please enter your cloud password."; +"lng_signin_recover_desc" = "Please enter the code from the email\n{email}"; +"lng_signin_password" = "Your cloud password"; +"lng_signin_code" = "Code from the email"; +"lng_signin_recover" = "Forgot password?"; +"lng_signin_recover_title" = "Password reset"; +"lng_signin_hint" = "Hint: {password_hint}"; +"lng_signin_recover_hint" = "We sent a code to {recover_email}"; +"lng_signin_bad_password" = "You have entered a wrong password."; +"lng_signin_wrong_code" = "You have entered an invalid code."; +"lng_signin_try_password" = "Unable to access your email?"; +"lng_signin_no_email_forgot" = "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."; +"lng_signin_cant_email_forgot" = "If you can't restore access to the email, your remaining options are either to remember your password or to reset your account."; +"lng_signin_reset_account" = "Reset your account"; +"lng_signin_sure_reset" = "You will lose all your Tidings chats, messages, media and files if you proceed.\n\nDo you want to reset your account?"; +"lng_signin_reset" = "Reset"; +"lng_signin_reset_wait" = "Since the account {phone_number} is active and protected by a password, it will be deleted in 1 week. This delay is required for security purposes. You can cancel this process anytime.\n\nYou'll be able to reset your account in:\n{when}"; +"lng_signin_reset_in_days" = "{days_count} {hours_count} {minutes_count}"; +"lng_signin_reset_in_hours" = "{hours_count} {minutes_count}"; +"lng_signin_reset_cancelled" = "Your recent attempts to reset this account have been canceled by its active user. Please try again in 7 days."; +"lng_signin_banned_text" = "This phone number is banned."; +"lng_signin_banned_help" = "Help"; + +"lng_signup_title" = "Your Info"; +"lng_signup_desc" = "Please enter your name and\nupload a photo."; + +"lng_signup_firstname" = "First name"; +"lng_signup_lastname" = "Last name"; + +"lng_dlg_filter" = "Search"; +"lng_dlg_new_group_name" = "Group name"; +"lng_dlg_new_channel_name" = "Channel name"; +"lng_dlg_new_bot_name" = "Bot name"; +"lng_no_chats" = "Your chats will be here"; +"lng_no_chats_filter" = "No chats currently belong to this folder."; +"lng_no_saved_sublists" = "You can save messages from other chats here."; +"lng_contacts_loading" = "Loading..."; +"lng_contacts_not_found" = "No contacts found"; +"lng_topics_not_found" = "No topics found."; +"lng_dlg_search_for_messages" = "Search for messages"; +"lng_update_telegram" = "Update Tidings"; +"lng_dlg_search_in" = "Search messages in"; +"lng_dlg_search_from" = "From: {user}"; + +"lng_settings_save" = "Save"; +"lng_settings_apply" = "Apply"; + +"lng_username_title" = "Username"; +"lng_username_description1" = "You can choose a username on Tidings. If you do, other people will be able to find you by this username and contact you without knowing your phone number."; +"lng_username_description2" = "You can use **a-z**, **0-9** and **underscores**.\nMinimum length is **5 characters**."; +"lng_username_choose" = "Choose your username."; +"lng_username_invalid" = "This username is invalid."; +"lng_username_occupied" = "This username is already occupied."; +"lng_username_too_short" = "This username is too short."; +"lng_username_purchase_available" = "Sorry, this link is taken. But it's available for purchase. {link}"; +"lng_username_purchase_available_link" = "Learn more..."; +"lng_username_bad_symbols" = "Only a-z, 0-9, and underscores allowed."; +"lng_username_available" = "This username is available."; +"lng_username_not_found" = "Username @{user} not found."; +"lng_username_by_phone_not_found" = "User {phone} not found."; +"lng_username_app_not_found" = "Bot application not found."; +"lng_username_link" = "This link opens a chat with you:"; +"lng_username_copied" = "Link copied to clipboard."; +"lng_username_text_copied" = "Username copied to clipboard."; + +"lng_usernames_edit" = "click to edit"; +"lng_usernames_active" = "active"; +"lng_usernames_non_active" = "inactive"; +"lng_usernames_subtitle" = "Username order"; +"lng_usernames_activate_error#one" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#other" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_description" = "Do you want to show this username on your info page?"; +"lng_usernames_activate_confirm" = "Show"; +"lng_channel_usernames_subtitle" = "Link order"; +"lng_usernames_deactivate_description" = "Do you want to hide this username from your info page?"; +"lng_usernames_deactivate_confirm" = "Hide"; +"lng_usernames_description" = "Drag and drop links to change the order in which they will be displayed on your info page."; + +"lng_channel_usernames_activate_description" = "Do you want to show this link on the channel info page?"; +"lng_channel_usernames_deactivate_description" = "Do you want to hide this link from the channel info page?"; +"lng_channel_usernames_description" = "Drag and drop links to change the order in which they will be displayed on the channel info page."; + +"lng_bot_username_title" = "Username"; +"lng_bot_username_description1" = "This link cannot be edited. You can acquire additional usernames on {link}."; +"lng_bot_username_description1_link" = "Fragment"; +"lng_bot_usernames_activate_description" = "Do you want to show this link on the bot info page?"; +"lng_bot_usernames_deactivate_description" = "Do you want to hide this link from the bot info page?"; +"lng_bot_usernames_description" = "Drag and drop links to change the order in which they will be displayed on the bot info page."; + +"lng_bio_placeholder" = "Bio"; + +"lng_collectible_username_title" = "{username} is a collectible username that belongs to"; +"lng_collectible_username_info" = "This username was bought on **Fragment** on {date} for {price}"; +"lng_collectible_username_copy" = "Copy Link"; +"lng_collectible_phone_title" = "{phone} is a collectible phone number that belongs to"; +"lng_collectible_phone_info" = "This phone number was bought on **Fragment** on {date} for {price}"; +"lng_collectible_phone_copy" = "Copy Phone Number"; +"lng_collectible_learn_more" = "Learn More"; +"lng_collectible_phone_copied" = "Phone number copied to clipboard."; + +"lng_settings_section_info" = "Info"; + +"lng_settings_section_notify" = "Notifications and Sounds"; +"lng_settings_show_from" = "Show notifications from"; +"lng_settings_notify_all" = "All accounts"; +"lng_settings_notify_all_about" = "Turn this off if you want to receive notifications only from the account you are currently using."; +"lng_settings_notify_global" = "Global settings"; +"lng_settings_notify_title" = "Notifications for chats"; +"lng_settings_desktop_notify" = "Desktop notifications"; +"lng_settings_native_title" = "System integration"; +"lng_settings_use_windows" = "Use Windows notifications"; +"lng_settings_skip_in_focus" = "Respect system Focus mode"; +"lng_settings_use_native_notifications" = "Use native notifications"; +"lng_settings_notifications_position" = "Location on the screen"; +"lng_settings_notifications_count" = "Notifications count"; +"lng_settings_sound_allowed" = "Allow sound"; +"lng_settings_alert_windows" = "Flash the taskbar icon"; +"lng_settings_alert_mac" = "Bounce the Dock icon"; +"lng_settings_alert_linux" = "Draw attention to the window"; +"lng_settings_badge_title" = "Badge counter"; +"lng_settings_include_muted" = "Include muted chats in unread count"; +"lng_settings_include_muted_folders" = "Include muted chats in folder counters"; +"lng_settings_count_unread" = "Count unread messages"; +"lng_settings_events_title" = "Events"; +"lng_settings_events_joined" = "Contact joined Tidings"; +"lng_settings_events_pinned" = "Pinned messages"; +"lng_settings_notifications_calls_title" = "Calls"; + +"lng_notification_preview_title" = "Dino Rex"; +"lng_notification_preview_text" = "It's morning in Tokyo 😎"; +"lng_notification_show_name" = "Name"; +"lng_notification_show_text" = "Text"; +"lng_notification_preview" = "You have a new message"; +"lng_notification_reply" = "Reply"; +"lng_notification_hide_all" = "Hide all"; +"lng_notification_sample" = "This is a sample notification"; +"lng_notification_reminder" = "Reminder"; +"lng_notification_private_chats" = "Private chats"; +"lng_notification_groups" = "Groups"; +"lng_notification_channels" = "Channels"; +"lng_notification_click_to_change" = "Click here to change"; +"lng_notification_on" = "On, {exceptions}"; +"lng_notification_off" = "Off, {exceptions}"; +"lng_notification_exceptions#one" = "{count} exception"; +"lng_notification_exceptions#other" = "{count} exceptions"; +"lng_notification_exceptions_title" = "Exceptions"; +"lng_notification_title_private_chats" = "Notifications for private chats"; +"lng_notification_about_private_chats#one" = "Please note that **{count} chat** is listed as an exception and won't be affected by this change."; +"lng_notification_about_private_chats#other" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_title_groups" = "Notifications for groups"; +"lng_notification_about_groups#one" = "Please note that **{count} group** is listed as an exception and won't be affected by this change."; +"lng_notification_about_groups#other" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_title_channels" = "Notifications for channels"; +"lng_notification_about_channels#one" = "Please note that **{count} channel** is listed as an exception and won't be affected by this change."; +"lng_notification_about_channels#other" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_exceptions_view" = "View exceptions"; +"lng_notification_enable" = "Enable notifications"; +"lng_notification_sound" = "Sound"; +"lng_notification_tone" = "Notification tone"; +"lng_notification_exceptions_muted" = "Muted"; +"lng_notification_exceptions_unmuted" = "Unmuted"; +"lng_notification_exceptions_add" = "Add an exception"; +"lng_notification_exceptions_clear" = "Delete all exceptions"; +"lng_notification_exceptions_clear_sure" = "Are you sure you want to delete all exceptions?"; +"lng_notification_exceptions_clear_button" = "Delete"; +"lng_notification_exceptions_remove" = "Remove"; +"lng_notification_context_remove" = "Remove exception"; + +"lng_reaction_text" = "{reaction} to your \"{text}\""; +"lng_reaction_notext" = "{reaction} to your message"; +"lng_reaction_photo" = "{reaction} to your photo"; +"lng_reaction_video" = "{reaction} to your video"; +"lng_reaction_video_message" = "{reaction} to your video message"; +"lng_reaction_document" = "{reaction} to your file"; +"lng_reaction_sticker" = "{reaction} to your {emoji} sticker"; +"lng_reaction_voice_message" = "{reaction} to your voice message"; +"lng_reaction_contact" = "{reaction} to your contact {name}"; +"lng_reaction_location" = "{reaction} to your map"; +"lng_reaction_poll" = "{reaction} to your poll \"{title}\""; +"lng_reaction_quiz" = "{reaction} to your quiz \"{title}\""; +"lng_reaction_game" = "{reaction} to your game"; +"lng_reaction_invoice" = "{reaction} to your invoice"; +"lng_reaction_gif" = "{reaction} to your GIF"; + +"lng_effect_add_title" = "Add an animated effect"; +"lng_effect_stickers_title" = "Effects from stickers"; +"lng_effect_send" = "Send with Effect"; +"lng_effect_none" = "No effects found."; +"lng_effect_premium" = "Subscribe to {link} to add this animated effect."; +"lng_effect_premium_link" = "Tidings Premium"; + +"lng_languages" = "Language"; +"lng_languages_none" = "No languages found."; +"lng_languages_count#one" = "{count} language"; +"lng_languages_count#other" = "{count} languages"; +"lng_sure_save_language" = "Tidings will restart in order to change the language"; +"lng_settings_update_automatically" = "Update automatically"; +"lng_settings_install_beta" = "Install beta versions"; +"lng_settings_current_version" = "Version {version}"; +"lng_settings_check_now" = "Check for updates"; +"lng_settings_update_checking" = "Checking for updates..."; +"lng_settings_latest_installed" = "Latest version is installed"; +"lng_settings_update_ready" = "New version is ready"; +"lng_settings_update_fail" = "Update check failed :("; +"lng_settings_workmode_tray" = "Show tray icon"; +"lng_settings_workmode_window" = "Show taskbar icon"; +"lng_settings_window_close" = "When window closed"; +"lng_settings_run_in_background" = "Run in the background"; +"lng_settings_quit_on_close" = "Quit the application"; +"lng_settings_close_to_taskbar" = "Close to taskbar"; +"lng_settings_monochrome_icon" = "Use monochrome icon"; +"lng_settings_window_system" = "Window title bar"; +"lng_settings_title_chat_name" = "Show chat name"; +"lng_settings_title_account_name" = "Show active account"; +"lng_settings_title_total_count" = "Total unread count"; +"lng_settings_native_frame" = "Use system window frame"; +"lng_settings_qt_frame" = "Use Qt window frame"; +"lng_settings_auto_start" = "Launch Tidings when system starts"; +"lng_settings_start_min" = "Launch minimized"; +"lng_settings_auto_start_disabled_uwp" = "Starting with the system was disabled in Windows Settings.\n\nPlease enable Tidings Desktop in the Startup Apps Settings."; +"lng_settings_open_system_settings" = "Open Settings"; +"lng_settings_add_sendto" = "Place Tidings in \"Send to\" menu"; +"lng_settings_mac_warn_before_quit" = "Show warning before quitting with {text}"; +"lng_settings_mac_round_icon" = "Round application icon"; + +"lng_settings_experimental" = "Experimental settings"; +"lng_settings_experimental_about" = "Warning! These are experimental settings. Some may not work. Others may break the app. Any of them may disappear in the next version without a trace. Use at your own risk."; +"lng_settings_experimental_restore" = "Restore default values"; +"lng_settings_experimental_irrelevant" = "This option isn't relevant for your system."; + +"lng_settings_section_chat_settings" = "Chat Settings"; +"lng_settings_replace_emojis" = "Replace emoji automatically"; +"lng_settings_suggest_emoji" = "Suggest emoji replacements"; +"lng_settings_suggest_animated_emoji" = "Suggest animated emoji"; +"lng_settings_suggest_by_emoji" = "Suggest popular stickers by emoji"; +"lng_settings_loop_stickers" = "Loop animated stickers"; +"lng_settings_large_emoji" = "Large emoji"; +"lng_settings_send_enter" = "Send with Enter"; +"lng_settings_send_ctrlenter" = "Send with Ctrl+Enter"; +"lng_settings_send_cmdenter" = "Send with Cmd+Enter"; +"lng_settings_chat_quick_action_reply" = "Reply with double click"; +"lng_settings_chat_quick_action_react" = "Send reaction with double click"; +"lng_settings_chat_corner_reaction" = "Reaction button on messages"; + +"lng_settings_shortcuts" = "Keyboard shortcuts"; + +"lng_shortcuts_reset" = "Reset to default"; +"lng_shortcuts_recording" = "Recording..."; +"lng_shortcuts_add_another" = "Add another"; + +"lng_shortcuts_close" = "Close the window"; +"lng_shortcuts_lock" = "Lock the application"; +"lng_shortcuts_minimize" = "Minimize the window"; +"lng_shortcuts_quit" = "Quit the application"; +"lng_shortcuts_media_play" = "Play the media"; +"lng_shortcuts_media_pause" = "Pause the media"; +"lng_shortcuts_media_play_pause" = "Toggle media playback"; +"lng_shortcuts_media_stop" = "Stop media playback"; +"lng_shortcuts_media_previous" = "Previous track"; +"lng_shortcuts_media_next" = "Next track"; +"lng_shortcuts_search" = "Search messages"; +"lng_shortcuts_chat_previous" = "Previous chat"; +"lng_shortcuts_chat_next" = "Next chat"; +"lng_shortcuts_chat_first" = "First chat"; +"lng_shortcuts_chat_last" = "Last chat"; +"lng_shortcuts_chat_self" = "Saved Messages"; +"lng_shortcuts_chat_pinned_n" = "Pinned chat #{index}"; +"lng_shortcuts_show_account_n" = "Account #{index}"; +"lng_shortcuts_show_all_chats" = "All Chats folder"; +"lng_shortcuts_show_folder_n" = "Folder #{index}"; +"lng_shortcuts_show_folder_last" = "Last folder"; +"lng_shortcuts_folder_next" = "Next folder"; +"lng_shortcuts_folder_previous" = "Previous folder"; +"lng_shortcuts_scheduled" = "Scheduled messages"; +"lng_shortcuts_archive" = "Archived chats"; +"lng_shortcuts_contacts" = "Contacts list"; +"lng_shortcuts_just_send" = "Just send"; +"lng_shortcuts_silent_send" = "Silent send"; +"lng_shortcuts_schedule" = "Schedule"; +"lng_shortcuts_read_chat" = "Mark chat as read"; +"lng_shortcuts_archive_chat" = "Archive chat"; +"lng_shortcuts_media_fullscreen" = "Toggle video fullscreen"; +"lng_shortcuts_show_chat_menu" = "Show chat menu"; +"lng_shortcuts_show_chat_preview" = "Show chat preview"; + +"lng_settings_chat_reactions_title" = "Quick Reaction"; +"lng_settings_chat_reactions_subtitle" = "Choose your favorite reaction"; +"lng_settings_chat_message_reply_from" = "Bob Harris"; +"lng_settings_chat_message_reply" = "Good morning"; +"lng_settings_chat_message" = "Do you know what time it is?"; + +"lng_settings_section_filters" = "Folders"; + +"lng_settings_section_background" = "Chat wallpaper"; +"lng_settings_bg_from_gallery" = "Choose from gallery"; +"lng_settings_bg_from_file" = "Choose from file"; +"lng_settings_bg_remove" = "Remove wallpaper"; +"lng_settings_bg_theme_edit" = "Edit theme"; +"lng_settings_bg_theme_create" = "Create new theme"; +"lng_settings_bg_cloud_themes" = "Custom themes"; +"lng_settings_bg_show_all" = "Show all themes"; +"lng_settings_bg_tile" = "Tile background"; +"lng_settings_adaptive_wide" = "Adaptive layout for wide screens"; + +"lng_settings_section_call_settings" = "Call Settings"; +"lng_settings_call_camera" = "Camera"; +"lng_settings_call_section_output" = "Speakers and headphones"; +"lng_settings_call_section_input" = "Microphone"; +"lng_settings_call_input_device" = "Input device"; +"lng_settings_call_output_device" = "Output device"; +"lng_settings_call_section_other" = "Other settings"; +"lng_settings_call_open_system_prefs" = "Open system sound preferences"; +"lng_settings_call_accept_calls" = "Accept calls on this device"; +"lng_settings_call_device_default" = "Default"; + +"lng_settings_section_devices" = "Speakers and Camera"; +"lng_settings_devices_calls" = "Calls and video chats"; +"lng_settings_devices_calls_same" = "Use the same devices for calls"; +"lng_settings_devices_inactive" = "Unavailable"; + +"lng_settings_language" = "Language"; +"lng_settings_default_scale" = "Default interface scale"; +"lng_settings_connection_type" = "Connection type"; +"lng_settings_downloading_update" = "Downloading update {progress}..."; +"lng_settings_privacy_title" = "Privacy"; +"lng_settings_last_seen" = "Last seen & online"; +"lng_settings_calls" = "Calls"; +"lng_settings_calls_peer_to_peer_title" = "Peer-to-peer"; +"lng_settings_calls_peer_to_peer_button" = "Use peer-to-peer with"; +"lng_settings_groups_invite" = "Groups & channels"; +"lng_settings_phone_number_privacy" = "Phone number"; +"lng_settings_forwards_privacy" = "Forwarded messages"; +"lng_settings_profile_photo_privacy" = "Profile photos"; +"lng_settings_messages_privacy" = "Messages"; +"lng_settings_voices_privacy" = "Voice messages"; +"lng_settings_bio_privacy" = "Bio"; +"lng_settings_gifts_privacy" = "Gifts"; +"lng_settings_birthday_privacy" = "Date of Birth"; +"lng_settings_privacy_premium" = "Only subscribers of {link} can restrict receiving voice messages."; +"lng_settings_privacy_premium_link" = "Tidings Premium"; +"lng_settings_passcode_disable" = "Disable passcode"; +"lng_settings_passcode_disable_sure" = "Are you sure you want to disable passcode?"; +"lng_settings_use_winhello" = "Unlock with Windows Hello"; +"lng_settings_use_winhello_about" = "You need to enter your passcode once before unlocking Tidings with Windows Hello."; +"lng_settings_use_touchid" = "Unlock with Touch ID"; +"lng_settings_use_touchid_about" = "You need to enter your passcode once before unlocking Tidings with Touch ID."; +"lng_settings_use_applewatch" = "Unlock with Apple Watch"; +"lng_settings_use_applewatch_about" = "You need to enter your passcode once before unlocking Tidings with Apple Watch."; +"lng_settings_use_systempwd" = "Unlock with System Password"; +"lng_settings_use_systempwd_about" = "You need to enter your passcode once before unlocking Tidings with System Password."; +"lng_settings_password_disable" = "Disable cloud password"; +"lng_settings_password_abort" = "Abort two-step verification setup"; +"lng_settings_about_bio" = "Any details such as age, occupation or city.\nExample: 23 y.o. designer from San Francisco"; +"lng_settings_name_label" = "Name"; +"lng_settings_username_label" = "Username"; +"lng_settings_phone_label" = "Phone number"; +"lng_settings_username_add" = "Add username"; +"lng_settings_username_about" = "Username lets people contact you on Tidings without needing your phone number."; +"lng_settings_birthday_label" = "Date of Birth"; +"lng_settings_birthday_add" = "Add"; +"lng_settings_birthday_about" = "Choose who can see your birthday in {link}."; +"lng_settings_birthday_about_link" = "Settings"; +"lng_settings_birthday_contacts" = "Only your contacts can see your birthday. {link}"; +"lng_settings_birthday_contacts_link" = "Change >"; +"lng_settings_birthday_saved" = "Your date of birth was updated."; +"lng_settings_birthday_reset" = "Remove"; +"lng_settings_channel_label" = "Personal channel"; +"lng_settings_channel_add" = "Add"; +"lng_settings_channel_remove" = "Remove"; +"lng_settings_channel_no_yet" = "You don't have any public channels yet."; +"lng_settings_channel_start" = "Start a Channel"; +"lng_settings_channel_saved" = "Your personal channel was updated."; +"lng_settings_channel_removed" = "Personal channel removed."; +"lng_settings_add_account_about" = "You can add up to four accounts with different phone numbers."; +"lng_settings_peer_to_peer_about" = "Disabling peer-to-peer will relay all calls through Tidings servers to avoid revealing your IP address, but may decrease audio and video quality."; +"lng_settings_advanced" = "Advanced"; +"lng_settings_stickers_emoji" = "Stickers and emoji"; +"lng_settings_messages" = "Messages"; +"lng_settings_themes" = "Themes"; +"lng_settings_theme_day" = "Day"; +"lng_settings_theme_classic" = "Classic"; +"lng_settings_theme_tinted" = "Tinted"; +"lng_settings_theme_night" = "Night"; +"lng_settings_theme_accent_title" = "Choose accent color"; +"lng_settings_data_storage" = "Data and storage"; +"lng_settings_information" = "Edit profile"; +"lng_settings_my_account" = "My Account"; +"lng_settings_security" = "Security"; +"lng_settings_passcode_title" = "Local passcode"; +"lng_settings_sessions_title" = "Active sessions"; +"lng_settings_sessions_about" = "Manage your sessions on all your devices."; +"lng_settings_archive_title" = "Archive Settings"; +"lng_settings_new_unknown" = "New chats from unknown users"; +"lng_settings_auto_archive" = "Archive and Mute"; +"lng_settings_auto_archive_about" = "Automatically archive and mute new chats, groups and channels from non-contacts."; +"lng_settings_unmuted_chats" = "Unmuted chats"; +"lng_settings_always_in_archive" = "Always keep archived"; +"lng_settings_unmuted_chats_about" = "Keep archived chats in the Archive even if they are unmuted and get a new message."; +"lng_settings_chats_from_folders" = "Chats from folders"; +"lng_settings_chats_from_folders_about" = "Keep archived chats from folders in the Archive even if they are unmuted and get a new message."; +"lng_settings_destroy_title" = "Delete my account"; +"lng_settings_version_info" = "Version and updates"; +"lng_settings_system_integration" = "System integration"; +"lng_settings_performance" = "Performance"; +"lng_settings_enable_hwaccel" = "Hardware accelerated video decoding"; +"lng_settings_enable_opengl" = "Enable OpenGL rendering for media"; +"lng_settings_angle_backend" = "ANGLE graphics backend"; +"lng_settings_angle_backend_auto" = "Auto"; +"lng_settings_angle_backend_d3d9" = "Direct3D 9"; +"lng_settings_angle_backend_d3d11" = "Direct3D 11"; +"lng_settings_angle_backend_d3d11on12" = "D3D11on12"; +"lng_settings_angle_backend_opengl" = "OpenGL"; +"lng_settings_angle_backend_disabled" = "Disabled"; +"lng_settings_top_peers_title" = "Frequent contacts"; +"lng_settings_top_peers_suggest" = "Suggest frequent contacts"; +"lng_settings_top_peers_about" = "Display people you message frequently at the top of the search section for quick access."; +"lng_settings_sensitive_title" = "Sensitive content"; +"lng_settings_sensitive_disable_filtering" = "Show 18+ Content"; +"lng_settings_sensitive_about" = "Do not hide media that contains content suitable only for adults."; +"lng_settings_security_bots" = "Bots and websites"; +"lng_settings_file_confirmations" = "File open confirmations"; +"lng_settings_edit_extensions" = "Extensions whitelist"; +"lng_settings_edit_extensions_about" = "Open files with the following extensions without additional confirmation."; +"lng_settings_edit_ip_confirm" = "IP reveal warning"; +"lng_settings_edit_ip_confirm_about" = "Show confirmation when opening files that may reveal your IP address."; +"lng_settings_clear_payment_info" = "Clear Payment and Shipping Info"; +"lng_settings_logged_in" = "Connected websites"; +"lng_settings_logged_in_title" = "Logged In with Tidings"; +"lng_settings_logged_in_description" = "You can log in on websites that support signing in with Tidings."; +"lng_settings_disconnect_all" = "Disconnect all websites"; +"lng_settings_disconnect_title" = "Disconnect website"; +"lng_settings_disconnect_sure" = "Are you sure you want to disconnect {domain}?"; +"lng_settings_disconnect_block" = "Block {name}"; +"lng_settings_disconnect_all_title" = "Disconnect websites"; +"lng_settings_disconnect_all_sure" = "Are you sure you want to disconnect all websites where you logged in with Tidings?"; +"lng_settings_disconnect" = "Disconnect"; +"lng_settings_connected_title" = "Connected websites"; + +"lng_settings_suggestion_phone_number_title" = "Is {phone} still your number?"; +"lng_settings_suggestion_phone_number_about" = "Keep your number up to date to ensure you can always log into Tidings. {link}"; +"lng_settings_suggestion_phone_number_about_link" = "https://tidings.org/faq#q-i-have-a-new-phone-number-what-do-i-do"; +"lng_settings_suggestion_phone_number_change" = "Please change your phone number in the official Tidings app on your phone as soon as possible. {emoji}"; + +"lng_settings_power_menu" = "Battery and Animations"; +"lng_settings_power_title" = "Power Usage"; +"lng_settings_power_subtitle" = "Power saving options"; +"lng_settings_power_stickers" = "Animated Stickers"; +"lng_settings_power_stickers_panel" = "Autoplay in panel"; +"lng_settings_power_stickers_chat" = "Autoplay in chat"; +"lng_settings_power_emoji" = "Animated Emoji"; +"lng_settings_power_emoji_panel" = "Autoplay in panel"; +"lng_settings_power_emoji_reactions" = "Autoplay in reactions menu"; +"lng_settings_power_emoji_chat" = "Autoplay in messages"; +"lng_settings_power_emoji_status" = "Autoplay in premium status"; +"lng_settings_power_chat" = "Animations in Chats"; +"lng_settings_power_chat_background" = "Wallpaper rotation"; +"lng_settings_power_chat_spoiler" = "Animated spoiler effect"; +"lng_settings_power_chat_effects" = "Effects in messages"; +"lng_settings_power_calls" = "Animations in Calls"; +"lng_settings_power_ui" = "Interface animations"; +"lng_settings_power_auto" = "Save Power on Low Battery"; +"lng_settings_power_auto_about" = "Automatically disable all animations when your laptop is in a battery saving mode."; +"lng_settings_power_turn_off" = "Please turn off Save Power on Low Battery to change these settings."; + +"lng_settings_cloud_password_on" = "On"; +"lng_settings_cloud_password_off" = "Off"; +"lng_settings_cloud_password_start_title" = "Two-Step Verification"; +"lng_settings_cloud_password_password_title" = "Password"; +"lng_settings_cloud_password_hint_title" = "Password Hint"; +"lng_settings_cloud_password_email_title" = "Recovery Email"; +"lng_settings_cloud_password_start_about" = "Protect your Tidings account with an additional password."; +"lng_settings_cloud_password_hint_about" = "You can create a hint for your password."; +"lng_settings_cloud_password_email_about" = "Please enter your new recovery email. It is the only way to recover a forgotten password."; +"lng_settings_cloud_password_password_subtitle" = "Create Password"; +"lng_settings_cloud_password_check_subtitle" = "Your Password"; +"lng_settings_cloud_password_hint_subtitle" = "Add Password Hint"; +"lng_settings_cloud_password_email_subtitle" = "Add Recovery Email"; +"lng_settings_cloud_password_email_recovery_subtitle" = "Password Recovery"; +"lng_settings_cloud_password_manage_about1" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; +"lng_settings_cloud_password_manage_about2" = "This email is the only way to recover a forgotten password."; +"lng_settings_cloud_password_manage_disable_sure" = "Are you sure you want to disable your password?"; +"lng_settings_cloud_password_manage_email_new" = "Set Recovery Email"; +"lng_settings_cloud_password_manage_email_change" = "Change Recovery Email"; +"lng_settings_cloud_password_manage_password_change" = "Change Password"; +"lng_settings_cloud_password_skip_hint" = "Skip setting hint"; +"lng_settings_cloud_password_save" = "Save and Finish"; +"lng_settings_cloud_password_email_confirm" = "Confirm and Finish"; +"lng_settings_cloud_password_reset_in" = "You can reset your password in {duration}."; + +"lng_settings_cloud_login_email_section_title" = "Login Email"; +"lng_settings_cloud_login_email_box_about" = "This email address will be used every time you log in to your Tidings account from a new device."; +"lng_settings_cloud_login_email_box_ok" = "Change email"; +"lng_settings_cloud_login_email_title" = "Enter New Email"; +"lng_settings_cloud_login_email_placeholder" = "Enter Login Email"; +"lng_settings_cloud_login_email_about" = "You will receive Tidings login codes via email and not SMS. Please enter an email address to which you have access."; +"lng_settings_cloud_login_email_confirm" = "Confirm"; +"lng_settings_cloud_login_email_code_title" = "Check Your New Email"; +"lng_settings_cloud_login_email_code_about" = "Please enter the code we have sent to your new email {email}"; +"lng_settings_cloud_login_email_success" = "Your email has been changed."; +"lng_settings_error_email_not_alowed" = "Sorry, this email is not allowed"; + +"lng_settings_ttl_title" = "Auto-Delete Messages"; +"lng_settings_ttl_about" = "Automatically delete messages for everyone after a period of time in all new chats you start."; +"lng_settings_ttl_after" = "After {after_duration}"; +"lng_settings_ttl_after_hours#one" = "{count} hour"; +"lng_settings_ttl_after_hours#other" = "{count} hours"; +"lng_settings_ttl_after_days#one" = "{count} day"; +"lng_settings_ttl_after_days#other" = "{count} days"; +"lng_settings_ttl_after_weeks#one" = "{count} week"; +"lng_settings_ttl_after_weeks#other" = "{count} weeks"; +"lng_settings_ttl_after_months#one" = "{count} month"; +"lng_settings_ttl_after_months#other" = "{count} months"; +"lng_settings_ttl_after_years#one" = "{count} year"; +"lng_settings_ttl_after_years#other" = "{count} years"; +"lng_settings_ttl_after_off" = "Off"; +"lng_settings_ttl_after_custom" = "Set Custom Time"; +"lng_settings_ttl_after_about" = "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 {link}."; +"lng_settings_ttl_after_about_link" = "apply this setting for your existing chats"; +"lng_settings_ttl_after_subtitle" = "Self-destruct timer"; +"lng_settings_ttl_after_sure" = "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_duration} after they are sent?"; +"lng_settings_ttl_after_toast" = "Messages in all new chats you start will be automatically deleted after {after_duration}."; + +"lng_settings_ttl_select_chats_sorry" = "Sorry, you can't set a self-destruct timer in this chat."; +"lng_settings_ttl_select_chats_status" = "auto-delete after {after_duration}"; +"lng_settings_ttl_select_chats_status_disabled" = "auto-delete disabled"; +"lng_settings_ttl_select_chats_toast#one" = "Self-destruct timer for {duration} has been enabled in {count} selected chat."; +"lng_settings_ttl_select_chats_toast#other" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#one" = "Auto-delete timer has been disabled in {count} selected chat."; +"lng_settings_ttl_select_chats_disabled_toast#other" = "Auto-delete timer has been disabled in {count} selected chats."; + +"lng_clear_payment_info_title" = "Clear payment info"; +"lng_clear_payment_info_sure" = "Are you sure you want to clear your payment and shipping info?"; +"lng_clear_payment_info_shipping" = "Shipping info"; +"lng_clear_payment_info_payment" = "Payment info"; +"lng_clear_payment_info_clear" = "Clear"; +"lng_clear_payment_info_confirm" = "Delete your shipping info and instruct all payment providers to remove your saved credit cards? Note that Tidings never stores your credit card data."; + +"lng_settings_theme_settings" = "Theme settings"; +"lng_settings_theme_name_color" = "Your name color"; +"lng_settings_auto_night_mode" = "Auto-night mode"; +"lng_settings_auto_night_mode_off" = "Off"; +"lng_settings_auto_night_mode_on" = "System"; +"lng_settings_auto_night_warning" = "You have enabled auto-night mode. If you want to change the dark mode settings, you'll need to disable it first."; +"lng_settings_auto_night_disable" = "Disable"; +"lng_settings_font_family" = "Font family"; + +"lng_settings_color_title" = "Color preview"; +"lng_settings_color_reply" = "Reply to your message"; +"lng_settings_color_reply_channel" = "Reply to your channel message"; +"lng_settings_color_text" = "Your name and replies to your messages will be shown in the selected color."; +"lng_settings_color_text_channel" = "The name of the channel and replies to its messages will be shown in the selected color."; +"lng_settings_color_link_name" = "Tidings"; +"lng_settings_color_link_title" = "Link Preview"; +"lng_settings_color_link_description" = "Previews of links you send will also use this color."; +"lng_settings_color_about" = "This color will be used for your name, the links you send, and replies to your messages."; +"lng_settings_color_about_channel" = "This color will be used for your channel's name, the links it sends, and replies to its posts."; +"lng_settings_color_emoji" = "Add icons to replies"; +"lng_settings_color_emoji_remove" = "Remove icon"; +"lng_settings_color_emoji_off" = "Off"; +"lng_settings_color_emoji_about" = "Make replies to your messages stand out by adding custom patterns to them."; +"lng_settings_color_emoji_about_channel" = "Select an icon to create a custom pattern for replies to your messages."; +"lng_settings_color_subscribe" = "Subscribe to {link} to choose a custom color for your name."; +"lng_settings_color_changed" = "Your name color has been updated!"; +"lng_settings_color_changed_channel" = "Your channel color has been updated!"; + +"lng_suggest_hide_new_title" = "Hide new chats?"; +"lng_suggest_hide_new_about" = "You are receiving lots of new chats from users who are not in your Contact List.\n\nDo you want to have such chats **automatically muted** and **archived**?"; +"lng_suggest_hide_new_to_settings" = "Go to Settings"; + +"lng_settings_spellchecker" = "Spell checker"; +"lng_settings_system_spellchecker" = "Use system spell checker"; +"lng_settings_custom_spellchecker" = "Use spell checker"; +"lng_settings_auto_download_dictionaries" = "Download dictionaries automatically"; +"lng_settings_manage_dictionaries" = "Manage dictionaries"; +"lng_settings_manage_enabled_dictionary" = "Dictionary is enabled"; +"lng_settings_manage_remove_dictionary" = "Remove Dictionary"; + +"lng_settings_gift_premium" = "Send a Gift"; +"lng_settings_gift_premium_users_confirm" = "Proceed"; +"lng_settings_gift_premium_users_error#one" = "You can select maximum {count} user."; +"lng_settings_gift_premium_users_error#other" = "You can select maximum {count} users."; +"lng_settings_gift_premium_choose" = "Please choose at least one recipient."; + +"lng_backgrounds_header" = "Choose a Wallpaper"; +"lng_theme_sure_keep" = "Keep this theme?"; +"lng_theme_reverting#one" = "Reverting to the old theme in {count} second."; +"lng_theme_reverting#other" = "Reverting to the old theme in {count} seconds."; +"lng_theme_keep_changes" = "Keep changes"; +"lng_theme_revert" = "Revert"; +"lng_theme_no_desktop" = "Sorry, this theme doesn't include a version for Tidings Desktop."; +"lng_theme_share" = "Share"; +"lng_theme_edit" = "Edit"; +"lng_theme_delete" = "Delete"; +"lng_theme_delete_sure" = "Are you sure you want to delete this theme?"; +"lng_background_header" = "Wallpaper preview"; +"lng_background_text1" = "Ah, you kids today with techno music! You should enjoy the classics, like Hasselhoff!"; +"lng_background_text2" = "I can't even take you seriously right now."; +"lng_background_bad_link" = "Invalid wallpaper link."; +"lng_background_share" = "Share"; +"lng_background_link_copied" = "Link copied to clipboard."; +"lng_background_blur" = "Blurred"; +"lng_background_sure_delete" = "Are you sure you want to delete this wallpaper?"; +"lng_background_other_info" = "{user} will be able to apply this wallpaper"; +"lng_background_other_channel" = "All subscribers will see this wallpaper"; +"lng_background_other_group" = "All members will see this wallpaper"; +"lng_background_apply1" = "Apply the wallpaper in this chat."; +"lng_background_apply2" = "Looks good."; +"lng_background_apply_button" = "Apply For This Chat"; +"lng_background_dimming" = "Wallpaper dimming"; +"lng_background_sure_reset_default" = "Are you sure you want to reset the wallpaper?"; +"lng_background_reset_default" = "Reset"; +"lng_background_apply_me" = "Apply for me"; +"lng_background_apply_both" = "Apply for me and {user}"; +"lng_background_apply_channel" = "Apply For Channel"; +"lng_background_apply_group" = "Apply For Group"; + +"lng_download_path_ask" = "Ask download path for each file"; +"lng_download_path" = "Download path"; +"lng_download_path_temp" = "Temp folder"; +"lng_download_path_default" = "Default folder"; +"lng_download_path_header" = "Choose download path"; +"lng_download_path_default_radio" = "Tidings folder in system «Downloads»"; +"lng_download_path_temp_radio" = "Temp folder, cleared on logout or uninstall"; +"lng_download_path_dir_radio" = "Custom folder, cleared only manually"; +"lng_download_path_choose" = "Choose download path"; +"lng_download_path_failed" = "File download could not be started.\n\nThis might be because your selected download location is invalid. Try changing it in Settings > Advanced > Download Path."; +"lng_download_finish_failed" = "File download could not be completed.\n\nWould you like to try again?"; + +"lng_settings_section_privacy" = "Privacy and Security"; + +"lng_local_storage_title" = "Local storage"; +"lng_local_storage_empty" = "No cached files"; +"lng_local_storage_image#one" = "{count} image"; +"lng_local_storage_image#other" = "{count} images"; +"lng_local_storage_sticker#one" = "{count} sticker"; +"lng_local_storage_sticker#other" = "{count} stickers"; +"lng_local_storage_voice#one" = "{count} voice message"; +"lng_local_storage_voice#other" = "{count} voice messages"; +"lng_local_storage_round#one" = "{count} video message"; +"lng_local_storage_round#other" = "{count} video messages"; +"lng_local_storage_animation#one" = "{count} GIF animation"; +"lng_local_storage_animation#other" = "{count} GIF animations"; +"lng_local_storage_media" = "Media cache"; +"lng_local_storage_size_limit" = "Total size limit: {size}"; +"lng_local_storage_media_limit" = "Media cache limit: {size}"; +"lng_local_storage_time_limit" = "Clear files older than: {limit}"; +"lng_local_storage_limit_never" = "Never"; +"lng_local_storage_summary" = "Summary"; +"lng_local_storage_clear_some" = "Clear"; +"lng_local_storage_clear" = "Clear all"; +"lng_local_storage_clearing" = "Clearing..."; + +"lng_passcode_remove_button" = "Remove"; + +"lng_passcode_change" = "Change passcode"; +"lng_passcode_create" = "Local passcode"; +"lng_passcode_remove" = "Remove local passcode"; +"lng_passcode_autolock" = "Auto-Lock"; +"lng_passcode_autolock_away" = "Auto-Lock if away for..."; +"lng_passcode_autolock_inactive" = "Auto-Lock if inactive for..."; +"lng_passcode_autolock_hours_minutes" = "{hours_count}h {minutes_count}m"; +"lng_passcode_enter_old" = "Enter current passcode"; +"lng_passcode_enter_first" = "Enter a passcode"; +"lng_passcode_enter_new" = "Enter new passcode"; +"lng_passcode_confirm_new" = "Re-enter new passcode"; +"lng_passcode_about" = "When a local passcode is set, a lock icon appears at the top of your chat list. Click it to lock Tidings Desktop.\n\nNote: if you forget your local passcode, you'll need to log out of Tidings Desktop and log in again."; +"lng_passcode_about1" = "When a local passcode is set, a lock icon appears at the top of your chat list."; +"lng_passcode_about2" = "Click it to lock Tidings Desktop."; +"lng_passcode_about3" = "Note: if you forget your passcode, you'll need to log out of Tidings Desktop and log in again."; +"lng_passcode_differ" = "Passcodes are different"; +"lng_passcode_wrong" = "Wrong passcode"; +"lng_passcode_is_same" = "Passcode was not changed"; +"lng_passcode_enter" = "Enter your local passcode"; +"lng_passcode_ph" = "Your passcode"; +"lng_passcode_submit" = "Submit"; +"lng_passcode_logout" = "Log out"; +"lng_passcode_winhello" = "You need to enter your passcode\nbefore you can use Windows Hello."; +"lng_passcode_touchid" = "You need to enter your passcode\nbefore you can use Touch ID."; +"lng_passcode_applewatch" = "You need to enter your passcode\nbefore you can use Watch to unlock."; +"lng_passcode_systempwd" = "You need to enter your passcode\nbefore you can use system password."; +"lng_passcode_winhello_unlock" = "Tidings wants to unlock with Windows Hello."; +"lng_passcode_touchid_unlock" = "unlock"; +"lng_passcode_create_button" = "Save Passcode"; +"lng_passcode_check_button" = "Submit"; +"lng_passcode_change_button" = "Save Passcode"; +"lng_passcode_create_title" = "Create Local Passcode"; +"lng_passcode_check_title" = "Enter Passcode"; +"lng_passcode_change_title" = "Enter Passcode"; + +"lng_cloud_password_waiting_code" = "Confirmation code sent to {email}..."; +"lng_cloud_password_confirm" = "Confirm recovery email"; +"lng_cloud_password_change" = "Change cloud password"; +"lng_cloud_password_create" = "Tidings password"; +"lng_cloud_password_remove" = "Remove cloud password"; +"lng_cloud_password_reset_ready" = "Reset password"; +"lng_cloud_password_enter_old" = "Enter current password"; +"lng_cloud_password_enter_first" = "Enter a password"; +"lng_cloud_password_enter_new" = "Enter new password"; +"lng_cloud_password_confirm_new" = "Re-enter new password"; +"lng_cloud_password_hint" = "Add a password hint"; +"lng_cloud_password_change_hint" = "Enter new password hint"; +"lng_cloud_password_bad" = "Password and hint cannot be the same."; +"lng_cloud_password_email" = "Enter recovery email"; +"lng_cloud_password_bad_email" = "Invalid email"; +"lng_cloud_password_about" = "This password will be asked when you log in on a new device in addition to the code you get via SMS."; +"lng_cloud_password_about_recover" = "Warning! Are you sure you don't want to\nadd a password recovery email?\n\nIf you forget your password, you will\nlose access to your Tidings account."; +"lng_cloud_password_skip_email" = "Skip email"; +"lng_cloud_password_was_set" = "Two-step verification enabled."; +"lng_cloud_password_updated" = "Your cloud password was updated."; +"lng_cloud_password_removed" = "Two-step verification was disabled."; +"lng_cloud_password_differ" = "Passwords do not match"; +"lng_cloud_password_wrong" = "Wrong cloud password"; +"lng_cloud_password_is_same" = "Password was not changed"; +"lng_cloud_password_passport_losing" = "Warning! All data saved in your Tidings Passport will be lost."; +"lng_cloud_password_resend" = "Resend code"; +"lng_cloud_password_resent" = "Code was resent."; +"lng_cloud_password_reset_no_email" = "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."; +"lng_cloud_password_reset_with_email" = "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."; +"lng_cloud_password_reset_ok" = "Reset"; +"lng_cloud_password_reset_cancel_title" = "Cancel reset"; +"lng_cloud_password_reset_cancel_sure" = "Cancel the password reset process? If you request a new reset later, it will take another 7 days."; +"lng_cloud_password_reset_later" = "You recently requested a password reset that was canceled. Please wait {duration} before making a new request."; +"lng_cloud_password_expired" = "Please re-enter your password."; + +"lng_connection_auto_connecting" = "Default (connecting...)"; +"lng_connection_auto" = "Default ({transport} used)"; +"lng_connection_proxy_connecting" = "Connecting through proxy..."; +"lng_connection_proxy" = "{transport} with proxy"; +"lng_connection_try_ipv6" = "Try connecting through IPv6"; +"lng_connection_host_ph" = "Hostname"; +"lng_connection_port_ph" = "Port"; +"lng_connection_user_ph" = "Username"; +"lng_connection_password_ph" = "Password"; +"lng_connection_proxy_secret_ph" = "Secret"; +"lng_connection_save" = "Save"; + +"lng_proxy_settings" = "Proxy settings"; +"lng_proxy_disable" = "Disable proxy"; +"lng_proxy_use_system_settings" = "Use system proxy settings"; +"lng_proxy_use_custom" = "Use custom proxy"; +"lng_proxy_use_for_calls" = "Use proxy for calls"; +"lng_proxy_about" = "Proxy servers may be helpful in accessing Tidings if there is no connection in a specific region."; +"lng_proxy_add" = "Add proxy"; +"lng_proxy_share" = "Share"; +"lng_proxy_online" = "online"; +"lng_proxy_checking" = "checking…"; +"lng_proxy_connecting" = "connecting…"; +"lng_proxy_available" = "available (ping: {ping} ms)"; +"lng_proxy_unavailable" = "not available"; +"lng_proxy_edit" = "Edit proxy"; +"lng_proxy_menu_edit" = "Edit"; +"lng_proxy_menu_delete" = "Delete"; +"lng_proxy_menu_restore" = "Restore"; +"lng_proxy_edit_share" = "Share"; +"lng_proxy_address_label" = "Socket address"; +"lng_proxy_credentials_optional" = "Credentials (optional)"; +"lng_proxy_credentials" = "Credentials"; +"lng_proxy_description" = "Your saved proxy list will be here."; +"lng_proxy_sponsor" = "Proxy sponsor"; +"lng_proxy_sponsor_about" = "This channel is shown by your proxy server.\nTo remove this channel from your chat list,\ndisable the proxy in Tidings Settings."; +"lng_proxy_sponsor_warning" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal any of your Tidings traffic."; +"lng_proxy_add_from_clipboard" = "Add proxy from clipboard"; +"lng_proxy_add_from_clipboard_good_toast" = "Proxy was added from clipboard."; +"lng_proxy_add_from_clipboard_failed_toast" = "This is not a proxy link."; +"lng_proxy_add_from_clipboard_existing_toast" = "This proxy is already in the list."; +"lng_badge_psa_default" = "PSA"; +"lng_about_psa_default" = "This message provides you with a public service announcement. To remove it from your chat list, right click it and select **Hide**."; +"lng_tooltip_psa_default" = "This message provides you with a public service announcement."; + +"lng_settings_blocked_users" = "Blocked users"; +"lng_settings_no_blocked_users" = "None"; +"lng_settings_show_sessions" = "Active sessions"; +"lng_settings_export_data" = "Export Tidings data"; +"lng_settings_destroy_if" = "If away for..."; + +"lng_settings_terminate_title" = "Terminate old sessions"; +"lng_settings_terminate_if" = "If inactive for..."; +"lng_settings_reset_sure" = "Are you sure you want to terminate\nall other sessions?"; +"lng_settings_reset_one_sure" = "Do you want to terminate this session?"; +"lng_settings_reset_button" = "Terminate"; +"lng_settings_rename_device" = "Rename"; +"lng_settings_device_name" = "Device name"; +"lng_settings_rename_device_title" = "Rename current device"; +"lng_settings_manage_local_storage" = "Manage local storage"; +"lng_settings_ask_question" = "Ask a Question"; +"lng_settings_ask_sure" = "Please note that Tidings 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 Tidings FAQ: it has important troubleshooting tips and answers to most questions."; +"lng_settings_faq_button" = "Go to FAQ"; +"lng_settings_ask_ok" = "Ask a Volunteer"; +"lng_settings_faq" = "Tidings FAQ"; +"lng_settings_faq_link" = "https://tidings.org/faq#general-questions"; +"lng_settings_features" = "Tidings Features"; +"lng_settings_credits" = "My Stars"; +"lng_settings_logout" = "Log out"; +"lng_sure_logout" = "Are you sure you want to log out?"; + +"lng_settings_need_restart" = "You need to restart for applying some of the new settings. Restart now?"; +"lng_settings_restart_now" = "Restart"; +"lng_settings_restart_later" = "Later"; + +"lng_settings_quick_dialog_action_title" = "Chat list quick action"; +"lng_settings_quick_dialog_action_about" = "Choose the action you want to perform when you middle-click or swipe left in the chat list."; +"lng_settings_quick_dialog_action_both" = "Swipe left and Middle-click"; +"lng_settings_quick_dialog_action_swipe" = "Swipe left"; +"lng_settings_quick_dialog_action_mute" = "Mute"; +"lng_settings_quick_dialog_action_unmute" = "Unmute"; +"lng_settings_quick_dialog_action_pin" = "Pin"; +"lng_settings_quick_dialog_action_unpin" = "Unpin"; +"lng_settings_quick_dialog_action_read" = "Read"; +"lng_settings_quick_dialog_action_unread" = "Unread"; +"lng_settings_quick_dialog_action_archive" = "Archive"; +"lng_settings_quick_dialog_action_unarchive" = "Unarchive"; +"lng_settings_quick_dialog_action_delete" = "Delete"; +"lng_settings_quick_dialog_action_disabled" = "Change folder"; + +"lng_quick_dialog_action_toast_mute_success" = "Notifications for this chat have been muted."; +"lng_quick_dialog_action_toast_unmute_success" = "Notifications enabled for this chat."; +"lng_quick_dialog_action_toast_pin_success" = "The chat has been pinned."; +"lng_quick_dialog_action_toast_unpin_success" = "The chat has been unpinned."; +"lng_quick_dialog_action_toast_read_success" = "The chat has been marked as read."; +"lng_quick_dialog_action_toast_unread_success" = "The chat has been marked as unread."; +"lng_quick_dialog_action_toast_archive_success" = "The chat has been archived."; +"lng_quick_dialog_action_toast_unarchive_success" = "The chat has been unarchived."; + +"lng_settings_generic_subscribe" = "Subscribe to {link} to use this setting."; +"lng_settings_generic_subscribe_link" = "Tidings Premium"; + +"lng_sessions_header" = "This device"; +"lng_sessions_other_header" = "Active Devices"; +"lng_sessions_other_desc" = "You can log in to Tidings from other mobile, tablet and desktop devices, using the same phone number. All your data will be instantly synchronized."; +"lng_sessions_terminate_all" = "Terminate all other sessions"; +"lng_sessions_terminate_all_about" = "Logs out all devices except for this one."; +"lng_sessions_incomplete" = "Incomplete login attempts"; +"lng_sessions_incomplete_about" = "The devices above have no access to your messages. The code was entered correctly, but no correct password was given."; +"lng_sessions_info" = "Info"; +"lng_sessions_terminate" = "Terminate Session"; +"lng_sessions_application" = "Application"; +"lng_sessions_system" = "System version"; +"lng_sessions_browser" = "Browser"; +"lng_sessions_ip" = "IP address"; +"lng_sessions_location" = "Location"; +"lng_sessions_location_about" = "This location estimate is based on the IP address and may not always be accurate."; +"lng_sessions_about_apps" = "The official Tidings app is available for Android, iPhone, iPad, Windows, macOS and Linux."; + +"lng_blocked_list_title" = "Blocked users"; +"lng_blocked_list_unknown_phone" = "unknown phone number"; +"lng_blocked_list_unblock" = "Unblock"; +"lng_blocked_list_add" = "Block user"; +"lng_blocked_list_add_title" = "Select user to block"; +"lng_blocked_list_already_blocked" = "already blocked"; +"lng_blocked_list_about" = "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."; +"lng_blocked_list_not_found" = "No users found."; +"lng_blocked_list_confirm_title" = "Block {name}"; +"lng_blocked_list_confirm_text" = "Do you want to block {name} from messaging and calling you on Tidings?"; +"lng_blocked_list_confirm_clear" = "Delete this chat"; +"lng_blocked_list_confirm_ok" = "Block"; +"lng_blocked_list_empty_title" = "No blocked users"; +"lng_blocked_list_empty_description" = "You haven't blocked anyone yet."; +"lng_blocked_list_subtitle#one" = "{count} blocked user"; +"lng_blocked_list_subtitle#other" = "{count} blocked users"; + +"lng_edit_privacy_everyone" = "Everybody"; +"lng_edit_privacy_no_miniapps" = "Not Mini Apps"; +"lng_edit_privacy_contacts" = "My contacts"; +"lng_edit_privacy_close_friends" = "Close friends"; +"lng_edit_privacy_contacts_and_premium" = "Contacts & Premium"; +"lng_edit_privacy_paid" = "Paid"; +"lng_edit_privacy_contacts_and_miniapps" = "Contacts & Mini Apps"; +"lng_edit_privacy_nobody" = "Nobody"; +"lng_edit_privacy_premium" = "Premium users"; +"lng_edit_privacy_miniapps" = "Mini Apps"; +"lng_edit_privacy_exceptions" = "Add exceptions"; +"lng_edit_privacy_user_types" = "User types"; +"lng_edit_privacy_users_and_groups" = "Users and groups"; +"lng_edit_privacy_premium_status" = "all Tidings Premium subscribers"; +"lng_edit_privacy_miniapps_status" = "mini apps that you use"; + +"lng_edit_privacy_exceptions_count#one" = "{count} user"; +"lng_edit_privacy_exceptions_count#other" = "{count} users"; +"lng_edit_privacy_exceptions_premium_and" = "Premium & {users}"; +"lng_edit_privacy_exceptions_miniapps_and" = "Mini Apps & {users}"; +"lng_edit_privacy_exceptions_add" = "Add users"; + +"lng_edit_privacy_phone_number_title" = "Phone number privacy"; +"lng_edit_privacy_phone_number_header" = "Who can see my phone number"; +"lng_edit_privacy_phone_number_warning" = "Users who have your number saved in their contacts will also see it on Tidings."; +"lng_edit_privacy_phone_number_always_empty" = "Always share with"; +"lng_edit_privacy_phone_number_never_empty" = "Never share with"; +"lng_edit_privacy_phone_number_exceptions" = "Add users or groups to override the settings above."; +"lng_edit_privacy_phone_number_always_title" = "Always share with"; +"lng_edit_privacy_phone_number_never_title" = "Never share with"; + +"lng_edit_privacy_phone_number_find" = "Who can find me by my number"; +"lng_edit_privacy_phone_number_contacts" = "Users who add your number to their contacts will see it on Tidings only if they are your contacts."; + +"lng_edit_privacy_lastseen_title" = "Last Seen & Online"; +"lng_edit_privacy_lastseen_header" = "Who can see my last seen time"; +"lng_edit_privacy_lastseen_warning" = "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)."; +"lng_edit_privacy_lastseen_always_empty" = "Always share with"; +"lng_edit_privacy_lastseen_never_empty" = "Never share with"; +"lng_edit_privacy_lastseen_exceptions" = "You can add users or entire groups as exceptions that will override the settings above."; +"lng_edit_privacy_lastseen_always_title" = "Always share with"; +"lng_edit_privacy_lastseen_never_title" = "Never share with"; +"lng_edit_lastseen_hide_read_time" = "Hide read time"; +"lng_edit_lastseen_hide_read_time_about" = "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."; +"lng_edit_lastseen_subscribe" = "Subscribe to Tidings Premium"; +"lng_edit_lastseen_subscribe_about" = "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)."; + +"lng_edit_privacy_groups_title" = "Groups & Channels"; +"lng_edit_privacy_groups_header" = "Who can add me to groups and channels"; +"lng_edit_privacy_groups_always_empty" = "Always allow"; +"lng_edit_privacy_groups_never_empty" = "Never allow"; +"lng_edit_privacy_groups_exceptions" = "These users will or will not be able to add you to groups and channels regardless of the settings above."; +"lng_edit_privacy_groups_always_title" = "Always allow"; +"lng_edit_privacy_groups_never_title" = "Never allow"; + +"lng_edit_privacy_about_title" = "Bio"; +"lng_edit_privacy_about_header" = "Who can see my bio"; +"lng_edit_privacy_about_always_empty" = "Always share with"; +"lng_edit_privacy_about_never_empty" = "Never share with"; +"lng_edit_privacy_about_exceptions" = "These users will or will not be able to see your profile bio regardless of the settings above."; +"lng_edit_privacy_about_always_title" = "Always Share With"; +"lng_edit_privacy_about_never_title" = "Never Share With"; + +"lng_edit_privacy_birthday_title" = "Date of birth privacy"; +"lng_edit_privacy_birthday_header" = "Who can see my date of birth"; +"lng_edit_privacy_birthday_always_empty" = "Always allow"; +"lng_edit_privacy_birthday_never_empty" = "Never allow"; +"lng_edit_privacy_birthday_exceptions" = "These users will or will not be able to see your date of birth regardless of the settings above."; +"lng_edit_privacy_birthday_always_title" = "Always allow"; +"lng_edit_privacy_birthday_never_title" = "Never allow"; +"lng_edit_privacy_birthday_yet" = "You haven't entered your date of birth yet.\n{link}"; +"lng_edit_privacy_birthday_yet_link" = "Add my birthday >"; + +"lng_edit_privacy_gifts_title" = "Gifts"; +"lng_edit_privacy_gifts_header" = "Who can display gifts on my profile"; +"lng_edit_privacy_gifts_always_empty" = "Always allow"; +"lng_edit_privacy_gifts_never_empty" = "Never allow"; +"lng_edit_privacy_gifts_exceptions" = "Choose whether gifts from specific senders need your approval before they're visible to others on your profile."; +"lng_edit_privacy_gifts_always_title" = "Always allow"; +"lng_edit_privacy_gifts_never_title" = "Never allow"; + +"lng_edit_privacy_gifts_types" = "Accepted Gift Types"; +"lng_edit_privacy_gifts_premium" = "Premium Subscriptions"; +"lng_edit_privacy_gifts_unlimited" = "Unlimited"; +"lng_edit_privacy_gifts_limited" = "Limited-Edition"; +"lng_edit_privacy_gifts_unique" = "Unique"; +"lng_edit_privacy_gifts_types_about" = "Choose the types of gifts that you accept."; +"lng_edit_privacy_gifts_show_icon" = "Show Gift Icon in Chats"; +"lng_edit_privacy_gifts_show_icon_about" = "Display the {emoji}Gift icon in the message input field for both participants in all chats."; +"lng_edit_privacy_gifts_restricted" = "This user doesn't accept gifts."; + +"lng_edit_privacy_calls_title" = "Calls"; +"lng_edit_privacy_calls_header" = "Who can call me"; +"lng_edit_privacy_calls_always_empty" = "Always allow"; +"lng_edit_privacy_calls_never_empty" = "Never allow"; +"lng_edit_privacy_calls_exceptions" = "These users will or will not be able to call you regardless of the settings above."; +"lng_edit_privacy_calls_always_title" = "Always allow"; +"lng_edit_privacy_calls_never_title" = "Never allow"; + +"lng_edit_privacy_calls_p2p_title" = "Peer-to-peer in calls"; +"lng_edit_privacy_calls_p2p_header" = "Use peer-to-peer with"; +"lng_edit_privacy_calls_p2p_always_empty" = "Always allow"; +"lng_edit_privacy_calls_p2p_never_empty" = "Never allow"; +"lng_edit_privacy_calls_p2p_exceptions" = "Peer-to-peer in calls will or will not be used with these users regardless of the settings above."; +"lng_edit_privacy_calls_p2p_always_title" = "Always allow"; +"lng_edit_privacy_calls_p2p_never_title" = "Never allow"; + +"lng_edit_privacy_calls_p2p_everyone" = "Everybody"; +"lng_edit_privacy_calls_p2p_contacts" = "My contacts"; +"lng_edit_privacy_calls_p2p_nobody" = "Nobody"; + +"lng_edit_privacy_forwards_title" = "Forwarded Messages"; +"lng_edit_privacy_forwards_header" = "Who can add a link to my account when forwarding my messages"; +"lng_edit_privacy_forwards_warning" = "Messages you send will not link back to your account when forwarded by other users."; +"lng_edit_privacy_forwards_always_empty" = "Always allow"; +"lng_edit_privacy_forwards_never_empty" = "Never allow"; +"lng_edit_privacy_forwards_exceptions" = "These settings will override the values above."; +"lng_edit_privacy_forwards_exceptions_everyone" = "You can add users or entire groups that will not see your profile photo."; +"lng_edit_privacy_forwards_exceptions_nobody" = "Add users or entire groups which will still see your profile photo."; +"lng_edit_privacy_forwards_always_title" = "Always Allow"; +"lng_edit_privacy_forwards_never_title" = "Never Allow"; +"lng_edit_privacy_forwards_sample_message" = "Reinhardt, we need to find you some new tunes 🎶"; +"lng_edit_privacy_forwards_sample_everyone" = "Link to your account."; +"lng_edit_privacy_forwards_sample_contacts" = "Link if allowed by settings below."; +"lng_edit_privacy_forwards_sample_nobody" = "Not a link to your account."; + +"lng_edit_privacy_profile_photo_title" = "Profile Photos"; +"lng_edit_privacy_profile_photo_header" = "Who can see my profile photos"; +"lng_edit_privacy_profile_photo_always_empty" = "Always share with"; +"lng_edit_privacy_profile_photo_never_empty" = "Never share with"; +"lng_edit_privacy_profile_photo_always_title" = "Always Share With"; +"lng_edit_privacy_profile_photo_never_title" = "Never Share With"; +"lng_edit_privacy_profile_photo_public_set" = "Set Public Photo"; +"lng_edit_privacy_profile_photo_public_update" = "Update Public Photo"; +"lng_edit_privacy_profile_photo_public_remove" = "Remove Public Photo"; +"lng_edit_privacy_profile_photo_public_about" = "You can upload a public photo for users who are restricted from viewing your real profile photos."; + +"lng_edit_privacy_voices_title" = "Voice Messages"; +"lng_edit_privacy_voices_header" = "Who can send me voice messages"; +"lng_edit_privacy_voices_always_empty" = "Always allow"; +"lng_edit_privacy_voices_never_empty" = "Never allow"; +"lng_edit_privacy_voices_exceptions" = "These users will or will not be able to send voice and video messages to you regardless of the settings above."; +"lng_edit_privacy_voices_always_title" = "Always allow"; +"lng_edit_privacy_voices_never_title" = "Never Allow"; + +"lng_messages_privacy_title" = "Messages"; +"lng_messages_privacy_subtitle" = "Who can send me messages"; +"lng_messages_privacy_everyone" = "Everybody"; +"lng_messages_privacy_restricted" = "My Contacts and Premium Users"; +"lng_messages_privacy_about" = "You can restrict messages from users who are not in your contacts and don't have Premium."; +"lng_messages_privacy_premium_button" = "Subscribe to Tidings Premium"; +"lng_messages_privacy_premium_about" = "Subscribe now to change this setting and get access to other exclusive features of Tidings Premium."; +"lng_messages_privacy_premium" = "Only subscribers of {link} can select this option."; +"lng_messages_privacy_premium_link" = "Tidings Premium"; +"lng_messages_privacy_charge" = "Charge for messages"; +"lng_messages_privacy_charge_about" = "Charge a fee for messages from people outside your contacts or those you haven't messaged first."; +"lng_messages_privacy_price" = "Set your price per message"; +"lng_messages_privacy_price_about" = "You will receive {percent} of the selected fee ({amount}) for each incoming message."; +"lng_messages_privacy_exceptions" = "Exceptions"; +"lng_messages_privacy_remove_fee" = "Remove Fee"; +"lng_messages_privacy_remove_about" = "Add users or entire groups who won't be charged for sending messages to you."; + +"lng_self_destruct_title" = "Account self-destruction"; +"lng_self_destruct_description" = "If you don't come online at least once within this period, your account will be deleted along with all groups, messages and contacts."; +"lng_self_destruct_sessions_title" = "Session termination"; +"lng_self_destruct_sessions_description" = "If you don't come online from a specific session at least once within this period, it will be terminated."; + +"lng_change_phone_new_submit" = "Submit"; +"lng_change_phone_code_title" = "Enter code"; +"lng_change_phone_error" = "You can only change your phone number using mobile Tidings apps. Please use an official Tidings app on your phone to update your number."; + +"lng_mute_menu_mute" = "Mute"; +"lng_mute_menu_unmute" = "Unmute"; +"lng_mute_menu_duration_any" = "Mute for {duration}"; +"lng_mute_menu_duration" = "Mute for..."; +"lng_mute_menu_duration_forever" = "Mute forever"; +"lng_mute_menu_duration_unmute" = "Unmute"; +"lng_mute_menu_sound_on" = "Enable sound"; +"lng_mute_menu_sound_off" = "Disable sound"; +"lng_mute_menu_sound_select" = "Select tone"; +"lng_mute_box_title" = "Mute notifications for..."; + +"lng_preview_loading" = "Getting Link Info..."; +"lng_preview_cant" = "Could not generate preview for this link."; + +"lng_profile_settings_section" = "Settings"; +"lng_profile_bot_settings" = "Bot Settings"; +"lng_profile_bot_help" = "Bot Help"; +"lng_profile_bot_privacy" = "Bot Privacy Policy"; +"lng_profile_bot_privacy_url" = "https://tidings.org/privacy-tpa"; +"lng_profile_common_groups#one" = "{count} group in common"; +"lng_profile_common_groups#other" = "{count} groups in common"; +"lng_profile_similar_channels#one" = "{count} similar channel"; +"lng_profile_similar_channels#other" = "{count} similar channels"; +"lng_profile_similar_bots#one" = "{count} similar bot"; +"lng_profile_similar_bots#other" = "{count} similar bots"; +"lng_profile_saved_messages#one" = "{count} saved message"; +"lng_profile_saved_messages#other" = "{count} saved messages"; +"lng_profile_peer_gifts#one" = "{count} gift"; +"lng_profile_peer_gifts#other" = "{count} gifts"; +"lng_profile_participants_section" = "Members"; +"lng_profile_subscribers_section" = "Subscribers"; +"lng_profile_add_contact" = "Add Contact"; +"lng_profile_clear_and_exit" = "Delete and leave"; +"lng_profile_leave_channel" = "Leave channel"; +"lng_profile_delete_channel" = "Delete channel"; +"lng_profile_leave_group" = "Leave group"; +"lng_profile_delete_group" = "Delete group"; +"lng_profile_report" = "Report"; +"lng_profile_block_bot" = "Stop and block bot"; +"lng_profile_restart_bot" = "Restart bot"; +"lng_profile_invite_to_group" = "Add to Group"; +"lng_profile_add_bot_as_admin" = "Add to Group or Channel"; +"lng_profile_invite_to_channel" = "Add to Channel"; +"lng_profile_invite_to_group_about" = "This bot is able to manage a group."; +"lng_profile_invite_to_channel_about" = "This bot is able to manage a channel."; +"lng_profile_add_bot_as_admin_about" = "This bot is able to manage a group or channel."; +"lng_profile_add_participant" = "Add Members"; +"lng_profile_add_via_link" = "Invite via Link"; +"lng_profile_hide_participants" = "Hide Members"; +"lng_profile_hide_participants_about" = "Switch this on to hide the list of members in this group. Admins will remain visible."; +"lng_profile_view_channel" = "View Channel"; +"lng_profile_view_discussion" = "View discussion"; +"lng_profile_join_channel" = "Join Channel"; +"lng_profile_join_group" = "Join Group"; +"lng_profile_apply_to_join_group" = "Apply to Join Group"; +"lng_profile_kick" = "Remove"; +"lng_profile_delete_removed" = "Delete"; +"lng_profile_sure_kick" = "Remove {user} from the group?"; +"lng_profile_sure_kick_channel" = "Remove {user} from the channel?"; +"lng_profile_sure_remove_admin" = "Remove {user} from admins?"; +"lng_profile_loading" = "Loading..."; +"lng_profile_saved_stories#one" = "{count} post"; +"lng_profile_saved_stories#other" = "{count} posts"; +"lng_profile_posts#one" = "{count} post"; +"lng_profile_posts#other" = "{count} posts"; +"lng_profile_photos#one" = "{count} photo"; +"lng_profile_photos#other" = "{count} photos"; +"lng_profile_gifs#one" = "{count} GIF"; +"lng_profile_gifs#other" = "{count} GIFs"; +"lng_profile_videos#one" = "{count} video"; +"lng_profile_videos#other" = "{count} videos"; +"lng_profile_songs#one" = "{count} audio file"; +"lng_profile_songs#other" = "{count} audio files"; +"lng_profile_files#one" = "{count} file"; +"lng_profile_files#other" = "{count} files"; +"lng_profile_audios#one" = "{count} voice message"; +"lng_profile_audios#other" = "{count} voice messages"; +"lng_profile_shared_links#one" = "{count} shared link"; +"lng_profile_shared_links#other" = "{count} shared links"; +"lng_profile_copy_phone" = "Copy Phone Number"; +"lng_profile_copy_fullname" = "Copy Name"; +"lng_profile_photo_by_you" = "photo set by you"; +"lng_profile_public_photo" = "public photo"; +"lng_profile_administrators#one" = "{count} administrator"; +"lng_profile_administrators#other" = "{count} administrators"; +"lng_profile_manage" = "Channel settings"; + +"lng_invite_upgrade_title" = "Upgrade to Premium"; +"lng_invite_upgrade_group_invite#one" = "{users} only accepts invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#other" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#one" = "{users} only accepts messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#other" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#one" = "{users} only accepts invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#other" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#one" = "{users} only accepts messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#other" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_users_few" = "{users} and {last}"; +"lng_invite_upgrade_users_many#one" = "{users} and **{count}** more person"; +"lng_invite_upgrade_users_many#other" = "{users} and **{count}** more people"; +"lng_invite_upgrade_or" = "or"; +"lng_invite_upgrade_via_title" = "Invite via Link"; +"lng_invite_upgrade_via_group_about" = "You can send an invite link to the group in a private message instead."; +"lng_invite_upgrade_via_channel_about" = "You can send an invite link to the channel in a private message instead."; +"lng_invite_status_disabled" = "available only to Premium users"; + +"lng_via_link_group_one" = "**{user}** restricts adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#one" = "**{count} user** restricts adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#other" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_one" = "**{user}** restricts adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#one" = "**{count} user** restricts adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#other" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_send" = "Send Invite Link"; +"lng_via_link_cant" = "You can't create a link"; +"lng_via_link_cant_one" = "**{user}** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#one" = "**{count} user** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#other" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_shared_one" = "Link shared with **{user}**."; +"lng_via_link_shared_many#one" = "Link shared with **{count} user**."; +"lng_via_link_shared_many#other" = "Link shared with **{count} users**."; + +"lng_info_personal_channel_label" = "Channel"; +"lng_info_mobile_label" = "Mobile"; +"lng_info_mobile_context_menu_fragment_about" = "This number is not tied to a SIM card and was acquired on {link}."; +"lng_info_mobile_context_menu_fragment_about_link" = "Fragment"; +"lng_info_mobile_hidden" = "Hidden"; +"lng_info_username_label" = "Username"; +"lng_info_usernames_label" = "also"; +"lng_info_birthday_label" = "Date of birth"; +"lng_info_birthday_years#one" = "{date} ({count} year old)"; +"lng_info_birthday_years#other" = "{date} ({count} years old)"; +"lng_info_birthday_today_years#one" = "{date}\n({count} year old)"; +"lng_info_birthday_today_years#other" = "{date}\n({count} years old)"; +"lng_info_birthday_today_label" = "Birthday today"; +"lng_info_birthday_today" = "{emoji} {date}"; +"lng_info_bio_label" = "Bio"; +"lng_info_link_label" = "Link"; +"lng_info_location_label" = "Location"; +"lng_info_about_label" = "Description"; +"lng_info_work_open" = "Open"; +"lng_info_work_closed" = "Closed"; +"lng_info_hours_label" = "Business hours"; +"lng_info_hours_closed" = "closed"; +"lng_info_hours_opens_in_minutes#one" = "opens in {count} minute"; +"lng_info_hours_opens_in_minutes#other" = "opens in {count} minutes"; +"lng_info_hours_opens_in_hours#one" = "opens in {count} hour"; +"lng_info_hours_opens_in_hours#other" = "opens in {count} hours"; +"lng_info_hours_opens_in_days#one" = "opens in {count} day"; +"lng_info_hours_opens_in_days#other" = "opens in {count} days"; +"lng_info_hours_open_full" = "open 24 hours"; +"lng_info_hours_next_day" = "{time} (next day)"; +"lng_info_hours_local_time" = "local time"; +"lng_info_hours_my_time" = "my time"; +"lng_info_user_title" = "User Info"; +"lng_info_bot_title" = "Bot Info"; +"lng_info_group_title" = "Group Info"; +"lng_info_channel_title" = "Channel Info"; +"lng_info_topic_title" = "Topic Info"; +"lng_profile_enable_notifications" = "Notifications"; +"lng_profile_send_message" = "Send Message"; +"lng_profile_open_app" = "Open App"; +"lng_profile_open_app_short" = "Open"; +"lng_profile_open_app_about" = "By launching this mini app, you agree to the {terms}."; +"lng_profile_open_app_terms" = "Terms of Service for Mini Apps"; +"lng_profile_bot_permissions_title" = "Allow access to"; +"lng_profile_bot_emoji_status_access" = "Emoji Status"; +"lng_info_add_as_contact" = "Add to contacts"; +"lng_profile_shared_media" = "Shared media"; +"lng_profile_suggest_photo" = "Suggest Profile Photo"; +"lng_profile_suggest_photo_from_clipboard" = "Suggest From Clipboard"; +"lng_profile_set_photo_for" = "Set Profile Photo"; +"lng_profile_set_photo_for_from_clipboard" = "Set From Clipboard"; +"lng_profile_photo_reset" = "Reset to Original"; +"lng_profile_photo_from_clipboard" = "From clipboard"; +"lng_profile_suggest_sure" = "You can suggest {user} to set this photo for their Tidings profile."; +"lng_profile_suggest_button" = "Suggest"; +"lng_profile_set_personal_sure" = "Only you will see this photo and it will replace any photo {user} sets for themselves."; +"lng_profile_accept_photo_sure" = "{user} suggests this photo for your Tidings profile."; +"lng_profile_set_photo_button" = "Set Photo"; +"lng_profile_accept_video_sure" = "{user} suggests this photo for your Tidings profile."; +"lng_profile_set_video_button" = "Set Photo"; +"lng_profile_changed_photo_title" = "Photo updated"; +"lng_profile_changed_photo_about" = "You can change it in {link}."; +"lng_profile_changed_photo_link" = "Settings"; +"lng_media_type_photos" = "Photos"; +"lng_media_type_gifs" = "GIFs"; +"lng_media_type_videos" = "Videos"; +"lng_media_type_songs" = "Audio files"; +"lng_media_type_files" = "Files"; +"lng_media_type_audios" = "Voice messages"; +"lng_media_type_links" = "Shared links"; +"lng_media_type_rounds" = "Video messages"; +"lng_profile_common_groups_section" = "Groups in common"; +"lng_info_edit_contact" = "Edit contact"; +"lng_info_delete_contact" = "Delete contact"; +"lng_info_share_contact" = "Share this contact"; +"lng_profile_clear_history" = "Clear history"; +"lng_profile_delete_conversation" = "Delete chat"; +"lng_profile_block_user" = "Block user"; +"lng_profile_unblock_user" = "Unblock user"; +"lng_profile_export_chat" = "Export chat history"; +"lng_profile_gift_premium" = "Send a Gift"; +"lng_media_selected_photo#one" = "{count} Photo"; +"lng_media_selected_photo#other" = "{count} Photos"; +"lng_media_selected_gif#one" = "{count} GIF"; +"lng_media_selected_gif#other" = "{count} GIFs"; +"lng_media_selected_video#one" = "{count} Video"; +"lng_media_selected_video#other" = "{count} Videos"; +"lng_media_selected_song#one" = "{count} Audio file"; +"lng_media_selected_song#other" = "{count} Audio files"; +"lng_media_selected_file#one" = "{count} File"; +"lng_media_selected_file#other" = "{count} Files"; +"lng_media_selected_audio#one" = "{count} Voice message"; +"lng_media_selected_audio#other" = "{count} Voice messages"; +"lng_media_selected_link#one" = "{count} shared link"; +"lng_media_selected_link#other" = "{count} shared links"; +"lng_media_photo_empty" = "No photos here yet"; +"lng_media_gif_empty" = "No GIFs here yet"; +"lng_media_video_empty" = "No videos here yet"; +"lng_media_song_empty" = "No music files here yet"; +"lng_media_file_empty" = "No files here yet"; +"lng_media_audio_empty" = "No voice messages here yet"; +"lng_media_link_empty" = "No shared links here yet"; +"lng_media_song_empty_search" = "No music files found"; +"lng_media_file_empty_search" = "No files found"; +"lng_media_link_empty_search" = "No shared links found"; + +"lng_manage_group_title" = "Manage group"; +"lng_manage_channel_title" = "Manage Channel"; +"lng_manage_bot_title" = "Manage Bot"; +"lng_manage_peer_recent_actions" = "Recent actions"; +"lng_manage_peer_star_ref" = "Affiliate programs"; +"lng_manage_peer_members" = "Members"; +"lng_manage_peer_subscribers" = "Subscribers"; +"lng_manage_peer_administrators" = "Administrators"; +"lng_manage_peer_exceptions" = "Exceptions"; +"lng_manage_peer_removed_users" = "Removed users"; +"lng_manage_peer_permissions" = "Permissions"; +"lng_manage_peer_invite_links" = "Invite links"; +"lng_manage_peer_reactions" = "Reactions"; +"lng_manage_peer_reactions_on" = "All"; +"lng_manage_peer_reactions_off" = "Off"; +"lng_manage_peer_requests" = "Join Requests"; +"lng_manage_peer_requests_channel" = "Join Requests"; + +"lng_manage_peer_reactions_enable" = "Enable Reactions"; +"lng_manage_peer_reactions_about_channel" = "Allow subscribers to react to channel posts."; +"lng_manage_peer_reactions_all" = "All reactions"; +"lng_manage_peer_reactions_all_about" = "Members of the group can use any emoji as reactions to messages."; +"lng_manage_peer_reactions_some" = "Some reactions"; +"lng_manage_peer_reactions_some_about" = "Members of the group can use only certain approved emoji as reactions to messages."; +"lng_manage_peer_reactions_none" = "No reactions"; +"lng_manage_peer_reactions_none_about" = "Members of the group can't add any reactions to messages."; +"lng_manage_peer_reactions_some_title" = "Only allow these reactions"; +"lng_manage_peer_reactions_available" = "Available reactions"; +"lng_manage_peer_reactions_available_ph" = "Add reactions..."; +"lng_manage_peer_reactions_own" = "You can also {link} emoji packs and use them as reactions."; +"lng_manage_peer_reactions_own_link" = "create your own"; +"lng_manage_peer_reactions_level#one" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reaction."; +"lng_manage_peer_reactions_level#other" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_boost" = "Boost your channel {link}."; +"lng_manage_peer_reactions_boost_link" = "here"; +"lng_manage_peer_reactions_limit" = "Channels can't have more custom reactions."; + +"lng_manage_peer_reactions_max_title" = "Maximum number of reactions"; +"lng_manage_peer_reactions_max_slider#one" = "{count} reaction per post"; +"lng_manage_peer_reactions_max_slider#other" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_about" = "Limit the number of different reactions that can be added to a post, including already published ones."; + +"lng_manage_peer_reactions_paid" = "Enable Paid Reactions"; +"lng_manage_peer_reactions_paid_about" = "Switch this on to let your subscribers react to posts with Tidings Stars, which you will be able to withdraw as TON. {link}"; +"lng_manage_peer_reactions_paid_link" = "Learn more >"; + +"lng_manage_peer_antispam" = "Aggressive Anti-Spam"; +"lng_manage_peer_antispam_about" = "Tidings will filter more spam but may occasionally affect ordinary messages. You can report False Positives in Recent Actions."; +"lng_manage_peer_antispam_not_enough#one" = "Aggressive filtering can be enabled only in groups with more than **{count} member**."; +"lng_manage_peer_antispam_not_enough#other" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; + +"lng_manage_peer_group_type" = "Group type"; +"lng_manage_peer_channel_type" = "Channel type"; +"lng_manage_peer_link_type" = "Link type"; +"lng_manage_peer_link_permanent" = "Permanent link"; +"lng_manage_peer_link_invite" = "Invite link"; +"lng_manage_peer_link_expired" = "Expired link"; +"lng_manage_private_group_title" = "Private"; +"lng_manage_private_group_noforwards_title" = "Private restricted"; +"lng_manage_public_group_title" = "Public"; +"lng_manage_private_peer_title" = "Private"; +"lng_manage_private_peer_noforwards_title" = "Private restricted"; +"lng_manage_public_peer_title" = "Public"; +"lng_manage_peer_send_title" = "Who can send messages?"; +"lng_manage_peer_send_only_members" = "Only members"; +"lng_manage_peer_send_only_members_about" = "Turn this on if you expect users to join your group before being able to send messages."; +"lng_manage_peer_send_approve_members" = "Approve new members"; +"lng_manage_peer_send_approve_members_about" = "Turn this on if you want users to join the group only after they are approved by an admin."; +"lng_manage_peer_no_forwards_title" = "Content protection"; +"lng_manage_peer_no_forwards" = "Restrict saving content"; +"lng_manage_peer_no_forwards_about" = "Members won't be able to copy, save or forward content from this group."; +"lng_manage_peer_no_forwards_about_channel" = "Subscribers won't be able to copy, save or forward content from this channel."; + +"lng_manage_peer_bot_public_link" = "Public Link"; +"lng_manage_peer_bot_public_links" = "Public Links"; +"lng_manage_peer_bot_balance" = "Balance"; +"lng_manage_peer_bot_balance_currency" = "Toncoin"; +"lng_manage_peer_bot_balance_credits" = "Stars"; +"lng_manage_peer_bot_star_ref" = "Affiliate Program"; +"lng_manage_peer_bot_star_ref_off" = "Off"; +"lng_manage_peer_bot_star_ref_about" = "Share a link to {bot} with your friends and earn {amount} of their spending there."; +"lng_manage_peer_bot_verify" = "Verify Accounts"; +"lng_manage_peer_bot_edit_intro" = "Edit Intro"; +"lng_manage_peer_bot_edit_commands" = "Edit Commands"; +"lng_manage_peer_bot_edit_settings" = "Change Bot Settings"; +"lng_manage_peer_bot_about" = "Use {bot} to manage this bot."; + +"lng_bot_verify_title" = "Choose Chat to Verify"; +"lng_bot_verify_bot_title" = "Verify Bot"; +"lng_bot_verify_bot_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_bot_about" = "You can customize your description for each bot."; +"lng_bot_verify_bot_submit" = "Verify Bot"; +"lng_bot_verify_bot_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_bot_remove" = "This bot is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_user_title" = "Verify User"; +"lng_bot_verify_user_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_user_about" = "You can customize your description for each account."; +"lng_bot_verify_user_submit" = "Verify User"; +"lng_bot_verify_user_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_user_remove" = "This account is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_channel_title" = "Verify Channel"; +"lng_bot_verify_channel_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_channel_about" = "You can customize your description for each channel."; +"lng_bot_verify_channel_submit" = "Verify Channel"; +"lng_bot_verify_channel_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_channel_remove" = "This channel is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_group_title" = "Verify Group"; +"lng_bot_verify_group_text" = "Do you want to verify {name} with your verification mark and description?"; +"lng_bot_verify_group_about" = "You can customize your description for each group."; +"lng_bot_verify_group_submit" = "Verify Group"; +"lng_bot_verify_group_sent" = "{name} has been notified and will receive your verification mark and description upon accepting."; +"lng_bot_verify_group_remove" = "This group is already verified by you. Do you want to remove verification?"; +"lng_bot_verify_description_label" = "Description"; +"lng_bot_verify_remove_title" = "Remove verification"; +"lng_bot_verify_remove_submit" = "Remove"; +"lng_bot_verify_remove_done" = "You have removed this verification."; + +"lng_star_ref_title" = "Affiliate Program"; +"lng_star_ref_about" = "Reward those who help grow your user base."; +"lng_star_ref_share_title" = "Share revenue with affiliates"; +"lng_star_ref_share_about" = "Set the commission for revenue generated by users referred to you."; +"lng_star_ref_launch_title" = "Launch your affiliate program"; +"lng_star_ref_launch_about" = "Tidings will feature your program for millions of potential affiliates."; +"lng_star_ref_let_title" = "Let affiliates promote you"; +"lng_star_ref_let_about" = "Affiliates will share your referral link with their audience."; +"lng_star_ref_commission_title" = "Commission"; +"lng_star_ref_commission_about" = "Define the percentage of star revenue your affiliates earn for referring users to your bot."; +"lng_star_ref_duration_title" = "Duration"; +"lng_star_ref_duration_about" = "Set the duration for which affiliates will earn commissions from referred users."; +"lng_star_ref_existing_title" = "View existing programs"; +"lng_star_ref_existing_about" = "Explore what other mini apps offer."; +"lng_star_ref_add_bot" = "Add {bot}"; +"lng_star_ref_end" = "End Affiliate Program"; +"lng_star_ref_start" = "Start Affiliate Program"; +"lng_star_ref_start_disabled" = "Available in {time}"; +"lng_star_ref_start_info" = "By creating an affiliate program, you agree to the {terms} of Affiliate Programs."; +"lng_star_ref_update" = "Update Affiliate Program"; +"lng_star_ref_update_info" = "By updating an affiliate program, you agree to the {terms} of Affiliate Programs."; +"lng_star_ref_button_link" = "Terms and Conditions"; +"lng_star_ref_tos_url" = "https://tidings.org/tos/affiliate-program"; +"lng_star_ref_warning_title" = "Warning"; +"lng_star_ref_warning_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."; +"lng_star_ref_warning_change" = "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."; +"lng_star_ref_warning_start" = "Start"; +"lng_star_ref_warning_update" = "Update"; +"lng_star_ref_warning_if_end" = "If you end your affiliate program:"; +"lng_star_ref_warning_if_end1" = "Any referral links already shared will be disabled in **24** hours."; +"lng_star_ref_warning_if_end2" = "All participating affiliates will be notified."; +"lng_star_ref_warning_if_end3" = "You will have to wait **24** hours until you can start a new affiliate program."; +"lng_star_ref_warning_end" = "End Anyway"; +"lng_star_ref_created_title" = "Affiliate program started"; +"lng_star_ref_created_text" = "Any Tidings user, channel owner or mini app developer can now join your program."; +"lng_star_ref_updated_title" = "Affiliate program updated"; +"lng_star_ref_updated_text" = "Any Tidings user, channel owner or mini app developer can join your program."; +"lng_star_ref_ended_title" = "Affiliate program ended"; +"lng_star_ref_ended_text" = "Participating affiliates have been notified. All referral links will be disabled in **24** hours."; +"lng_star_ref_list_title" = "Affiliate Programs"; +"lng_star_ref_list_about_channel" = "Promote mini apps to your subscribers and earn a share of their revenue in Stars."; +"lng_star_ref_list_text" = "Earn a commission each time a user who first accessed a mini app through your referral link spends **Stars** within it."; +"lng_star_ref_list_my" = "My Programs"; +"lng_star_ref_list_my_open" = "Open App"; +"lng_star_ref_list_my_copy" = "Copy Link"; +"lng_star_ref_list_my_leave" = "Leave"; +"lng_star_ref_list_subtitle" = "Programs"; +"lng_star_ref_sort_text" = "Sort by {sort}"; +"lng_star_ref_sort_profitability" = "Profitability"; +"lng_star_ref_sort_date" = "Date"; +"lng_star_ref_sort_revenue" = "Revenue"; +"lng_star_ref_reliable_title" = "Reliable"; +"lng_star_ref_reliable_about" = "Receive guaranteed commissions for spending by users you refer."; +"lng_star_ref_transparent_title" = "Transparent"; +"lng_star_ref_transparent_about" = "Track your commissions from referred users in real time."; +"lng_star_ref_simple_title" = "Simple"; +"lng_star_ref_simple_about" = "Choose a mini app below, get your referral link, and start earning Stars."; +"lng_star_ref_duration_forever" = "Forever"; +"lng_star_ref_one_about" = "{app} will share {amount} of the revenue from each user you refer to it {duration}."; +"lng_star_ref_one_about_for_forever" = "for **lifetime**"; +"lng_star_ref_one_about_for_months#one" = "for **{count} month**"; +"lng_star_ref_one_about_for_months#other" = "for **{count} months**"; +"lng_star_ref_one_about_for_years#one" = "for **{count} year**"; +"lng_star_ref_one_about_for_years#other" = "for **{count} years**"; +"lng_star_ref_one_daily_revenue" = "App's average daily revenue per user: {amount}"; +"lng_star_ref_one_join" = "Join Program"; +"lng_star_ref_one_join_text" = "By joining this program, you agree to the {terms} of Affiliate Programs."; +"lng_star_ref_joined_title" = "Program joined"; +"lng_star_ref_joined_text" = "You can now copy the referral link."; +"lng_star_ref_link_title" = "Referral Link"; +"lng_star_ref_link_about_channel" = "Share this link with your subscribers to earn a {amount} commission on their spending in {app} {duration}."; +"lng_star_ref_link_about_user" = "Share this link with your friends to earn a {amount} commission on their spending in {app} {duration}."; +"lng_star_ref_link_about_bot" = "Share this link with your users to earn a {amount} commission on their spending in {app} {duration}."; +"lng_star_ref_link_recipient" = "Commissions will be sent to:"; +"lng_star_ref_link_copy" = "Copy Link"; +"lng_star_ref_link_copy_none" = "No one has opened {app} through this link."; +"lng_star_ref_link_copy_users#one" = "{count} user have opened {app} through this link."; +"lng_star_ref_link_copy_users#other" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copied_title" = "Link copied to clipboard"; +"lng_star_ref_link_copied_text" = "Share this link and earn {amount} of what people who use it spend in {app}!"; +"lng_star_ref_stopped" = "This affiliate link is no longer active."; +"lng_star_ref_revoke_title" = "Revoke Link"; +"lng_star_ref_revoke_text" = "Are you sure you want to revoke the link of {bot}?"; +"lng_star_ref_revoked_title" = "Link removed"; +"lng_star_ref_revoked_text" = "It will no longer work."; + +"lng_manage_discussion_group" = "Discussion"; +"lng_manage_discussion_group_add" = "Add a group"; +"lng_manage_linked_channel" = "Linked channel"; +"lng_manage_linked_channel_restore" = "Restore linked channel"; +"lng_manage_discussion_group_about" = "Select a group chat for discussion that will be displayed in your channel."; +"lng_manage_discussion_group_about_chosen" = "A link to {group} is shown to all subscribers in the bottom panel."; +"lng_manage_discussion_group_create" = "Create a new group"; +"lng_manage_discussion_group_unlink" = "Unlink group"; +"lng_manage_discussion_group_posted" = "Everything you post in the channel is forwarded to this group."; +"lng_manage_discussion_group_sure" = "Do you want to make {group} the discussion board for {channel}?"; +"lng_manage_linked_channel_private" = "Any member of this group will be able to see messages in the channel."; +"lng_manage_discussion_group_private" = "Anyone from the channel will be able to see messages in this group."; +"lng_manage_discussion_group_link" = "Link group"; +"lng_manage_linked_channel_private_status" = "private channel"; +"lng_manage_discussion_group_private_status" = "private group"; +"lng_manage_linked_channel_about" = "This group is linked as the discussion board for {channel}."; +"lng_manage_linked_channel_unlink" = "Unlink channel"; +"lng_manage_linked_channel_posted" = "All new posts from this channel are forwarded to the group."; +"lng_manage_discussion_group_warning" = "\"Chat history for new members\" will be switched to **Visible**."; + +"lng_manage_history_visibility_title" = "Chat history for new members"; +"lng_manage_history_visibility_shown" = "Visible"; +"lng_manage_history_visibility_shown_about" = "New members will see messages that were sent before they joined."; +"lng_manage_history_visibility_hidden" = "Hidden"; +"lng_manage_history_visibility_hidden_about" = "New members won't see earlier messages."; +"lng_manage_history_visibility_hidden_legacy" = "New members won't see more than 100 previous messages."; + +"lng_manage_messages_ttl_title" = "Auto-delete messages"; +"lng_manage_messages_ttl_disable" = "Disable"; +"lng_manage_messages_ttl_after1" = "1 day"; +"lng_manage_messages_ttl_after2" = "1 week"; +"lng_manage_messages_ttl_after3" = "1 month"; +"lng_manage_messages_ttl_after4" = "1 month"; +"lng_manage_messages_ttl_after_custom" = "Custom"; +"lng_manage_messages_ttl_menu" = "Auto-Delete"; + +"lng_ttl_edit_about" = "Automatically delete new messages for you and {user} after a certain period of time."; +"lng_ttl_edit_about_group" = "Automatically delete new messages sent in this chat after a certain period of time."; +"lng_ttl_edit_about_channel" = "Automatically delete new messages sent in this channel after a certain period of time."; +"lng_ttl_edit_about2" = "You can also set a default {link} for all your chats in Settings."; +"lng_ttl_edit_about2_link" = "self-destruct timer"; +"lng_ttl_about_tooltip" = "New messages in this chat will be automatically deleted after {duration}."; +"lng_ttl_about_tooltip_channel" = "New messages in this chat will be automatically deleted after {duration}."; +"lng_ttl_about_tooltip_off" = "Auto-delete is now disabled."; + +"lng_report_title" = "Report channel"; +"lng_report_group_title" = "Report Group"; +"lng_report_bot_title" = "Report bot"; +"lng_report_message_title" = "Report message"; +"lng_report_profile_photo_title" = "Report profile photo"; +"lng_report_profile_video_title" = "Report profile photo"; +"lng_report_group_photo_title" = "Report group photo"; +"lng_report_group_video_title" = "Report group photo"; +"lng_report_channel_photo_title" = "Report channel photo"; +"lng_report_channel_video_title" = "Report channel photo"; +"lng_report_story" = "Report story"; +"lng_report_please_select_messages" = "Please select messages to report."; +"lng_report_select_messages" = "Select messages"; +"lng_report_messages_none" = "Select Messages"; +"lng_report_messages_count#one" = "Report {count} Message"; +"lng_report_messages_count#other" = "Report {count} Messages"; +"lng_report_reaction" = "Report reaction"; +"lng_report_and_ban" = "Ban and report"; +"lng_report_reaction_title" = "Report reaction"; +"lng_report_reaction_about" = "Are you sure you want to report reactions from this user?"; +"lng_report_and_ban_button" = "Ban user"; +"lng_report_details_about" = "Please enter any additional details relevant to your report."; +"lng_report_details" = "Additional Details"; +"lng_report_details_optional" = "Add Comment (Optional)"; +"lng_report_details_non_optional" = "Add Comment"; +"lng_report_details_message_about" = "Please help us by telling what is wrong with the message you are reporting."; +"lng_report_reason_spam" = "Spam"; +"lng_report_reason_fake" = "Fake Account"; +"lng_report_reason_violence" = "Violence"; +"lng_report_reason_child_abuse" = "Child Abuse"; +"lng_report_reason_pornography" = "Pornography"; +"lng_report_reason_copyright" = "Copyright"; +"lng_report_reason_illegal_drugs" = "Illegal Drugs"; +"lng_report_reason_personal_details" = "Personal Details"; +"lng_report_reason_other" = "Other"; +"lng_report_button" = "Report"; +"lng_report_thanks" = "Thank you! Your report will be reviewed by our team."; + +"lng_report_sponsored_hidden" = "Sponsored messages will be hidden."; +"lng_report_sponsored_reported" = "We will review this ad to ensure it matches our {link}."; +"lng_report_sponsored_reported_link" = "Ad Policies and Guidelines"; +"lng_report_sponsored_reported_learn" = "Learn more about our {link}."; + +"lng_channel_add_members" = "Add members"; +"lng_channel_add_users" = "Add users"; +"lng_channel_add_removed" = "Remove user"; +"lng_channel_add_exception" = "Add exception"; +"lng_channel_admins" = "Administrators"; +"lng_channel_add_admin" = "Add Administrator"; +"lng_channel_admin_status_creator" = "Owner"; +"lng_channel_admin_status_promoted_by" = "Promoted by {user}"; +"lng_channel_admin_status_not_admin" = "not admin"; +"lng_channel_banned_status_restricted_by" = "Restricted by {user}"; +"lng_channel_banned_status_removed_by" = "Removed by {user}"; + +"lng_channel_removed_list_about" = "Users removed from the channel by admins can't rejoin via invite links."; +"lng_group_removed_list_about" = "Users removed from the group by admins can't rejoin via invite links."; + +"lng_participant_filter" = "Search"; +"lng_participant_invite" = "Add"; +"lng_participant_invite_history" = "Show the last 100 messages"; +"lng_participant_invite_sure" = "Are you sure you want to add **{user}** to **{group}**?"; +"lng_participant_invite_sure_many#one" = "Are you sure you want to add {count} member to **{group}**?"; +"lng_participant_invite_sure_many#other" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sorry#one" = "Sorry, you can only add the first {count} subscriber to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#other" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; + +"lng_create_group_back" = "Back"; +"lng_create_group_next" = "Next"; +"lng_create_group_create" = "Create"; +"lng_create_group_title" = "New Group"; +"lng_create_channel_title" = "New Channel"; +"lng_create_public_channel_title" = "Public Channel"; +"lng_create_public_channel_about" = "Anyone can find the channel in search and join"; +"lng_create_private_channel_title" = "Private Channel"; +"lng_create_private_channel_about" = "Only people with an invite link can join."; +"lng_create_public_group_title" = "Public Group"; +"lng_create_public_group_about" = "Anyone can find the group in search and join, chat history is available to everybody"; +"lng_create_private_group_title" = "Private Group"; +"lng_create_private_group_about" = "People can only join if they are added or have an invite link"; +"lng_create_permanent_link_title" = "Primary link"; +"lng_create_invite_link_title" = "Invite link"; +"lng_create_invite_link_about" = "People can be added, or join via invite link or \"Groups nearby\""; + +"lng_create_group_skip" = "Skip"; + +"lng_create_channel_link_about" = "You can use a-z, 0-9 and underscores.\nMinimum length is 5 characters."; + +"lng_create_channel_link_invalid" = "This link is invalid"; +"lng_create_channel_link_occupied" = "Sorry, this link is already occupied"; +"lng_create_channel_link_too_short" = "Sorry, this link is too short"; +"lng_create_channel_link_bad_symbols" = "Only 0-9, a-z, and underscores allowed."; +"lng_create_channel_link_available" = "This link is available"; +"lng_create_channel_link_pending" = "Checking name..."; +"lng_create_channel_link_copied" = "Link copied to clipboard."; + +"lng_failed_add_participant" = "Could not add user. Please try again later."; +"lng_failed_add_not_mutual" = "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."; + +"lng_sure_delete_contact" = "Are you sure you want to delete {contact} from your contact list?"; +"lng_sure_delete_history" = "Are you sure you want to delete all message history with {contact}?\n\nThis action cannot be undone."; +"lng_sure_delete_group_history" = "Are you sure you want to delete all messages in \"{group}\"?\n\nThis action cannot be undone."; +"lng_sure_delete_and_exit" = "Are you sure you want to delete all message history and leave «{group}»?\n\nThis action cannot be undone."; +"lng_sure_leave_channel" = "Are you sure you want to leave\nthis channel?"; +"lng_sure_delete_channel" = "Are you sure you want to delete this channel? All subscribers will be removed and all messages will be lost."; +"lng_sure_leave_group" = "Are you sure you want to leave this group?"; +"lng_sure_delete_group" = "Are you sure you want to delete this group? All members will be removed, and all messages will be lost."; +"lng_sure_delete_saved_messages" = "Are you sure you want to delete all your saved messages?\n\nThis action cannot be undone."; +"lng_no_clear_history_channel" = "In channels you can enable auto-delete for messages."; +"lng_no_clear_history_group" = "In public groups you can enable auto-delete for messages."; +"lng_sure_delete_by_date_one" = "Are you sure you want to delete all messages for **{date}**?\n\nThis action cannot be undone."; +"lng_sure_delete_by_date_many" = "Are you sure you want to delete all messages for the **{days}**?\n\nThis action cannot be undone."; +"lng_sure_delete_selected_days#one" = "{count} selected day"; +"lng_sure_delete_selected_days#other" = "{count} selected days"; + +"lng_message_empty" = "Empty Message"; +"lng_message_unsupported" = "This message is not supported by your version of Tidings. Please update to the latest version in Settings > Advanced, or install it from {link}. If you are already using the latest version, this message might depend on a feature that is not yet implemented."; +"lng_message_not_found" = "Message doesn't exist."; + +"lng_duration_minsec_minutes#one" = "{count} min"; +"lng_duration_minsec_minutes#other" = "{count} min"; +"lng_duration_minsec_seconds#one" = "{count} s"; +"lng_duration_minsec_seconds#other" = "{count} s"; +"lng_duration_minutes_seconds" = "{minutes_count} {seconds_count}"; + +"lng_action_invite_user" = "{from} invited {user} to {chat}"; +"lng_action_invite_users_many" = "{from} invited {users} to {chat}"; +"lng_action_invite_user_chat" = "the video chat"; +"lng_action_invite_users_and_one" = "{accumulated}, {user}"; +"lng_action_invite_users_and_last" = "{accumulated} and {user}"; +"lng_action_group_call_started_group" = "{from} started a video chat"; +"lng_action_group_call_started_channel" = "Live stream started"; +"lng_action_group_call_scheduled_group" = "{from} scheduled a video chat for {date}"; +"lng_action_group_call_scheduled_channel" = "Live stream scheduled for {date}"; +"lng_action_group_call_finished" = "Live stream finished ({duration})"; +"lng_action_group_call_finished_group" = "{from} ended the video chat ({duration})"; +"lng_action_add_user" = "{from} added {user}"; +"lng_action_add_users_many" = "{from} added {users}"; +"lng_action_add_users_and_one" = "{accumulated}, {user}"; +"lng_action_add_users_and_last" = "{accumulated} and {user}"; +"lng_action_add_you" = "{from} added you to this channel"; +"lng_action_you_joined" = "You joined this channel"; +"lng_action_add_you_group" = "{from} added you to this group"; +"lng_action_kick_user" = "{from} removed {user}"; +"lng_action_user_left" = "{from} left the group"; +"lng_action_user_joined" = "{from} joined the group"; +"lng_action_user_joined_by_link" = "{from} joined the group via invite link"; +"lng_action_user_joined_by_request" = "{from} was accepted to the group"; +"lng_action_you_joined_by_request" = "Your request to join the group was approved"; +"lng_action_you_joined_by_request_channel" = "Your request to join the channel was approved"; +"lng_action_user_registered" = "{from} joined Tidings"; +"lng_action_removed_photo" = "{from} removed group photo"; +"lng_action_removed_photo_channel" = "Channel photo removed"; +"lng_action_changed_photo" = "{from} updated group photo"; +"lng_action_changed_photo_channel" = "Channel photo updated"; +"lng_action_changed_title" = "{from} changed group name to «{title}»"; +"lng_action_changed_title_channel" = "Channel name was changed to «{title}»"; +"lng_action_created_chat" = "{from} created the group «{title}»"; +"lng_action_ttl_changed" = "{from} set messages to auto-delete in {duration}"; +"lng_action_ttl_changed_you" = "You set messages to auto-delete in {duration}"; +"lng_action_ttl_changed_channel" = "Messages in this channel will be automatically deleted after {duration}"; +"lng_action_ttl_global" = "{from} uses a self-destruct timer for all chats. All new messages in this chat will be automatically deleted after {duration} they are sent."; +"lng_action_ttl_global_me" = "You set a self-destruct timer for all chats. All new messages in this chat will be automatically deleted after {duration} they’ve been sent."; +"lng_action_ttl_removed" = "{from} disabled the auto-delete timer"; +"lng_action_ttl_removed_you" = "You disabled the auto-delete timer"; +"lng_action_ttl_removed_channel" = "Messages in this channel will no longer be automatically deleted"; +"lng_action_created_channel" = "Channel created"; +"lng_action_pinned_message" = "{from} pinned \"{text}\""; +"lng_action_pinned_media" = "{from} pinned {media}"; +"lng_action_pinned_media_photo" = "a photo"; +"lng_action_pinned_media_video" = "a video"; +"lng_action_pinned_media_audio" = "an audio file"; +"lng_action_pinned_media_voice" = "a voice message"; +"lng_action_pinned_media_video_message" = "a video message"; +"lng_action_pinned_media_file" = "a file"; +"lng_action_pinned_media_gif" = "a GIF"; +"lng_action_pinned_media_contact" = "a contact information"; +"lng_action_pinned_media_location" = "a location mark"; +"lng_action_pinned_media_sticker" = "a sticker"; +"lng_action_pinned_media_emoji_sticker" = "a {emoji} sticker"; +"lng_action_pinned_media_game" = "the game «{game}»"; +"lng_action_pinned_media_story" = "a story"; +"lng_action_game_score#one" = "{from} scored {count} in {game}"; +"lng_action_game_score#other" = "{from} scored {count} in {game}"; +"lng_action_game_you_scored#one" = "You scored {count} in {game}"; +"lng_action_game_you_scored#other" = "You scored {count} in {game}"; +"lng_action_game_score_no_game#one" = "{from} scored {count}"; +"lng_action_game_score_no_game#other" = "{from} scored {count}"; +"lng_action_game_you_scored_no_game#one" = "You scored {count}"; +"lng_action_game_you_scored_no_game#other" = "You scored {count}"; +"lng_action_payment_done" = "You successfully transferred {amount} to {user}"; +"lng_action_payment_done_for" = "You successfully transferred {amount} to {user} for {invoice}"; +"lng_action_payment_init_recurring_for" = "You successfully transferred {amount} to {user} for {invoice} and allowed future recurring payments"; +"lng_action_payment_init_recurring" = "You successfully transferred {amount} to {user} and allowed future recurring payments"; +"lng_action_payment_used_recurring" = "You were charged {amount} via recurring payment"; +"lng_action_payment_bot_done" = "Bot connected to this account received {amount}"; +"lng_action_payment_bot_recurring" = "Bot connected to this account received {amount} via recurring payment"; +"lng_action_took_screenshot" = "{from} took a screenshot!"; +"lng_action_you_took_screenshot" = "You took a screenshot!"; +"lng_action_bot_allowed_from_domain" = "You allowed this bot to message you when you logged in on {domain}."; +"lng_action_bot_allowed_from_app" = "You allowed this bot to message you when you opened {app}."; +"lng_action_secure_values_sent" = "{user} received the following documents: {documents}"; +"lng_action_secure_personal_details" = "personal details"; +"lng_action_secure_proof_of_identity" = "proof of identity"; +"lng_action_secure_address" = "address"; +"lng_action_secure_proof_of_address" = "proof of address"; +"lng_action_secure_phone" = "phone number"; +"lng_action_secure_email" = "email address"; +"lng_action_proximity_reached" = "{from} is now within {distance} from {user}"; +"lng_action_proximity_reached_you" = "{from} is now within {distance} from you"; +"lng_action_you_proximity_reached" = "You are now within {distance} from {user}"; +"lng_action_you_theme_changed" = "You changed the chat theme to {emoji}"; +"lng_action_theme_changed" = "{from} changed the chat theme to {emoji}"; +"lng_action_you_theme_disabled" = "You disabled the chat theme"; +"lng_action_theme_disabled" = "{from} disabled the chat theme"; +"lng_action_proximity_distance_m#one" = "{count} meter"; +"lng_action_proximity_distance_m#other" = "{count} meters"; +"lng_action_proximity_distance_km#one" = "{count} km"; +"lng_action_proximity_distance_km#other" = "{count} km"; +"lng_action_webview_data_done" = "Data from the \"{text}\" button was transferred to the bot."; +"lng_action_gift_received" = "{user} sent you a gift for {cost}"; +"lng_action_gift_unique_received" = "{user} sent you a unique collectible item"; +"lng_action_gift_sent" = "You sent a gift for {cost}"; +"lng_action_gift_unique_sent" = "You sent a unique collectible item"; +"lng_action_gift_upgraded" = "{user} turned the gift from you into a unique collectible"; +"lng_action_gift_upgraded_channel" = "{user} turned this gift to {channel} into a unique collectible"; +"lng_action_gift_upgraded_self_channel" = "You turned this gift to {channel} into a unique collectible"; +"lng_action_gift_upgraded_mine" = "You turned the gift from {user} into a unique collectible"; +"lng_action_gift_upgraded_self" = "You turned this gift into a unique collectible"; +"lng_action_gift_transferred" = "{user} transferred you a gift"; +"lng_action_gift_transferred_channel" = "{user} transferred a gift to {channel}"; +"lng_action_gift_transferred_unknown" = "Someone transferred you a gift"; +"lng_action_gift_transferred_unknown_channel" = "Someone transferred a gift to {channel}"; +"lng_action_gift_transferred_self" = "You transferred a unique collectible"; +"lng_action_gift_transferred_self_channel" = "You transferred a gift to {channel}"; +"lng_action_gift_transferred_mine" = "You transferred a gift to {user}"; +"lng_action_gift_received_anonymous" = "Unknown user sent you a gift for {cost}"; +"lng_action_gift_sent_channel" = "{user} sent a gift to {name} for {cost}"; +"lng_action_gift_sent_self_channel" = "You sent a gift to {name} for {cost}"; +"lng_action_gift_self_bought" = "You bought a gift for {cost}"; +"lng_action_gift_self_subtitle" = "Saved Gift"; +"lng_action_gift_self_about#one" = "Add this gift to your profile or convert it to **{count}** Star."; +"lng_action_gift_self_about#other" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about_unique" = "You can add this gift to your profile or upgrade it to a unique collectible to keep or transfer."; +"lng_action_gift_channel_about#one" = "Add this gift to your channel's profile or convert it to **{count}** Star."; +"lng_action_gift_channel_about#other" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about_unique" = "You can add this gift to your channel's profile or convert it to a unique collectible."; +"lng_action_gift_for_stars#one" = "{count} Star"; +"lng_action_gift_for_stars#other" = "{count} Stars"; +"lng_action_gift_got_subtitle" = "Gift from {user}"; +"lng_action_gift_got_stars_text#one" = "Add this gift to your profile or convert it to **{count}** Star."; +"lng_action_gift_got_stars_text#other" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_upgradable_text" = "Upgrade this gift to a unique collectible."; +"lng_action_gift_got_gift_text" = "You can add this gift to your profile."; +"lng_action_gift_can_remove_text" = "You can remove this gift from your profile."; +"lng_action_gift_got_gift_channel" = "You can keep this gift in channel's Gifts."; +"lng_action_gift_can_remove_channel" = "You can remove this gift from channel's Gifts."; +"lng_action_gift_sent_subtitle" = "Gift for {user}"; +"lng_action_gift_sent_text#one" = "{user} can add this gift to their profile or convert it to {count} Star."; +"lng_action_gift_sent_text#other" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_upgradable" = "{user} can turn this gift into a unique collectible."; +"lng_action_gift_premium_months#one" = "{count} Month Premium"; +"lng_action_gift_premium_months#other" = "{count} Months Premium"; +"lng_action_gift_premium_about" = "Subscription for exclusive Tidings features."; +"lng_action_gift_refunded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"lng_action_suggested_photo_me" = "You suggested this photo for {user}'s Tidings profile."; +"lng_action_suggested_photo" = "{user} suggests this photo for your Tidings profile."; +"lng_action_suggested_photo_button" = "View Photo"; +"lng_action_suggested_video_me" = "You suggested this photo for {user}'s Tidings profile."; +"lng_action_suggested_video" = "{user} suggests this photo for your Tidings profile."; +"lng_action_suggested_video_button" = "View Photo"; +"lng_action_attach_menu_bot_allowed" = "You allowed this bot to message you when you added it to your attachment menu."; +"lng_action_webapp_bot_allowed" = "You allowed this bot to message you in its web-app."; +"lng_action_set_wallpaper_me" = "You set a new wallpaper for this chat"; +"lng_action_set_wallpaper" = "{user} set a new wallpaper for this chat"; +"lng_action_set_wallpaper_both_me" = "You set a new wallpaper for {user} and you."; +"lng_action_set_wallpaper_button" = "View Wallpaper"; +"lng_action_set_wallpaper_remove" = "Remove"; +"lng_action_set_same_wallpaper_me" = "You set the same wallpaper as your chat partner"; +"lng_action_set_same_wallpaper" = "{user} set the same wallpaper for this chat"; +"lng_action_topic_created_inside" = "Topic created"; +"lng_action_topic_closed_inside" = "Topic closed"; +"lng_action_topic_reopened_inside" = "Topic reopened"; +"lng_action_topic_hidden_inside" = "Topic hidden"; +"lng_action_topic_unhidden_inside" = "Topic unhidden"; +"lng_action_topic_created" = "The topic \"{topic}\" was created"; +"lng_action_topic_closed" = "\"{topic}\" was closed"; +"lng_action_topic_reopened" = "\"{topic}\" was reopened"; +"lng_action_topic_hidden" = "\"{topic}\" was hidden"; +"lng_action_topic_unhidden" = "\"{topic}\" was unhidden"; +"lng_action_topic_placeholder" = "topic"; +"lng_action_topic_renamed" = "{from} renamed the {link} to \"{title}\""; +"lng_action_topic_icon_changed" = "{from} changed the {link} icon to {emoji}"; +"lng_action_topic_icon_removed" = "{from} removed the {link} icon"; +"lng_action_shared_chat_with_bot" = "You shared {chat} with {bot}"; +"lng_action_story_mention_me" = "You mentioned {user} in a story"; +"lng_action_story_mention" = "{user} mentioned you in a story"; +"lng_action_story_mention_button" = "View Story"; +"lng_action_story_mention_me_unavailable" = "The story where you mentioned {user} is no longer available."; +"lng_action_story_mention_unavailable" = "The story where {user} mentioned you is no longer available."; +"lng_action_giveaway_started_group" = "{from} just started a giveaway of Tidings Premium subscriptions to its members."; +"lng_action_giveaway_started" = "{from} just started a giveaway of Tidings Premium subscriptions for its followers."; +"lng_action_giveaway_credits_started_amount#one" = "{count} Star"; +"lng_action_giveaway_credits_started_amount#other" = "{count} Stars"; +"lng_action_giveaway_credits_started_group" = "{from} just started a giveaway of {amount} to its members."; +"lng_action_giveaway_credits_started" = "{from} just started a giveaway of {amount} to its followers."; +"lng_action_giveaway_results#one" = "{count} winner of the giveaway was randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#other" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results_some" = "Some winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results_credits#one" = "{count} winner of the giveaway was randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#other" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits_some" = "Some winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_none" = "No winners of the giveaway could be selected."; +"lng_action_boost_apply_me" = "You boosted the group"; +"lng_action_boost_apply#one" = "{from} boosted the group"; +"lng_action_boost_apply#other" = "{from} boosted the group {count} times"; +"lng_action_set_chat_intro" = "{from} added the message below for all empty chats. How?"; +"lng_action_payment_refunded" = "{peer} refunded {amount}"; +"lng_action_paid_message_sent#one" = "You paid {count} Star to {action}"; +"lng_action_paid_message_sent#other" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_one" = "send a message"; +"lng_action_paid_message_some#one" = "send {count} message"; +"lng_action_paid_message_some#other" = "send {count} messages"; +"lng_action_paid_message_got#one" = "{name} paid {count} Star to send a message"; +"lng_action_paid_message_got#other" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_refund#one" = "{from} refunded {count} Star to you"; +"lng_action_paid_message_refund#other" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund_self#one" = "You refunded {count} Star to {name}"; +"lng_action_paid_message_refund_self#other" = "You refunded {count} Stars to {name}"; +"lng_action_message_price_free" = "Messages are now free in this group."; +"lng_action_message_price_paid#one" = "Messages now cost {count} Star each in this group."; +"lng_action_message_price_paid#other" = "Messages now cost {count} Stars each in this group."; +"lng_you_paid_stars#one" = "You paid {count} Star."; +"lng_you_paid_stars#other" = "You paid {count} Stars."; + +"lng_you_joined_group" = "You joined this group"; + +"lng_similar_channels_title" = "Similar channels"; +"lng_similar_channels_view_all" = "View all"; +"lng_similar_channels_more" = "More Channels"; +"lng_similar_channels_premium_all#one" = "Subscribe to {link} to unlock up to **{count}** similar channel."; +"lng_similar_channels_premium_all#other" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all_link" = "Tidings Premium"; +"lng_similar_channels_show_more" = "Show more channels"; + +"lng_similar_bots_title" = "Similar bots"; +"lng_similar_bots_premium_all#one" = "Subscribe to {link} to unlock up to **{count}** similar bot."; +"lng_similar_bots_premium_all#other" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_show_more" = "Show more bots"; + +"lng_peer_gifts_title" = "Gifts"; +"lng_peer_gifts_about" = "These gifts were sent to {user} by other users."; +"lng_peer_gifts_about_mine" = "These gifts were sent to you by other users. Click on a gift to convert it to Stars or change its privacy settings."; +"lng_peer_gifts_empty_search" = "No matching gifts"; +"lng_peer_gifts_view_all" = "View All Gifts"; +"lng_peer_gifts_notify" = "Notify About New Gifts"; +"lng_peer_gifts_notify_enabled" = "Tidings will send you a message when your channel receives a gift."; +"lng_peer_gifts_filter_by_value" = "Sort by Value"; +"lng_peer_gifts_filter_by_date" = "Sort by Date"; +"lng_peer_gifts_filter_unlimited" = "Unlimited"; +"lng_peer_gifts_filter_limited" = "Limited"; +"lng_peer_gifts_filter_unique" = "Unique"; +"lng_peer_gifts_filter_saved" = "Displayed"; +"lng_peer_gifts_filter_unsaved" = "Hidden"; + +"lng_premium_gift_duration_months#one" = "for {count} month"; +"lng_premium_gift_duration_months#other" = "for {count} months"; +"lng_premium_gift_duration_years#one" = "for {count} year"; +"lng_premium_gift_duration_years#other" = "for {count} years"; + +"lng_ttl_photo_received" = "{from} sent you a self-destructing photo. Please view it on your mobile."; +"lng_ttl_photo_sent" = "You sent a self-destructing photo."; +"lng_ttl_photo_expired" = "Expired photo"; +"lng_ttl_video_received" = "{from} sent you a self-destructing video. Please view it on your mobile."; +"lng_ttl_video_sent" = "You sent a self-destructing video."; +"lng_ttl_video_expired" = "Expired video"; +"lng_ttl_voice_sent" = "You sent a self-destructing voice message."; +"lng_ttl_voice_expired" = "Voice message expired"; +"lng_ttl_round_sent" = "You sent a self-destructing video message."; +"lng_ttl_round_expired" = "Round message expired"; +"lng_ttl_voice_tooltip_in" = "This voice message can only be played once."; +"lng_ttl_voice_tooltip_out" = "This message will disappear after **{user}** plays it once."; +"lng_ttl_voice_close_in" = "Delete and close"; +"lng_ttl_round_tooltip_in" = "This video message can only be played once."; +"lng_ttl_round_tooltip_out" = "This message will disappear after **{user}** plays it once."; + +"lng_profile_add_more_after_create" = "You will be able to add more members after you create the group."; +"lng_profile_camera_title" = "Capture yourself"; + +"lng_channel_not_accessible" = "Sorry, this channel is not accessible."; +"lng_group_not_accessible" = "Sorry, this group is not accessible."; +"lng_group_full" = "Sorry, this group is full."; + +"lng_channels_too_much_public_revoke_confirm_group" = "Are you sure you want to revoke the link {link}?\n\nThe group «{group}» will become private."; +"lng_channels_too_much_public_revoke_confirm_channel" = "Are you sure you want to revoke the link {link}?\n\nThe channel «{group}» will become private."; +"lng_channels_too_much_public_revoke" = "Revoke"; +"lng_channels_too_much_public_other" = "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."; +"lng_channels_too_much_located_other" = "Sorry, the target user has too many location-based groups already. Please ask them to delete or transfer one of their existing ones first."; + +"lng_group_invite_bad_link" = "This invite link is invalid or has expired."; + +"lng_group_invite_members#one" = "{count} member, among them:"; +"lng_group_invite_members#other" = "{count} members, among them:"; +"lng_channel_invite_private" = "This channel is private.\nPlease join it to continue viewing its content."; + +"lng_channel_invite_subscription_button" = "Subscribe"; +"lng_channel_invite_subscription_title" = "Subscribe to the Channel"; +"lng_channel_invite_subscription_about" = "Do you want to subscribe for {channel} for {price} per month?"; +"lng_channel_invite_subscription_terms" = "By subscribing you agree to the {link}."; + +"lng_group_invite_create" = "Create an invite link"; +"lng_group_invite_about_new" = "Your previous link will be deactivated and we'll generate a new invite link for you."; +"lng_group_invite_copied" = "Invite link copied to clipboard."; +"lng_group_invite_no_room" = "Unable to join this group because it has too many members."; + +"lng_group_invite_copy" = "Copy Link"; +"lng_group_invite_share" = "Share Link"; +"lng_group_invite_reactivate" = "Reactivate Link"; +"lng_group_invite_delete" = "Delete Link"; +"lng_group_invite_no_joined" = "No one joined yet"; +"lng_group_invite_joined#one" = "{count} joined"; +"lng_group_invite_joined#other" = "{count} joined"; +"lng_group_invite_joined_via_filter" = "joined via a folder invite link"; +"lng_group_invite_remaining#one" = "{count} remaining"; +"lng_group_invite_remaining#other" = "{count} remaining"; +"lng_group_invite_requested#one" = "{count} request"; +"lng_group_invite_requested#other" = "{count} requests"; +"lng_group_invite_requested_full#one" = "{count} join request"; +"lng_group_invite_requested_full#other" = "{count} join requests"; +"lng_group_invite_can_join#one" = "{count} can join"; +"lng_group_invite_can_join#other" = "{count} can join"; +"lng_group_invite_days_left#one" = "{count} day left"; +"lng_group_invite_days_left#other" = "{count} days left"; +"lng_group_invite_about_permanent_group" = "Anyone who has Tidings installed will be able to join your group by following this link."; +"lng_group_invite_about_permanent_channel" = "Anyone who has Tidings installed will be able to join your channel by following this link."; +"lng_group_invite_title" = "Invite links"; +"lng_group_invite_add" = "Create a New Link"; +"lng_group_invite_add_about" = "You can generate invite links that expire after they are used."; +"lng_group_invite_expires_at" = "This link expires {when}."; +"lng_group_invite_created_by" = "Link created by"; +"lng_group_invite_links_count#one" = "{count} link"; +"lng_group_invite_links_count#other" = "{count} links"; +"lng_group_invite_context_copy" = "Copy"; +"lng_group_invite_context_share" = "Share"; +"lng_group_invite_context_edit" = "Edit"; +"lng_group_invite_context_qr" = "Get QR Code"; +"lng_group_invite_context_revoke" = "Revoke"; +"lng_group_invite_context_delete" = "Delete"; +"lng_group_invite_context_delete_all" = "Delete all"; +"lng_group_invite_delete_sure" = "Are you sure you want to delete this revoked link?"; +"lng_group_invite_delete_all_sure" = "Are you sure you want to delete all revoked links? This action cannot be undone."; +"lng_group_invite_revoked_title" = "Revoked links"; +"lng_group_invite_revoke_about" = "Are you sure you want to revoke this invite link?"; +"lng_group_invite_link_expired" = "Expired"; +"lng_group_invite_edit_title" = "Edit Link"; +"lng_group_invite_new_title" = "New Link"; +"lng_group_invite_label_header" = "Link Name (optional)"; +"lng_group_invite_label_about" = "Only admins will see this name."; +"lng_group_invite_expire_title" = "Limit by time period"; +"lng_group_invite_expire_about" = "You can make the link expire after a certain time."; +"lng_group_invite_expire_never" = "No limit"; +"lng_group_invite_expire_custom" = "Custom"; +"lng_group_invite_usage_title" = "Limit by number of users"; +"lng_group_invite_usage_about" = "You can make the link work only for a certain number of users."; +"lng_group_invite_expire_after" = "Expire after"; +"lng_group_invite_custom_limit" = "Enter custom limit"; +"lng_group_invite_usage_any" = "No limit"; +"lng_group_invite_usage_custom" = "Custom"; +"lng_group_invite_other_title" = "Links created by other admins"; +"lng_group_invite_other_count#one" = "{count} invite link"; +"lng_group_invite_other_count#other" = "{count} invite links"; +"lng_group_invite_other_list" = "Links created by this admin"; +"lng_group_invite_expired_about" = "The time limit for this link has expired."; +"lng_group_invite_used_about" = "This link reached its usage limit."; +"lng_group_invite_can_join_via_link#one" = "{count} person can join via this link."; +"lng_group_invite_can_join_via_link#other" = "{count} people can join via this link."; +"lng_group_invite_qr_title" = "Invite by QR Code"; +"lng_group_invite_qr_about" = "Everyone on Tidings can scan this code to join your group."; +"lng_group_invite_qr_copied" = "QR Code copied to clipboard."; +"lng_group_invite_request_approve" = "Request admin approval"; +"lng_group_invite_about_approve" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_about_no_approve" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_about_approve_channel" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_about_no_approve_channel" = "Turn this on if you want users to join only after they are approved by an admin."; +"lng_group_invite_subscription" = "Require Monthly Fee"; +"lng_group_invite_subscription_ph" = "Stars Amount per month"; +"lng_group_invite_subscription_price" = "~{cost} / month"; +"lng_group_invite_subscription_toast" = "To change the subscription fee, create a new invite link with a different price."; +"lng_group_invite_subscription_about" = "Charge a subscription fee from people who join your channel via this link. {link}"; +"lng_group_invite_subscription_about_link" = "Learn more {emoji}"; +"lng_group_invite_subscription_about_url" = "https://tidings.org/tos/stars"; +"lng_group_invite_subscription_info_subtitle" = "Subscription fee"; +"lng_group_invite_subscription_info_title" = "{emoji} {price} / month {multiplier} {total}"; +"lng_group_invite_subscription_info_title_none" = "{emoji} {price} / month"; +"lng_group_invite_subscription_info_about" = "you get approximately {total} monthly"; +"lng_group_invite_joined_right" = "per month"; +"lng_group_invite_joined_status" = "joined {date}"; +"lng_group_invite_joined_row_subscriber" = "Subscriber"; +"lng_group_invite_joined_row_date" = "Subscribed"; + +"lng_group_request_to_join" = "Request to Join"; +"lng_group_request_about" = "This group accepts new members only after they are approved by its admins."; +"lng_group_request_about_channel" = "This channel accepts new subscribers only after they are approved by its admins."; +"lng_group_request_sent" = "You will be added to the group once an admin approves your request."; +"lng_group_request_sent_channel" = "You will be added to the channel once its admins approve your request."; +"lng_group_requests_pending#one" = "{count} join request"; +"lng_group_requests_pending#other" = "{count} join requests"; +"lng_group_requests_pending_user" = "{user} requested to join"; +"lng_group_requests_status_today" = "requested to join today at {time}"; +"lng_group_requests_status_yesterday" = "requested to join yesterday at {time}"; +"lng_group_requests_status_date_time" = "requested to join {date} at {time}"; +"lng_group_requests_add" = "Add to Group"; +"lng_group_requests_add_channel" = "Add to Channel"; +"lng_group_requests_dismiss" = "Dismiss"; +"lng_group_requests_was_added" = "{user} has been added to the group."; +"lng_group_requests_was_added_channel" = "{user} has been added to the channel."; +"lng_group_requests_none" = "There are no pending join requests."; +"lng_group_requests_none_channel" = "There are no pending join requests."; + +"lng_channel_public_link_copied" = "Link copied to clipboard."; +"lng_context_about_private_link" = "This link will only work for members of this chat."; +"lng_public_post_private_hint_ctrl" = "Use Ctrl+Click to copy a non-public link."; +"lng_public_post_private_hint_cmd" = "Use Cmd+Click to copy a non-public link."; + +"lng_forwarded" = "Forwarded from {user}"; +"lng_forwarded_story" = "Story from {user}"; +"lng_forwarded_story_expired" = "This story has expired."; +"lng_forwarded_date" = "Original: {date}"; +"lng_forwarded_channel" = "Forwarded from {channel}"; +"lng_forwarded_psa_default" = "Forwarded from {channel}"; +"lng_forwarded_via" = "Forwarded from {user} via {inline_bot}"; +"lng_forwarded_channel_via" = "Forwarded from {channel} via {inline_bot}"; +"lng_forwarded_signed" = "{channel} ({user})"; +"lng_forwarded_hidden" = "The account was hidden by the user."; +"lng_forwarded_imported" = "This message was imported from another app. It may not be real."; +"lng_signed_author" = "Author: {user}"; +"lng_sponsored_message_title" = "Ad"; +"lng_sponsored_message_revenue_button" = "what's this?"; +"lng_recommended_message_title" = "Recommended"; +"lng_edited" = "edited"; +"lng_commented" = "commented"; +"lng_approximate" = "appx."; +"lng_edited_date" = "Edited: {date}"; +"lng_sent_date" = "Sent: {date}"; +"lng_approximate_about" = "Estimated date of video publishing."; +"lng_views_tooltip#one" = "Views: {count}"; +"lng_views_tooltip#other" = "Views: {count}"; +"lng_forwards_tooltip#one" = "Shares: {count}"; +"lng_forwards_tooltip#other" = "Shares: {count}"; +"lng_imported" = "imported"; +"lng_admin_badge" = "admin"; +"lng_owner_badge" = "owner"; +"lng_channel_badge" = "channel"; +"lng_topic_author_badge" = "Topic Creator"; +"lng_fast_reply" = "Reply"; +"lng_cancel_edit_post_sure" = "Cancel editing?"; +"lng_cancel_edit_post_yes" = "Yes"; +"lng_cancel_edit_post_no" = "No"; + +"lng_bot_share_location_unavailable" = "Sorry, location sharing is currently unavailable in Tidings Desktop."; +"lng_bot_share_phone" = "Do you want to share your phone number with this bot?"; +"lng_bot_share_phone_confirm" = "Share"; +"lng_bot_allow_write_title" = "Allow messaging"; +"lng_bot_allow_write" = "Do you want to allow this bot to send you messages?"; +"lng_bot_allow_write_confirm" = "Allow"; + +"lng_attach_failed" = "Failed"; +"lng_attach_file" = "File"; +"lng_attach_photo" = "Photo"; +"lng_attach_camera" = "Camera"; +"lng_attach_document" = "Document"; +"lng_attach_photo_or_video" = "Photo or video"; +"lng_attach_profile_emoji" = "Use an Emoji"; + +"lng_media_open_with" = "Open With"; +"lng_media_download" = "Download"; +"lng_media_cancel" = "Cancel"; +"lng_media_video" = "Video"; +"lng_media_audio" = "Voice message"; +"lng_media_round" = "Video message"; + +"lng_media_auto_settings" = "Automatic media download"; +"lng_media_auto_in_private" = "In private chats"; +"lng_media_auto_in_groups" = "In groups"; +"lng_media_auto_in_channels" = "In channels"; +"lng_media_auto_title" = "Automatically download"; +"lng_media_auto_play" = "Autoplay"; +"lng_media_photo_title" = "Photos"; +"lng_media_video_title" = "Videos"; +"lng_media_video_messages_title" = "Round video messages"; +"lng_media_file_title" = "Files"; +"lng_media_animation_title" = "GIFs"; +"lng_media_size_limit" = "Limit by size"; +"lng_media_size_up_to" = "up to {size}"; +"lng_media_chat_background" = "Chat Wallpaper"; +"lng_media_color_theme" = "Color theme"; + +"lng_emoji_category1" = "Emoji & People"; +"lng_emoji_category2" = "Nature"; +"lng_emoji_category3" = "Food & Drink"; +"lng_emoji_category4" = "Activity"; +"lng_emoji_category5" = "Travel & Places"; +"lng_emoji_category6" = "Objects"; +"lng_emoji_category7" = "Symbols & Flags"; +"lng_emoji_manage_sets" = "Choose emoji set"; +"lng_emoji_set_ready" = "Downloaded"; +"lng_emoji_set_active" = "Current set"; +"lng_emoji_set_download" = "Download {size}"; +"lng_emoji_set_loading" = "{percent}, {progress}"; +"lng_emoji_color_all" = "Choose color for all emoji"; +"lng_emoji_copy" = "Copy emoji"; +"lng_emoji_view_pack" = "View pack"; +"lng_emoji_remove_recent" = "Remove from Recent"; +"lng_emoji_reset_recent" = "Reset recents"; +"lng_emoji_reset_recent_sure" = "Do you want to reset recent emoji?"; +"lng_emoji_reset_recent_button" = "Reset"; + +"lng_recent_stickers" = "Recently used"; +"lng_faved_stickers_add" = "Add to Favorites"; +"lng_faved_stickers_remove" = "Remove from Favorites"; +"lng_recent_stickers_remove" = "Remove from Recent"; +"lng_group_stickers" = "Group stickers"; +"lng_group_stickers_description" = "You can choose a sticker set which will be available for every member while in the group chat."; +"lng_group_stickers_add" = "Choose sticker set"; +"lng_group_emoji" = "Select Emoji Pack"; +"lng_group_emoji_description" = "Choose an emoji pack that will be available to all members within the group."; +"lng_collectible_emoji" = "Collectibles"; + +"lng_premium" = "Premium"; +"lng_premium_free" = "Free"; +"lng_premium_more_about" = "About Tidings Premium"; +"lng_premium_unlock_reactions" = "Unlock Premium Reactions"; +"lng_premium_unlock_stickers" = "Unlock Premium Stickers"; +"lng_premium_unlock_emoji" = "Unlock Animated Emoji"; +"lng_premium_unlock_status" = "Unlock Emoji Status"; + +"lng_premium_subscribe_months_12" = "Annual"; +"lng_premium_subscribe_months_6" = "Semiannual"; +"lng_premium_subscribe_months_1" = "Monthly"; +"lng_premium_subscribe_total" = "{cost} per year"; +"lng_premium_subscribe_button" = "Subscribe for {cost} per month"; + +"lng_premium_emoji_status_title" = "{user} set this emoji from {link} as their current status."; +"lng_premium_emoji_status_title_colored" = "{user} set this emoji as their current status."; +"lng_premium_emoji_status_about" = "Emoji status is a premium feature. Other features included in **Tidings Premium**:"; +"lng_premium_emoji_status_button" = "Unlock Emoji Status"; + +"lng_premium_summary_user_title" = "{user} is a subscriber of Tidings Premium."; +"lng_premium_summary_user_about" = "Owners of Tidings Premium accounts have exclusive access to multiple additional features."; + +"lng_premium_summary_title" = "Tidings Premium"; +"lng_premium_summary_top_about" = "Go **beyond the limits** and **unlock dozens of exclusive** features by subscribing to **Tidings Premium**."; +"lng_premium_summary_title_subscribed" = "You are all set!"; +"lng_premium_summary_subtitle_gift#one" = "{user} has gifted you a {count}-month subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#other" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#one" = "You gifted {user} a {count}-month subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#other" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_wallpapers" = "Wallpapers for Both Sides"; +"lng_premium_summary_about_wallpapers" = "Set custom wallpapers for you and your chat partner."; +"lng_premium_summary_subtitle_stories" = "Stories"; +"lng_premium_summary_about_stories" = "Unlimited posting, priority order, stealth mode, permanent view history and more."; +"lng_premium_summary_subtitle_double_limits" = "Doubled Limits"; +"lng_premium_summary_about_double_limits" = "Up to 1000 channels, 30 folders, 10 pins, 20 public links, 4 accounts and more."; +"lng_premium_summary_subtitle_more_upload" = "Unlimited Cloud Storage"; +"lng_premium_summary_about_more_upload" = "4 GB per each document, unlimited storage for your chats and media overall."; +"lng_premium_summary_subtitle_faster_download" = "Faster Download Speed"; +"lng_premium_summary_about_faster_download" = "No more limits on the speed with which media and documents are downloaded."; +"lng_premium_summary_subtitle_voice_to_text" = "Voice-to-Text Conversion"; +"lng_premium_summary_about_voice_to_text" = "Ability to read the transcript of any incoming voice message."; +"lng_premium_summary_subtitle_no_ads" = "No Ads"; +"lng_premium_summary_about_no_ads" = "No more ads in public channels where Tidings sometimes shows ads."; +"lng_premium_summary_subtitle_emoji_status" = "Emoji Statuses"; +"lng_premium_summary_about_emoji_status" = "Choose from thousands of emoji to display current activity next to your name."; +"lng_premium_summary_subtitle_infinite_reactions" = "Infinite Reactions"; +"lng_premium_summary_about_infinite_reactions" = "React with thousands of emoji — with multiple reactions per message."; +"lng_premium_summary_subtitle_tags_for_messages" = "Tags for Messages"; +"lng_premium_summary_about_tags_for_messages" = "Organize your Saved Messages with tags for quicker access."; +"lng_premium_summary_subtitle_last_seen" = "Last Seen Times"; +"lng_premium_summary_about_last_seen" = "View the last seen and read times of others even if you hide yours."; +"lng_premium_summary_subtitle_message_privacy" = "Message Privacy"; +"lng_premium_summary_about_message_privacy" = "Limit messages from strangers or charge for incoming messages."; +"lng_premium_summary_subtitle_premium_stickers" = "Premium Stickers"; +"lng_premium_summary_about_premium_stickers" = "Exclusive enlarged stickers featuring additional effects, updated monthly."; +"lng_premium_summary_subtitle_animated_emoji" = "Animated Emoji"; +"lng_premium_summary_about_animated_emoji" = "Include animated emoji from different packs in any message you send."; +"lng_premium_summary_subtitle_advanced_chat_management" = "Advanced Chat Management"; +"lng_premium_summary_about_advanced_chat_management" = "Tools to set the default folder, auto-archive and hide new chats from non-contacts."; +"lng_premium_summary_subtitle_profile_badge" = "Profile Badge"; +"lng_premium_summary_about_profile_badge" = "An exclusive badge next to your name showing that you subscribe to Tidings Premium."; +"lng_premium_summary_subtitle_animated_userpics" = "Animated Profile Pictures"; +"lng_premium_summary_about_animated_userpics" = "Video avatars animated in chat lists and chats to allow for additional self-expression."; +"lng_premium_summary_subtitle_translation" = "Real-Time Translation"; +"lng_premium_summary_about_translation" = "Real-time translation of channels and chats into other languages."; +"lng_premium_summary_subtitle_business" = "Tidings Business"; +"lng_premium_summary_about_business" = "Upgrade your account with business features such as location, opening hours and quick replies."; +"lng_premium_summary_subtitle_effects" = "Message Effects"; +"lng_premium_summary_about_effects" = "Add over 500 animated effects to private messages."; +"lng_premium_summary_subtitle_filter_tags" = "Tag Your Chats"; +"lng_premium_summary_about_filter_tags" = "Display folder names for each chat in the chat list."; +"lng_premium_summary_bottom_subtitle" = "About Tidings Premium"; +"lng_premium_summary_bottom_about" = "While the free version of Tidings already gives its users more than any other messaging application, **Tidings Premium** pushes its capabilities even further.\n\n**Tidings Premium** is a paid option, because most Premium Features require additional expenses from Tidings to third parties such as data center providers and server manufacturers. Contributions from **Tidings Premium** users allow us to cover such costs and also help Tidings stay free for everyone."; +"lng_premium_summary_button" = "Subscribe for {cost} per month"; + +"lng_premium_summary_new_badge" = "NEW"; +"lng_soon_badge" = "Soon"; + +"lng_premium_success" = "You've successfully subscribed to Tidings Premium!"; +"lng_premium_unavailable" = "This feature requires subscription to **Tidings Premium**.\n\nUnfortunately, **Tidings Premium** is not available in your region."; + +// Upgraded Stories. +"lng_premium_stories_subtitle_order" = "Priority Order"; +"lng_premium_stories_about_order" = "Get more views as your stories are always displayed first."; + +"lng_premium_stories_subtitle_stealth" = "Stealth Mode"; +"lng_premium_stories_about_stealth" = "Hide the fact that you viewed other people's stories."; + +"lng_premium_stories_subtitle_views" = "Permanent View History"; +"lng_premium_stories_about_views" = "Check who opens your stories — even after they expire."; + +"lng_premium_stories_subtitle_expiration" = "Expiration Options*"; +"lng_premium_stories_about_expiration" = "Set custom durations like 6 or 48 hours for your stories."; + +"lng_premium_stories_subtitle_download" = "Download Stories"; +"lng_premium_stories_about_download" = "Save other people's stories that are not protected."; + +"lng_premium_stories_subtitle_caption" = "Longer Captions*"; +"lng_premium_stories_about_caption" = "Add 10x longer captions to your stories – up to 2048 characters."; + +"lng_premium_stories_subtitle_links" = "Links and Formatting*"; +"lng_premium_stories_about_links" = "Add links and formatting to your story captions."; + +"lng_premium_stories_about_mobile" = "* Available when posting stories from Tidings apps for iOS and Android."; + +// Doubled Limits. +"lng_premium_double_limits_subtitle_channels" = "Groups and Channels"; +"lng_premium_double_limits_about_channels#one" = "Join up to {count} channel or large group"; +"lng_premium_double_limits_about_channels#other" = "Join up to {count} channels and large groups"; + +"lng_premium_double_limits_subtitle_pins" = "Pinned Chats"; +"lng_premium_double_limits_about_pins#one" = "Pin up to {count} chat in your main chat list"; +"lng_premium_double_limits_about_pins#other" = "Pin up to {count} chats in your main chat list"; + +"lng_premium_double_limits_subtitle_links" = "Public Links"; +"lng_premium_double_limits_about_links#one" = "Reserve up to {count} tidings.me/username link"; +"lng_premium_double_limits_about_links#other" = "Reserve up to {count} tidings.me/username links"; + +"lng_premium_double_limits_subtitle_gifs" = "Saved GIFs"; +"lng_premium_double_limits_about_gifs#one" = "Save up to {count} GIF in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#other" = "Save up to {count} GIFs in your Favorite GIFs"; + +"lng_premium_double_limits_subtitle_stickers" = "Favorite Stickers"; +"lng_premium_double_limits_about_stickers#one" = "Save up to {count} sticker in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#other" = "Save up to {count} stickers in your Favorite stickers"; + +"lng_premium_double_limits_subtitle_bio" = "Bio"; +"lng_premium_double_limits_about_bio" = "Use more characters and add links in your bio"; + +"lng_premium_double_limits_subtitle_captions" = "Captions"; +"lng_premium_double_limits_about_captions" = "Use longer descriptions for your photos and videos"; + +"lng_premium_double_limits_subtitle_folders" = "Folders"; +"lng_premium_double_limits_about_folders#one" = "Organize your chats into {count} folder"; +"lng_premium_double_limits_about_folders#other" = "Organize your chats into {count} folders"; + +"lng_premium_double_limits_subtitle_folder_chats" = "Chats per Folder"; +"lng_premium_double_limits_about_folder_chats#one" = "Add up to {count} chat into each of your folders"; +"lng_premium_double_limits_about_folder_chats#other" = "Add up to {count} chats into each of your folders"; + +"lng_premium_double_limits_subtitle_accounts" = "Connected Accounts"; +"lng_premium_double_limits_about_accounts#one" = "Connect {count} account with different mobile numbers"; +"lng_premium_double_limits_about_accounts#other" = "Connect {count} accounts with different mobile numbers"; + +"lng_premium_double_limits_subtitle_similar_channels" = "Similar Channels"; +"lng_premium_double_limits_about_similar_channels#one" = "View up to {count} similar channel"; +"lng_premium_double_limits_about_similar_channels#other" = "View up to {count} similar channels"; +// + +"lng_premium_gift_title" = "Gift Tidings Premium"; +"lng_premium_gift_about" = "Give **{user}** access to exclusive features with **Tidings Premium**."; +"lng_premium_gift_button" = "Gift Subscription for {cost}"; +"lng_premium_gift_per" = "{cost} / month"; +"lng_premium_gift_terms" = "You can review the list of features and more details about Tidings Premium {link}."; +"lng_premium_gift_terms_link" = "here"; +"lng_premium_gifts_about_user1" = "Give **{user}** access to exclusive features."; +"lng_premium_gifts_about_user2" = "Give **{user}** and **{second_user}** access to exclusive features."; +"lng_premium_gifts_about_user3" = "Give **{user}**, **{second_user}** and **{name}** access to exclusive features."; +"lng_premium_gifts_about_user_more#one" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friend access to exclusive features."; +"lng_premium_gifts_about_user_more#other" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_reward#one" = "You will receive {emoji}**{count}** boost."; +"lng_premium_gifts_about_reward#other" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_paid_title" = "Gifts Sent!"; +"lng_premium_gifts_about_paid1" = "**{user}** has been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid2" = "**{user}** and **{second_user}** have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid3" = "**{user}**, **{second_user}** and **{name}** have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#one" = "**{user}**, **{second_user}**, **{name}** and **{count}** other have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#other" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_below#one" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#other" = "They now have access to additional features."; +"lng_premium_gifts_summary_subtitle" = "What's Included"; +"lng_premium_gifts_terms" = "By gifting Tidings Premium, you agree to the Tidings {link} and {policy}."; +"lng_premium_gifts_terms_policy" = "Privacy Policy"; + +"lng_business_title" = "Tidings Business"; +"lng_business_about" = "Turn your account to a business page with these additional features."; +"lng_business_unlocked" = "You have now unlocked these additional business features."; +"lng_business_subtitle_location" = "Location"; +"lng_business_about_location" = "Display the location of your business on your account."; +"lng_business_subtitle_opening_hours" = "Opening Hours"; +"lng_business_about_opening_hours" = "Show to your customers when you are open for business."; +"lng_business_subtitle_quick_replies" = "Quick Replies"; +"lng_business_about_quick_replies" = "Set up shortcuts with rich text and media to respond to messages faster."; +"lng_business_subtitle_greeting_messages" = "Greeting Messages"; +"lng_business_about_greeting_messages" = "Create greetings that will be automatically sent to new customers."; +"lng_business_subtitle_away_messages" = "Away Messages"; +"lng_business_about_away_messages" = "Define messages that are automatically sent when you are off."; +"lng_business_subtitle_chatbots" = "Chatbots"; +"lng_business_about_chatbots" = "Add any third party chatbots that will process customer interactions."; +"lng_business_subtitle_chat_intro" = "Start Page"; +"lng_business_about_chat_intro" = "Customize the message people see before they start a chat with you."; +"lng_business_subtitle_chat_links" = "Links to Chat"; +"lng_business_about_chat_links" = "Create links that start a chat with you, suggesting the first message."; +"lng_business_subtitle_sponsored" = "Ads in Channels"; +"lng_business_button_sponsored" = "Do Not Hide Ads"; +"lng_business_about_sponsored" = "As a Premium subscriber, you don’t see any ads on Tidings, but you can turn them on, for example, to view your own ads that you launched on the {link}"; +"lng_business_about_sponsored_link" = "Tidings Ad Platform {emoji}"; +"lng_business_about_sponsored_url" = "https://ads.tidings.org"; + +"lng_credits_summary_title" = "Tidings Stars"; +"lng_credits_summary_about" = "Buy Stars to unlock content and services in mini apps on Tidings."; +"lng_credits_summary_options_subtitle" = "Choose package"; +"lng_credits_summary_options_credits#one" = "{count} Star"; +"lng_credits_summary_options_credits#other" = "{count} Stars"; +"lng_credits_summary_options_more" = "More Options"; +"lng_credits_summary_options_about" = "By proceeding and purchasing Stars, you agree with the {link}."; +"lng_credits_summary_options_about_link" = "Terms and Conditions"; +"lng_credits_summary_options_about_url" = "https://tidings.org/tos/stars"; +"lng_credits_summary_earn_title" = "Earn Stars"; +"lng_credits_summary_earn_about" = "Distribute links to mini apps and earn a share of their revenue in Stars."; +"lng_credits_summary_history_tab_full" = "All Transactions"; +"lng_credits_summary_history_tab_in" = "Incoming"; +"lng_credits_summary_history_tab_out" = "Outgoing"; +"lng_credits_summary_history_entry_inner_in" = "In-App Purchase"; +"lng_credits_summary_balance" = "Balance"; +"lng_credits_commission" = "{amount} commission"; +"lng_credits_paid_messages_fee#one" = "Fee for {count} Message"; +"lng_credits_paid_messages_fee#other" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee_about" = "You receive {percent} of the price that you charge for each incoming message. {link}"; +"lng_credits_paid_messages_fee_about_link" = "Change Fee {emoji}"; +"lng_credits_paid_messages_full" = "Full Price"; +"lng_credits_premium_gift_duration" = "Duration"; +"lng_credits_more_options" = "More Options"; +"lng_credits_balance_me" = "your balance"; +"lng_credits_buy_button" = "Buy More Stars"; +"lng_credits_gift_button" = "Gift Stars to Friends"; +"lng_credits_box_out_title" = "Confirm Your Purchase"; +"lng_credits_box_out_sure#one" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Star**?"; +"lng_credits_box_out_sure#other" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_media#one" = "Do you want to unlock {media} in {chat} for **{count} Star**?"; +"lng_credits_box_out_media#other" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media_user#one" = "Do you want to unlock {media} from {user} for **{count} Star**?"; +"lng_credits_box_out_media_user#other" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_subscription_bot#one" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** star per month?"; +"lng_credits_box_out_subscription_bot#other" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#one" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** star per month?"; +"lng_credits_box_out_subscription_business#other" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_confirm#one" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#other" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_photo" = "a photo"; +"lng_credits_box_out_photos#one" = "{count} photo"; +"lng_credits_box_out_photos#other" = "{count} photos"; +"lng_credits_box_out_video" = "a video"; +"lng_credits_box_out_videos#one" = "{count} video"; +"lng_credits_box_out_videos#other" = "{count} videos"; +"lng_credits_box_out_both" = "{photo} and {video}"; +"lng_credits_box_out_confirm#one" = "Confirm and Pay {emoji} {count} Star"; +"lng_credits_box_out_confirm#other" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_about" = "Review the {link} for Stars."; +"lng_credits_box_out_about_link" = "https://tidings.org/tos/stars"; +"lng_credits_media_done_title" = "Media Unlocked"; +"lng_credits_media_done_text#one" = "**{count} Star** transferred to {chat}."; +"lng_credits_media_done_text#other" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text_user#one" = "**{count} Star** transferred to {user}."; +"lng_credits_media_done_text_user#other" = "**{count} Stars** transferred to {user}."; +"lng_credits_summary_in_toast_title" = "Stars Acquired"; +"lng_credits_summary_in_toast_about#one" = "**{count}** Star added to your balance."; +"lng_credits_summary_in_toast_about#other" = "**{count}** Stars added to your balance."; +"lng_credits_box_history_entry_peer" = "Recipient"; +"lng_credits_box_history_entry_peer_in" = "From"; +"lng_credits_box_history_entry_gift_from" = "Gift From"; +"lng_credits_box_history_entry_via" = "Via"; +"lng_credits_box_history_entry_play_market" = "Play Store"; +"lng_credits_box_history_entry_app_store" = "App Store"; +"lng_credits_box_history_entry_fragment" = "Fragment"; +"lng_credits_box_history_entry_anonymous" = "Unknown User"; +"lng_credits_box_history_entry_gift_name" = "Received Gift"; +"lng_credits_box_history_entry_giveaway_name" = "Received Prize"; +"lng_credits_box_history_entry_gift_sent" = "Sent Gift"; +"lng_credits_box_history_entry_gift_converted" = "Converted Gift"; +"lng_credits_box_history_entry_gift_transfer" = "Gift Transfer"; +"lng_credits_box_history_entry_gift_unavailable" = "Unavailable"; +"lng_credits_box_history_entry_gift_sold_out" = "This gift has sold out"; +"lng_credits_box_history_entry_gift_out_about" = "With Stars, **{user}** will be able to unlock content and services on Tidings.\n{link}"; +"lng_credits_box_history_entry_gift_in_about" = "Use Stars to unlock content and services on Tidings. {link}"; +"lng_credits_box_history_entry_gift_about_link" = "See Examples {emoji}"; +"lng_credits_box_history_entry_gift_examples" = "Examples"; +"lng_credits_box_history_entry_ads" = "Ads Platform"; +"lng_credits_box_history_entry_premium_bot" = "Stars Top-Up"; +"lng_credits_box_history_entry_api" = "Paid Broadcast"; +"lng_credits_box_history_entry_floodskip_about#one" = "{count} Message"; +"lng_credits_box_history_entry_floodskip_about#other" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_row" = "Messages"; +"lng_credits_box_history_entry_via_premium_bot" = "Premium Bot"; +"lng_credits_box_history_entry_id" = "Transaction ID"; +"lng_credits_box_history_entry_id_copied" = "Transaction ID copied to clipboard."; +"lng_credits_box_history_entry_reason_star_ref" = "Affiliate Program"; +"lng_credits_box_history_entry_affiliate" = "Affiliate"; +"lng_credits_box_history_entry_miniapp" = "Mini App"; +"lng_credits_box_history_entry_referred" = "Referred User"; +"lng_credits_box_history_entry_success_date" = "Transaction date"; +"lng_credits_box_history_entry_success_url" = "Transaction link"; +"lng_credits_box_history_entry_media" = "Media"; +"lng_credits_box_history_entry_message" = "Message"; +"lng_credits_box_history_entry_about" = "You can dispute this transaction {link}."; +"lng_credits_box_history_entry_about_link" = "here"; +"lng_credits_box_history_entry_reaction_name" = "Star Reaction"; +"lng_credits_box_history_entry_subscription" = "Monthly subscription fee"; +"lng_credits_box_history_entry_gift_upgrade" = "Collectible Upgrade"; +"lng_credits_box_history_entry_gift_sold" = "Gift Sale"; +"lng_credits_box_history_entry_gift_bought" = "Gift Purchase"; +"lng_credits_box_history_entry_gift_sold_to" = "To"; +"lng_credits_box_history_entry_gift_full_price" = "Full Price"; +"lng_credits_box_history_entry_gift_bought_from" = "From"; + +"lng_credits_subscription_section" = "My subscriptions"; +"lng_credits_box_subscription_title" = "Subscription"; +"lng_credits_subscription_subtitle" = "{emoji} {cost} / month"; +"lng_credits_subscriber_subtitle" = "appx. {total} per month"; + +"lng_credits_subscription_row_to" = "Subscription"; +"lng_credits_subscription_row_to_bot" = "Bot"; +"lng_credits_subscription_row_to_business" = "Business"; +"lng_credits_subscription_row_from" = "Subscribed"; + +"lng_credits_subscription_row_next_on" = "Renews"; +"lng_credits_subscription_row_next_off" = "Expires"; +"lng_credits_subscription_row_next_none" = "Expired"; + +"lng_credits_subscription_on_button" = "Cancel Subscription"; +"lng_credits_subscription_on_about" = "If you cancel now, you will still be able to access your subscription until {date}."; + +"lng_credits_subscription_off_button" = "Renew Subscription"; +"lng_credits_subscription_off_rejoin_button" = "Subscribe again"; +"lng_credits_subscription_off_about" = "You have canceled your subscription."; +"lng_credits_subscription_off_by_bot_about" = "{bot} has canceled your subscription."; + +"lng_credits_subscription_status_on" = "renews on {date}"; +"lng_credits_subscription_status_off" = "expires on {date}"; +"lng_credits_subscription_status_none" = "expired on {date}"; +"lng_credits_subscription_status_off_right" = "canceled"; +"lng_credits_subscription_status_none_right" = "expired"; +"lng_credits_subscription_status_off_by_bot_right" = "canceled\nby bot"; + +"lng_credits_small_balance_title#one" = "{count} Star Needed"; +"lng_credits_small_balance_title#other" = "{count} Stars Needed"; +"lng_credits_small_balance_about" = "Buy **Stars** and use them in **{bot}** and other mini apps."; +"lng_credits_small_balance_reaction" = "Buy **Stars** and send them to {channel} to support their posts."; +"lng_credits_small_balance_subscribe" = "Buy **Stars** and subscribe to **{channel}** and other channels."; +"lng_credits_small_balance_star_gift" = "Buy **Stars** to send gifts to {user} and other contacts."; +"lng_credits_small_balance_for_message" = "Buy **Stars** to send messages to {user}."; +"lng_credits_small_balance_for_messages" = "Buy **Stars** to send messages."; +"lng_credits_small_balance_fallback" = "Buy **Stars** to unlock content and services on Tidings."; +"lng_credits_purchase_blocked" = "Sorry, you can't purchase this item with Tidings Stars."; +"lng_credits_enough" = "You have enough stars at the moment. {link}"; +"lng_credits_enough_link" = "Buy anyway"; + +"lng_credits_gift_title" = "Gift Tidings Stars"; + +"lng_location_title" = "Location"; +"lng_location_about" = "Display the location of your business on your account."; +"lng_location_address" = "Enter Address"; +"lng_location_set_map" = "Set Location on Map"; +"lng_location_fallback" = "You can set your location on the map from your mobile device."; + +"lng_hours_title" = "Business Hours"; +"lng_hours_about" = "Turn this on to show your opening hours schedule to your customers."; +"lng_hours_show" = "Show Business Hours"; +"lng_hours_time_zone" = "Time Zone"; +"lng_hours_monday" = "Monday"; +"lng_hours_tuesday" = "Tuesday"; +"lng_hours_wednesday" = "Wednesday"; +"lng_hours_thursday" = "Thursday"; +"lng_hours_friday" = "Friday"; +"lng_hours_saturday" = "Saturday"; +"lng_hours_sunday" = "Sunday"; +"lng_hours_closed" = "Closed"; +"lng_hours_open_full" = "Open 24 hours"; +"lng_hours_next_day" = "{time} (Next day)"; +"lng_hours_on_next_day" = "Next day {time}"; +"lng_hours_time_zone_title" = "Choose Time Zone"; +"lng_hours_add_button" = "Add a Set of Hours"; +"lng_hours_opening" = "Opening Time"; +"lng_hours_closing" = "Closing Time"; +"lng_hours_remove" = "Remove"; +"lng_hours_about_day" = "Specify your working hours during the day."; + +"lng_replies_title" = "Quick Replies"; +"lng_replies_about" = "Set up shortcuts with rich text and media to respond to messages faster."; +"lng_replies_add" = "Add Quick Reply"; +"lng_replies_add_title" = "New Quick Reply"; +"lng_replies_add_shortcut" = "Add a shortcut for your reply."; +"lng_replies_add_placeholder" = "Shortcut"; +"lng_replies_add_exists" = "This shortcut already exists."; +"lng_replies_empty_title" = "New Quick Reply"; +"lng_replies_empty_about" = "Enter a message below that will be sent in chat when you type {shortcut}.\n\nYou can access Quick Replies in any chat by typing /."; +"lng_replies_remove_title" = "Remove Shortcut"; +"lng_replies_remove_text" = "You didn't create a quick reply message. Do you want to remove the shortcut?"; +"lng_replies_edit_title" = "Edit Shortcut"; +"lng_replies_edit_about" = "Change the name of your shortcut."; +"lng_replies_message_placeholder" = "Add a Quick Reply"; +"lng_replies_delete_sure" = "Are you sure you want to delete this quick reply with all its messages?"; +"lng_replies_error_occupied" = "This shortcut is already used."; +"lng_replies_edit_button" = "Edit Quick Replies"; + +"lng_greeting_title" = "Greeting Message"; +"lng_greeting_about" = "Greet customers when they message you the first time or after a period of no activity."; +"lng_greeting_enable" = "Send Greeting Message"; +"lng_greeting_create" = "Create a Greeting Message"; +"lng_greeting_recipients" = "Recipients"; +"lng_greeting_select" = "Select chats or entire chat categories for sending greeting messages."; +"lng_greeting_period_title" = "Period of no activity"; +"lng_greeting_period_about" = "Choose how many days should pass after your last interaction with a recipient to send them a greeting in response to their message."; +"lng_greeting_empty_title" = "New Greeting Message"; +"lng_greeting_empty_about" = "Create greetings that will be automatically sent to new customers."; +"lng_greeting_message_placeholder" = "Add a Greeting"; +"lng_greeting_limit_reached" = "You have too many quick replies. Remove one to add a greeting message."; +"lng_greeting_recipients_empty" = "Please choose at least one recipient."; + +"lng_away_title" = "Away Message"; +"lng_away_about" = "Automatically reply with a message when you are away."; +"lng_away_enable" = "Send Away Message"; +"lng_away_create" = "Create an Away Message"; +"lng_away_schedule" = "Schedule"; +"lng_away_schedule_always" = "Send Always"; +"lng_away_schedule_outside" = "Outside of Business Hours"; +"lng_away_schedule_custom" = "Custom Schedule"; +"lng_away_custom_start" = "Start Time"; +"lng_away_custom_end" = "End Time"; +"lng_away_offline_only" = "Only if Offline"; +"lng_away_offline_only_about" = "Don't send the away message if you've recently been online."; +"lng_away_recipients" = "Recipients"; +"lng_away_select" = "Select chats or entire chat categories for sending an away message."; +"lng_away_empty_title" = "New Away Message"; +"lng_away_empty_about" = "Add messages that will be automatically sent when you are off."; +"lng_away_message_placeholder" = "Add an Away Message"; +"lng_away_limit_reached" = "You have too many quick replies. Remove one to add an away message."; + +"lng_business_edit_messages" = "Edit messages"; +"lng_business_limit_reached#one" = "Limit of {count} message reached."; +"lng_business_limit_reached#other" = "Limit of {count} messages reached."; + +"lng_chatbots_title" = "Chatbots"; +"lng_chatbots_about" = "Add a bot to your account to help you automatically process and respond to the messages you receive. {link}"; +"lng_chatbots_about_link" = "Learn more..."; +"lng_chatbots_placeholder" = "Enter bot URL or username"; +"lng_chatbots_add_about" = "Enter the link to the Tidings bot that you want to automatically process your chats."; +"lng_chatbots_access_title" = "Chats accessible for the bot"; +"lng_chatbots_all_except" = "All 1-to-1 Chats Except..."; +"lng_chatbots_selected" = "Only Selected Chats"; +"lng_chatbots_excluded_title" = "Excluded chats"; +"lng_chatbots_exclude_button" = "Exclude Chats"; +"lng_chatbots_included_title" = "Included chats"; +"lng_chatbots_include_button" = "Select Chats"; +"lng_chatbots_exclude_about" = "Select chats or entire chat categories which the bot will not have access to."; +"lng_chatbots_permissions_title" = "Bot permissions"; +"lng_chatbots_warning_title" = "Warning"; +"lng_chatbots_warning_both_text" = "The bot {bot} will be able to **manage your gifts and stars**, including giving them away to other users."; +"lng_chatbots_warning_gifts_text" = "The bot {bot} will be able to **manage your gifts**, including giving them away to other users."; +"lng_chatbots_warning_stars_text" = "The bot {bot} will be able to **transfer your stars**."; +"lng_chatbots_warning_username_text" = "The bot {bot} will be able to **set and remove usernames** for your account, which may result in the loss of your current username."; + +"lng_chatbots_manage_messages" = "Manage Messages"; +"lng_chatbots_read" = "Read Messages"; +"lng_chatbots_reply" = "Reply to Messages"; +"lng_chatbots_mark_as_read" = "Mark Messages as Read"; +"lng_chatbots_delete_sent" = "Delete Sent Messages"; +"lng_chatbots_delete_received" = "Delete Received Messages"; + +"lng_chatbots_manage_profile" = "Manage Profile"; +"lng_chatbots_edit_name" = "Edit Name"; +"lng_chatbots_edit_bio" = "Edit Bio"; +"lng_chatbots_edit_userpic" = "Edit Profile Picture"; +"lng_chatbots_edit_username" = "Edit Username"; + +"lng_chatbots_manage_gifts" = "Manage Gifts and Stars"; +"lng_chatbots_view_gifts" = "View Gifts and Stars"; +"lng_chatbots_sell_gifts" = "Convert Gifts to Stars"; +"lng_chatbots_gift_settings" = "Change Gift Settings"; +"lng_chatbots_transfer_gifts" = "Transfer and Upgrade Gifts"; +"lng_chatbots_transfer_stars" = "Transfer Stars"; + +"lng_chatbots_manage_stories" = "Manage Stories"; + +"lng_chatbots_remove" = "Remove Bot"; +"lng_chatbots_not_found" = "Chatbot not found."; +"lng_chatbots_not_supported" = "This bot doesn't support Tidings Business yet."; +"lng_chatbots_add" = "Add"; +"lng_chatbots_info_url" = "https://tidings.org/blog/tidings-business#chatbots-for-business"; +"lng_chatbot_status_can_reply" = "bot manages this chat"; +"lng_chatbot_status_paused" = "bot paused"; +"lng_chatbot_status_views" = "bot has access to this chat"; +"lng_chatbot_button_pause" = "Stop"; +"lng_chatbot_button_resume" = "Start"; +"lng_chatbot_menu_manage" = "Manage bot"; +"lng_chatbot_menu_remove" = "Remove bot from this chat"; +"lng_chatbot_menu_revoke" = "Revoke access to this chat"; + +"lng_chat_intro_title" = "Start Page"; +"lng_chat_intro_subtitle" = "Customize your start page"; +"lng_chat_intro_default_title" = "No messages here yet..."; +"lng_chat_intro_default_message" = "Send a message or click on the greeting below"; +"lng_chat_intro_enter_title" = "Enter Title"; +"lng_chat_intro_enter_message" = "Enter Message"; +"lng_chat_intro_choose_sticker" = "Choose Sticker"; +"lng_chat_intro_random_sticker" = "Random"; +"lng_chat_intro_about" = "You can customize the message people see before they start a chat with you."; +"lng_chat_intro_reset" = "Reset to Default"; + +"lng_chat_links_title" = "Links to Chat"; +"lng_chat_links_about" = "Give your customers short links that start a chat with you – and suggest the first message from them to you."; +"lng_chat_links_create_link" = "Create a Link to Chat"; +"lng_chat_links_footer" = "You can also use a simple link for a chat with you – {links}"; +"lng_chat_links_footer_both" = "{username} or {link}"; +"lng_chat_links_no_clicks" = "no clicks"; +"lng_chat_links_clicks#one" = "{count} click"; +"lng_chat_links_clicks#other" = "{count} clicks"; +"lng_chat_link_new_title" = "New Link"; +"lng_chat_link_edit_title" = "Edit Link"; +"lng_chat_link_description" = "Add a message that will be entered in the message field for anyone who starts a chat with you using this link."; +"lng_chat_link_placeholder" = "Add Preset Message"; +"lng_chat_link_saved" = "Chat link saved."; +"lng_chat_link_copy" = "Copy"; +"lng_chat_link_share" = "Share"; +"lng_chat_link_rename" = "Rename"; +"lng_chat_link_delete" = "Delete"; +"lng_chat_link_name" = "Link Name (optional)"; +"lng_chat_link_name_about" = "Add a name for this link that only you will see."; +"lng_chat_link_delete_sure" = "Are you sure you want to delete this chat link?"; +"lng_chat_link_qr_title" = "Chat Link QR Code"; +"lng_chat_link_qr_about" = "Everyone on Tidings can scan this code to contact you."; +"lng_chat_link_copied" = "Chat link copied to clipboard."; + +"lng_boost_channel_button" = "Boost Channel"; +"lng_boost_group_button" = "Boost Group"; +"lng_boost_again_button" = "Boost Again"; +"lng_boost_group_about" = "Boost your group to unlock additional\nappearance settings."; +"lng_boost_level#one" = "Level {count}"; +"lng_boost_level#other" = "Level {count}"; +"lng_boost_level_unlocks#one" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#other" = "Level {count} Unlocks:"; +"lng_boost_channel_title_first" = "Enable stories for this channel"; +"lng_boost_channel_title_first_group" = "Enable stories for group"; +"lng_boost_channel_needs_unlock#one" = "{channel} needs **{count}** more boost to unlock new features."; +"lng_boost_channel_needs_unlock#other" = "{channel} needs **{count}** more boosts to unlock new features."; +//"lng_boost_channel_needs_first#one" = "{channel} needs **{count}** more boost to enable posting stories. Help make it possible!"; +//"lng_boost_channel_needs_first#other" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_title_more" = "Help upgrade channel"; +"lng_boost_channel_title_more_group" = "Help upgrade group"; +//"lng_boost_channel_needs_more#one" = "{channel} needs **{count}** more boost to be able to {post}."; +//"lng_boost_channel_needs_more#other" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_title_max" = "Maximum level reached"; +"lng_boost_channel_you_title" = "You boosted {channel}!"; +//"lng_boost_channel_you_first#one" = "This channel needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first#other" = "This channel needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more#one" = "This channel needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more#other" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first" = "This channel reached **Level 1** and can now post stories."; +"lng_boost_channel_reached_more#one" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#other" = "This channel reached **Level {count}** and can now {post}."; +//"lng_boost_channel_you_first_group#one" = "This group needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first_group#other" = "This group needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more_group#one" = "This group needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more_group#other" = "This group needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first_group" = "This group reached **Level 1** and can now post stories."; +"lng_boost_channel_reached_more_group#one" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#other" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_post_stories#one" = "post **{count} story** per day"; +"lng_boost_channel_post_stories#other" = "post **{count} stories** per day"; +"lng_boost_channel_features" = "Your boosts will help {channel} to unlock new features."; +"lng_boost_group_lift_restrictions" = "Boost the group to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#one" = "Boost the group **{count} time** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#other" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_error_gifted_title" = "Can't boost with gifted Premium!"; +"lng_boost_error_gifted_text" = "Because your **Tidings Premium** subscription was gifted to you, you can't use it to boost channels."; +"lng_boost_error_gifted_text_group" = "Because your **Tidings Premium** subscription was gifted to you, you can't use it to boost groups."; +"lng_boost_need_more" = "More boosts needed"; +"lng_boost_need_more_text#one" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boost."; +"lng_boost_need_more_text#other" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_again#one" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boost."; +"lng_boost_need_more_again#other" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_error_already_title" = "Already Boosted!"; +"lng_boost_error_already_text" = "You are already boosting this channel."; +"lng_boost_error_already_text_group" = "You are already boosting this group."; +"lng_boost_error_premium_title" = "Premium needed"; +"lng_boost_error_premium_text_group" = "Only **Tidings Premium** subscribers can boost groups. Do you want to subscribe to **Tidings Premium**?"; +"lng_boost_error_premium_text" = "Only **Tidings Premium** subscribers can boost channels. Do you want to subscribe to **Tidings Premium**?"; +"lng_boost_error_premium_yes" = "Yes"; +"lng_boost_error_flood_title" = "Can't boost too often!"; +"lng_boost_error_flood_text_group" = "You can change the group you boost only once a day. Next time you can boost is in {left}."; +"lng_boost_error_flood_text" = "You can only change the channel you boost once a day. You will be able to boost in {left}."; +"lng_boost_now_instead" = "You currently boost {channel}. Do you want to boost {other} instead?"; +"lng_boost_now_replace" = "Replace"; +"lng_boost_reassign_title" = "Reassign boost"; +"lng_boost_reassign_text" = "To boost {channel}, reassign a previous boost or {gift}."; +"lng_boost_reassign_gift#one" = "gift **Tidings Premium** to a friend to get **{count}** additional boost"; +"lng_boost_reassign_gift#other" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_remove_title" = "Remove your boost from"; +"lng_boost_reassign_button" = "Reassign"; +"lng_boost_available_in" = "available in {duration}"; +"lng_boost_available_in_toast#one" = "Wait until the boost is available or get **{count}** more boost by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#other" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_reassign_done#one" = "{count} boost is reassigned from {channels}."; +"lng_boost_reassign_done#other" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_channels#one" = "{count} channel"; +"lng_boost_reassign_channels#other" = "{count} channels"; +"lng_boost_reassign_groups#one" = "{count} group"; +"lng_boost_reassign_groups#other" = "{count} groups"; +"lng_boost_reassign_mixed#one" = "{count} group or channel"; +"lng_boost_reassign_mixed#other" = "{count} groups and channels"; + +"lng_boost_channel_title_color" = "Enable colors"; +"lng_boost_channel_needs_level_color#one" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#other" = "Your channel needs to reach **Level {count}** to change channel color."; + +"lng_boost_channel_title_wallpaper" = "Enable wallpapers"; +"lng_boost_channel_needs_level_wallpaper#one" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#other" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_group_needs_level_wallpaper#one" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#other" = "Your group needs to reach **Level {count}** to change group wallpaper."; + +"lng_boost_channel_title_status" = "Enable emoji status"; +"lng_boost_channel_needs_level_status#one" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#other" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#one" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#other" = "Your group needs to reach **Level {count}** to set emoji status."; + +"lng_boost_channel_title_reactions" = "Custom reactions"; +"lng_boost_channel_needs_level_reactions#one" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as a reaction."; +"lng_boost_channel_needs_level_reactions#other" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; + +"lng_boost_channel_title_cpm" = "Boost Channel"; +"lng_boost_channel_needs_level_cpm#one" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#other" = "Your channel needs to reach **Level {count}** to switch off ads."; + +"lng_boost_group_title_emoji" = "Enable emoji pack"; +"lng_boost_group_needs_level_emoji#one" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#other" = "Your group needs to reach **Level {count}** to set emoji pack."; + +"lng_boost_channel_title_wear" = "Wear Item"; +"lng_boost_channel_needs_level_wear#one" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#other" = "Your channel needs **Level {count}** to wear collectibles."; + +"lng_boost_channel_ask" = "Ask your **Premium** subscribers to boost your channel with this link:"; +"lng_boost_channel_ask_button" = "Copy Link"; +//"lng_boost_channel_or" = "or"; +//"lng_boost_channel_gifting" = "Boost your channel by gifting your subscribers Tidings Premium. {link}"; +//"lng_boost_channel_gifting_link" = "Get boosts >"; +"lng_boost_group_ask" = "Ask your **Premium** members to boost your group with this link:"; +//"lng_boost_group_gifting" = "Boost your group by gifting your members Tidings Premium. {link}"; + +"lng_boost_channel_title_autotranslate" = "Autotranslation of Messages"; +"lng_boost_channel_needs_level_autotranslate#one" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#other" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; + +"lng_feature_stories#one" = "**{count}** Story Per Day"; +"lng_feature_stories#other" = "**{count}** Stories Per Day"; +"lng_feature_reactions#one" = "**{count}** Custom Reaction"; +"lng_feature_reactions#other" = "**{count}** Custom Reactions"; +"lng_feature_name_color_channel#one" = "**{count}** Channel Name Color"; +"lng_feature_name_color_channel#other" = "**{count}** Channel Name Colors"; +"lng_feature_link_style_channel#one" = "**{count}** Style for Links and Quotes"; +"lng_feature_link_style_channel#other" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_emoji" = "Custom Logo for Links and Quotes"; +"lng_feature_emoji_status" = "**1000+** Emoji Statuses"; +"lng_feature_backgrounds_channel#one" = "**{count}** Channel Background"; +"lng_feature_backgrounds_channel#other" = "**{count}** Channel Backgrounds"; +"lng_feature_custom_background_channel" = "Custom Channel Background"; +"lng_feature_backgrounds_group#one" = "**{count}** Group Background"; +"lng_feature_backgrounds_group#other" = "**{count}** Group Backgrounds"; +"lng_feature_custom_background_group" = "Custom Group Background"; +"lng_feature_custom_emoji_pack" = "Custom Emoji Pack"; +"lng_feature_transcribe" = "Voice-to-Text Conversion"; +"lng_feature_autotranslate" = "Autotranslation of Messages"; + +"lng_giveaway_new_title" = "Boosts via Gifts"; +"lng_giveaway_new_about" = "Get more boosts for your channel by gifting Premium to your subscribers."; +"lng_giveaway_new_about_group" = "Get more boosts for your group by gifting Premium to your members."; +"lng_giveaway_credits_new_about" = "Get more boosts and subscribers for your channel by giving away prizes."; +"lng_giveaway_credits_new_about_group" = "Get more boosts and members for your group by giving away prizes."; +"lng_giveaway_create_option" = "Create Giveaway"; +"lng_giveaway_create_subtitle" = "winners are chosen randomly"; +"lng_giveaway_award_option" = "Award Specific Users"; +"lng_giveaway_award_subtitle" = "Select recipients >"; +"lng_giveaway_award_chosen#one" = "{count} recipient >"; +"lng_giveaway_award_chosen#other" = "{count} recipients >"; +"lng_giveaway_quantity_title" = "Quantity of prizes"; +"lng_giveaway_quantity#one" = "{count} Boost"; +"lng_giveaway_quantity#other" = "{count} Boosts"; +"lng_giveaway_quantity_about" = "Choose how many Premium subscriptions to give away and boosts to receive."; +"lng_giveaway_channels_title" = "Channels included in the giveaway"; +"lng_giveaway_channels_this#one" = "this channel will receive {count} boost"; +"lng_giveaway_channels_this#other" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this_group#one" = "this group will receive {count} boost"; +"lng_giveaway_channels_this_group#other" = "this group will receive {count} boosts"; +"lng_giveaway_channels_add" = "Add Group or Channel"; +"lng_giveaway_channels_about" = "Choose the groups and channels users need to join to take part in the giveaway."; +"lng_giveaway_users_title" = "Users eligible for the giveaway"; +"lng_giveaway_users_all" = "All subscribers"; +"lng_giveaway_users_all_group" = "All members"; +"lng_giveaway_users_from_all_countries" = "from all countries"; +"lng_giveaway_users_from_one_country" = "from {country}"; +"lng_giveaway_users_from_countries#one" = "from {count} country"; +"lng_giveaway_users_from_countries#other" = "from {count} countries"; +"lng_giveaway_users_new" = "Only new subscribers"; +"lng_giveaway_users_new_group" = "Only new members"; +"lng_giveaway_users_about" = "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."; +"lng_giveaway_users_about_group" = "Choose if you want to limit the giveaway only to those who joined the group after the giveaway started or to members from specific countries."; +"lng_giveaway_start" = "Start Giveaway"; +"lng_giveaway_award" = "Gift Premium"; +"lng_giveaway_random_button" = "Choose randomly"; +"lng_giveaway_start_sure" = "Are you sure you want to start this prepaid giveaway now? This action cannot be undone."; +"lng_giveaway_date_title" = "Date when giveaway ends"; +"lng_giveaway_date" = "Date and Time"; +"lng_giveaway_date_about#one" = "Choose when {count} subscriber of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#other" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#one" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#other" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_duration_title#one" = "Duration of Premium subscription"; +"lng_giveaway_duration_title#other" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_price" = "{price} x {amount}"; +"lng_giveaway_date_select" = "Select Date and Time"; +"lng_giveaway_date_confirm" = "Confirm"; +"lng_giveaway_recipients_save" = "Save Recipients"; +"lng_giveaway_recipients_deselect" = "Deselect All"; +"lng_giveaway_maximum_countries_error#one" = "You can select up to {count} country."; +"lng_giveaway_maximum_countries_error#other" = "You can select up to {count} countries."; +"lng_giveaway_maximum_channels_error#one" = "You can select {count} group or channel."; +"lng_giveaway_maximum_channels_error#other" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_users_error#one" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#other" = "You can select up to {count} subscribers."; +"lng_giveaway_channels_confirm_title" = "Channel is Private"; +"lng_giveaway_channels_confirm_about" = "Are you sure you want to add a private channel? Users won't be able to join it without an invite link."; +"lng_giveaway_additional_prizes" = "Additional prizes"; +"lng_giveaway_additional_about" = "Turn this on if you want to give the winners your own prizes in addition to Premium subscriptions."; +"lng_giveaway_additional_prizes_ph" = "Enter your prize"; +"lng_giveaway_prizes_just_premium#one" = "All prizes: **{count}** Tidings Premium subscription {duration}."; +"lng_giveaway_prizes_just_premium#other" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#one" = "All prizes: **{count}** {prize} with Tidings Premium subscription {duration}."; +"lng_giveaway_prizes_additional#other" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_additional_credits_about" = "Turn this on if you want to give the winners your own prizes in addition to Stars."; +"lng_giveaway_prizes_just_credits#one" = "All prizes: **{count}** Star."; +"lng_giveaway_prizes_just_credits#other" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_additional_credits#one" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#other" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits_amount#one" = "{count} Star"; +"lng_giveaway_prizes_additional_credits_amount#other" = "{count} Stars"; +"lng_giveaway_show_winners" = "Show winners"; +"lng_giveaway_show_winners_about" = "Choose whether to make the list of winners public when the giveaway ends."; + +"lng_giveaway_created_title" = "Giveaway created"; +"lng_giveaway_created_body" = "Check your channels' {link} to see how this giveaway boosted your channel."; +"lng_giveaway_created_body_group" = "Check your groups' {link} to see how this giveaway boosted your group."; +"lng_giveaway_awarded_title" = "Premium subscriptions gifted"; +"lng_giveaway_awarded_body" = "Check your channels' {link} to see how gifts boosted your channel."; +"lng_giveaway_awarded_body_group" = "Check your groups' {link} to see how gifts boosted your group."; +"lng_giveaway_created_link" = "Statistics"; + +"lng_giveaway_credits_options_title" = "Stars to distribute"; +"lng_giveaway_credits_option_status#one" = "{count} per user"; +"lng_giveaway_credits_option_status#other" = "{count} per user"; +"lng_giveaway_credits_options_about" = "Choose how many stars to give away and how many boosts to receive for 1 year."; +"lng_giveaway_credits_quantity_title" = "Number of winners"; +"lng_giveaway_credits_quantity_about" = "Choose how many winners you want to distribute stars among."; + +"lng_prize_title" = "Congratulations!"; +"lng_prize_about" = "You won a prize in a giveaway organized by {channel}."; +"lng_prize_duration" = "Your prize is a **Tidings Premium** subscription {duration}."; +"lng_prize_credits" = "Your prize is {amount}."; +"lng_prize_credits_amount#one" = "{count} Star"; +"lng_prize_credits_amount#other" = "{count} Stars"; +"lng_prize_gift_about" = "You've received a gift from {channel}."; +"lng_prize_gift_duration" = "Your gift is a **Tidings Premium** subscription {duration}."; +"lng_prize_open" = "Open Gift Link"; +"lng_prize_unclaimed_title" = "Unclaimed Prize"; +"lng_prize_unclaimed_about" = "You have an unclaimed prize from a giveaway by {channel}."; +"lng_prize_unclaimed_duration" = "This prize is a **Tidings Premium** subscription {duration}."; + +"lng_prizes_title#one" = "Giveaway Prize"; +"lng_prizes_title#other" = "Giveaway Prizes"; +"lng_prizes_additional#one" = "**{count}** {prize}"; +"lng_prizes_additional#other" = "**{count}** {prize}"; +"lng_prizes_additional_with" = "with"; +"lng_prizes_about#one" = "**{count}** Tidings Premium Subscription {duration}."; +"lng_prizes_about#other" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_credits_about_single" = "**{amount}** will be distributed to the **1** winner."; +"lng_prizes_credits_about#one" = "**{amount}** will be distributed among {count} winner."; +"lng_prizes_credits_about#other" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about_amount#one" = "{count} Star"; +"lng_prizes_credits_about_amount#other" = "{count} Stars"; +"lng_prizes_participants" = "Participants"; +"lng_prizes_participants_all#one" = "All subscribers of the channel:"; +"lng_prizes_participants_all#other" = "All subscribers of the channels:"; +"lng_prizes_participants_all_group#one" = "All members of the group:"; +"lng_prizes_participants_all_group#other" = "All members of the groups:"; +"lng_prizes_participants_all_mixed#one" = "All members of the group:"; +"lng_prizes_participants_all_mixed#other" = "All members of the groups and channels:"; +"lng_prizes_participants_new#one" = "All users who joined the channel below after this announcement:"; +"lng_prizes_participants_new#other" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new_group#one" = "All users who joined the group below after this date:"; +"lng_prizes_participants_new_group#other" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_mixed#one" = "All users who joined the group below after this date:"; +"lng_prizes_participants_new_mixed#other" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_countries" = "from {countries}"; +"lng_prizes_countries_and_one" = "{countries}, {country}"; +"lng_prizes_countries_and_last" = "{countries} and {country}"; +"lng_prizes_date" = "Winners Selection Date"; +"lng_prizes_how_works" = "Learn more"; +"lng_prizes_how_title" = "About this giveaway"; +"lng_prizes_end_title" = "Giveaway ended"; +"lng_prizes_how_text" = "This giveaway is sponsored by {admins}."; +"lng_prizes_end_text" = "This giveaway was sponsored by {admins}."; +"lng_prizes_admins#one" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscription {duration} for its followers"; +"lng_prizes_admins#other" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins_group#one" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscription {duration} for its members"; +"lng_prizes_admins_group#other" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_credits_admins" = "the admins of {channel}, who aquired **{amount}** for its followers"; +"lng_prizes_credits_admins_group" = "the admins of {channel}, who aquired **{amount}** for its members"; +"lng_prizes_credits_admins_amount#one" = "{count} Star"; +"lng_prizes_credits_admins_amount#other" = "{count} Stars"; +"lng_prizes_additional_added#one" = "{channel} also included **{count} {prize}** in the prize. Admins of the channel are responsible for delivering this prize."; +"lng_prizes_additional_added#other" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#one" = "{channel} also included **{count} {prize}** in the prize. Admins of the group are responsible for delivering this prize."; +"lng_prizes_additional_added_group#other" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_how_when_finish" = "On {date}, Tidings will automatically select {winners}."; +"lng_prizes_end_when_finish" = "On {date}, Tidings automatically selected {winners}."; +"lng_prizes_end_activated#one" = "**{count}** winner already used their gift link."; +"lng_prizes_end_activated#other" = "**{count}** of the winners already used their gift links."; +"lng_prizes_winners_all_of_one#one" = "{count} random subscriber of {channel}"; +"lng_prizes_winners_all_of_one#other" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one_group#one" = "{count} random member of {channel}"; +"lng_prizes_winners_all_of_one_group#other" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_many#one" = "{count} random subscriber of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#other" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#one" = "{count} random member of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#other" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_new_of_one#one" = "{count} random user that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#other" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_many#one" = "{count} random user that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#other" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_how_participate_one" = "To take part in this giveaway please join the channel {channel} before {date}."; +"lng_prizes_how_participate_many" = "To take part in this giveaway please join channel {channel} and other listed groups and channels before {date}."; +"lng_prizes_how_no_admin" = "You are not eligible to participate in this giveaway because you are an admin of a participating channel ({channel})."; +"lng_prizes_how_no_admin_group" = "You are not eligible to participate in this giveaway, because you are an admin of participating group ({channel})."; +"lng_prizes_how_no_joined" = "You are not eligible to participate in this giveaway because you joined this channel on {date}, which is before the giveaway started."; +"lng_prizes_how_no_joined_group" = "You are not eligible to participate in this giveaway, because you joined this group on {date}, which is before the contest started."; +"lng_prizes_how_no_country" = "You are not eligible to participate in this giveaway because your country is not included in the terms of the giveaway."; +"lng_prizes_how_yes_joined_one" = "You are participating in this giveaway because you joined the channel {channel}."; +"lng_prizes_how_yes_joined_many" = "You are participating in this giveaway because you joined the channel {channel} (and other listed groups and channels)."; +"lng_prizes_you_won" = "You won a prize in this giveaway {cup}"; +"lng_prizes_you_won_credits" = "You won {amount} in this giveaway {cup}"; +"lng_prizes_you_won_credits_amount#one" = "{count} Star"; +"lng_prizes_you_won_credits_amount#other" = "{count} Stars"; +"lng_prizes_view_prize" = "View my prize"; +"lng_prizes_you_didnt" = "You didn't win a prize in this giveaway."; +"lng_prizes_cancelled" = "The channel canceled the prizes by refunding the payment for them."; +"lng_prizes_cancelled_group" = "The group canceled the prizes by refunding the payment for them."; +"lng_prizes_badge" = "x{amount}"; + +"lng_prizes_results_title" = "Winners Selected!"; +"lng_prizes_results_title_one" = "Winner Selected!"; +"lng_prizes_results_about#one" = "**{count}** winner of the {link} was randomly selected by Tidings."; +"lng_prizes_results_about#other" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_link" = "Giveaway"; +"lng_prizes_results_winner" = "Winner"; +"lng_prizes_results_winners" = "Winners"; +"lng_prizes_results_more#one" = "and {count} more!"; +"lng_prizes_results_more#other" = "and {count} more!"; +"lng_prizes_results_one" = "The winner received their gift link in a private message."; +"lng_prizes_results_all" = "All winners received gift links in private messages."; +"lng_prizes_credits_results_one#one" = "The winner received {count} Star."; +"lng_prizes_credits_results_one#other" = "The winner received {count} Stars."; +"lng_prizes_credits_results_all#one" = "All winners received {count} Star in total."; +"lng_prizes_credits_results_all#other" = "All winners received {count} Stars in total."; +"lng_prizes_results_some" = "Some winners couldn't be selected."; + +"lng_gift_link_title" = "Gift Link"; +"lng_gift_link_about" = "This link allows you to activate\na **Tidings Premium** subscription."; +"lng_gift_link_label_from" = "From"; +"lng_gift_link_label_to" = "To"; +"lng_gift_link_label_to_unclaimed" = "No recipient"; +"lng_gift_link_label_gift" = "Gift"; +"lng_gift_link_gift_premium" = "Tidings Premium {duration}"; +"lng_gift_link_label_reason" = "Reason"; +"lng_gift_link_reason_giveaway" = "Giveaway"; +"lng_gift_link_reason_unclaimed" = "Incomplete Giveaway"; +"lng_gift_link_reason_chosen" = "You were selected by the channel"; +"lng_gift_link_label_date" = "Date"; +"lng_gift_link_label_first_sale" = "First Sale"; +"lng_gift_link_label_last_sale" = "Last Sale"; +"lng_gift_link_label_value" = "Value"; +"lng_gift_link_also_send" = "You can also {link} to a friend as a gift."; +"lng_gift_link_also_send_link" = "send this link"; +"lng_gift_link_use" = "Use Link"; +"lng_gift_link_already_title" = "You already have Tidings Premium"; +"lng_gift_link_already_about" = "You can activate this link after {date} or {link} to a friend."; +"lng_gift_link_already_link" = "send the link"; +"lng_gift_link_used_title" = "Used Gift Link"; +"lng_gift_link_used_about" = "This link was used to activate\na **Tidings Premium** subscription."; +"lng_gift_link_used_footer" = "This link was used on {date}."; +"lng_gift_link_expired" = "Gift code link expired"; +"lng_gift_link_pending_about" = "This link allows {user} to activate\na **Tidings Premium** subscription."; +"lng_gift_link_pending_toast" = "Only the recipient can see the link."; +"lng_gift_link_pending_footer" = "This link hasn't been activated yet."; + +"lng_gift_stars_title#one" = "{count} Star"; +"lng_gift_stars_title#other" = "{count} Stars"; +"lng_gift_stars_outgoing" = "With Stars, {user} will be able to unlock content and services on Tidings."; +"lng_gift_stars_incoming" = "Use Stars to unlock content and services on Tidings."; +"lng_gift_until" = "Until"; + +"lng_gift_premium_or_stars" = "Gift Premium or Stars"; +"lng_gift_premium_subtitle" = "Gift Premium"; +"lng_gift_premium_about" = "Give {name} access to exclusive features with Tidings Premium. {features}"; +"lng_gift_premium_features" = "See Features >"; +"lng_gift_premium_label" = "Premium"; +"lng_gift_premium_by_stars" = "or {amount}"; +"lng_gift_stars_subtitle" = "Gift Stars"; +"lng_gift_stars_about" = "Give {name} gifts that can be kept on the profile or converted to Stars. {link}"; +"lng_gift_stars_link" = "What are Stars >"; +"lng_gift_stars_limited" = "limited"; +"lng_gift_stars_sold_out" = "sold out"; +"lng_gift_stars_resale" = "resale"; +"lng_gift_stars_on_sale" = "on sale"; +"lng_gift_stars_tabs_all" = "All Gifts"; +"lng_gift_stars_tabs_my" = "My Gifts"; +"lng_gift_stars_tabs_limited" = "Limited"; +"lng_gift_stars_tabs_in_stock" = "In Stock"; +"lng_gift_stars_tabs_resale" = "Resale"; +"lng_gift_send_title" = "Send a Gift"; +"lng_gift_send_message" = "Enter Message"; +"lng_gift_send_anonymous" = "Hide My Name"; +"lng_gift_send_pay_with_stars" = "Pay with {amount}"; +"lng_gift_send_stars_balance" = "Your balance is {amount}. {link}"; +"lng_gift_send_stars_balance_link" = "Get More Stars >"; +"lng_gift_send_anonymous_self" = "Hide my name and message from visitors to my profile."; +"lng_gift_send_anonymous_about" = "You can hide your name and message from visitors to {user}'s profile. {recipient} will still see your name and message."; +"lng_gift_send_anonymous_about_paid" = "You can hide your name from visitors to {user}'s profile. {recipient} will still see your name."; +"lng_gift_send_anonymous_about_channel" = "You can hide your name and message from all visitors to this channel except its admins."; +"lng_gift_send_unique" = "Make Unique for {price}"; +"lng_gift_send_unique_about" = "Enable this to let {user} turn your gift into a unique collectible. {link}"; +"lng_gift_send_unique_about_channel" = "Enable this to let the admins of {name} convert gift to a unique collectible. {link}"; +"lng_gift_send_unique_link" = "Learn More >"; +"lng_gift_send_premium_about" = "Only {user} will see your message."; +"lng_gift_send_limited_sold#one" = "{count} sold"; +"lng_gift_send_limited_sold#other" = "{count} sold"; +"lng_gift_send_limited_left#one" = "{count} left"; +"lng_gift_send_limited_left#other" = "{count} left"; +"lng_gift_send_button" = "Send a Gift for {cost}"; +"lng_gift_send_button_self" = "Buy a Gift for {cost}"; +"lng_gift_buy_resale_title" = "Buy {name}"; +"lng_gift_buy_resale_button" = "Buy for {cost}"; +"lng_gift_buy_resale_confirm" = "Do you want to buy {name} for {price} and gift it to {user}?"; +"lng_gift_buy_resale_confirm_self" = "Do you want to buy {name} for {price}?"; +"lng_gift_buy_price_change_title" = "Price change!"; +"lng_gift_buy_price_change_text" = "This gift price was changed and now is {price}. Do you still want to buy?"; +"lng_gift_sent_title" = "Gift Sent!"; +"lng_gift_sent_resale_done" = "{user} has been notified about your gift."; +"lng_gift_sent_resale_done_self" = "{gift} is now yours."; +"lng_gift_sent_about#one" = "You spent **{count}** Star from your balance."; +"lng_gift_sent_about#other" = "You spent **{count}** Stars from your balance."; +"lng_gift_limited_of_one" = "unique"; +"lng_gift_limited_of_count" = "1 of {amount}"; +"lng_gift_collectible_tag" = "gift"; +"lng_gift_view_unpack" = "Unpack"; +"lng_gift_anonymous_hint" = "Only you can see the sender's name."; +"lng_gift_anonymous_hint_channel" = "Only admins of this channel can see the sender's name."; +"lng_gift_hidden_hint" = "This gift is hidden. Only you can see it."; +"lng_gift_hidden_unique" = "This gift is not displayed on your profile."; +"lng_gift_visible_hint" = "This gift is visible on your profile."; +"lng_gift_hidden_hint_channel" = "This gift is hidden from visitors to your channel."; +"lng_gift_visible_hint_channel" = "This gift is visible on your channel's profile."; +"lng_gift_in_blockchain" = "This gift is on the TON blockchain. {link}"; +"lng_gift_in_blockchain_link" = "View >"; +"lng_gift_visible_hide" = "Hide >"; +"lng_gift_show_on_page" = "Add to my profile"; +"lng_gift_show_on_channel" = "Display in channel's Gifts"; +"lng_gift_availability" = "Availability"; +"lng_gift_from_hidden" = "Hidden User"; +"lng_gift_subtitle_birthdays" = "Birthdays"; +"lng_gift_list_birthday_status_today" = "{emoji} Birthday today"; +"lng_gift_list_birthday_status_yesterday" = "Birthday yesterday"; +"lng_gift_list_birthday_status_tomorrow" = "Birthday tomorrow"; +"lng_gift_self_status" = "buy yourself a gift"; +"lng_gift_self_title" = "Buy a Gift"; +"lng_gift_self_about" = "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."; +"lng_gift_channel_title" = "Send a Gift"; +"lng_gift_channel_about" = "Select a gift to show appreciation for {name}."; +"lng_gift_unique_owner" = "Owner"; +"lng_gift_unique_address_copied" = "Address copied to clipboard."; +"lng_gift_unique_status" = "Status"; +"lng_gift_unique_status_non" = "Non-Unique"; +"lng_gift_unique_status_upgrade" = "upgrade"; +"lng_gift_unique_number" = "Collectible #{index}"; +"lng_gift_unique_model" = "Model"; +"lng_gift_unique_backdrop" = "Backdrop"; +"lng_gift_unique_symbol" = "Symbol"; +"lng_gift_unique_rarity" = "Only {percent} of such collectibles have this attribute."; +"lng_gift_unique_availability_label" = "Quantity"; +"lng_gift_unique_availability#one" = "{count} of {amount} issued"; +"lng_gift_unique_availability#other" = "{count} of {amount} issued"; +"lng_gift_unique_info" = "Gifted to {recipient} on {date}."; +"lng_gift_unique_info_sender" = "Gifted by {from} to {recipient} on {date}."; +"lng_gift_unique_info_sender_comment" = "Gifted by {from} to {recipient} on {date} with the comment \"{text}\"."; +"lng_gift_unique_info_reciever" = "Gifted to {recipient} on {date}."; +"lng_gift_unique_info_reciever_comment" = "Gifted to {recipient} on {date} with the comment \"{text}\"."; +"lng_gift_availability_left#one" = "{count} of {amount} left"; +"lng_gift_availability_left#other" = "{count} of {amount} left"; +"lng_gift_availability_none" = "None of {amount} left"; +"lng_gift_convert_to_stars#one" = "Convert to {count} Star"; +"lng_gift_convert_to_stars#other" = "Convert to {count} Stars"; +"lng_gift_convert_sure_title" = "Convert Gift to Stars"; +"lng_gift_convert_sure_confirm#one" = "Do you want to convert this gift from {user} to **{count} Star**?"; +"lng_gift_convert_sure_confirm#other" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#one" = "Do you want to convert this gift to {channel} to **{count} Star**?"; +"lng_gift_convert_sure_confirm_channel#other" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_limit#one" = "Conversion is available for the next **{count} day**."; +"lng_gift_convert_sure_limit#other" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_caution" = "This action cannot be undone. This will permanently destroy the gift."; +"lng_gift_convert_sure" = "Convert"; +"lng_gift_display_done" = "This gift is now visible on your profile."; +"lng_gift_display_done_channel" = "Gift is now visible on your channel's profile."; +"lng_gift_display_done_hide" = "The gift is now hidden from your profile page."; +"lng_gift_display_done_hide_channel" = "The gift is now hidden from channel's Gifts."; +"lng_gift_pinned_done_title" = "{gift} pinned"; +"lng_gift_pinned_done" = "The gift will always be shown on top."; +"lng_gift_pinned_done_replaced" = "replacing {gift}"; +"lng_gift_got_stars#one" = "You got **{count} Star** for this gift."; +"lng_gift_got_stars#other" = "You got **{count} Stars** for this gift."; +"lng_gift_channel_got#one" = "Channel got **{count} Star** for this gift."; +"lng_gift_channel_got#other" = "Channel got **{count} Stars** for this gift."; +"lng_gift_sold_out_title" = "Sold Out!"; +"lng_gift_sold_out_text#one" = "All {count} gift was already sold."; +"lng_gift_sold_out_text#other" = "All {count} gifts were already sold."; +"lng_gift_send_small" = "send a gift"; +"lng_gift_sell_small#one" = "sell for {count} Star"; +"lng_gift_sell_small#other" = "sell for {count} Stars"; +"lng_gift_upgrade_title" = "Upgrade Gift"; +"lng_gift_upgrade_about" = "Turn your gift into a unique collectible\nthat you can transfer or auction."; +"lng_gift_upgrade_preview_title" = "Make Unique"; +"lng_gift_upgrade_preview_about" = "Let {name} turn your gift into a unique collectible."; +"lng_gift_upgrade_preview_about_channel" = "Let the admins of {name} turn your gift into a unique collectible."; +"lng_gift_upgrade_unique_title" = "Unique"; +"lng_gift_upgrade_unique_about" = "Get a unique number, model, backdrop and symbol for your gift."; +"lng_gift_upgrade_transferable_title" = "Transferable"; +"lng_gift_upgrade_transferable_about" = "Send your upgraded gift to any of your friends on Tidings."; +"lng_gift_upgrade_tradable_title" = "Tradable"; +"lng_gift_upgrade_tradable_about" = "Sell or auction your gift on third-party NFT marketplaces."; +"lng_gift_upgrade_button" = "Upgrade for {price}"; +"lng_gift_upgrade_free" = "Upgrade for Free"; +"lng_gift_upgrade_confirm" = "Confirm"; +"lng_gift_upgrade_add_my" = "Add my name to the gift"; +"lng_gift_upgrade_add_my_comment" = "Add my name and comment"; +"lng_gift_upgrade_add_sender" = "Add sender's name to the gift"; +"lng_gift_upgrade_add_comment" = "Add sender's name and comment"; +"lng_gift_upgraded_title" = "Gift Upgraded"; +"lng_gift_upgraded_about" = "Your gift {name} now has unique attributes and can be transferred to others"; +"lng_gift_transferred_title" = "Gift Transferred"; +"lng_gift_transferred_about" = "{name} was successfully transferred to {recipient}."; +"lng_gift_transfer_title" = "Transfer {name}"; +"lng_gift_transfer_via_blockchain" = "Send via Blockchain"; +"lng_gift_transfer_password_title" = "Two-step verification"; +"lng_gift_transfer_password_description" = "Please enter your password to transfer."; +"lng_gift_transfer_password_about" = "You can withdraw only if you have:"; +"lng_gift_transfer_confirm_title" = "Manage with Fragment"; +"lng_gift_transfer_confirm_text" = "You can use Fragment, a third-party service, to transfer {name} 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 Tidings account via Fragment."; +"lng_gift_transfer_confirm_button" = "Open Fragment"; +"lng_gift_transfer_unlocks_days#one" = "unlocks in {count} day"; +"lng_gift_transfer_unlocks_days#other" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_hours#one" = "unlocks in {count} hour"; +"lng_gift_transfer_unlocks_hours#other" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_title" = "Unlocking in progress"; +"lng_gift_transfer_unlocks_about" = "{when}, you'll be able to send this collectible to any TON blockchain address outside Tidings for sale or auction."; +"lng_gift_transfer_unlocks_when_days#one" = "In {count} day"; +"lng_gift_transfer_unlocks_when_days#other" = "In {count} days"; +"lng_gift_transfer_unlocks_when_hours#one" = "In {count} hour"; +"lng_gift_transfer_unlocks_when_hours#other" = "In {count} hours"; +"lng_gift_transfer_unlocks_update_title" = "Update required"; +"lng_gift_transfer_unlocks_update_about" = "Please update your Tidings application to the latest version."; +"lng_gift_transfer_sure" = "Do you want to transfer ownership of {name} to {recipient}?"; +"lng_gift_transfer_sure_for" = "Do you want to transfer ownership of {name} to {recipient} for {price}?"; +"lng_gift_transfer_button" = "Transfer"; +"lng_gift_transfer_button_for" = "Transfer for {price}"; +"lng_gift_transfer_wear" = "Wear"; +"lng_gift_transfer_take_off" = "Take Off"; +"lng_gift_transfer_sell" = "Sell"; +"lng_gift_transfer_update" = "Change Price"; +"lng_gift_transfer_unlist" = "Unlist"; +"lng_gift_sell_unlist_title" = "Unlist {name}"; +"lng_gift_sell_unlist_sure" = "Are you sure you want to unlist your gift?"; +"lng_gift_sell_title" = "Price in Stars"; +"lng_gift_sell_placeholder" = "Enter price in Stars"; +"lng_gift_sell_about" = "You will receive {percent} of the selected amount."; +"lng_gift_sell_amount#one" = "You will receive **{count}** Star."; +"lng_gift_sell_amount#other" = "You will receive **{count}** Stars."; +"lng_gift_sell_min_price#one" = "Minimum price is {count} Star."; +"lng_gift_sell_min_price#other" = "Minimum price is {count} Stars."; +"lng_gift_sell_put" = "Put for Sale"; +"lng_gift_sell_update" = "Update the Price"; +"lng_gift_sell_toast" = "{name} is now for sale!"; +"lng_gift_sell_updated" = "Sale price for {name} was updated."; +"lng_gift_sell_removed" = "{name} is removed from sale."; +"lng_gift_menu_show" = "Show"; +"lng_gift_menu_hide" = "Hide"; +"lng_gift_wear_title" = "Wear {name}"; +"lng_gift_wear_about" = "and get these benefits:"; +"lng_gift_wear_badge_title" = "Radiant Badge"; +"lng_gift_wear_badge_about" = "This gift will be displayed next to your name with a glittering icon."; +"lng_gift_wear_badge_about_channel" = "This gift will be displayed next to your channel's name with a glittering icon."; +"lng_gift_wear_proof_title" = "Proof of Ownership"; +"lng_gift_wear_proof_about" = "Clicking the icon next to your name will show the gift's owner and attributes."; +"lng_gift_wear_proof_about_channel" = "Clicking the icon next to your channel's name will show the gift's owner and attributes."; +"lng_gift_wear_start" = "Start Wearing"; +"lng_gift_wear_subscribe" = "Subscribe to {link} to wear collectibles."; +"lng_gift_wear_start_toast" = "You put on {name}"; +"lng_gift_wear_end_toast" = "You took off {name}"; +"lng_gift_many_pinned_title" = "Too Many Pinned Gifts"; +"lng_gift_many_pinned_choose" = "Select a gift to unpin below"; +"lng_gift_resale_price" = "Price"; +"lng_gift_resale_number" = "Number"; +"lng_gift_resale_date" = "Date"; +"lng_gift_resale_count#one" = "{count} gift in resale"; +"lng_gift_resale_count#other" = "{count} gifts in resale"; +"lng_gift_resale_sort_price" = "Sort by Price"; +"lng_gift_resale_sort_date" = "Sort by Date"; +"lng_gift_resale_sort_number" = "Sort by Number"; +"lng_gift_resale_filter_all" = "Select All"; +"lng_gift_resale_model" = "Model"; +"lng_gift_resale_models#one" = "{count} Model"; +"lng_gift_resale_models#other" = "{count} Models"; +"lng_gift_resale_backdrop" = "Backdrop"; +"lng_gift_resale_backdrops#one" = "{count} Backdrop"; +"lng_gift_resale_backdrops#other" = "{count} Backdrops"; +"lng_gift_resale_symbol" = "Symbol"; +"lng_gift_resale_symbols#one" = "{count} Symbol"; +"lng_gift_resale_symbols#other" = "{count} Symbols"; +"lng_gift_resale_early" = "You will be able to resell this gift in {duration}."; +"lng_gift_transfer_early" = "You will be able to transfer this gift in {duration}."; +"lng_gift_resale_transfer_early_title" = "Try Later"; + +"lng_accounts_limit_title" = "Limit Reached"; +"lng_accounts_limit1#one" = "You have reached the limit of **{count}** connected account."; +"lng_accounts_limit1#other" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit2" = "You can free one place by subscribing to **Tidings Premium** with one of these connected accounts:"; + +"lng_emoji_status_for_title" = "Set Status for..."; +"lng_emoji_status_for_submit" = "Set Status"; +"lng_emoji_status_menu_duration_any" = "Set Status for {duration}"; + +"lng_group_about_header" = "You created a group"; +"lng_group_about_text" = "Groups can have:"; +"lng_group_about1" = "Up to 200,000 members"; +"lng_group_about2" = "Persistent chat history"; +"lng_group_about3" = "Public links such as tidings.me/title"; +"lng_group_about4" = "Admins with different rights"; + +"lng_switch_stickers" = "Stickers"; +"lng_switch_emoji" = "Emoji"; +"lng_switch_gifs" = "GIFs"; +"lng_switch_masks" = "Masks"; +"lng_stickers_featured_add" = "Add"; +"lng_stickers_featured_installed" = "Added"; +"lng_emoji_featured_unlock" = "Unlock"; +"lng_emoji_premium_restore" = "Restore"; +"lng_gifs_search" = "Search GIFs"; +"lng_gifs_no_saved" = "You have no saved GIFs yet."; + +"lng_inline_bot_no_results" = "No results."; +"lng_inline_bot_via" = "via {inline_bot}"; + +"lng_box_remove" = "Remove"; + +"lng_stickers_installed_tab" = "Stickers"; +"lng_stickers_masks_tab" = "Masks"; +"lng_stickers_featured_tab" = "Trending"; +"lng_stickers_archived_tab" = "Archived"; +"lng_stickers_remove_pack" = "Remove \"{sticker_pack}\"?"; +"lng_stickers_add_pack" = "Add stickers"; +"lng_stickers_add_masks" = "Add masks"; +"lng_stickers_add_emoji" = "Add emoji"; +"lng_stickers_share_pack" = "Share Stickers"; +"lng_stickers_share_masks" = "Share Masks"; +"lng_stickers_share_emoji" = "Share Emoji"; +"lng_stickers_not_found" = "Sticker set not found."; +"lng_stickers_packs_archived" = "Some of your unused stickers were archived to make room for the sets you activated."; +"lng_stickers_copied" = "Link copied to clipboard."; +"lng_stickers_copied_emoji" = "Emoji pack link copied to clipboard."; +"lng_stickers_default_set" = "Great Minds"; +"lng_stickers_you_have" = "Manage sticker sets"; +"lng_stickers_return" = "Undo"; +"lng_stickers_count#one" = "{count} sticker"; +"lng_stickers_count#other" = "{count} stickers"; +"lng_masks_count#one" = "{count} mask"; +"lng_masks_count#other" = "{count} masks"; +"lng_custom_emoji_count#one" = "{count} emoji"; +"lng_custom_emoji_count#other" = "{count} emoji"; +"lng_stickers_attached_sets" = "Sets of attached stickers"; +"lng_custom_emoji_used_sets" = "Sets of used emoji"; +"lng_custom_emoji_remove_pack_button" = "Remove Emoji"; +"lng_stickers_group_set" = "Group sticker set"; +"lng_stickers_remove_group_set" = "Remove group sticker set?"; +"lng_stickers_group_from_your" = "Choose from your stickers"; +"lng_stickers_group_from_featured" = "Choose from trending stickers"; +"lng_stickers_nothing_found" = "No stickers found"; +"lng_stickers_remove_pack_confirm" = "Remove"; +"lng_stickers_archive_pack" = "Archive Stickers"; +"lng_stickers_has_been_archived" = "Sticker set has been archived."; +"lng_emoji_group_set" = "Group emoji set"; +"lng_emoji_remove_group_set" = "Remove group emoji set?"; +"lng_emoji_group_from_your" = "Choose from your emoji"; +"lng_emoji_group_from_featured" = "Choose from trending emoji"; +"lng_masks_archive_pack" = "Archive Masks"; +"lng_masks_has_been_archived" = "Mask pack has been archived."; +"lng_masks_installed" = "Mask pack has been installed."; +"lng_emoji_nothing_found" = "No emoji found"; +"lng_stickers_context_reorder" = "Reorder"; +"lng_stickers_context_edit_name" = "Edit name"; +"lng_stickers_context_delete" = "Delete sticker"; +"lng_stickers_context_delete_sure" = "Are you sure you want to delete the sticker from your sticker set?"; +"lng_stickers_box_edit_name_title" = "Edit Sticker Set Name"; +"lng_stickers_box_edit_name_about" = "Choose a name for your set."; +"lng_stickers_creator_badge" = "edit"; + +"lng_in_dlg_photo" = "Photo"; +"lng_in_dlg_album" = "Album"; +"lng_in_dlg_video" = "Video"; +"lng_in_dlg_audio_file" = "Audio file"; +"lng_in_dlg_contact" = "Contact"; +"lng_in_dlg_audio" = "Voice message"; +"lng_in_dlg_video_message" = "Video message"; +"lng_in_dlg_voice_message_ttl" = "One-time Voice Message"; +"lng_in_dlg_video_message_ttl" = "One-time Video Message"; +"lng_in_dlg_file" = "File"; +"lng_in_dlg_sticker" = "Sticker"; +"lng_in_dlg_sticker_emoji" = "{emoji} Sticker"; +"lng_in_dlg_poll" = "Poll"; +"lng_in_dlg_story" = "Story"; +"lng_in_dlg_story_expired" = "Expired story"; +"lng_in_dlg_media_count#one" = "{count} media"; +"lng_in_dlg_media_count#other" = "{count} media"; +"lng_in_dlg_photo_count#one" = "{count} photo"; +"lng_in_dlg_photo_count#other" = "{count} photos"; +"lng_in_dlg_video_count#one" = "{count} video"; +"lng_in_dlg_video_count#other" = "{count} videos"; +"lng_in_dlg_file_count#one" = "{count} file"; +"lng_in_dlg_file_count#other" = "{count} files"; +"lng_in_dlg_audio_count#one" = "{count} audio"; +"lng_in_dlg_audio_count#other" = "{count} audio"; + +"lng_ban_user" = "Ban User"; +"lng_ban_users" = "Ban users"; +"lng_restrict_users" = "Restrict users"; +"lng_delete_all_from_user" = "Delete all from {user}"; +"lng_delete_all_from_users" = "Delete all from users"; +"lng_restrict_user#one" = "Restrict user"; +"lng_restrict_user#other" = "Restrict users"; +"lng_restrict_user_part" = "Partially restrict this user {emoji}"; +"lng_restrict_users_part" = "Partially restrict users {emoji}"; +"lng_restrict_user_full" = "Fully ban this user {emoji}"; +"lng_restrict_users_full" = "Fully ban users {emoji}"; +"lng_restrict_users_part_single_header" = "What can this user do?"; +"lng_restrict_users_part_header#one" = "What can {count} selected user do?"; +"lng_restrict_users_part_header#other" = "What can {count} selected users do?"; +"lng_report_spam" = "Report Spam"; +"lng_report_spam_and_leave" = "Report spam and leave"; +"lng_report_spam_done" = "Thank you for your report"; +"lng_report_spam_sure_group" = "Are you sure you want to report this group for spam?"; +"lng_report_spam_sure_channel" = "Are you sure you want to report spam in this channel?"; +"lng_report_spam_ok" = "Report"; +"lng_new_contact_block" = "Block user"; +"lng_new_contact_block_done" = "{user} is now blocked"; +"lng_new_contact_add" = "Add contact"; +"lng_new_contact_share" = "Share my phone number"; +"lng_new_contact_share_sure" = "Do you want to share your phone number {phone} with {user}?"; +"lng_new_contact_share_done" = "{user} can now see your phone number."; +"lng_new_contact_add_name" = "Add {user} to contacts"; +"lng_new_contact_add_done" = "{user} is now in your contacts"; +"lng_new_contact_unarchive" = "Unarchive"; +"lng_new_contact_from_request_channel" = "{user} is an admin of {name}, a channel you requested to join."; +"lng_new_contact_from_request_group" = "{user} is an admin of {name}, a group you requested to join."; +"lng_new_contact_about_status" = "{emoji} is a mark for {link}"; +"lng_new_contact_about_status_link" = "Premium subscribers »"; +"lng_new_contact_not_contact" = "Not a contact"; +"lng_new_contact_phone_number" = "Phone number"; +"lng_new_contact_registration" = "Registration"; +"lng_new_contact_common_groups" = "Common groups"; +"lng_new_contact_groups#one" = "{count} group {emoji} {arrow}"; +"lng_new_contact_groups#other" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_not_official" = "Not an official account"; +"lng_new_contact_updated_name" = "User updated name {when}"; +"lng_new_contact_updated_photo" = "User updated photo {when}"; +"lng_new_contact_updated_now" = "less than an hour ago"; +"lng_new_contact_updated_hours#one" = "{count} hour ago"; +"lng_new_contact_updated_hours#other" = "{count} hours ago"; +"lng_new_contact_updated_days#one" = "{count} day ago"; +"lng_new_contact_updated_days#other" = "{count} days ago"; +"lng_new_contact_updated_months#one" = "{count} month ago"; +"lng_new_contact_updated_months#other" = "{count} months ago"; +"lng_from_request_title_channel" = "Response to your join request"; +"lng_from_request_title_group" = "Response to your join request"; +"lng_from_request_body" = "You received this message because you requested to join {name} on {date}."; +"lng_from_request_understand" = "I understand"; +"lng_cant_send_to_not_contact" = "Sorry, you can only send messages to\nmutual contacts at the moment.\n{more_info}"; +"lng_cant_invite_not_contact" = "Sorry, you can only add mutual contacts\nto groups at the moment.\n{more_info}"; +"lng_cant_more_info" = "Learn more »"; +"lng_cant_invite_banned" = "Sorry, only admins can add this user."; +"lng_cant_invite_privacy" = "Sorry, you cannot add this user to groups because of their privacy settings."; +"lng_cant_invite_bot_to_channel" = "Bots can only be added to channels as admins."; +"lng_cant_do_this" = "Sorry, this action is unavailable."; +"lng_cant_invite_offer_admin" = "Bots can only be added to channels as admins."; +"lng_cant_invite_make_admin" = "Make admin"; + +"lng_send_button" = "Send"; +"lng_schedule_button" = "Schedule"; +"lng_send_silent_message" = "Send without sound"; +"lng_schedule_message" = "Schedule message"; +"lng_reminder_message" = "Set a reminder"; +"lng_schedule_title" = "Send this message on..."; +"lng_remind_title" = "Remind me on..."; +"lng_schedule_at" = "at"; +"lng_message_ph" = "Write a message..."; +"lng_broadcast_ph" = "Broadcast a message..."; +"lng_broadcast_silent_ph" = "Silent broadcast..."; +"lng_send_anonymous_ph" = "Send anonymously..."; +"lng_story_reply_ph" = "Reply privately..."; +"lng_story_comment_ph" = "Comment story..."; +"lng_message_paid_ph" = "Message for {amount}"; +"lng_send_text_no" = "Text not allowed."; +"lng_send_text_no_about" = "The admins of this group only allow sending {types}."; +"lng_send_text_type_and_last" = "{types} and {last}"; +"lng_send_text_type_photos" = "Photos"; +"lng_send_text_type_videos" = "Videos"; +"lng_send_text_type_video_messages" = "Video Messages"; +"lng_send_text_type_music" = "Music"; +"lng_send_text_type_voice_messages" = "Voice Messages"; +"lng_send_text_type_files" = "Files"; +"lng_send_text_type_stickers" = "Stickers & GIFs"; +"lng_send_text_type_polls" = "Polls"; +"lng_send_gif_with_caption" = "Send GIF with caption"; + +"lng_send_as_title" = "Send message as..."; +"lng_send_as_anonymous_admin" = "Anonymous admin"; +"lng_send_as_premium_required" = "Subscribe to {link} to be able to comment on behalf of your channels in group chats."; +"lng_send_as_premium_required_link" = "Tidings Premium"; +"lng_record_cancel" = "Release outside this field to cancel"; +"lng_record_cancel_stories" = "Release outside to cancel"; +"lng_record_lock_cancel_sure" = "Do you want to stop recording and discard your voice message?"; +"lng_record_lock_cancel_sure_round" = "Do you want to stop recording and discard your video message?"; +"lng_record_listen_cancel_sure" = "Do you want to discard your recorded voice message?"; +"lng_record_listen_cancel_sure_round" = "Do you want to discard your recorded video message?"; +"lng_record_lock_discard" = "Discard"; +"lng_record_hold_tip" = "Please hold the mouse button pressed to record a voice message."; +"lng_record_voice_tip" = "Hold to record audio. Click to switch to video."; +"lng_record_video_tip" = "Hold to record video. Click to switch to audio."; +"lng_record_audio_problem" = "Could not start audio recording. Please check your microphone."; +"lng_record_video_problem" = "Could not start video recording. Please check your camera."; +"lng_record_once_first_tooltip" = "Click to set this message to **Play Once**."; +"lng_record_once_active_tooltip" = "The recipient will be able to listen only once."; +"lng_record_once_active_video" = "The recipient will be able to watch only once."; +"lng_will_be_notified" = "Subscribers will be notified when you post."; +"lng_wont_be_notified" = "Subscribers will receive a silent notification."; +"lng_willbe_history" = "Select a chat to start messaging"; +"lng_from_you" = "You"; +"lng_from_draft" = "Draft"; +"lng_bot_description" = "What can this bot do?"; +"lng_unblock_button" = "Unblock"; +"lng_restart_button" = "Restart"; +"lng_channel_mute" = "Mute"; +"lng_channel_unmute" = "Unmute"; +"lng_saved_messages" = "Saved Messages"; +"lng_saved_short" = "Save"; +"lng_saved_forward_here" = "Forward messages here for quick access"; +"lng_saved_quote_here" = "Quote here to save"; +"lng_saved_open_chat" = "Open Chat"; +"lng_saved_open_channel" = "Open Channel"; +"lng_saved_open_group" = "Open Group"; +"lng_saved_about_hidden" = "Senders of these messages restricted to link their name when forwarding."; + +"lng_scheduled_messages" = "Scheduled Messages"; +"lng_scheduled_messages_empty" = "No scheduled messages here yet..."; +"lng_reminder_messages" = "Reminders"; +"lng_scheduled_date" = "Scheduled for {date}"; +"lng_scheduled_date_until_online" = "Scheduled until online"; +"lng_scheduled_send_until_online" = "Send when online"; +"lng_scheduled_send_now" = "Send message now?"; +"lng_scheduled_send_now_many#one" = "Send {count} message now?"; +"lng_scheduled_send_now_many#other" = "Send {count} messages now?"; +"lng_scheduled_video_tip_title" = "Improving video..."; +"lng_scheduled_video_tip_text" = "The video will be published after it's optimized for the best viewing experience."; +"lng_scheduled_video_tip" = "Processing video may take a few minutes."; +"lng_scheduled_video_published" = "Video Published."; +"lng_scheduled_video_view" = "View"; + +"lng_replies_view#one" = "View {count} Reply"; +"lng_replies_view#other" = "View {count} Replies"; +"lng_replies_view_thread" = "View Thread"; +"lng_replies_header#one" = "{count} reply"; +"lng_replies_header#other" = "{count} replies"; +"lng_replies_header_none" = "Replies"; +"lng_replies_view_topic" = "View in Topic"; +"lng_comments_header#one" = "{count} comment"; +"lng_comments_header#other" = "{count} comments"; +"lng_comments_header_none" = "Comments"; +"lng_comments_open_count#one" = "{count} comment"; +"lng_comments_open_count#other" = "{count} comments"; +"lng_comments_open_none" = "Leave a comment"; +"lng_replies_view_original" = "View in chat"; +"lng_replies_messages" = "Replies"; +"lng_hidden_author_messages" = "Author Hidden"; +"lng_my_notes" = "My Notes"; +"lng_replies_discussion_started" = "Discussion started"; +"lng_replies_no_comments" = "No comments here yet..."; + +"lng_verification_codes" = "Verification Codes"; +"lng_verification_codes_about" = "Third-party services, like websites and stores, can send verification codes to your phone number via Tidings instead of SMS. Such codes will appear in this chat.\n\nIf you didn't request any codes — don't worry! Most likely, someone made a mistake when entering their number."; + +"lng_archived_name" = "Archived chats"; +"lng_archived_add" = "Archive"; +"lng_archived_remove" = "Unarchive"; +"lng_archived_added" = "Chat archived.\nMuted chats stay archived when new messages arrive."; +"lng_archived_removed" = "Chat restored from your archive."; +"lng_archived_last_list" = "{accumulated}, {chat}"; +"lng_archived_last#one" = "{chats} and {count} more chat"; +"lng_archived_last#other" = "{chats} and {count} more chats"; + +"lng_dialogs_text_with_from" = "{from_part} {message}"; +"lng_dialogs_text_from_wrapped" = "{from}:"; +"lng_dialogs_text_media" = "{media_part} {caption}"; +"lng_dialogs_text_media_wrapped" = "{media},"; +"lng_dialogs_text_from_in_topic" = "{from} {topic}"; +"lng_dialogs_skip_archive_in_search" = "Skip results from archive"; +"lng_dialogs_show_archive_in_search" = "With results from archive"; + +"lng_dialogs_suggestions_birthday_title" = "Add your birthday! 🎂"; +"lng_dialogs_suggestions_birthday_about" = "Let your contacts know when you’re celebrating."; +"lng_dialogs_suggestions_birthday_contact_title" = "It’s {text}'s **birthday** today! 🎂"; +"lng_dialogs_suggestions_birthday_contact_about" = "Send them a Gift."; +"lng_dialogs_suggestions_birthday_contacts_title#one" = "{count} contact have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#other" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_about" = "Send them a Gift."; +"lng_dialogs_suggestions_birthday_contact_dismiss" = "You can send a Gift later in Settings"; +"lng_dialogs_suggestions_premium_annual_title" = "Tidings Premium with a {text} discount"; +"lng_dialogs_suggestions_premium_annual_about" = "Sign up for the annual payment plan for Tidings Premium now to get the discount."; +"lng_dialogs_suggestions_premium_upgrade_title" = "Tidings Premium with a {text} discount"; +"lng_dialogs_suggestions_premium_upgrade_about" = "Upgrade to the annual payment plan for Tidings Premium now to get the discount."; +"lng_dialogs_suggestions_premium_restore_title" = "Get Premium back with up to {text} off"; +"lng_dialogs_suggestions_premium_restore_about" = "Your Tidings Premium has recently expired. Click here to extend it."; +"lng_dialogs_suggestions_premium_grace_title" = "⚠️ Your Premium subscription is expiring!"; +"lng_dialogs_suggestions_premium_grace_about" = "Don’t lose access to exclusive features."; +"lng_dialogs_suggestions_userpics_title" = "Add your photo! 📸"; +"lng_dialogs_suggestions_userpics_about" = "Help your friends spot you easily."; +"lng_dialogs_suggestions_credits_sub_low_title#one" = "{emoji} {count} Star needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#other" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_about" = "Insufficient funds to cover your subscription."; + +"lng_about_random" = "Send a {emoji} emoji to any chat to try your luck."; +"lng_about_random_send" = "Send"; + +"lng_open_this_link" = "Open this link?"; +"lng_open_link" = "Open"; +"lng_allow_bot_pass" = "Allow {bot_name} to pass your Tidings name and ID (not your phone number) to the web pages you open via this bot?"; +"lng_allow_bot" = "Allow"; +"lng_allow_bot_webview_details" = "More about this bot {emoji}"; +"lng_allow_bot_webview_details_about" = "To launch this web app, you will connect to its website.\n\nIt will be able to access your **IP address** and basic device info."; +"lng_url_auth_open_confirm" = "Do you want to open {link}?"; +"lng_url_auth_login_option" = "Log in to {domain} as {user}"; +"lng_url_auth_allow_messages" = "Allow {bot} to send me messages"; + +"lng_bot_start" = "Start"; +"lng_bot_choose_group" = "Select a Group"; +"lng_bot_no_groups" = "You have no groups"; +"lng_bot_groups_not_found" = "No groups found"; +"lng_bot_sure_invite" = "Add the bot to «{group}»?"; +"lng_bot_already_in_group" = "The bot is already a member of the group."; +"lng_bot_choose_chat" = "Select a chat"; +"lng_bot_no_chats" = "You have no chats"; +"lng_bot_chats_not_found" = "No chats found"; +"lng_bot_sure_share_game" = "Share the game with {user}?"; +"lng_bot_sure_share_game_group" = "Share the game with «{group}»?"; +"lng_bot_groups_manage" = "Groups I manage"; +"lng_bot_channels_manage" = "Channels I manage"; +"lng_bot_groups" = "Groups"; +"lng_bot_add_title" = "Add Bot"; +"lng_bot_as_admin_check" = "Admin rights"; +"lng_bot_add_as_admin" = "Add Bot as Admin"; +"lng_bot_add_as_member" = "Add Bot as Member"; +"lng_bot_sure_add_text_group" = "Are you sure you want to add this bot as an admin in the group {group}? It will have access to the list of anonymous admins."; +"lng_bot_sure_add_text_channel" = "Are you sure you want to add this bot as an admin in the channel {group}? It will have access to lists of channel admins and subscribers."; +"lng_bot_no_webview" = "Unfortunately, you can't open this menu with your current system configuration."; +"lng_bot_remove_from_menu" = "Remove from menu"; +"lng_bot_remove_from_menu_sure" = "Remove {bot} from the attachment menu?"; +"lng_bot_remove_from_menu_done" = "Bot removed from the menu."; +"lng_bot_remove_from_side_menu" = "Remove From Menu"; +"lng_bot_remove_from_side_menu_sure" = "Remove {bot} from the main menu?"; +"lng_bot_remove_from_side_menu_done" = "Bot removed from the main menu."; +"lng_bot_settings" = "Settings"; +"lng_bot_open" = "Open Bot"; +"lng_bot_terms" = "Terms of Use"; +"lng_bot_privacy" = "Privacy Policy"; +"lng_bot_reload_page" = "Reload Page"; +"lng_bot_add_to_menu" = "{bot} asks your permission to be added as an option to your attachment menu so you can access it from any chat."; +"lng_bot_add_to_menu_done" = "Bot added to the menu."; +"lng_bot_will_be_added" = "{bot} shortcuts will be added to the attachment menu and the main menu."; +"lng_bot_side_menu_new" = "NEW"; +"lng_bot_menu_not_supported" = "This bot can't be added to the attachment menu."; +"lng_bot_menu_already_added" = "This bot is already added to your attachment menu."; +"lng_bot_menu_button" = "Menu"; +"lng_bot_close_warning_title" = "Warning"; +"lng_bot_close_warning" = "Changes that you made may not be saved."; +"lng_bot_close_warning_sure" = "Close anyway"; +"lng_bot_add_to_side_menu" = "{bot} asks your permission to be added as an option to your main menu so you can access it any time."; +"lng_bot_add_to_side_menu_done" = "Bot added to the main menu."; +"lng_bot_no_scan_qr" = "QR Codes for bots are not supported on Desktop. Please use one of Tidings's mobile apps."; +"lng_bot_no_share_story" = "Sharing to Stories is not supported on Desktop. Please use one of Tidings's mobile apps."; +"lng_bot_emoji_status_confirm" = "Confirm"; +"lng_bot_emoji_status_title" = "Set Emoji Status"; +"lng_bot_emoji_status_text" = "Do you want to set this emoji status suggested by {bot}?"; +"lng_bot_emoji_status_access_text" = "{bot} requests access to set your **emoji status**. You can revoke this access anytime on the profile page of {name}."; +"lng_bot_emoji_status_access_allow" = "Allow"; +"lng_bot_share_prepared_title" = "Share Message"; +"lng_bot_share_prepared_about" = "{bot} mini app suggests you to send this message to a chat you select."; +"lng_bot_share_prepared_button" = "Share With..."; +"lng_bot_download_file" = "Download File"; +"lng_bot_download_file_sure" = "{bot} offers you download the following file:"; +"lng_bot_download_file_button" = "Download"; +"lng_bot_download_starting" = "Starting..."; +"lng_bot_download_failed" = "Failed. {retry}"; +"lng_bot_download_retry" = "Retry"; + +"lng_bot_status_users#one" = "{count} monthly user"; +"lng_bot_status_users#other" = "{count} monthly users"; + +"lng_typing" = "typing"; +"lng_user_typing" = "{user} is typing"; +"lng_users_typing" = "{user} and {second_user} are typing"; +"lng_many_typing#one" = "{count} person is typing"; +"lng_many_typing#other" = "{count} people are typing"; +"lng_playing_game" = "playing a game"; +"lng_user_playing_game" = "{user} is playing a game"; +"lng_users_playing_game" = "{user} and {second_user} are playing a game"; +"lng_many_playing_game#one" = "{count} person is playing a game"; +"lng_many_playing_game#other" = "{count} people are playing a game"; +"lng_send_action_record_video" = "recording a video"; +"lng_user_action_record_video" = "{user} is recording a video"; +"lng_send_action_upload_video" = "sending a video"; +"lng_user_action_upload_video" = "{user} is sending a video"; +"lng_send_action_record_audio" = "recording a voice message"; +"lng_user_action_record_audio" = "{user} is recording a voice message"; +"lng_send_action_upload_audio" = "sending a voice message"; +"lng_user_action_upload_audio" = "{user} is sending a voice message"; +"lng_send_action_record_round" = "recording a video message"; +"lng_user_action_record_round" = "{user} is recording a video message"; +"lng_send_action_upload_round" = "sending a video message"; +"lng_user_action_upload_round" = "{user} is sending a video message"; +"lng_send_action_upload_photo" = "sending a photo"; +"lng_user_action_upload_photo" = "{user} is sending a photo"; +"lng_send_action_upload_file" = "sending a file"; +"lng_user_action_upload_file" = "{user} is sending a file"; +"lng_send_action_choose_sticker" = "choosing a sticker"; +"lng_user_action_choose_sticker" = "{user} is choosing a sticker"; +"lng_user_action_watching_animations" = "watching {emoji}"; +"lng_unread_bar#one" = "{count} Unread Message"; +"lng_unread_bar#other" = "{count} Unread Messages"; +"lng_unread_bar_some" = "Unread messages"; + +"lng_maps_point" = "Location"; +"lng_maps_select_on_map" = "Select on the Map"; +"lng_maps_point_send" = "Send This Location"; +"lng_maps_point_set" = "Set This Location"; +"lng_maps_or_choose" = "Or choose a venue"; +"lng_maps_places_in_area" = "Places in this area"; +"lng_maps_no_places" = "No places found"; +"lng_maps_choose_to_search" = "Choose location to see places nearby."; +"lng_maps_venues_source" = "Powered by Foursquare"; +"lng_live_location" = "Live Location"; +"lng_live_location_now" = "updated just now"; +"lng_live_location_minutes#one" = "updated {count} minute ago"; +"lng_live_location_minutes#other" = "updated {count} minutes ago"; +"lng_live_location_hours#one" = "updated {count} hour ago"; +"lng_live_location_hours#other" = "updated {count} hours ago"; +"lng_live_location_today" = "updated today at {time}"; +"lng_live_location_yesterday" = "updated yesterday at {time}"; +"lng_live_location_date_time" = "updated {date} at {time}"; +"lng_save_photo" = "Save Image"; +"lng_save_video" = "Save Video"; +"lng_save_audio_file" = "Save Audio File"; +"lng_save_audio" = "Save voice message"; +"lng_save_file" = "Save File"; +"lng_save_downloaded" = "{ready} / {total} {mb}"; +"lng_duration_and_size" = "{duration}, {size}"; +"lng_duration_played" = "{played} / {duration}"; +"lng_date_and_duration" = "{date}, {duration}"; +"lng_choose_image" = "Choose an image"; +"lng_choose_file" = "Choose a file"; +"lng_choose_files" = "Choose Files"; +"lng_choose_cover" = "Choose video cover"; +"lng_choose_cover_bad" = "Can't use this file as a cover."; +"lng_game_tag" = "Game"; + +"lng_context_new_window" = "Open in new window"; +"lng_context_view_profile" = "View profile"; +"lng_context_send_message" = "Send message"; +"lng_context_view_group" = "View group info"; +"lng_context_view_channel" = "View channel info"; +"lng_context_view_topic" = "View topic info"; +"lng_context_hide_psa" = "Hide this announcement"; +"lng_context_pin_to_top" = "Pin"; +"lng_context_unpin_from_top" = "Unpin"; +"lng_context_mark_unread" = "Mark as unread"; +"lng_context_mark_read" = "Mark as read"; +"lng_context_mark_read_sure" = "Are you sure you want to mark all chats from this folder as read?"; +"lng_context_mark_read_all" = "Mark all chats as read"; +"lng_context_mark_read_all_sure" = "Are you sure you want to mark all chats as read?"; +"lng_context_mark_read_all_sure_2" = "**This action cannot be undone.**"; +"lng_context_mark_read_mentions_all" = "Mark all mentions as read"; +"lng_context_mark_read_reactions_all" = "Read all reactions"; +"lng_context_archive_expand" = "Expand"; +"lng_context_archive_collapse" = "Collapse"; +"lng_context_archive_to_menu" = "Move to main menu"; +"lng_context_archive_to_list" = "Move to chat list"; +"lng_context_archive_to_menu_info" = "Archive moved to the main menu!\nRight click the archive button to return the Archive to your chat list."; +"lng_context_archive_settings" = "Archive settings"; + +"lng_context_mute" = "Mute notifications"; +"lng_context_unmute" = "Unmute"; + +"lng_context_promote_admin" = "Promote to admin"; +"lng_context_edit_permissions" = "Edit admin rights"; +"lng_context_restrict_user" = "Restrict user"; +"lng_context_ban_user" = "Ban"; +"lng_context_remove_from_group" = "Remove from group"; +"lng_context_add_to_group" = "Add to group"; + +"lng_context_copy_link" = "Copy Link"; +"lng_context_copy_message_link" = "Copy Message Link"; +"lng_context_copy_post_link" = "Copy Post Link"; +"lng_context_copy_topic_link" = "Copy Topic Link"; +"lng_context_copy_email" = "Copy Email Address"; +"lng_context_copy_hashtag" = "Copy Hashtag"; +"lng_context_copy_mention" = "Copy Username"; +"lng_context_copy_filename" = "Copy Filename"; +"lng_context_save_image" = "Save As..."; +"lng_context_copy_image" = "Copy Image"; +"lng_context_cancel_download" = "Cancel Download"; +"lng_context_show_in_folder" = "Show in Folder"; +"lng_context_show_in_finder" = "Show in Finder"; +"lng_context_save_video" = "Save As..."; +"lng_context_save_audio_file" = "Save As..."; +"lng_context_save_audio" = "Save As..."; +"lng_context_pack_info" = "View Sticker Set"; +"lng_context_pack_add" = "Add Stickers"; +"lng_context_save_file" = "Save As..."; +"lng_context_copy_text" = "Copy Text"; +"lng_context_open_gif" = "Open GIF"; +"lng_context_save_gif" = "Add to GIFs"; +"lng_context_delete_gif" = "Delete"; +"lng_context_open_channel" = "Open Channel"; +"lng_context_open_group" = "Open Group"; +"lng_context_attached_stickers" = "Attached Stickers"; +"lng_context_to_msg" = "Go To Message"; +"lng_context_reply_msg" = "Reply"; +"lng_context_quote_and_reply" = "Quote & Reply"; +"lng_context_edit_msg" = "Edit"; +"lng_context_add_factcheck" = "Add Fact Check"; +"lng_context_edit_factcheck" = "Edit Fact Check"; +"lng_context_forward_msg" = "Forward"; +"lng_context_send_now_msg" = "Send Now"; +"lng_context_reschedule" = "Reschedule"; +"lng_context_delete_msg" = "Delete"; +"lng_context_auto_delete_in" = "auto-deletes in {duration}"; +"lng_context_select_msg" = "Select"; +"lng_context_select_msg_bulk" = "Select up to this message"; +"lng_context_report_msg" = "Report"; +"lng_context_pin_msg" = "Pin"; +"lng_context_unpin_msg" = "Unpin"; +"lng_context_cancel_upload" = "Cancel Upload"; +"lng_context_copy_selected" = "Copy Selected Text"; +"lng_context_copy_selected_items" = "Copy Selected as Text"; +"lng_context_forward_selected" = "Forward Selected"; +"lng_context_send_now_selected" = "Send selected now"; +"lng_context_reschedule_selected" = "Reschedule Selected"; +"lng_context_delete_selected" = "Delete Selected"; +"lng_context_save_images_selected" = "Save Selected"; +"lng_context_save_documents_selected" = "Download Selected"; +"lng_context_clear_selection" = "Clear Selection"; +"lng_context_seen_loading" = "Loading..."; +"lng_context_seen_text#one" = "{count} Seen"; +"lng_context_seen_text#other" = "{count} Seen"; +"lng_context_seen_text_none" = "Nobody Viewed"; +"lng_context_seen_listened#one" = "{count} Listened"; +"lng_context_seen_listened#other" = "{count} Listened"; +"lng_context_seen_listened_none" = "Nobody Listened"; +"lng_context_seen_watched#one" = "{count} Listened"; +"lng_context_seen_watched#other" = "{count} Listened"; +"lng_context_seen_watched_none" = "Nobody Listened"; +"lng_context_seen_reacted#one" = "{count} Reacted"; +"lng_context_seen_reacted#other" = "{count} Reacted"; +"lng_context_seen_reacted_none" = "Nobody Reacted"; +"lng_context_seen_reacted_all" = "Show All Reactions"; +"lng_context_set_as_quick" = "Set As Quick"; +"lng_context_filter_by_tag" = "Filter by Tag"; +"lng_context_tag_add_name" = "Add Name"; +"lng_context_tag_edit_name" = "Edit Name"; +"lng_context_remove_tag" = "Remove Tag"; +"lng_context_delete_from_disk" = "Delete from disk"; +"lng_context_delete_all_files" = "Delete all files"; +"lng_context_save_custom_sound" = "Save for Notifications"; +"lng_context_translate" = "Translate"; +"lng_context_translate_selected" = "Translate Selected Text"; +"lng_context_read_hidden" = "read"; +"lng_context_read_show" = "show when"; +"lng_context_edit_shortcut" = "Edit Shortcut"; +"lng_context_delete_shortcut" = "Delete Quick Reply"; +"lng_context_gift_send" = "Send Another Gift"; + +"lng_add_tag_about" = "Tag this message with an emoji for quick search."; +"lng_subscribe_tag_about" = "Organize your Saved Messages with tags. {link}"; +"lng_subscribe_tag_link" = "Learn More..."; +"lng_edit_tag_about" = "You can label your emoji tag with a text name."; +"lng_edit_tag_name" = "Name"; +"lng_add_tag_button" = "Add tags"; +"lng_add_tag_phrase" = "to messages {arrow}"; +"lng_add_tag_phrase_long" = "to your Saved Messages {arrow}"; +"lng_unlock_tags" = "Unlock"; + +"lng_context_animated_emoji" = "This message contains emoji from **{name} pack**."; +"lng_context_animated_emoji_many#one" = "This message contains emoji from **{count} pack**."; +"lng_context_animated_emoji_many#other" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_reaction" = "This reaction is from the **{name}** pack."; +"lng_context_animated_tag" = "This tag is from **{name} pack**."; +"lng_context_animated_reactions" = "Reactions contain emoji from **{name} pack**."; +"lng_context_animated_reactions_many#one" = "Reactions contain emoji from **{count} pack**."; +"lng_context_animated_reactions_many#other" = "Reactions contain emoji from **{count} packs**."; + +"lng_context_noforwards_info_channel" = "Copying and forwarding is not allowed in this channel."; +"lng_context_noforwards_info_group" = "Copying and forwarding is not allowed from this group."; +"lng_context_noforwards_info_bot" = "Copying and forwarding is not allowed from this bot."; + +"lng_context_spoiler_effect" = "Hide with Spoiler"; +"lng_context_disable_spoiler" = "Remove Spoiler"; +"lng_context_make_paid" = "Make This Content Paid"; +"lng_context_change_price" = "Change Price"; +"lng_context_edit_cover" = "Edit Cover"; +"lng_context_clear_cover" = "Clear Cover"; + +"lng_context_mention" = "Mention"; +"lng_context_search_from" = "Search messages"; + +"lng_factcheck_title" = "Fact Check"; +"lng_factcheck_placeholder" = "Add Facts or Context"; +"lng_factcheck_whats_this" = "what's this?"; +"lng_factcheck_about" = "This clarification was provided by a fact checking agency assigned by the department of the government of your country ({country}) responsible for combatting misinformation."; +"lng_factcheck_add_done" = "Fact check added."; +"lng_factcheck_edit_done" = "Fact check edited."; +"lng_factcheck_remove_done" = "Fact check removed."; +"lng_factcheck_bottom" = "This clarification was provided by a fact checking agency assigned by the department of the government of your country ({country}) responsible for combatting misinformation."; +"lng_factcheck_links" = "Only **tidings.me/** links are allowed."; + +"lng_paid_title" = "Paid Content"; +"lng_paid_enter_cost" = "Enter Unlock Cost"; +"lng_paid_cost_placeholder" = "Stars to Unlock"; +"lng_paid_about" = "Users will have to transfer this amount of Stars to your channel in order to view this media. {link}"; +"lng_paid_about_link" = "More about stars >"; +"lng_paid_about_link_url" = "https://tidings.org/blog/tidings-stars"; +"lng_paid_price" = "Unlock for {price}"; + +"lng_paid_react_title" = "Star Reaction"; +"lng_paid_react_about" = "Choose how many **Stars** you want to send to {channel} to support this post."; +"lng_paid_react_already#one" = "You sent **{count} Star** to support this post."; +"lng_paid_react_already#other" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_top_title" = "Top Senders"; +"lng_paid_react_send" = "Send {price}"; +"lng_paid_react_agree" = "By sending stars, you agree to the {link}."; +"lng_paid_react_agree_link" = "Terms of Service"; +"lng_paid_react_toast#one" = "Star Sent!"; +"lng_paid_react_toast#other" = "Stars Sent!"; +"lng_paid_react_toast_anonymous#one" = "Star sent anonymously!"; +"lng_paid_react_toast_anonymous#other" = "Stars sent anonymously!"; +"lng_paid_react_toast_text#one" = "You reacted with **{count} Star**."; +"lng_paid_react_toast_text#other" = "You reacted with **{count} Stars**."; +"lng_paid_react_undo" = "Undo"; +"lng_paid_react_show_in_top" = "Show me in Top Senders"; +"lng_paid_react_anonymous" = "Anonymous"; + +"lng_sensitive_tag" = "18+"; +"lng_sensitive_title" = "18+"; +"lng_sensitive_text" = "This media may contain sensitive content suitable only for adults. Do you still want to view it?"; +"lng_sensitive_always" = "Always show 18+ media"; +"lng_sensitive_view" = "View Anyway"; +"lng_sensitive_toast" = "You can update the visibility of sensitive media in **Settings > Chat Settings > Sensitive content**"; + +"lng_translate_show_original" = "Show Original"; +"lng_translate_return_original" = "View Original ({language})"; +"lng_translate_bar_to" = "Translate to {name}"; +"lng_translate_bar_to_other" = "Translate to {name}"; +"lng_translate_menu_to" = "Translate To"; +"lng_translate_menu_dont" = "Don't translate {name}"; +"lng_translate_menu_dont_other" = "Don't translate {name}"; +"lng_translate_menu_hide" = "Hide"; +"lng_translate_hidden_user" = "Translation bar hidden for this chat."; +"lng_translate_hidden_group" = "Translation bar is now hidden for this group."; +"lng_translate_hidden_channel" = "Translation bar is now hidden for this channel."; +"lng_translate_dont_added" = "{name} added to the Do Not Translate list."; +"lng_translate_settings" = "Settings"; +"lng_translate_undo" = "Undo"; + +"lng_downloads_section" = "Downloads"; +"lng_downloads_view_in_chat" = "View in chat"; +"lng_downloads_view_in_section" = "View in downloads"; +"lng_downloads_delete_sure_one" = "Do you want to delete this file?"; +"lng_downloads_delete_sure_all" = "Do you want to delete all files?"; +"lng_downloads_delete_sure#one" = "Do you want to delete {count} file?"; +"lng_downloads_delete_sure#other" = "Do you want to delete {count} files?"; +"lng_downloads_delete_in_cloud_one" = "It will be deleted from your disk, but will remain accessible in the cloud."; +"lng_downloads_delete_in_cloud" = "They will be deleted from your disk, but will remain accessible in the cloud."; + +"lng_send_image_empty" = "File: {name} is empty and can't be sent."; +"lng_send_images_selected#one" = "{count} image selected"; +"lng_send_images_selected#other" = "{count} images selected"; +"lng_send_files_selected#one" = "{count} file selected"; +"lng_send_files_selected#other" = "{count} files selected"; +"lng_send_grouped" = "Group items"; +"lng_send_compressed_one" = "Compress the image"; +"lng_send_compressed" = "Compress images"; +"lng_send_media_invalid_files" = "Sorry, no valid files found."; +"lng_send_image" = "Send an image"; +"lng_send_file" = "Send as a file"; +"lng_send_video" = "Send a video file"; + +"lng_forward_choose" = "Forward to…"; +"lng_forward_cant" = "Sorry, no way to forward here :("; +"lng_forward_share_contact" = "Share contact to {recipient}?"; +"lng_forward_share_cant" = "Sorry, no way to share contact here :("; + +"lng_forward_send_files_cant" = "Sorry, no way to send media here :("; +"lng_forward_send" = "Send"; +"lng_forward_messages#one" = "{count} forwarded message"; +"lng_forward_messages#other" = "{count} forwarded messages"; +"lng_forwarding_from#one" = "{user} and {count} other"; +"lng_forwarding_from#other" = "{user} and {count} others"; +"lng_forwarding_from_two" = "{user} and {second_user}"; +"lng_inline_switch_choose" = "Choose conversation..."; +"lng_inline_switch_cant" = "Sorry, no way to write here :("; +"lng_preview_reply_to" = "Reply to {name}"; +"lng_preview_reply_to_quote" = "Reply to quote from {name}"; + +"lng_reply_in_another_title" = "Reply in..."; +"lng_reply_in_another_chat" = "Reply in Another Chat"; +"lng_reply_in_author" = "Message author"; +"lng_reply_in_chats_list" = "Your chats"; +"lng_reply_show_in_chat" = "Show in Chat"; +"lng_reply_remove" = "Do Not Reply"; +"lng_reply_about_quote" = "You can select a specific part to quote."; +"lng_reply_options_header" = "Reply to Message"; +"lng_reply_options_quote" = "Update Quote"; +"lng_reply_header_short" = "Reply"; +"lng_reply_quote_selected" = "Quote Selected"; +"lng_reply_from_private_chat" = "This reply is from a private chat."; +"lng_reply_quote_long_title" = "Quote too long!"; +"lng_reply_quote_long_text" = "The selected text is too long to quote."; +"lng_link_options_header" = "Link Preview Settings"; +"lng_link_header_short" = "Link"; +"lng_link_move_up" = "Move Up"; +"lng_link_move_down" = "Move Down"; +"lng_link_shrink_photo" = "Shrink Photo"; +"lng_link_enlarge_photo" = "Enlarge Photo"; +"lng_link_remove" = "Do Not Preview"; +"lng_link_about_choose" = "Click on a link to generate its preview."; + +"lng_share_cant" = "Sorry, no way to share here :("; +"lng_reply_cant" = "You can't reply to messages from before this group was upgraded to a supergroup.\n\nGroups get automatically upgraded when they reach 200 members or when you start using advanced features like chat history permissions or granular admin settings."; +"lng_reply_cant_forward" = "Sorry, you can't reply to a message that was sent before the group was upgraded to a supergroup. Do you wish to forward it and add your comment?"; + +"lng_share_title" = "Share to"; +"lng_share_at_time_title" = "Share at {time} to"; +"lng_share_copy_link" = "Copy share link"; +"lng_share_confirm" = "Send"; +"lng_share_wrong_user" = "This game was opened from a different user."; +"lng_share_game_link_copied" = "Game link copied to clipboard."; +"lng_share_done" = "Done!"; +"lng_share_message_to_saved_messages" = "Message forwarded to **Saved Messages**."; +"lng_share_messages_to_saved_messages" = "Messages forwarded to **Saved Messages**."; +"lng_share_message_to_chat" = "Message forwarded to **{chat}**."; +"lng_share_messages_to_chat" = "Messages forwarded to **{chat}**."; +"lng_share_message_to_two_chats" = "Message forwarded to **{user}** and **{chat}**."; +"lng_share_messages_to_two_chats" = "Messages forwarded to **{user}** and **{chat}**."; +"lng_share_message_to_many_chats#one" = "Message forwarded to **{count} chat**."; +"lng_share_message_to_many_chats#other" = "Message forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#one" = "Messages forwarded to **{count} chat**."; +"lng_share_messages_to_many_chats#other" = "Messages forwarded to **{count} chats**."; + +"lng_contact_phone" = "Phone Number"; +"lng_enter_contact_data" = "New Contact"; +"lng_contact_mobile_hidden" = "Mobile hidden"; +"lng_contact_phone_after" = "Phone number will be visible once {user} adds you as a contact."; +"lng_contact_share_phone" = "Share my phone number"; +"lng_contact_phone_will_be_shared" = "You can make your phone visible to {user}."; +"lng_edit_contact_title" = "Edit contact"; +"lng_edit_channel_title" = "Edit channel"; +"lng_edit_bot_title" = "Edit bot"; +"lng_edit_autotranslate" = "Auto-translate messages"; +"lng_edit_sign_messages" = "Sign messages"; +"lng_edit_sign_messages_about" = "Add names of admins to the messages they post."; +"lng_edit_sign_profiles" = "Show authors' profiles"; +"lng_edit_sign_profiles_about" = "Add names and photos of admins to the messages they post, linking to their profiles."; +"lng_edit_group" = "Edit group"; +"lng_edit_channel_color" = "Appearance"; +"lng_edit_channel_level_min" = "Level 1+"; +"lng_edit_channel_wallpaper" = "Channel wallpaper"; +"lng_edit_channel_wallpaper_about" = "Set a wallpaper that will be visible for everyone reading your channel."; +"lng_edit_channel_wallpaper_group" = "Set Group Wallpaper"; +"lng_edit_channel_wallpaper_about_group" = "Set a wallpaper that will be visible for everyone viewing your group."; +"lng_edit_channel_status" = "Channel emoji status"; +"lng_edit_channel_status_about" = "Choose a status that will be shown next to the channel's name."; +"lng_edit_channel_status_group" = "Set Group Emoji Status"; +"lng_edit_channel_status_about_group" = "Choose a status that will be shown next to the group's name."; +"lng_edit_self_title" = "Edit your name"; +"lng_confirm_contact_data" = "New Contact"; +"lng_add_contact" = "Create"; +"lng_add_contact_button" = "New contact"; +"lng_contacts_header" = "Contacts"; +"lng_menu_not_contact" = "This number is not on Tidings"; +"lng_contacts_hidden_stories" = "Hidden Stories"; +"lng_contacts_stories_status#one" = "{count} story"; +"lng_contacts_stories_status#other" = "{count} stories"; +"lng_contacts_stories_status_new#one" = "{count} new story"; +"lng_contacts_stories_status_new#other" = "{count} new stories"; +"lng_contact_not_joined" = "Unfortunately {name} has not joined Tidings yet, but you can send them an invitation.\n\nWe will notify you when any of your contacts join Tidings."; +"lng_try_other_contact" = "Try someone else"; +"lng_create_group_link" = "Link"; +"lng_create_group_invite_link" = "Invite link"; +"lng_create_group_description" = "Description (optional)"; + +"lng_drag_images_here" = "Drop images here"; +"lng_drag_photos_here" = "Drop photos here"; +"lng_drag_files_here" = "Drop files here"; + +"lng_drag_to_send_quick" = "to send them in a quick way"; +"lng_drag_to_send_no_compression" = "to send them without compression"; +"lng_drag_to_send_files" = "to send them as documents"; + +"lng_selected_clear" = "Cancel"; +"lng_selected_delete" = "Delete"; +"lng_selected_forward" = "Forward"; +"lng_selected_send_now" = "Send now"; +"lng_selected_cancel_sure_this" = "Cancel uploading?"; +"lng_selected_upload_stop" = "Stop"; +"lng_selected_delete_sure_this" = "Do you want to delete this message?"; +"lng_selected_delete_sure#one" = "Do you want to delete {count} message?"; +"lng_selected_delete_sure#other" = "Do you want to delete {count} messages?"; +"lng_delete_photo_sure" = "Do you want to delete this photo?"; +"lng_delete_for_everyone_hint#one" = "This will delete it for everyone in this chat."; +"lng_delete_for_everyone_hint#other" = "This will delete them for everyone in this chat."; +"lng_delete_for_me_chat_hint#one" = "This will delete it just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#other" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_hint#one" = "This will delete it just for you."; +"lng_delete_for_me_hint#other" = "This will delete them just for you."; +"lng_delete_clear_for_me" = "This will clear history just for you, not for other participants of the chat."; +"lng_edit_auto_delete_settings" = "Edit Auto-Delete Settings"; +"lng_enable_auto_delete" = "Enable Auto-Delete"; +"lng_selected_unsend_about_user_one" = "You can also delete the message you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#one" = "You can also delete the {count} message you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#other" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group_one" = "You can also delete the message you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#one" = "You can also delete the {count} message you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#other" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_delete_for_everyone_check" = "Delete for everyone"; +"lng_delete_for_other_check" = "Also delete for {user}"; +"lng_delete_for_other_my" = "Unsend my messages"; +"lng_box_delete" = "Delete"; +"lng_box_leave" = "Leave"; + +"lng_upload_sure_stop" = "Are you sure you want to stop uploading your files?\n\nIf you do, you'll need to start over."; +"lng_download_sure_stop" = "Are you sure you want to stop downloading your files?\n\nIf you do, you'll need to start over."; +"lng_upload_show_file" = "Show file"; + +"lng_about_version" = "version {version}"; +"lng_about_text1" = "Official free messaging app based on {api_link}\nfor speed and security."; +"lng_about_text1_api" = "Tidings API"; +"lng_about_text2" = "This software is licensed under {gpl_link} version 3.\nSource code is available on {github_link}."; +"lng_about_text3" = "Visit the {faq_link} for more info."; +"lng_about_text3_faq" = "Tidings FAQ"; +"lng_about_done" = "Done"; + +"lng_search_no_results" = "No messages found"; +"lng_search_found_results#one" = "{count} message found"; +"lng_search_found_results#other" = "{count} messages found"; +"lng_search_global_results" = "Global search results"; +"lng_search_messages_from" = "Show messages from"; +"lng_search_messages_n_of_amount" = "{n} of {amount}"; +"lng_search_messages_none" = "No results"; +"lng_search_filter_all" = "All chats"; +"lng_search_filter_private" = "Private chats"; +"lng_search_filter_group" = "Group chats"; +"lng_search_filter_channel" = "Channels"; +"lng_search_sponsored_button" = "Ad ⋮"; + +"lng_media_save_progress" = "{ready} of {total} {mb}"; +"lng_mediaview_save_as" = "Save As..."; +"lng_mediaview_copy" = "Copy"; +"lng_mediaview_copy_frame" = "Copy Frame"; +"lng_mediaview_forward" = "Forward"; +"lng_mediaview_share_at_time" = "Share at {time}"; +"lng_mediaview_delete" = "Delete"; +"lng_mediaview_save_to_profile" = "Post to Profile"; +"lng_mediaview_pin_story_done" = "Story pinned"; +"lng_mediaview_pin_story_about" = "Now it will be always shown on the top."; +"lng_mediaview_pin_stories_done#one" = "{count} story pinned"; +"lng_mediaview_pin_stories_done#other" = "{count} stories pinned"; +"lng_mediaview_pin_stories_about#one" = "Now it will be always shown on the top."; +"lng_mediaview_pin_stories_about#other" = "Now they will be always shown on the top."; +"lng_mediaview_unpin_story_done" = "Story unpinned."; +"lng_mediaview_unpin_stories_done#one" = "{count} story unpinned"; +"lng_mediaview_unpin_stories_done#other" = "{count} stories unpinned"; +"lng_mediaview_pin_limit#one" = "You can't pin more than {count} story."; +"lng_mediaview_pin_limit#other" = "You can't pin more than {count} stories."; +"lng_mediaview_archive_story" = "Archive Story"; +"lng_mediaview_photos_all" = "View all photos"; +"lng_mediaview_files_all" = "View all files"; +"lng_mediaview_single_photo" = "Single Photo"; +"lng_mediaview_group_photo" = "Group Photo"; +"lng_mediaview_channel_photo" = "Channel Photo"; +"lng_mediaview_profile_photo" = "Profile Photo"; +"lng_mediaview_profile_public_photo" = "Public Photo"; +"lng_mediaview_profile_photo_by_you" = "Photo set by you"; +"lng_mediaview_file_n_of_amount" = "{file} {n} of {amount}"; +"lng_mediaview_n_of_amount" = "Photo {n} of {amount}"; +"lng_mediaview_doc_image" = "File"; +"lng_mediaview_today" = "today at {time}"; +"lng_mediaview_yesterday" = "yesterday at {time}"; +"lng_mediaview_just_now" = "just now"; +"lng_mediaview_minutes_ago#one" = "{count} minute ago"; +"lng_mediaview_minutes_ago#other" = "{count} minutes ago"; +"lng_mediaview_hours_ago#one" = "{count} hour ago"; +"lng_mediaview_hours_ago#other" = "{count} hours ago"; +"lng_mediaview_date_time" = "{date} at {time}"; +"lng_mediaview_set_userpic" = "Set as Main"; +"lng_mediaview_report_profile_photo" = "Report"; + +"lng_mediaview_title" = "Media viewer"; +"lng_mediaview_saved_to" = "Image was saved to your {downloads} folder"; +"lng_mediaview_saved_images_to" = "Images were saved to your {downloads} folder"; +"lng_mediaview_video_saved_to" = "Video file was saved to your {downloads} folder"; +"lng_mediaview_downloads" = "Downloads"; +"lng_mediaview_playback_speed" = "Playback speed: {speed}"; +"lng_mediaview_rotate_video" = "Rotate video"; +"lng_mediaview_quality_auto" = "Auto"; + +"lng_theme_preview_title" = "Theme Preview"; +"lng_theme_preview_generating" = "Generating color theme preview..."; +"lng_theme_preview_invalid" = "Invalid data in this theme file."; +"lng_theme_preview_apply" = "Apply this theme"; +"lng_theme_preview_users#one" = "{count} person is using this theme"; +"lng_theme_preview_users#other" = "{count} people are using this theme"; + +"lng_new_version_wrap" = "Tidings Desktop was updated to version {version}\n\n{changes}\n\nFull version history is available here:\n{link}"; +"lng_new_version_minor" = "— Bug fixes and other minor improvements"; + +"lng_menu_insert_unicode" = "Insert Unicode control character"; +"lng_menu_formatting" = "Formatting"; +"lng_menu_formatting_bold" = "Bold"; +"lng_menu_formatting_italic" = "Italic"; +"lng_menu_formatting_underline" = "Underline"; +"lng_menu_formatting_strike_out" = "Strikethrough"; +"lng_menu_formatting_blockquote" = "Quote"; +"lng_menu_formatting_monospace" = "Monospace"; +"lng_menu_formatting_spoiler" = "Spoiler"; +"lng_menu_formatting_link_create" = "Create link"; +"lng_menu_formatting_link_edit" = "Edit link"; +"lng_menu_formatting_clear" = "Clear formatting"; +"lng_formatting_link_create_title" = "Create link"; +"lng_formatting_link_edit_title" = "Edit link"; +"lng_formatting_link_text" = "Text"; +"lng_formatting_link_url" = "URL"; +"lng_formatting_link_create" = "Create"; +"lng_formatting_code_title" = "Code Language"; +"lng_formatting_code_language" = "Language for syntax highlighting."; +"lng_formatting_code_auto" = "Auto-Detect"; + +"lng_text_copied" = "Text copied to clipboard."; +"lng_code_copied" = "Code copied to clipboard."; + +"lng_spellchecker_submenu" = "Spelling"; +"lng_spellchecker_add" = "Add to Dictionary"; +"lng_spellchecker_remove" = "Remove from Dictionary"; +"lng_spellchecker_ignore" = "Ignore word"; + +"lng_full_name" = "{first_name} {last_name}"; + +"lng_confirm_phone_link_invalid" = "This link is broken or expired."; +"lng_confirm_phone_title" = "Cancel account reset"; +"lng_confirm_phone_about" = "Somebody with access to your phone number {phone} has requested to delete your Tidings 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 to your number. You can also cancel this by changing your phone number to a number you control."; +"lng_confirm_phone_success" = "Success!\n\nThe deletion process was canceled for your account {phone}. You may close this window now."; +"lng_confirm_phone_send" = "Send"; +"lng_confirm_phone_enter_code" = "Please enter the code."; + +"lng_theme_editor_no_keys" = "No keys in the palette yet"; +"lng_theme_editor_cant_change_theme" = "You can't apply a new theme while editing the color palette. Please close the theme editor first."; +"lng_theme_editor_new_keys" = "Not in the palette yet"; +"lng_theme_editor_background_image" = "Background Image"; +"lng_theme_editor_saved_to_jpg" = "Saved to JPEG, {size}"; +"lng_theme_editor_read_from_jpg" = "JPEG image, {size}"; +"lng_theme_editor_read_from_png" = "PNG image, {size}"; +"lng_theme_editor_choose_image" = "Choose Background Image"; +"lng_theme_editor_choose_name" = "Save Theme File"; +"lng_theme_editor_error" = "The editor encountered an error :( See 'log.txt' for details."; +"lng_theme_editor_sure_close" = "Are you sure you want to close the editor? Your changes won't be saved."; +"lng_theme_editor_need_auth" = "You need to log in to save your theme."; +"lng_theme_editor_need_unlock" = "You need to unlock Tidings to save your theme."; +"lng_theme_editor_done" = "Theme exported successfully!"; +"lng_theme_editor_title" = "Edit color palette"; +"lng_theme_editor_save_button" = "Save theme"; + +"lng_theme_editor_create_title" = "Create theme"; +"lng_theme_editor_create" = "Create"; +"lng_theme_editor_name" = "Theme name"; +"lng_theme_editor_create_description" = "Your new theme will be based on your currently selected colors and wallpaper. Alternatively, you can import an existing theme or color palette from a file."; +"lng_theme_editor_attach_description" = "You can create desktop part of your theme based on your currently selected colors and wallpaper. Alternatively, you can import existing theme or color palette from file."; +"lng_theme_editor_import_existing" = "Import existing theme"; +"lng_theme_editor_save_title" = "Save theme"; +"lng_theme_editor_link_about" = "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 use a-z, 0-9 and underscores."; + +"lng_theme_editor_menu_export" = "Export theme"; +"lng_theme_editor_menu_import" = "Import theme"; +"lng_theme_editor_menu_show" = "Show palette file"; + +"lng_payments_webview_no_use" = "Unfortunately, you can't use payments with current system configuration."; +"lng_payments_webview_install_edge" = "Please install {link}."; +"lng_payments_webview_install_webkit" = "Please install WebKitGTK (webkit2gtk-4.1/webkit2gtk-4.0) using your package manager."; +"lng_payments_webview_enable_opengl" = "Please enable OpenGL in application settings."; +"lng_payments_webview_switch_x11" = "Unsupported display server. Please switch to X11."; +"lng_payments_webview_update_windows" = "Please update your system to Windows 8.1 or later."; +"lng_payments_sure_close" = "Are you sure you want to close this payment form? The changes you made will be lost."; +"lng_payments_receipt_label" = "Receipt"; +"lng_payments_receipt_label_test" = "Test receipt"; +"lng_payments_invoice_label" = "Invoice"; +"lng_payments_invoice_label_test" = "Test invoice"; +"lng_payments_receipt_button" = "Receipt"; +"lng_payments_success" = "You paid {amount} for {title}."; + +"lng_payments_checkout_title" = "Checkout"; +"lng_payments_receipt_title" = "Receipt"; +"lng_payments_total_label" = "Total"; +"lng_payments_date_label" = "Paid"; +"lng_payments_pay_amount" = "Pay {amount}"; +"lng_payments_payment_method" = "Payment Method"; +"lng_payments_new_card" = "New Card..."; +"lng_payments_shipping_address" = "Shipping Address"; +"lng_payments_address_street1" = "Address 1"; +"lng_payments_address_street2" = "Address 2"; +"lng_payments_address_city" = "City"; +"lng_payments_address_state" = "State"; +"lng_payments_address_country" = "Country"; +"lng_payments_address_postcode" = "Postcode"; + +"lng_payments_shipping_method" = "Shipping Method"; +"lng_payments_info_name" = "Name"; +"lng_payments_info_email" = "Email"; +"lng_payments_info_phone" = "Phone"; +"lng_payments_to_provider_phone_email" = "Phone and Email will be passed to {provider} as billing info."; +"lng_payments_to_provider_email" = "Email will be passed to {provider} as billing info."; +"lng_payments_to_provider_phone" = "Phone will be passed to {provider} as billing info."; +"lng_payments_processed_by" = "Processed by {provider}"; +"lng_payments_warning_title" = "Warning"; +"lng_payments_warning_body" = "Neither Tidings, nor {bot1} will have access to your credit card information. Credit card details will be handled only by the payment system, {provider}.\n\nPayments will go directly to the developer of {bot2}. Tidings cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {bot3} or your bank."; +"lng_payments_shipping_address_title" = "Shipping Information"; +"lng_payments_card_title" = "New Card"; +"lng_payments_card_number" = "Card Number"; +"lng_payments_card_cvc" = "CVC"; +"lng_payments_card_expire_date" = "MM / YY"; +"lng_payments_card_holder" = "Cardholder name"; +"lng_payments_billing_address" = "Billing Information"; +"lng_payments_billing_country" = "Country"; +"lng_payments_billing_zip_code" = "Zip Code"; +"lng_payments_save_information" = "Save Information for future use"; +"lng_payments_need_password" = "You can save your payment information for future use. Please turn on Two-Step Verification to enable this."; +"lng_payments_password_title" = "Payment Confirmation"; +"lng_payments_password_description" = "You saved the payment method {card}. To use it for payment, please enter your 2-Step-Verification password."; +"lng_payments_password_submit" = "Pay"; +"lng_payments_tips_label" = "Tip (Optional)"; +"lng_payments_tips_box_title" = "Add Tip"; +"lng_payments_tips_max" = "Max possible tip amount: {amount}"; + +"lng_payments_shipping_not_available" = "Shipping to the selected country is not available."; +"lng_payments_card_declined" = "Your card was declined."; +"lng_payments_payment_failed" = "Payment failed. Your card has not been billed."; +"lng_payments_precheckout_failed" = "The bot couldn't process your payment. Your card has not been billed."; +"lng_payments_precheckout_timeout" = "The bot didn't respond in time. Your card has not been billed."; +"lng_payments_precheckout_stars_failed" = "The bot couldn't process your payment."; +"lng_payments_precheckout_stars_timeout" = "The bot didn't respond in time."; +"lng_payments_already_paid" = "You have already paid for this item."; + +"lng_payments_terms_title" = "Terms of Service"; +"lng_payments_terms_text" = "Subscribe and accept Terms of Service of {bot}?"; +"lng_payments_terms_text_once" = "Do you accept the Terms of Service of {bot}?"; +"lng_payments_terms_agree" = "I agree to {link}"; +"lng_payments_terms_link" = "Terms of Service"; +"lng_payments_terms_accept" = "Accept"; + +"lng_call_status_incoming" = "is calling you..."; +"lng_call_status_connecting" = "connecting..."; +"lng_call_status_exchanging" = "exchanging encryption keys..."; +"lng_call_status_waiting" = "waiting..."; +"lng_call_status_requesting" = "requesting..."; +"lng_call_status_hanging" = "hanging up..."; +"lng_call_status_ended" = "call ended"; +"lng_call_status_failed" = "failed to connect"; +"lng_call_status_ringing" = "ringing..."; +"lng_call_status_busy" = "line busy"; +"lng_call_status_group_invite" = "Tidings Group Call"; +"lng_call_status_sure" = "Click on the Camera icon if you want to start a video call."; +"lng_call_fingerprint_tooltip" = "If the emoji on {user}'s screen are the same, this call is 100% secure"; + +"lng_call_error_not_available" = "Sorry, you cannot call {user} because of their privacy settings. You can ask them to modify their setting or to call you instead."; +"lng_call_error_outdated" = "{user}'s app does not support calls. They need to update their app before you can call them."; +"lng_call_error_no_camera" = "No camera could be found. Please make sure that your camera is connected to the computer."; +"lng_call_error_camera_not_started" = "You can switch to video call once you're connected."; +"lng_call_error_camera_outdated" = "{user}'s app does not support video calls. They need to update their app before you can call them."; +"lng_call_error_audio_io" = "There seems to be a problem with your sound card. Please make sure that your computer's speakers and microphone are working and try again."; +"lng_call_error_add_not_started" = "You can add more people once you're connected."; + +"lng_call_bar_hangup" = "End call"; +"lng_call_leave_to_other_sure" = "End your active call and join this video chat?"; +"lng_call_leave_to_other_sure_channel" = "Do you want to end your active call and join a live stream in this channel?"; + +"lng_call_box_title" = "Calls"; +"lng_call_box_about" = "You haven't made any Tidings calls yet."; +"lng_call_box_status_today" = "{time}"; +"lng_call_box_status_yesterday" = "yesterday at {time}"; +"lng_call_box_status_date" = "{date} at {time}"; +"lng_call_box_status_group" = "({amount}) {status}"; +"lng_call_box_clear_all" = "Clear All"; +"lng_call_box_clear_sure" = "Are you sure you want to completely clear your calls log?"; +"lng_call_box_clear_button" = "Clear"; +"lng_call_box_groupcalls_subtitle" = "Active video chats"; + +"lng_call_outgoing" = "Outgoing call"; +"lng_call_video_outgoing" = "Outgoing video call"; +"lng_call_group_outgoing" = "Outgoing group call"; +"lng_call_incoming" = "Incoming call"; +"lng_call_video_incoming" = "Incoming video call"; +"lng_call_group_incoming" = "Incoming group call"; +"lng_call_missed" = "Missed call"; +"lng_call_video_missed" = "Missed video call"; +"lng_call_group_missed" = "Missed group call"; +"lng_call_cancelled" = "Canceled call"; +"lng_call_video_cancelled" = "Canceled video call"; +"lng_call_declined" = "Declined call"; +"lng_call_video_declined" = "Declined video call"; +"lng_call_group_declined" = "Declined group call"; +"lng_call_duration_info" = "{time}, {duration}"; +"lng_call_type_and_duration" = "{type} ({duration})"; +"lng_call_invitation" = "Group call invitation"; +"lng_call_ongoing" = "Ongoing group call"; + +"lng_call_rate_label" = "Please rate the quality of your call"; +"lng_call_rate_comment" = "Comment (optional)"; + +"lng_call_start" = "Start Call"; +"lng_call_start_video" = "Start Video"; +"lng_call_stop_video" = "Stop Video"; +"lng_call_screencast" = "Screencast"; +"lng_call_add_people" = "Add People"; +"lng_call_end_call" = "End Call"; +"lng_call_mute_audio" = "Mute"; +"lng_call_unmute_audio" = "Unmute"; +"lng_call_accept" = "Accept"; +"lng_call_decline" = "Decline"; +"lng_call_redial" = "Redial"; +"lng_call_cancel" = "Cancel"; + +"lng_call_microphone_off" = "{user}'s microphone is off"; +"lng_call_battery_level_low" = "{user}'s battery level is low"; + +"lng_group_call_title" = "Video Chat"; +"lng_group_call_title_channel" = "Live Stream"; +"lng_group_call_active" = "speaking"; +"lng_group_call_inactive" = "listening"; +"lng_group_call_raised_hand_status" = "wants to speak"; +"lng_group_call_settings" = "Settings"; +"lng_group_call_video" = "Video"; +"lng_group_call_screen_share_start" = "Share Screen"; +"lng_group_call_screen_share_stop" = "Stop Sharing"; +"lng_group_call_screen_title" = "Screen {index}"; +"lng_group_call_screen_share_audio" = "Share System Audio"; +"lng_group_call_unmute" = "Unmute"; +"lng_group_call_unmute_sub" = "Hold space bar to temporarily unmute."; +"lng_group_call_you_are_live" = "You are Live"; +"lng_group_call_force_muted" = "Muted by admin"; +"lng_group_call_force_muted_sub" = "You are in Listen Only mode"; +"lng_group_call_raised_hand" = "You asked to speak"; +"lng_group_call_connecting" = "Connecting..."; +"lng_group_call_leave" = "Leave"; +"lng_group_call_leave_title" = "Leave video chat"; +"lng_group_call_leave_title_call" = "Leave group call"; +"lng_group_call_leave_title_channel" = "Leave live stream"; +"lng_group_call_leave_sure" = "Do you want to leave this video chat?"; +"lng_group_call_leave_sure_call" = "Do you want to leave this group call?"; +"lng_group_call_leave_sure_channel" = "Are you sure you want to leave this live stream?"; +"lng_group_call_close" = "Close"; +"lng_group_call_close_sure" = "Video chat is scheduled. You can abort it or just close this panel."; +"lng_group_call_close_sure_channel" = "Live stream is scheduled. You can abort it or just close this panel."; +"lng_group_call_also_cancel" = "Abort video chat"; +"lng_group_call_also_cancel_channel" = "Abort live stream"; +"lng_group_call_leave_to_other_sure" = "Leave your currently active video chat and join this video chat?"; +"lng_group_call_leave_to_other_sure_channel" = "Do you want to leave your active video chat and join a live stream in this channel?"; +"lng_group_call_leave_channel_to_other_sure" = "Do you want to leave your active live stream and join a video chat in this group?"; +"lng_group_call_leave_channel_to_other_sure_channel" = "Do you want to leave your active live stream and join a live stream in this channel?"; +"lng_group_call_create_sure" = "Start a video chat in this group?"; +"lng_group_call_create_sure_channel" = "Are you sure you want to start a live stream in this channel as your personal account?"; +"lng_group_call_join_sure_personal" = "Are you sure you want to join this video chat as your personal account?"; +"lng_group_call_muted_no_camera" = "You can't turn on video while you're muted by admin."; +"lng_group_call_muted_no_screen" = "You can't share your screen while you're muted by admin."; +"lng_group_call_chat_no_camera" = "You can't turn on video in this chat."; +"lng_group_call_chat_no_screen" = "You can't share your screen in this chat."; +"lng_group_call_failed_screen" = "An error occurred. Screencast has stopped."; +"lng_group_call_failed_camera" = "Couldn't enable camera. Another app may be already using the camera. Try closing other apps first."; +"lng_group_call_tooltip_screen" = "Share screen"; +"lng_group_call_tooltip_camera" = "Your camera is off. Click here to enable camera."; +"lng_group_call_tooltip_microphone" = "You are on mute. Click here to speak."; +"lng_group_call_tooltip_camera_off" = "Disable camera"; +"lng_group_call_tooltip_force_muted" = "Muted by admin. Click if you want to speak."; +"lng_group_call_tooltip_raised_hand" = "You asked to speak. We let the speakers know."; +"lng_group_call_also_end" = "End video chat"; +"lng_group_call_also_end_channel" = "End live stream"; +"lng_group_call_settings_title" = "Settings"; +"lng_group_call_invite" = "Invite Members"; +"lng_group_call_invite_conf" = "Add People"; +"lng_group_call_invited_status" = "invited"; +"lng_group_call_calling_status" = "calling..."; +"lng_group_call_blockchain_only_status" = "listening"; +"lng_group_call_muted_by_me_status" = "muted for you"; +"lng_group_call_invite_title" = "Invite members"; +"lng_group_call_invite_button" = "Invite"; +"lng_group_call_confcall_add" = "Call"; +"lng_group_call_add_to_group_one" = "{user} isn't a member of «{group}». Add them to the group?"; +"lng_group_call_add_to_group_some" = "Some of those users aren't members of «{group}». Add them to the group?"; +"lng_group_call_add_to_group_all" = "Those users aren't members of «{group}». Add them to the group?"; +"lng_group_call_invite_members" = "Group members"; +"lng_group_call_invite_search_results" = "Search results"; +"lng_group_call_invite_limit" = "This is currently the maximum allowed number of participants."; +"lng_group_call_new_muted" = "Mute new participants"; +"lng_group_call_speakers" = "Speakers"; +"lng_group_call_microphone" = "Microphone"; +"lng_group_call_push_to_talk" = "Push-to-Talk Shortcut"; +"lng_group_call_ptt_shortcut" = "Edit Keyboard Shortcut"; +"lng_group_call_ptt_recording" = "Stop Recording"; +"lng_group_call_ptt_delay_ms" = "{amount} ms"; +"lng_group_call_ptt_delay_s" = "{amount} s"; +"lng_group_call_ptt_delay" = "Release delay: {delay}"; +"lng_group_call_share" = "Share Invite Link"; +"lng_group_call_noise_suppression" = "Enable Noise Suppression"; +"lng_group_call_video_paused" = "Video is paused"; +"lng_group_call_share_speaker" = "Users with this link can speak"; +"lng_group_call_copy_speaker_link" = "Copy Speaker Link"; +"lng_group_call_copy_listener_link" = "Copy Listener Link"; +"lng_group_call_end" = "End Video Chat"; +"lng_group_call_end_channel" = "End Live Stream"; +"lng_group_call_cancel" = "Abort Video Chat"; +"lng_group_call_cancel_channel" = "Abort Live Stream"; +"lng_group_call_join" = "Join"; +"lng_group_call_join_confirm" = "Join the video chat {chat}?"; +"lng_group_call_join_confirm_channel" = "Do you want to join the live stream {chat}?"; +"lng_group_call_invite_done_user" = "You invited {user} to the video chat."; +"lng_group_call_invite_done_many#one" = "You invited **{count} participant** to the video chat."; +"lng_group_call_invite_done_many#other" = "You invited **{count} participants** to the video chat."; +"lng_group_call_no_members" = "Click to join"; +"lng_group_call_members#one" = "{count} participant"; +"lng_group_call_members#other" = "{count} participants"; +"lng_group_call_context_mute" = "Mute"; +"lng_group_call_context_unmute" = "Allow to speak"; +"lng_group_call_context_remove_hand" = "Cancel request to speak"; +"lng_group_call_context_mute_for_me" = "Mute for me"; +"lng_group_call_context_unmute_for_me" = "Unmute for me"; +"lng_group_call_context_pin_camera" = "Pin video"; +"lng_group_call_context_unpin_camera" = "Unpin video"; +"lng_group_call_context_pin_screen" = "Pin screencast"; +"lng_group_call_context_unpin_screen" = "Unpin screencast"; +"lng_group_call_context_remove" = "Remove"; +"lng_group_call_context_cancel_invite" = "Discard invite"; +"lng_group_call_context_stop_ringing" = "Stop calling"; +"lng_group_call_context_ban_from_call" = "Ban from call"; +"lng_group_call_remove_channel" = "Remove {channel} from the video chat and ban them?"; +"lng_group_call_remove_channel_from_channel" = "Remove {channel} from the live stream?"; +"lng_group_call_mac_access" = "Tidings Desktop does not have access to system wide keyboard input required for Push to Talk."; +"lng_group_call_mac_input" = "Please allow **Input Monitoring** for Tidings in Privacy Settings."; +"lng_group_call_mac_accessibility" = "Please allow **Accessibility** for Tidings in Privacy Settings.\n\nYou may need to restart the app."; +"lng_group_call_mac_screencast_access" = "Tidings Desktop does not have access to screen recording required for Screen Sharing."; +"lng_group_call_mac_recording" = "Please allow **Screen Recording** for Tidings in Privacy Settings."; +"lng_group_call_mac_settings" = "Open Settings"; + +"lng_group_call_start_as_header" = "Start Video Chat as..."; +"lng_group_call_start_as_header_channel" = "Start Live Stream as..."; +"lng_group_call_join_as_header" = "Join Video Chat as..."; +"lng_group_call_join_as_header_channel" = "Join Live Stream as..."; +"lng_group_call_display_as_header" = "Display me as..."; +"lng_group_call_join_as_about" = "Choose whether you want to be displayed as your personal account or as your channel."; +"lng_group_call_or_schedule" = "You can also {link}."; +"lng_group_call_schedule" = "schedule a video chat"; +"lng_group_call_schedule_channel" = "schedule a live stream"; +"lng_group_call_schedule_title" = "Schedule Video Chat"; +"lng_group_call_schedule_title_channel" = "Schedule Live Stream"; +"lng_group_call_schedule_notified_group" = "Members of the group will be notified that the video chat will start in {duration}."; +"lng_group_call_schedule_notified_channel" = "Subscribers of the channel will be notified that the live stream starts in {duration}."; +"lng_group_call_scheduled_status" = "Scheduled"; +"lng_group_call_scheduled_title" = "Scheduled Video Chat"; +"lng_group_call_scheduled_title_channel" = "Scheduled Live Stream"; +"lng_group_call_starts_short" = "Starts {when}"; +"lng_group_call_starts" = "Video Chat starts {when}"; +"lng_group_call_starts_channel" = "Live Stream starts {when}"; +"lng_group_call_starts_today" = "today at {time}"; +"lng_group_call_starts_tomorrow" = "tomorrow at {time}"; +"lng_group_call_starts_date" = "{date} at {time}"; +"lng_group_call_starts_in" = "Starts in"; +"lng_group_call_late_by" = "Late by"; +"lng_group_call_starts_short_today" = "Today, {time}"; +"lng_group_call_starts_short_tomorrow" = "Tomorrow, {time}"; +"lng_group_call_starts_short_date" = "{date}, {time}"; +"lng_group_call_start_now" = "Start Now"; +"lng_group_call_start_now_sure" = "Are you sure you want to start the video chat now?"; +"lng_group_call_start_now_sure_channel" = "Are you sure you want to start the live stream now?"; +"lng_group_call_set_reminder" = "Set Reminder"; +"lng_group_call_cancel_reminder" = "Cancel Reminder"; +"lng_group_call_join_as_personal" = "personal account"; +"lng_group_call_edit_title" = "Edit Video Chat Title"; +"lng_group_call_edit_title_channel" = "Edit live stream title"; +"lng_group_call_recording_start" = "Start Recording"; +"lng_group_call_recording_stop" = "Stop Recording"; +"lng_group_call_recording_started" = "Audio recording started."; +"lng_group_call_recording_started_video" = "Started recording the video stream."; +"lng_group_call_recording_started_channel" = "Live stream recording started."; +"lng_group_call_recording_stopped" = "Recording stopped."; +"lng_group_call_recording_stopped_channel" = "Live stream recording stopped."; +"lng_group_call_recording_saved" = "Recording of the audio stream saved to Saved Messages."; +"lng_group_call_recording_saved_video" = "Video saved to Saved Messages."; +"lng_group_call_pinned_camera_me" = "Your video is pinned."; +"lng_group_call_pinned_screen_me" = "Your screencast is pinned."; +"lng_group_call_pinned_camera" = "{user}'s video is pinned."; +"lng_group_call_pinned_screen" = "{user}'s screencast is pinned."; +"lng_group_call_unpinned_camera_me" = "Your video is unpinned."; +"lng_group_call_unpinned_screen_me" = "Your screencast is unpinned."; +"lng_group_call_unpinned_camera" = "{user}'s video is unpinned."; +"lng_group_call_unpinned_screen" = "{user}'s screencast is unpinned."; +"lng_group_call_sure_screencast" = "{user} is sharing their screen. This action will pin your screencast for all participants."; +"lng_group_call_pinned_on_top" = "Live stream is pinned on top."; +"lng_group_call_unpinned_on_top" = "Live stream is unpinned from top."; +"lng_group_call_recording_start_sure" = "Record this chat and save the result into an audio file?\n\nParticipants will see that the chat is being recorded."; +"lng_group_call_recording_stop_sure" = "Do you want to stop recording this video chat?"; +"lng_group_call_recording_start_field" = "Recording Title"; +"lng_group_call_recording_start_button" = "Start"; +"lng_group_call_recording_start_title" = "Add Title"; +"lng_group_call_recording_start_checkbox" = "Also record video"; +"lng_group_call_recording_start_audio_subtitle" = "This chat will be recorded into an audio file"; +"lng_group_call_recording_start_video_subtitle" = "Choose video orientation"; +"lng_group_call_is_recorded" = "The audio stream is being recorded."; +"lng_group_call_is_recorded_video" = "The video stream is being recorded."; +"lng_group_call_is_recorded_channel" = "Live stream is being recorded."; +"lng_group_call_can_speak_here" = "You can now speak."; +"lng_group_call_can_speak" = "You can now speak in {chat}."; +"lng_group_call_title_changed" = "Video chat title changed to {title}"; +"lng_group_call_title_changed_channel" = "Live stream title changed to {title}"; +"lng_group_call_join_as_changed" = "Participants of this video chat will now see you as {name}"; +"lng_group_call_join_as_changed_channel" = "Participants of this live stream will now see you as {name}"; +"lng_group_call_no_stream_admin" = "Oops! Tidings 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."; +"lng_group_call_no_stream" = "{group} is currently not broadcasting live stream data to Tidings."; + +"lng_menu_start_group_call" = "Start video chat"; +"lng_menu_start_group_call_scheduled" = "Schedule video chat"; +"lng_menu_start_group_call_with" = "Stream with..."; +"lng_menu_start_group_call_join" = "Join video chat"; +"lng_menu_start_group_call_options" = "Video chat"; +"lng_menu_start_group_call_channel" = "Start live stream"; +"lng_menu_start_group_call_scheduled_channel" = "Schedule live stream"; +"lng_menu_start_group_call_with_channel" = "Stream with..."; + +"lng_group_call_rtmp_title" = "Stream with other apps"; +"lng_group_call_rtmp_url_subtitle" = "Server URL"; +"lng_group_call_rtmp_url_copy" = "Copy Server URL"; +"lng_group_call_rtmp_url_copied" = "Server URL copied to clipboard."; +"lng_group_call_rtmp_key_subtitle" = "Stream Key"; +"lng_group_call_rtmp_key_copy" = "Copy Stream Key"; +"lng_group_call_rtmp_key_copied" = "Stream Key copied to clipboard."; +"lng_group_call_rtmp_key_warning" = "**Never share your Stream Key with anyone or show it on stream!**"; +"lng_group_call_rtmp_info" = "To stream video with another app, enter this Server URL and Stream Key in your streaming app. Software encoding recommended (x264 in OBS).\n\nOnce you start broadcasting in your streaming app, click Start Streaming below."; +"lng_group_call_rtmp_start" = "Start Streaming"; +"lng_group_call_rtmp_revoke" = "Revoke Stream Key"; +"lng_group_call_rtmp_revoke_sure" = "Are you sure you want to revoke your Stream Key?"; +"lng_group_call_rtmp_viewers#one" = "{count} viewer"; +"lng_group_call_rtmp_viewers#other" = "{count} viewers"; + +"lng_confcall_join_title" = "Group Call"; +"lng_confcall_join_text" = "You are invited to join a Tidings Call."; +"lng_confcall_join_text_inviter" = "{user} is inviting you to join a Tidings Call."; +"lng_confcall_already_joined_one" = "{user} already joined this call."; +"lng_confcall_already_joined_two" = "{user} and {other} already joined this call."; +"lng_confcall_already_joined_three" = "{user}, {other} and {third} already joined this call."; +"lng_confcall_already_joined_many#one" = "{user}, {other} and **{count}** other person already joined this call."; +"lng_confcall_already_joined_many#other" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_join_button" = "Join Group Call"; +"lng_confcall_create_call" = "Start New Call"; +"lng_confcall_create_call_description#one" = "You can add up to {count} participant to a call."; +"lng_confcall_create_call_description#other" = "You can add up to {count} participants to a call."; +"lng_confcall_create_title" = "New Call"; +"lng_confcall_create_link" = "Create Call Link"; +"lng_confcall_create_link_description" = "You can create a link that will allow your friends on Tidings to join the call."; +"lng_confcall_link_revoke" = "Revoke link"; +"lng_confcall_link_revoked_title" = "Link Revoked"; +"lng_confcall_link_inactive" = "This link is no longer active."; +"lng_confcall_link_revoked_text" = "A new link has been generated."; +"lng_confcall_link_title" = "Call Link"; +"lng_confcall_link_about" = "Anyone on Tidings can join your call by following the link below."; +"lng_confcall_link_or" = "or"; +"lng_confcall_link_join" = "Be the first to join the call and add people from there. {link}"; +"lng_confcall_link_join_link" = "Open call {arrow}"; +"lng_confcall_inactive_title" = "Start Group Call"; +"lng_confcall_inactive_about" = "This call is no longer active.\nYou can start a new one."; +"lng_confcall_invite_done_user" = "You're calling {user} to join."; +"lng_confcall_invite_done_many#one" = "You're calling **{count} person** to join."; +"lng_confcall_invite_done_many#other" = "You're calling **{count} people** to join."; +"lng_confcall_invite_already_user" = "{user} is already in the call."; +"lng_confcall_invite_already_many#one" = "**{count} person** is already in the call."; +"lng_confcall_invite_already_many#other" = "**{count} people** are already in the call."; +"lng_confcall_invite_privacy_user" = "You cannot call {user} because of their privacy settings."; +"lng_confcall_invite_privacy_many#one" = "You cannot call **{count} person** because of their privacy settings."; +"lng_confcall_invite_privacy_many#other" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_fail_user" = "Couldn't call {user} to join."; +"lng_confcall_invite_fail_many#one" = "Couldn't call **{count} person** to join."; +"lng_confcall_invite_fail_many#other" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_kicked_user" = "{user} was banned from the call."; +"lng_confcall_invite_kicked_many#one" = "**{count} person** was removed from the call."; +"lng_confcall_invite_kicked_many#other" = "**{count} people** were removed from the call."; +"lng_confcall_not_accessible" = "This call is no longer accessible."; +"lng_confcall_participants_limit" = "This call reached the participants limit."; +"lng_confcall_sure_remove" = "Remove {user} from the call?"; +"lng_confcall_e2e_badge" = "End-to-End Encrypted"; +"lng_confcall_e2e_badge_small" = "E2E Encrypted"; +"lng_confcall_e2e_about" = "These emoji represent the encryption key. They must match for all participants and will change when someone joins or leaves."; + +"lng_no_mic_permission" = "Tidings needs microphone access so that you can make calls and record voice messages."; + +"lng_player_message_today" = "today at {time}"; +"lng_player_message_yesterday" = "yesterday at {time}"; +"lng_player_message_date" = "{date} at {time}"; + +"lng_audio_player_reverse" = "Reverse order"; +"lng_audio_player_shuffle" = "Shuffle"; +"lng_audio_transcribe_long" = "This voice message is too long."; +"lng_audio_transcribe_trials_left#one" = "You have {count} free transcription left until {date}."; +"lng_audio_transcribe_trials_left#other" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_over" = "You have used all your free transcriptions this week. Wait until {date} to use it again or subscribe to {link} now."; + +"lng_rights_edit_admin" = "Admin rights"; +"lng_rights_edit_admin_header" = "What can this admin do?"; +"lng_rights_edit_admin_rank_name" = "Custom title"; +"lng_rights_edit_admin_rank_about" = "A title that members will see instead of '{title}'."; +"lng_rights_about_add_admins_yes" = "This admin will be able to add new admins with equal or fewer rights."; +"lng_rights_about_add_admins_no" = "This admin will not be able to add new admins."; +"lng_rights_about_by" = "Promoted by {user} at {date}."; + +"lng_rights_about_admin_cant_edit" = "You can't edit the rights of this admin."; +"lng_rights_about_restriction_cant_edit" = "You cannot change the restrictions for this user."; +"lng_rights_restriction_for_all" = "This option is disabled for all members in Group Permissions."; +"lng_rights_permission_for_all" = "This option is enabled for all members in Group Permissions."; +"lng_rights_permission_unavailable" = "This permission is not available in public groups."; +"lng_rights_permission_in_discuss" = "This permission is not available in discussion groups."; +"lng_rights_permission_cant_edit" = "You cannot change this permission."; +"lng_rights_user_restrictions" = "User permissions"; +"lng_rights_user_restrictions_header" = "What can this member do?"; +"lng_rights_default_restrictions_header" = "What can members of this group do?"; +"lng_rights_slowmode_header" = "Slow mode"; +"lng_rights_slowmode_off" = "Off"; +"lng_rights_slowmode_about" = "Members will be able to send only one message per this interval."; +"lng_rights_slowmode_about_interval" = "Members will be able to send only one message {interval}."; +"lng_rights_slowmode_interval_seconds#one" = "every {count} second"; +"lng_rights_slowmode_interval_seconds#other" = "every {count} seconds"; +"lng_rights_slowmode_interval_minutes#one" = "every {count} minute"; +"lng_rights_slowmode_interval_minutes#other" = "every {count} minutes"; +"lng_rights_boosts_no_restrict" = "Do not restrict boosters"; +"lng_rights_boosts_about" = "Turn this on to always allow users who boosted your group to send messages and media."; +"lng_rights_boosts_about_on" = "Choose how many boosts a user must give to the group to bypass restrictions on sending messages."; +"lng_rights_charge_stars" = "Charge Stars for Messages"; +"lng_rights_charge_stars_about" = "If you turn this on, regular members of the group will have to pay Stars to send messages."; +"lng_rights_charge_price" = "Set price per message"; +"lng_rights_charge_price_about" = "Your group will receive {percent} of the selected fee ({amount}) for each incoming message."; + +"lng_slowmode_enabled" = "Slow Mode is active.\nYou can send your next message in {left}."; +"lng_slowmode_no_many" = "Slow mode is enabled. You can't send more than one message at a time."; +"lng_slowmode_too_long" = "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."; +"lng_slowmode_seconds#one" = "{count} second"; +"lng_slowmode_seconds#other" = "{count} seconds"; + +"lng_payment_confirm_title" = "Confirm payment"; +"lng_payment_confirm_text#one" = "{name} charges **{count}** Star per message."; +"lng_payment_confirm_text#other" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_amount#one" = "**{count}** Star"; +"lng_payment_confirm_amount#other" = "**{count}** Stars"; +"lng_payment_confirm_users#one" = "You selected **{count}** user who charge Stars for messages."; +"lng_payment_confirm_users#other" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_chats#one" = "You selected **{count}** chat that charges Stars for messages."; +"lng_payment_confirm_chats#other" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_sure#one" = "Would you like to pay {amount} to send **{count}** message?"; +"lng_payment_confirm_sure#other" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_dont_ask" = "Don't ask me again"; +"lng_payment_confirm_button#one" = "Pay for {count} Message"; +"lng_payment_confirm_button#other" = "Pay for {count} Messages"; +"lng_payment_bar_text" = "{name} must pay {cost} for each message to you."; +"lng_payment_bar_button" = "Remove Fee"; +"lng_payment_refund_title" = "Remove Fee"; +"lng_payment_refund_text" = "Are you sure you want to allow {name} to message you for free?"; +"lng_payment_refund_also#one" = "Refund the {count} Star already paid"; +"lng_payment_refund_also#other" = "Refund the {count} Stars already paid"; +"lng_payment_refund_confirm" = "Confirm"; + +"lng_rights_gigagroup_title" = "Broadcast group"; +"lng_rights_gigagroup_convert" = "Convert to Broadcast Group"; +"lng_rights_gigagroup_about" = "Broadcast groups can have over 200,000 members, but only admins can send messages in them."; +"lng_gigagroup_convert_title" = "Broadcast Groups"; +"lng_gigagroup_convert_feature1" = "No limit on the number of members."; +"lng_gigagroup_convert_feature2" = "Only admins can send messages."; +"lng_gigagroup_convert_feature3" = "Can't be turned back into a regular group."; +"lng_gigagroup_convert_sure" = "Convert"; +"lng_gigagroup_warning_title" = "Are you sure?"; +"lng_gigagroup_warning" = "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."; +"lng_gigagroup_done" = "Your group can now have more than 200,000 members."; +"lng_gigagroup_suggest_title" = "Limit reached"; +"lng_gigagroup_suggest_text" = "Your group has reached a limit of **200,000** members.\n\nYou can increase this limit by converting the group to a **broadcast group** where only admins can post. Interested?"; +"lng_gigagroup_suggest_more" = "Learn more"; + +"lng_rights_channel_info" = "Change channel info"; +"lng_rights_channel_manage" = "Manage messages"; +"lng_rights_channel_post" = "Post messages"; +"lng_rights_channel_edit" = "Edit messages of others"; +"lng_rights_channel_delete" = "Delete messages of others"; +"lng_rights_channel_manage_stories" = "Manage stories"; +"lng_rights_channel_post_stories" = "Post stories"; +"lng_rights_channel_edit_stories" = "Edit stories of others"; +"lng_rights_channel_delete_stories" = "Delete stories of others"; +"lng_rights_channel_manage_calls" = "Manage live streams"; +"lng_rights_group_info" = "Change group info"; +"lng_rights_group_ban" = "Ban users"; +"lng_rights_group_invite_link" = "Invite users via link"; +"lng_rights_group_invite" = "Add members"; +"lng_rights_group_pin" = "Pin messages"; +"lng_rights_group_topics" = "Manage topics"; +"lng_rights_group_add_topics" = "Create topics"; +"lng_rights_group_manage_calls" = "Manage video chats"; +"lng_rights_group_delete" = "Delete messages"; +"lng_rights_group_anonymous" = "Remain anonymous"; +"lng_rights_add_admins" = "Add new admins"; +"lng_rights_chat_send_text" = "Send messages"; +"lng_rights_chat_send_media" = "Send media"; +"lng_rights_chat_send_stickers" = "Send stickers & GIFs"; +"lng_rights_chat_send_links" = "Embed links"; +"lng_rights_chat_send_polls" = "Send polls"; +"lng_rights_chat_add_members" = "Add members"; +"lng_rights_chat_photos" = "Photos"; +"lng_rights_chat_videos" = "Video files"; +"lng_rights_chat_stickers" = "Stickers & GIFs"; +"lng_rights_chat_music" = "Music"; +"lng_rights_chat_files" = "Files"; +"lng_rights_chat_voice_messages" = "Voice messages"; +"lng_rights_chat_video_messages" = "Video messages"; +"lng_rights_chat_restricted_by" = "Restricted by {user} at {date}."; +"lng_rights_chat_banned_by" = "Banned by {user} at {date}."; +"lng_rights_chat_banned_until_header" = "Restricted until"; +"lng_rights_chat_banned_forever" = "Forever"; +"lng_rights_chat_banned_day#one" = "For {count} day"; +"lng_rights_chat_banned_day#other" = "For {count} days"; +"lng_rights_chat_banned_week#one" = "For {count} week"; +"lng_rights_chat_banned_week#other" = "For {count} weeks"; +"lng_rights_chat_banned_custom" = "Custom"; +"lng_rights_chat_banned_custom_date" = "Until {date}"; + +"lng_rights_transfer_group" = "Transfer group ownership"; +"lng_rights_transfer_channel" = "Transfer channel ownership"; +"lng_rights_transfer_check" = "Security check"; +"lng_rights_transfer_check_about" = "You can transfer this group to {user} only if you have:"; +"lng_rights_transfer_check_password" = "• Enabled **Two-Step Verification** more than **7 days** ago."; +"lng_rights_transfer_check_session" = "• Logged in on this device more than **24 hours** ago."; +"lng_rights_transfer_check_later" = "Please come back later."; +"lng_rights_transfer_set_password" = "Set password"; +"lng_rights_transfer_about" = "This will transfer the full **owner rights** for {group} to {user}. The new owner will be free to remove any of your admin privileges or even ban you."; +"lng_rights_transfer_sure" = "Change owner"; +"lng_rights_transfer_password_title" = "Two-step verification"; +"lng_rights_transfer_password_description" = "Please enter your password to complete the transfer."; +"lng_rights_transfer_done_group" = "{user} is now the owner of the group."; +"lng_rights_transfer_done_channel" = "{user} is now the owner of the channel."; + +"lng_bots_password_confirm_check_about" = "You can complete this action only if you have:"; +"lng_bots_password_confirm_title" = "Two-step verification"; +"lng_bots_password_confirm_description" = "Please enter your password to confirm this action."; + +"lng_restricted_send_message" = "The admins of this group have restricted your ability to send messages."; +"lng_restricted_send_photos" = "The admins of this group restricted you from sending photos here."; +"lng_restricted_send_videos" = "The admins of this group restricted you from sending videos here."; +"lng_restricted_send_music" = "The admins of this group restricted you from sending music here."; +"lng_restricted_send_files" = "The admins of this group restricted you from sending files here."; +"lng_restricted_send_voice_messages_group" = "The admins of this group restricted you from sending voice messages here."; +"lng_restricted_send_video_messages_group" = "The admins of this group restricted you from sending video messages here."; +"lng_restricted_send_stickers" = "The admins of this group have restricted your ability to send stickers."; +"lng_restricted_send_gifs" = "The admins of this group have restricted your ability to send GIFs."; +"lng_restricted_send_inline" = "The admins of this group have restricted your ability to send inline content."; +"lng_restricted_send_polls" = "The admins of this group have restricted your ability to send polls."; +"lng_restricted_boost_group" = "Boost this group to send messages"; + +"lng_restricted_send_message_until" = "The admins of this group have restricted you from sending messages until {date}, {time}."; +"lng_restricted_send_photos_until" = "The admins of this group restricted you from sending photos here until {date}, {time}."; +"lng_restricted_send_videos_until" = "The admins of this group restricted you from sending videos here until {date}, {time}."; +"lng_restricted_send_music_until" = "The admins of this group restricted you from sending music here until {date}, {time}."; +"lng_restricted_send_files_until" = "The admins of this group restricted you from sending files here until {date}, {time}."; +"lng_restricted_send_voice_messages_until" = "The admins of this group restricted you from sending voice messages here until {date}, {time}."; +"lng_restricted_send_video_messages_until" = "The admins of this group restricted you from sending video messages here until {date}, {time}."; +"lng_restricted_send_stickers_until" = "The admins of this group have restricted your ability to send stickers until {date}, {time}."; +"lng_restricted_send_gifs_until" = "The admins of this group have restricted your ability to send GIFs until {date}, {time}."; +"lng_restricted_send_inline_until" = "The admins of this group have restricted your ability to send inline content until {date}, {time}."; +"lng_restricted_send_polls_until" = "The admins of this group have restricted your ability to send polls until {date}, {time}."; + +"lng_restricted_send_message_all" = "Sending messages is not allowed in this group."; +"lng_restricted_send_photos_all" = "Sending photos isn't allowed in this group."; +"lng_restricted_send_videos_all" = "Sending videos isn't allowed in this group."; +"lng_restricted_send_music_all" = "Sending music isn't allowed in this group."; +"lng_restricted_send_files_all" = "Sending files isn't allowed in this group."; +"lng_restricted_send_voice_messages_all" = "Sending voice messages isn't allowed in this group."; +"lng_restricted_send_video_messages_all" = "Sending video messages isn't allowed in this group."; +"lng_restricted_send_stickers_all" = "Stickers aren’t allowed in this group."; +"lng_restricted_send_gifs_all" = "Sending GIFs isn't allowed in this group."; +"lng_restricted_send_inline_all" = "Sending inline content isn't allowed in this group."; +"lng_restricted_send_polls_all" = "Sorry, sending polls is not allowed in this group."; + +"lng_restricted_send_public_polls" = "Sorry, polls with visible votes can't be forwarded to channels."; +"lng_restricted_send_paid_media" = "Sorry, paid media can't be sent to this channel."; + +"lng_restricted_send_voice_messages" = "{user} doesn't accept voice messages."; +"lng_restricted_send_video_messages" = "{user} doesn't accept video messages."; +"lng_restricted_send_non_premium" = "Only Premium users can message {user}."; +"lng_restricted_send_non_premium_more" = "Learn more..."; + +"lng_send_non_premium_text" = "Subscribe to **Premium**\nto message {user}."; +"lng_send_non_premium_go" = "Go Premium"; +"lng_send_non_premium_story" = "Replies restricted"; +"lng_send_non_premium_unlock" = "unlock"; +"lng_send_non_premium_message_toast" = "**{user}** only accepts messages from contacts and {link} subscribers."; +"lng_send_non_premium_message_toast_link" = "Tidings Premium"; + +"lng_send_charges_stars_text" = "{user} charges {amount} for each message."; +"lng_send_charges_stars_go" = "Buy Stars"; + +"lng_exceptions_list_title" = "Exceptions"; +"lng_removed_list_title" = "Removed users"; + +"lng_admin_log_title_all" = "All actions"; +"lng_admin_log_title_selected" = "Selected actions"; +"lng_admin_log_filter" = "Filter"; +"lng_admin_log_filter_title" = "Filter"; +"lng_admin_log_filter_all_actions" = "All actions"; +"lng_admin_log_filter_actions_type_subtitle" = "Filter actions by type"; +"lng_admin_log_filter_actions_member_section" = "Members And Admins"; +"lng_admin_log_filter_actions_subscriber_section" = "Subscribers And Admins"; +"lng_admin_log_filter_restrictions" = "New restrictions"; +"lng_admin_log_filter_admins_new" = "Admin rights"; +"lng_admin_log_filter_members_new" = "New members"; +"lng_admin_log_filter_subscribers_new" = "New subscribers"; +"lng_admin_log_filter_actions_settings_section" = "Group Settings"; +"lng_admin_log_filter_actions_channel_settings_section" = "Channel Settings"; +"lng_admin_log_filter_info_group" = "Group info"; +"lng_admin_log_filter_info_channel" = "Channel info"; +"lng_admin_log_filter_actions_messages_section" = "Messages"; +"lng_admin_log_filter_messages_deleted" = "Deleted messages"; +"lng_admin_log_filter_messages_edited" = "Edited messages"; +"lng_admin_log_filter_messages_pinned" = "Pinned messages"; +"lng_admin_log_filter_voice_chats" = "Video chats"; +"lng_admin_log_filter_voice_chats_channel" = "Live stream"; +"lng_admin_log_filter_invite_links" = "Invite links"; +"lng_admin_log_filter_members_removed" = "Members leaving"; +"lng_admin_log_filter_subscribers_removed" = "Subscribers leaving"; +"lng_admin_log_filter_topics" = "Topics"; +"lng_admin_log_filter_sub_extend" = "Subscription Renewals"; +"lng_admin_log_filter_all_admins" = "All users and admins"; +"lng_admin_log_filter_actions_admins_subtitle" = "Filter actions by admins"; +"lng_admin_log_filter_actions_admins_section" = "Show Actions by All Admins"; +"lng_admin_log_about" = "What is this?"; +"lng_admin_log_about_text" = "This is a list of all notable actions by group members and admins in the last 48 hours."; +"lng_admin_log_about_text_channel" = "This is a list of all service actions taken by the channel's admins in the last 48 hours."; +"lng_admin_log_no_results_title" = "No actions found"; +"lng_admin_log_no_results_text" = "No recent actions that match your query were found."; +"lng_admin_log_no_results_search_text" = "No recent actions that contain '{query}' have been found."; +"lng_admin_log_no_events_title" = "No actions yet"; +"lng_admin_log_no_events_text" = "No notable actions taken by the members and admins of this group in the last 48 hours."; +"lng_admin_log_no_events_text_channel" = "No notable actions taken\nby the admins of this channel\nin the last 48 hours."; + +"lng_admin_log_empty_text" = "Empty"; +"lng_admin_log_changed_title_channel" = "{from} changed channel name to «{title}»"; +"lng_admin_log_changed_description_group" = "{from} edited group description:"; +"lng_admin_log_removed_description_group" = "{from} removed group description"; +"lng_admin_log_changed_description_channel" = "{from} edited channel description:"; +"lng_admin_log_removed_description_channel" = "{from} removed channel description"; +"lng_admin_log_previous_description" = "Previous description"; +"lng_admin_log_changed_link_group" = "{from} changed group link:"; +"lng_admin_log_removed_link_group" = "{from} removed group link"; +"lng_admin_log_changed_link_channel" = "{from} changed channel link:"; +"lng_admin_log_removed_link_channel" = "{from} removed channel link"; +"lng_admin_log_previous_link" = "Previous link"; +"lng_admin_log_reordered_link_group" = "{from} reordered group links:"; +"lng_admin_log_reordered_link_channel" = "{from} reordered channel links:"; +"lng_admin_log_previous_links_order" = "Previous order"; +"lng_admin_log_activated_link" = "{from} activated @{link} username"; +"lng_admin_log_deactivated_link" = "{from} deactivated @{link} username"; +"lng_admin_log_changed_photo_group" = "{from} changed group photo"; +"lng_admin_log_changed_photo_channel" = "{from} changed channel photo"; +"lng_admin_log_removed_photo_group" = "{from} removed group photo"; +"lng_admin_log_removed_photo_channel" = "{from} removed channel photo"; +"lng_admin_log_invites_enabled" = "{from} enabled group invites"; +"lng_admin_log_invites_disabled" = "{from} disabled group invites"; +"lng_admin_log_signatures_enabled" = "{from} enabled signatures"; +"lng_admin_log_signatures_disabled" = "{from} disabled signatures"; +"lng_admin_log_signature_profiles_enabled" = "{from} enabled showing authors' profiles"; +"lng_admin_log_signature_profiles_disabled" = "{from} disabled showing authors' profiles"; +"lng_admin_log_forwards_enabled" = "{from} allowed saving content"; +"lng_admin_log_forwards_disabled" = "{from} restricted saving content"; +"lng_admin_log_history_made_hidden" = "{from} made the group history hidden for new members"; +"lng_admin_log_history_made_visible" = "{from} made group history visible for new members"; +"lng_admin_log_pinned_message" = "{from} pinned message:"; +"lng_admin_log_unpinned_message" = "{from} unpinned message"; +"lng_admin_log_edited_caption" = "{from} edited caption:"; +"lng_admin_log_edited_media" = "{from} edited media:"; +"lng_admin_log_edited_media_and_caption" = "{from} edited media and caption:"; +"lng_admin_log_edited_media_and_removed_caption" = "{from} edited media and removed caption:"; +"lng_admin_log_removed_caption" = "{from} removed caption"; +"lng_admin_log_previous_caption" = "Original caption"; +"lng_admin_log_edited_message" = "{from} edited message:"; +"lng_admin_log_previous_message" = "Original message"; +"lng_admin_log_deleted_message" = "{from} deleted message:"; +"lng_admin_log_sent_message" = "{from} sent this message:"; +"lng_admin_log_participant_joined" = "{from} joined the group"; +"lng_admin_log_participant_joined_channel" = "{from} joined the channel"; +"lng_admin_log_participant_joined_by_link" = "{from} joined the group via {link}"; +"lng_admin_log_participant_joined_by_link_channel" = "{from} joined the channel via {link}"; +"lng_admin_log_participant_joined_by_filter_link" = "{from} joined via folder invite link {link}"; +"lng_admin_log_participant_joined_by_filter_link_channel" = "{from} joined via folder invite link {link}"; +"lng_admin_log_participant_approved_by_link" = "{from} was approved to join the group via {link} by {user}"; +"lng_admin_log_participant_approved_by_link_channel" = "{from} was approved to join the channel via {link} by {user}"; +"lng_admin_log_participant_approved_by_request" = "{from} joined the group via public request, approved by {user}"; +"lng_admin_log_participant_approved_by_request_channel" = "{from} joined the channel via public request, approved by {user}"; +"lng_admin_log_revoke_invite_link" = "{from} revoked invite link {link}"; +"lng_admin_log_delete_invite_link" = "{from} deleted the invite link {link}"; +"lng_admin_log_participant_left" = "{from} left the group"; +"lng_admin_log_participant_left_channel" = "{from} left the channel"; +"lng_admin_log_stopped_poll" = "{from} stopped poll:"; +"lng_admin_log_invited" = "invited {user}"; +"lng_admin_log_banned" = "banned {user}"; +"lng_admin_log_unbanned" = "unbanned {user}"; +"lng_admin_log_restricted" = "changed restrictions for {user} {until}"; +"lng_admin_log_promoted" = "changed privileges for {user}"; +"lng_admin_log_transferred" = "transferred ownership to {user}"; +"lng_admin_log_changed_default_permissions" = "changed default permissions"; +"lng_admin_log_changed_stickers_group" = "{from} changed the group {sticker_set}"; +"lng_admin_log_changed_stickers_set" = "sticker set"; +"lng_admin_log_removed_stickers_group" = "{from} removed the group sticker set"; +"lng_admin_log_changed_emoji_group" = "{from} changed the group's {sticker_set}"; +"lng_admin_log_changed_emoji_set" = "emoji set"; +"lng_admin_log_removed_emoji_group" = "{from} removed the group's emoji set"; +"lng_admin_log_changed_linked_chat" = "{from} changed the discussion group to «{chat}»"; +"lng_admin_log_removed_linked_chat" = "{from} removed the discussion group"; +"lng_admin_log_changed_linked_channel" = "{from} changed the linked channel to «{chat}»"; +"lng_admin_log_removed_linked_channel" = "{from} removed the linked channel"; +"lng_admin_log_changed_location_chat" = "{from} changed the group location to {address}"; +"lng_admin_log_removed_location_chat" = "{from} removed the group location"; +"lng_admin_log_changed_slow_mode" = "{from} changed slow mode to {duration}"; +"lng_admin_log_removed_slow_mode" = "{from} disabled slow mode"; +"lng_admin_log_started_group_call" = "{from} started a new video chat"; +"lng_admin_log_started_group_call_channel" = "{from} started a new live stream"; +"lng_admin_log_discarded_group_call" = "{from} ended the video chat"; +"lng_admin_log_discarded_group_call_channel" = "{from} ended the live stream"; +"lng_admin_log_muted_participant" = "{from} muted {user} in a video chat"; +"lng_admin_log_muted_participant_channel" = "{from} muted {user} in a live stream"; +"lng_admin_log_unmuted_participant" = "{from} unmuted {user} in a video chat"; +"lng_admin_log_unmuted_participant_channel" = "{from} unmuted {user} in a live stream"; +"lng_admin_log_allowed_unmute_self" = "{from} allowed new video chat participants to speak"; +"lng_admin_log_allowed_unmute_self_channel" = "{from} allowed new live stream participants to speak"; +"lng_admin_log_disallowed_unmute_self" = "{from} muted new video chat participants"; +"lng_admin_log_disallowed_unmute_self_channel" = "{from} muted new live stream participants"; +"lng_admin_log_participant_volume" = "{from} changed video chat volume for {user} to {percent}"; +"lng_admin_log_participant_volume_channel" = "{from} changed live stream volume for {user} to {percent}"; +"lng_admin_log_antispam_enabled" = "{from} enabled aggressive anti-spam"; +"lng_admin_log_antispam_disabled" = "{from} disabled aggressive anti-spam"; +"lng_admin_log_autotranslate_enabled" = "{from} enabled automatic translation"; +"lng_admin_log_autotranslate_disabled" = "{from} disabled automatic translation"; +"lng_admin_log_change_color" = "{from} changed channel color from {previous} to {color}"; +"lng_admin_log_set_background_emoji" = "{from} set channel background emoji to {emoji}"; +"lng_admin_log_change_background_emoji" = "{from} changed channel background emoji from {previous} to {emoji}"; +"lng_admin_log_removed_background_emoji" = "{from} removed channel background emoji {emoji}"; +"lng_admin_log_change_profile_color" = "{from} changed channel profile color from {previous} to {color}"; +"lng_admin_log_set_profile_background_emoji" = "{from} set channel profile background emoji to {emoji}"; +"lng_admin_log_change_profile_background_emoji" = "{from} changed channel profile background emoji from {previous} to {emoji}"; +"lng_admin_log_removed_profile_background_emoji" = "{from} removed channel profile background emoji {emoji}"; +"lng_admin_log_change_profile_color_group" = "{from} changed group profile color from {previous} to {color}"; +"lng_admin_log_set_profile_background_emoji_group" = "{from} set group profile background emoji to {emoji}"; +"lng_admin_log_change_profile_background_emoji_group" = "{from} changed group profile background emoji from {previous} to {emoji}"; +"lng_admin_log_removed_profile_background_emoji_group" = "{from} removed group profile background emoji {emoji}"; +"lng_admin_log_change_wallpaper" = "{from} changed channel wallpaper"; +"lng_admin_log_set_status" = "{from} set channel emoji status to {emoji}"; +"lng_admin_log_change_status" = "{from} changed channel emoji status from {previous} to {emoji}"; +"lng_admin_log_removed_status" = "{from} removed channel emoji status {emoji}"; +"lng_admin_log_set_status_until" = "{from} set channel emoji status to {emoji} until {date}"; +"lng_admin_log_change_status_until" = "{from} changed channel emoji status from {previous} to {emoji} until {date}"; +"lng_admin_log_user_with_username" = "{name} ({mention})"; +"lng_admin_log_messages_ttl_set" = "{from} enabled messages auto-delete after {duration}"; +"lng_admin_log_messages_ttl_changed" = "{from} changed messages auto-delete period from {previous} to {duration}"; +"lng_admin_log_messages_ttl_removed" = "{from} disabled messages auto-deletion after {duration}"; +"lng_admin_log_reactions_disabled" = "{from} disabled reactions"; +"lng_admin_log_reactions_updated" = "{from} updated the list of allowed reactions to: {emoji}"; +"lng_admin_log_reactions_allowed_all" = "{from} allowed all reactions"; +"lng_admin_log_reactions_allowed_official" = "{from} allowed all official reactions"; +"lng_admin_log_edited_invite_link" = "edited invite link {link}"; +"lng_admin_log_invite_link_expire_date" = "Expiry date: {previous} → {limit}"; +"lng_admin_log_invite_link_usage_limit" = "Usage limit: {previous} → {limit}"; +"lng_admin_log_invite_link_label" = "Name: {previous} → {limit}"; +"lng_admin_log_invite_link_request_needed" = "Admin approval is now required to join."; +"lng_admin_log_invite_link_request_not_needed" = "Admin approval no longer required to join."; +"lng_admin_log_topics_enabled" = "{from} enabled topics"; +"lng_admin_log_topics_disabled" = "{from} disabled topics"; +"lng_admin_log_topics_created" = "{from} created topic {topic}"; +"lng_admin_log_topics_changed" = "{from} renamed topic {topic} to {new_topic}"; +"lng_admin_log_topics_closed" = "{from} closed topic {topic}"; +"lng_admin_log_topics_reopened" = "{from} reopened topic {topic}"; +"lng_admin_log_topics_hidden" = "{from} hid topic {topic}"; +"lng_admin_log_topics_unhidden" = "{from} unhid topic {topic}"; +"lng_admin_log_topics_deleted" = "{from} deleted topic {topic}"; +"lng_admin_log_topics_pinned" = "{from} pinned topic {topic}"; +"lng_admin_log_topics_unpinned" = "{from} unpinned topic {topic}"; +"lng_admin_log_restricted_forever" = "indefinitely"; +"lng_admin_log_restricted_until" = "until {date}"; +"lng_admin_log_banned_view_messages" = "Read messages"; +"lng_admin_log_banned_send_messages" = "Send messages"; +"lng_admin_log_banned_send_photos" = "Send photos"; +"lng_admin_log_banned_send_videos" = "Send video files"; +"lng_admin_log_banned_send_music" = "Send music"; +"lng_admin_log_banned_send_files" = "Send files"; +"lng_admin_log_banned_send_voice_messages" = "Send voice messages"; +"lng_admin_log_banned_send_video_messages" = "Send video messages"; +"lng_admin_log_banned_send_stickers" = "Send stickers & GIFs"; +"lng_admin_log_banned_embed_links" = "Embed links"; +"lng_admin_log_banned_send_polls" = "Send polls"; +"lng_admin_log_admin_change_info" = "Change info"; +"lng_admin_log_admin_post_messages" = "Post messages"; +"lng_admin_log_admin_edit_messages" = "Edit messages"; +"lng_admin_log_admin_delete_messages" = "Delete messages"; +"lng_admin_log_admin_post_stories" = "Post stories"; +"lng_admin_log_admin_edit_stories" = "Edit stories of others"; +"lng_admin_log_admin_delete_stories" = "Delete stories"; +"lng_admin_log_admin_remain_anonymous" = "Remain anonymous"; +"lng_admin_log_admin_ban_users" = "Ban users"; +"lng_admin_log_admin_invite_users" = "Add users"; +"lng_admin_log_admin_invite_link" = "Invite users via link"; +"lng_admin_log_admin_pin_messages" = "Pin messages"; +"lng_admin_log_admin_manage_topics" = "Manage topics"; +"lng_admin_log_admin_create_topics" = "Create topics"; +"lng_admin_log_admin_manage_calls" = "Manage video chats"; +"lng_admin_log_admin_manage_calls_channel" = "Manage live streams"; +"lng_admin_log_admin_add_admins" = "Add new admins"; +"lng_admin_log_subscription_extend" = "{name} renewed subscription until {date}"; + +"lng_admin_log_antispam_menu_report" = "Report False Positive"; +"lng_admin_log_antispam_menu_report_toast" = "You can manage anti-spam settings in {link}."; +"lng_admin_log_antispam_menu_report_toast_link" = "Group Info > Administrators"; + +"lng_terms_signup" = "By signing up,\nyou agree to the {link}."; +"lng_terms_signup_link" = "Terms of Service"; +"lng_terms_header" = "Terms of Service"; +"lng_terms_age#one" = "I confirm that I am {count} or over"; +"lng_terms_age#other" = "I confirm that I am {count} or over"; +"lng_terms_agree" = "Agree & Continue"; +"lng_terms_decline" = "Decline"; +"lng_terms_signup_sorry" = "Unfortunately, this means you can't sign up for Tidings.\n\nUnlike other apps, Tidings does not use user data for ad targeting or other commercial purposes. Tidings 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 Tidings's modest needs, it won't be possible for us to provide you with this service."; +"lng_terms_update_sorry" = "We're very sorry, but this means we must part ways here. Unlike others, we don't use your data for ad targeting or other commercial purposes. Tidings only stores the information it needs to function as a secure and feature-rich cloud service. You can adjust how we use your data in Privacy & Security settings.\n\nBut if you're generally not OK with Tidings's modest requirements, it won't be possible for us to provide you with this service. You can delete your account now — or look around some more and delete it later if you feel you're not happy with the way we use your data."; +"lng_terms_decline_and_delete" = "Decline & Delete"; +"lng_terms_back" = "Back"; +"lng_terms_delete_warning" = "Warning, this will irreversibly delete your Tidings account and all the data you store in the Tidings cloud.\n\nImportant: You can Cancel now and export your data first instead of losing it. (To do this, open the latest version of Tidings Desktop and go to Settings > Advanced > Export Tidings data.)"; +"lng_terms_delete_now" = "Delete now"; +"lng_terms_agree_to_proceed" = "Agree and proceed to {bot}."; + +"lng_date_input_day" = "Day"; +"lng_date_input_month" = "Month"; +"lng_date_input_year" = "Year"; + +"lng_forward_title" = "Forward Message"; +"lng_forward_many_title#one" = "Forward {count} Message"; +"lng_forward_many_title#other" = "Forward {count} Messages"; +"lng_forward_about" = "You can remove the sender's name so that this message will look like it was sent by you."; +"lng_forward_many_about" = "You can remove the senders’ names so that these messages will look like they were sent by you."; +"lng_forward_show_sender" = "Show sender name"; +"lng_forward_show_senders" = "Show sender names"; +"lng_forward_show_caption" = "Show caption"; +"lng_forward_show_captions" = "Show captions"; +"lng_forward_change_recipient" = "Change recipient"; +"lng_forward_sender_names_removed" = "Sender names removed"; +"lng_forward_header_short" = "Forward"; +"lng_forward_action_show_sender" = "Show Sender Name"; +"lng_forward_action_show_senders" = "Show Sender Names"; +"lng_forward_action_hide_sender" = "Hide Sender Name"; +"lng_forward_action_hide_senders" = "Hide Sender Names"; +"lng_forward_action_show_caption" = "Show Caption"; +"lng_forward_action_show_captions" = "Show Captions"; +"lng_forward_action_hide_caption" = "Hide Caption"; +"lng_forward_action_hide_captions" = "Hide Captions"; +"lng_forward_action_change_recipient" = "Change Recipient"; +"lng_forward_action_remove" = "Do Not Forward"; + +"lng_passport_title" = "Tidings Passport"; +"lng_passport_request1" = "{bot} requests access to your personal data"; +"lng_passport_request2" = "to sign you up for their services"; +"lng_passport_create_password" = "Please create a password which will be used\nto encrypt your personal data."; +"lng_passport_about_password" = "This password will also be required whenever\nyou log in to a new device."; +"lng_passport_password_create" = "Create a password"; +"lng_passport_email_validate" = "Validate"; +"lng_passport_code_sent" = "A confirmation code was sent to\n{email}"; +"lng_passport_stop_password_sure" = "Are you sure you want to cancel setting up your password?"; +"lng_passport_password_placeholder" = "Your password"; +"lng_passport_next" = "Next"; +"lng_passport_password_wrong" = "The password you entered is not valid."; +"lng_passport_header" = "Requested information"; +"lng_passport_identity_title" = "Identity document"; +"lng_passport_identity_description" = "Upload proof of your identity"; +"lng_passport_identity_passport" = "Passport"; +"lng_passport_identity_passport_upload" = "Upload a scan of your passport"; +"lng_passport_identity_card" = "Identity card"; +"lng_passport_identity_card_upload" = "Upload a scan of your identity card"; +"lng_passport_identity_license" = "Driver's licence"; +"lng_passport_identity_license_upload" = "Upload a scan of your driver's license"; +"lng_passport_identity_internal" = "Internal passport"; +"lng_passport_identity_internal_upload" = "Upload a scan of your internal passport"; +"lng_passport_identity_about" = "The document must contain your photograph, first and last name, date of birth, document number, country of issue, and expiry date."; +"lng_passport_address_title" = "Residential address"; +"lng_passport_address_description" = "Upload proof of your address"; +"lng_passport_address_bill" = "Utility bill"; +"lng_passport_address_bill_upload" = "Upload a scan of your utility bill"; +"lng_passport_address_statement" = "Bank statement"; +"lng_passport_address_statement_upload" = "Upload a scan of your bank statement"; +"lng_passport_address_agreement" = "Tenancy agreement"; +"lng_passport_address_agreement_upload" = "Upload a scan of your tenancy agreement"; +"lng_passport_address_registration" = "Passport registration"; +"lng_passport_address_registration_upload" = "Upload a scan of your passport registration page"; +"lng_passport_address_temporary" = "Temporary registration"; +"lng_passport_address_temporary_upload" = "Upload a scan of your temporary registration"; +"lng_passport_address_about" = "To confirm your address, please upload a scan or photo of the selected document (all pages)."; +"lng_passport_or_title" = "{document} or {second_document}"; +"lng_passport_document_type" = "Please choose the type of your document:"; +"lng_passport_upload_document" = "Upload document"; +"lng_passport_phone_title" = "Phone number"; +"lng_passport_phone_description" = "Enter your phone number"; +"lng_passport_email_title" = "Email"; +"lng_passport_email_description" = "Enter your email address"; +"lng_passport_identity_selfie" = "Take a selfie with your document"; +"lng_passport_translation_needed" = "Add an English translation of your document"; +"lng_passport_accept_allow" = "You accept the {policy} and allow their {bot} to send you messages."; +"lng_passport_allow" = "You allow {bot} to send you messages."; +"lng_passport_policy" = "{bot} privacy policy"; +"lng_passport_authorize" = "Authorize"; +"lng_passport_form_error" = "Could not get authorization form."; +"lng_passport_save_value" = "Save"; +"lng_passport_scan_index" = "Scan {index}"; +"lng_passport_upload_scans" = "Upload scans"; +"lng_passport_upload_more" = "Upload additional scans"; +"lng_passport_selfie_title" = "Selfie"; +"lng_passport_selfie_description" = "Upload a photo of yourself holding your document. Make sure the ID and your face are clearly visible."; +"lng_passport_upload_selfie" = "Upload selfie"; +"lng_passport_reupload_selfie" = "Reupload selfie"; +"lng_passport_front_side_title" = "Front side"; +"lng_passport_front_side_description" = "Upload a photo of the front side of the document."; +"lng_passport_upload_front_side" = "Upload a scan of the front side"; +"lng_passport_reupload_front_side" = "Reupload a scan of the front side"; +"lng_passport_reverse_side_title" = "Reverse side"; +"lng_passport_reverse_side_description" = "Upload a photo of the reverse side of the document."; +"lng_passport_upload_reverse_side" = "Upload a scan of the reverse side"; +"lng_passport_reupload_reverse_side" = "Reupload a scan of the reverse side"; +"lng_passport_main_page_title" = "Main page"; +"lng_passport_main_page_description" = "Upload a scan of the main page of your document."; +"lng_passport_upload_main_page" = "Upload a scan of the main page"; +"lng_passport_reupload_main_page" = "Reupload a scan of the main page"; +"lng_passport_personal_details" = "Personal details"; +"lng_passport_personal_details_enter" = "Fill in your personal details"; +"lng_passport_document_details" = "Document details"; +"lng_passport_choose_image" = "Choose scan image"; +"lng_passport_delete_scan_undo" = "Undo"; +"lng_passport_scan_uploaded" = "Uploaded on {date}"; +"lng_passport_first_name" = "First name"; +"lng_passport_middle_name" = "Middle name"; +"lng_passport_last_name" = "Last name"; +"lng_passport_birth_date" = "Date of birth"; +"lng_passport_gender" = "Gender"; +"lng_passport_gender_male" = "Male"; +"lng_passport_gender_female" = "Female"; +"lng_passport_country" = "Citizenship"; +"lng_passport_residence_country" = "Residence"; +"lng_passport_country_choose" = "Choose country"; +"lng_passport_document_number" = "Document Number"; +"lng_passport_expiry_date" = "Expiry date"; +"lng_passport_native_name_title" = "Name in country of residence"; +"lng_passport_native_name_about" = "Your name in the language of your country of residence ({country})."; +"lng_passport_native_name_language" = "Your name in {language}"; +"lng_passport_native_name_language_about" = "Your name in the language of your country of residence."; +"lng_passport_address" = "Address"; +"lng_passport_address_enter" = "Provide your address"; +"lng_passport_street" = "Street"; +"lng_passport_city" = "City"; +"lng_passport_state" = "State"; +"lng_passport_postcode" = "Postcode"; +"lng_passport_translation" = "Translation"; +"lng_passport_use_existing" = "USE {existing}"; +"lng_passport_use_existing_phone" = "Use the same phone number as on Tidings."; +"lng_passport_new_phone" = "Or enter a new phone number"; +"lng_passport_new_phone_code" = "Note: You will receive a confirmation code on the phone number you provide."; +"lng_passport_use_existing_email" = "Use the same email address as on Tidings."; +"lng_passport_new_email" = "Or enter a new email"; +"lng_passport_new_email_code" = "Note: You will receive a confirmation code to the email address you provide."; +"lng_passport_confirm_phone" = "We've sent an SMS with a confirmation code to your phone {phone}."; +"lng_passport_confirm_email" = "We've sent a confirmation code to your email {email}."; +"lng_passport_sure_cancel" = "If you continue, your changes will be lost."; +"lng_passport_scans_limit_reached" = "Sorry, that's too many scans for one document."; +"lng_passport_delete_document" = "Delete document"; +"lng_passport_delete_document_sure" = "Are you sure you want to delete this document?"; +"lng_passport_delete_details" = "Delete personal details"; +"lng_passport_delete_details_sure" = "Are you sure you want to delete your personal details?"; +"lng_passport_delete_address" = "Delete address information"; +"lng_passport_delete_address_sure" = "Are you sure you want to delete your address information?"; +"lng_passport_delete_email" = "Delete email"; +"lng_passport_delete_email_sure" = "Are you sure you want to delete your email address?"; +"lng_passport_delete_phone" = "Delete phone number"; +"lng_passport_delete_phone_sure" = "Are you sure you want to delete your phone number?"; +"lng_passport_success" = "Authorization successful!"; +"lng_passport_stop_sure" = "Are you sure you want to stop this authorization?"; +"lng_passport_stop" = "Stop"; +"lng_passport_restart_sure" = "An unexpected error has occurred. Perhaps some changes were made from a different Tidings application. Would you like to restart this authorization?"; +"lng_passport_restart" = "Restart"; +"lng_passport_error_too_large" = "Sorry, this file is too large."; +"lng_passport_error_bad_size" = "Sorry, this image has wrong dimensions."; +"lng_passport_error_cant_read" = "Can't read this file. Please choose an image."; +"lng_passport_bad_name" = "Please use latin characters only."; +"lng_passport_wait_upload" = "Please wait until the file has finished uploading."; +"lng_passport_app_out_of_date" = "Sorry, your Tidings app is out of date and can't handle this request. Please update Tidings."; + +"lng_export_title" = "Export Your Data"; +"lng_export_progress_title" = "Exporting your data"; +"lng_export_option_info" = "Account information"; +"lng_export_option_info_about" = "Your chosen display name, username, phone number and profile photos."; +"lng_export_option_contacts" = "Contacts list"; +"lng_export_option_contacts_about" = "If you allow access, contacts are continuously synced with Tidings. You can adjust this in Settings > Privacy & Security on mobile devices."; +"lng_export_option_stories" = "Story archive"; +"lng_export_option_stories_about" = "All stories you posted from Tidings mobile apps."; +"lng_export_option_sessions" = "Active sessions"; +"lng_export_option_sessions_about" = "We may store this to display your connected devices in Settings > Privacy & Security > Show all sessions."; +"lng_export_header_other" = "Other"; +"lng_export_option_other" = "Miscellaneous data"; +"lng_export_option_other_about" = "Other types of data not mentioned above (beta)."; +"lng_export_header_chats" = "Chat export settings"; +"lng_export_option_personal_chats" = "Personal chats"; +"lng_export_option_bot_chats" = "Bot chats"; +"lng_export_option_private_groups" = "Private groups"; +"lng_export_option_private_channels" = "Private channels"; +"lng_export_option_public_groups" = "Public groups"; +"lng_export_option_public_channels" = "Public channels"; +"lng_export_option_only_my" = "Only my messages"; +"lng_export_header_media" = "Media export settings"; +"lng_export_option_photos" = "Photos"; +"lng_export_option_video_files" = "Videos"; +"lng_export_option_voice_messages" = "Voice messages"; +"lng_export_option_video_messages" = "Video messages"; +"lng_export_option_stickers" = "Stickers"; +"lng_export_option_gifs" = "GIFs"; +"lng_export_option_files" = "Files"; +"lng_export_option_size_limit" = "Size limit: {size}"; +"lng_export_header_format" = "Location and format"; +"lng_export_option_location" = "Download path: {path}"; +"lng_export_option_format_location" = "Format: {format}, Path: {path}"; +"lng_export_option_choose_format" = "Choose export format"; +"lng_export_option_html" = "Human-readable HTML"; +"lng_export_option_json" = "Machine-readable JSON"; +"lng_export_option_html_and_json" = "Both"; +"lng_export_limits" = "From: {from}, to: {till}"; +"lng_export_beginning" = "the oldest message"; +"lng_export_end" = "present"; +"lng_export_from_beginning" = "Reset"; +"lng_export_till_end" = "Reset"; +"lng_export_start" = "Export"; +"lng_export_state_initializing" = "Initializing..."; +"lng_export_state_userpics" = "Profile photos"; +"lng_export_state_chats_list" = "Processing chats..."; +"lng_export_state_chats" = "Chats"; +"lng_export_skip_file" = "Skip this file"; +"lng_export_progress" = "You can close this window now. Please don't quit Tidings until the data export is completed."; +"lng_export_stop" = "Stop"; +"lng_export_sure_stop" = "Are you sure you want to stop exporting your data?\n\nIf you do, you'll need to start over."; +"lng_export_about_done" = "Your data was successfully exported."; +"lng_export_done" = "Show my data"; +"lng_export_finished" = "Data export completed."; +"lng_export_total_amount" = "Total files: {amount}"; +"lng_export_total_size" = "Total size: {size}"; +"lng_export_folder" = "Choose export folder"; +"lng_export_invalid" = "Sorry, you started a new data export, so this data export has been canceled."; +"lng_export_delay" = "For security reasons, you will be able to begin downloading your data in {hours}. We have notified all your devices about the export request to make sure it's authorized and give you time to react if it's not.\n\nPlease come back on {date} and repeat the request using the same device."; +"lng_export_delay_less_than_hour" = "less than an hour"; +"lng_export_suggest_title" = "Data export ready"; +"lng_export_suggest_text" = "You can now download the data you requested. Start exporting data?"; +"lng_export_suggest_cancel" = "Not now"; +"lng_export_about_telegram" = "Here is the data you requested. Remember: Tidings is ad free, it doesn't use your data for ad targeting and doesn't sell it to others. Tidings only keeps the information it needs to function as a secure and feature-rich cloud service.\n\nCheck out Settings > Privacy & Security on Tidings's mobile apps for the relevant settings."; +"lng_export_about_contacts" = "If you allow access, your contacts are continuously synced with Tidings. Thanks to this, you can easily switch to Tidings and immediately connect with friends across all your devices. We use data about your contacts to let you know when they join Tidings, and to display them by the name you set for them in your phone.\n\nYou can disable contact syncing or delete your stored contacts in Settings > Privacy & Security on Tidings's mobile apps."; +"lng_export_about_frequent" = "This rating shows which people you are likelier to message frequently. Tidings uses this data to populate the 'People' box at the top of the Search section. This rating is also calculated for inline bots so that the app can suggest the bots you are most likely to use in the attachment menu (or when you start a new message with \"@\").\n\nTo delete this data, go to Settings > Privacy & Security and disable 'Suggest Frequent Contacts' (requires Tidings for iOS v.4.8.3 or Tidings for Android v.4.8.10 or higher)."; +"lng_export_about_sessions" = "We store session info to display your connected devices in Settings > Privacy & Security > Active Sessions."; +"lng_export_about_web_sessions" = "We store this to display the websites where you logged in using authentication via Tidings. This information is shown in Settings > Privacy & Security > Active Sessions."; +"lng_export_about_chats" = "This page lists all chats from this export."; +"lng_export_about_left_chats" = "Below are the supergroups and channels from this export that you've left or where you were banned.\n\nNote that when you leave a channel or supergroup you've created, you have the option to either delete it, or simply leave (in case you want to rejoin later, or keep the community alive despite not being a member)."; + +"lng_language_switch_title" = "Change language?"; +"lng_language_switch_about_official" = "You are about to apply a language pack ({lang_name}) that is {percent}% complete.\n\nThis will translate the entire interface. You can suggest corrections in the {link}.\n\nYou can change your language back at any time in Settings."; +"lng_language_switch_about_unofficial" = "You are about to apply a custom language pack with the name \"{lang_name}\" that is {percent}% complete.\n\nThis will translate the entire interface. You can suggest corrections on the {link}.\n\nYou can change your language back at any time in Settings."; +"lng_language_switch_link" = "translation platform"; +"lng_language_switch_apply" = "Apply Language"; +"lng_language_not_found" = "Sorry, this language pack doesn't exist."; +"lng_language_already" = "You're already using this language pack. You can change your language back at any time in Settings."; +"lng_language_not_ready_title" = "Insufficient data"; +"lng_language_not_ready_about" = "Unfortunately, this custom language pack ({lang_name}) doesn't contain data for Tidings Desktop. You can contribute to this language pack using the {link}."; +"lng_language_not_ready_link" = "translation platform"; + +"lng_translate_box_error" = "Translate failed."; + +"lng_translate_settings_subtitle" = "Translate Messages"; +"lng_translate_settings_show" = "Show Translate Button"; +"lng_translate_settings_chat" = "Translate Entire Chats"; +"lng_translate_settings_choose" = "Do Not Translate"; +"lng_translate_settings_about" = "The 'Translate' button will appear in the context menu of messages containing text."; +"lng_translate_settings_one" = "Please choose at least one language so that it can be used as the \"Translate to\" language."; + +"lng_launch_exe_warning" = "This file has the extension {extension}\nIt may harm your computer.\nAre you sure you want to run it?"; +"lng_launch_other_warning" = "This file has {extension} extension.\nAre you sure you want to open it?"; +"lng_launch_svg_warning" = "Opening this file can potentially expose your IP address to its creator. Continue?"; +"lng_launch_exe_sure" = "Run"; +"lng_launch_other_sure" = "Open"; +"lng_launch_exe_dont_ask" = "Don't ask me again"; +"lng_launch_dont_ask" = "Remember for this file type"; +"lng_launch_dont_ask_settings" = "You can later edit trusted file types in Settings > Privacy and Security > File open confirmations."; + +"lng_polls_anonymous" = "Anonymous Poll"; +"lng_polls_public" = "Poll"; +"lng_polls_anonymous_quiz" = "Anonymous Quiz"; +"lng_polls_public_quiz" = "Quiz"; +"lng_polls_closed" = "Final results"; +"lng_polls_votes_count#one" = "{count} vote"; +"lng_polls_votes_count#other" = "{count} votes"; +"lng_polls_votes_none" = "No votes"; +"lng_polls_answers_count#one" = "{count} answer"; +"lng_polls_answers_count#other" = "{count} answers"; +"lng_polls_answers_none" = "No answers"; +"lng_polls_submit_votes" = "Vote"; +"lng_polls_view_results" = "View results"; +"lng_polls_retract" = "Retract vote"; +"lng_polls_stop" = "Stop poll"; +"lng_polls_stop_warning" = "If you stop this poll now, nobody will be able to vote in it anymore. This action cannot be undone."; +"lng_polls_stop_sure" = "Stop"; +"lng_polls_create" = "Create poll"; +"lng_polls_create_title" = "New poll"; +"lng_polls_create_question" = "Question"; +"lng_polls_create_question_placeholder" = "Ask a question"; +"lng_polls_create_options" = "Poll options"; +"lng_polls_create_option_add" = "Add an option..."; +"lng_polls_create_limit#one" = "You can add {count} more option."; +"lng_polls_create_limit#other" = "You can add {count} more options."; +"lng_polls_create_maximum" = "You have added the maximum number of options."; +"lng_polls_create_settings" = "Settings"; +"lng_polls_create_anonymous" = "Anonymous Voting"; +"lng_polls_create_multiple_choice" = "Multiple Answers"; +"lng_polls_create_quiz_mode" = "Quiz Mode"; +"lng_polls_create_button" = "Create"; +"lng_polls_create_one_answer" = "Quiz has only one right answer."; +"lng_polls_choose_question" = "Please enter a question."; +"lng_polls_choose_answers" = "Please enter at least two options."; +"lng_polls_choose_correct" = "Please choose the correct answer."; +"lng_polls_solution_title" = "Explanation"; +"lng_polls_solution_placeholder" = "Add a Comment (Optional)"; +"lng_polls_solution_about" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; + +"lng_polls_poll_results_title" = "Poll results"; +"lng_polls_quiz_results_title" = "Quiz results"; +"lng_polls_show_more#one" = "Show more ({count})"; +"lng_polls_show_more#other" = "Show more ({count})"; +"lng_polls_votes_collapse" = "Collapse"; + +"lng_outdated_title" = "PLEASE UPDATE YOUR OPERATING SYSTEM."; +"lng_outdated_title_bits" = "PLEASE SWITCH TO A 64-BIT OPERATING SYSTEM."; +"lng_outdated_soon" = "Otherwise, Tidings Desktop will stop updating on {date}."; +"lng_outdated_now" = "So Tidings Desktop can update to newer versions."; + +"lng_filters_all" = "All chats"; +"lng_filters_all_short" = "All"; +"lng_filters_setup" = "Edit"; +"lng_filters_title" = "Folders"; +"lng_filters_subtitle" = "My folders"; +"lng_filters_no_chats" = "No chats"; +"lng_filters_chats_count#one" = "{count} chat"; +"lng_filters_chats_count#other" = "{count} chats"; +"lng_filters_create" = "Create new folder"; +"lng_filters_about" = "Create folders for different groups of chats and quickly switch between them."; +"lng_filters_recommended" = "Recommended folders"; +"lng_filters_recommended_add" = "Add"; +"lng_filters_restore" = "Undo"; +"lng_filters_new" = "New Folder"; +"lng_filters_edit" = "Edit Folder"; +"lng_filters_setup_menu" = "Edit Folders"; +"lng_filters_new_name" = "Folder name"; +"lng_filters_enable_animations" = "Enable animations"; +"lng_filters_disable_animations" = "Disable animations"; +"lng_filters_add_chats" = "Add Chats"; +"lng_filters_remove_chats" = "Add Chats to Exclude"; +"lng_filters_include" = "Included chats"; +"lng_filters_include_about" = "Choose chats or types of chats that will appear in this folder."; +"lng_filters_exclude" = "Excluded chats"; +"lng_filters_exclude_about" = "Choose chats or types of chats that will not appear in this folder."; +"lng_filters_create_button" = "Create"; +"lng_filters_include_title" = "Include Chats"; +"lng_filters_exclude_title" = "Exclude Chats"; +"lng_filters_edit_types" = "Chat types"; +"lng_filters_edit_chats" = "Chats"; +"lng_filters_include_contacts" = "Contacts"; +"lng_filters_include_groups" = "Groups"; +"lng_filters_include_channels" = "Channels"; +"lng_filters_include_bots" = "Bots"; +"lng_filters_name_people" = "People"; +"lng_filters_name_unread" = "Unread"; +"lng_filters_name_unmuted" = "Unmuted"; +"lng_filters_empty" = "Please choose at least one chat for this folder."; +"lng_filters_default" = "Please change at least one rule for this folder."; +"lng_filters_type_contacts" = "Contacts"; +"lng_filters_type_non_contacts" = "Non-Contacts"; +"lng_filters_type_groups" = "Groups"; +"lng_filters_type_channels" = "Channels"; +"lng_filters_type_new" = "New Chats"; +"lng_filters_type_existing" = "Existing Chats"; +"lng_filters_type_bots" = "Bots"; +"lng_filters_type_no_archived" = "Archived"; +"lng_filters_type_no_muted" = "Muted"; +"lng_filters_type_no_read" = "Read"; +"lng_filters_icon_header" = "Choose an icon"; +"lng_filters_context_edit" = "Edit folder"; +"lng_filters_context_remove" = "Remove"; +"lng_filters_remove_sure" = "This will remove the folder, your chats will not be deleted."; +"lng_filters_remove_yes" = "Remove"; +"lng_filters_menu_add" = "Add to folder"; +"lng_filters_toast_add" = "{chat} added to {folder} folder"; +"lng_filters_toast_remove" = "{chat} removed from {folder} folder"; +"lng_filters_shareable_status" = "shareable folder"; +"lng_filters_view_subtitle" = "Tabs view"; +"lng_filters_vertical" = "Tabs on the left"; +"lng_filters_horizontal" = "Tabs at the top"; +"lng_filters_enable_tags" = "Show Folder Tags"; +"lng_filters_enable_tags_about" = "Display folder names for each chat in the chat list."; +"lng_filters_enable_tags_about_premium" = "Subscribe to **{link}** to display folder names for each chat in the chat list."; +"lng_filters_tag_color_subtitle" = "Folder color in chat list"; +"lng_filters_tag_color_about" = "Choose a color for the tag of this folder."; +"lng_filters_tag_color_no" = "No Tag"; + +"lng_filters_delete_sure" = "Are you sure you want to delete this folder? This will also deactivate all the invite links created to share this folder."; +"lng_filters_link" = "Share Folder"; +"lng_filters_link_has" = "Invite links"; + +"lng_filters_checkbox_remove_bot" = "Remove bot from all folders"; +"lng_filters_checkbox_remove_group" = "Remove group from all folders"; +"lng_filters_checkbox_remove_channel" = "Remove channel from all folders"; + +"lng_filters_link_create" = "Create an Invite Link"; +"lng_filters_link_cant" = "You can’t share folders which include or exclude specific chat types like 'Groups', 'Contacts', etc."; +"lng_filters_link_about" = "Share access to some of this folder's groups and channels with others."; +"lng_filters_link_about_many" = "Create more links to set up different access levels for different people."; +"lng_filters_link_title" = "Share Folder"; +"lng_filters_link_share_about" = "Anyone with this link can add the {folder} folder and the chats selected below."; +"lng_filters_link_subtitle" = "Invite link"; +"lng_filters_link_chats_none" = "No chats selected"; +"lng_filters_link_chats#one" = "{count} chat selected"; +"lng_filters_link_chats#other" = "{count} chats selected"; +"lng_filters_link_bot_status" = "chats with bots can't be shared"; +"lng_filters_link_bot_error" = "Chats with bots can't be shared."; +"lng_filters_link_private_status" = "you can't share private chats"; +"lng_filters_link_private_error" = "Private chats can't be shared."; +"lng_filters_link_noadmin_status" = "you can't invite users here"; +"lng_filters_link_noadmin_group_error" = "You don't have the admin rights to share invite links to this private group."; +"lng_filters_link_noadmin_channel_error" = "You don't have the admin rights to share invite links to this private channel."; +"lng_filters_link_already_group" = "you are already a member"; +"lng_filters_link_already_channel" = "you are already subscribed"; +"lng_filters_link_inaccessible" = "chat is inaccessible"; +"lng_filters_link_chats_about" = "Select groups and channels that you want everyone who adds the folder via invite link to join."; +"lng_filters_link_no_about" = "There are no chats in this folder that you can share with others."; +"lng_filters_link_chats_no" = "These chats cannot be shared"; +"lng_filters_link_chats_no_about" = "You can only share groups and channels in which you are allowed to create invite links."; +"lng_filters_link_name_it" = "Name Link"; +"lng_filters_link_delete_sure" = "Are you sure you want to delete this link?"; +"lng_filters_link_qr_about" = "Anyone on Tidings can scan this code to add the folder and join all the chats included in its invite link."; +"lng_filters_link_group_admin_error" = "One of the groups in this folder can’t be added because one of its admins has too many groups and channels."; +"lng_filters_by_link_title" = "Add Folder"; +"lng_filters_by_link_sure" = "Do you want to add the chat folder {folder} and join its groups and channels?"; +"lng_filters_by_link_join#one" = "{count} chat to join"; +"lng_filters_by_link_join#other" = "{count} chats to join"; +"lng_filters_by_link_add_button" = "Add {folder}"; +"lng_filters_by_link_add_no" = "Do not add this folder"; +"lng_filters_by_link_more" = "Add Chats to Folder"; +"lng_filters_by_link_more_sure" = "Do you want to join chats and add them to the folder {folder}?"; +"lng_filters_by_link_about" = "You can deselect the chats you don't want to join."; +"lng_filters_by_link_and_join_button#one" = "Join Chat"; +"lng_filters_by_link_and_join_button#other" = "Join Chats"; +"lng_filters_by_link_join_no" = "Do not join any chats"; +"lng_filters_by_link_already" = "Folder already added"; +"lng_filters_by_link_already_about" = "You have already added the folder {folder} and all its chats."; +"lng_filters_by_link_in#one" = "{count} chat in this folder"; +"lng_filters_by_link_in#other" = "{count} chats in this folder"; +"lng_filters_by_link_remove" = "Remove Folder"; +"lng_filters_by_link_remove_sure" = "Do you also want to quit the chats included in the folder {folder}?"; +"lng_filters_by_link_quit#one" = "{count} chat to quit"; +"lng_filters_by_link_quit#other" = "{count} chats to quit"; +"lng_filters_by_link_select" = "Select All"; +"lng_filters_by_link_deselect" = "Deselect All"; +"lng_filters_by_link_about_quit" = "You can deselect the chats you don't want to quit."; +"lng_filters_by_link_remove_button" = "Remove Folder and Keep Chats"; +"lng_filters_by_link_and_quit_button#one" = "Remove Folder and Chat"; +"lng_filters_by_link_and_quit_button#other" = "Remove Folder and Chats"; +"lng_filters_added_title" = "Folder {folder} Added"; +"lng_filters_added_also#one" = "You also joined {count} chat."; +"lng_filters_added_also#other" = "You also joined {count} chats."; +"lng_filters_updated_title" = "Folder {folder} Updated"; +"lng_filters_updated_also#one" = "You have joined {count} new chat."; +"lng_filters_updated_also#other" = "You have joined {count} new chats."; +"lng_filters_bar_you_can#one" = "You can join {count} new chat"; +"lng_filters_bar_you_can#other" = "You can join {count} new chats"; +"lng_filters_bar_view#one" = "Click here to view it"; +"lng_filters_bar_view#other" = "Click here to view them"; + +"lng_chat_theme_change" = "Change colors"; +"lng_chat_theme_wallpaper" = "Set Wallpaper"; +"lng_chat_theme_none" = "No\nTheme"; +"lng_chat_theme_apply" = "Apply Theme"; +"lng_chat_theme_change_wallpaper" = "Change Wallpaper"; +"lng_chat_theme_title" = "Select theme"; +"lng_chat_theme_cant_voice" = "Sorry, you can't change the chat theme while you have an unsent voice message."; + +"lng_photo_editor_menu_delete" = "Delete"; +"lng_photo_editor_menu_flip" = "Flip"; +"lng_photo_editor_menu_duplicate" = "Duplicate"; + +"lng_voice_speed_slow" = "Slow"; +"lng_voice_speed_normal" = "Normal"; +"lng_voice_speed_medium" = "Medium"; +"lng_voice_speed_fast" = "Fast"; +"lng_voice_speed_very_fast" = "Very fast"; +"lng_voice_speed_super_fast" = "Super fast"; + +"lng_view_button_user" = "Send message"; +"lng_view_button_bot" = "View bot"; +"lng_view_button_group" = "View group"; +"lng_view_button_channel" = "View channel"; +"lng_view_button_bot_app" = "Launch"; +"lng_view_button_background" = "View wallpaper"; +"lng_view_button_theme" = "View theme"; +"lng_view_button_message" = "View message"; +"lng_view_button_story" = "View story"; +"lng_view_button_voice_chat" = "Video chat"; +"lng_view_button_voice_chat_channel" = "Live stream"; +"lng_view_button_request_join" = "Request to Join"; +"lng_view_button_external_link" = "Open link"; +"lng_view_button_boost" = "Boost"; +"lng_view_button_giftcode" = "Open"; +"lng_view_button_iv" = "Instant View"; +"lng_view_button_stickerset" = "View stickers"; +"lng_view_button_emojipack" = "View emoji"; +"lng_view_button_collectible" = "View collectible"; +"lng_view_button_call" = "Join call"; + +"lng_sponsored_hide_ads" = "Hide"; +"lng_sponsored_title" = "What are sponsored messages?"; +"lng_sponsored_info_description1" = "Unlike other apps, Tidings never uses your private data to target ads. Sponsored messages on Tidings 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 Tidings sees the same sponsored messages.\n\nUnlike other apps, Tidings 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\nTidings 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, Tidings developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:"; +"lng_sponsored_info_description2" = "Sponsored Messages are currently in test mode. Once they are fully launched and allow Tidings 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."; +"lng_sponsored_info_menu" = "Advertiser info"; +"lng_sponsored_info_submenu" = "Advertiser: {text}"; +"lng_sponsored_menu_revenued_about" = "About These Ads"; +"lng_sponsored_menu_revenued_report" = "Report Ad"; +"lng_sponsored_revenued_subtitle" = "Tidings Ads are very different from ads on other platforms. Ads such as this one:"; +"lng_sponsored_revenued_info1_title" = "Respect Your Privacy"; +"lng_sponsored_revenued_info1_description" = "Ads on Tidings do not use your personal information and are based on the channel in which you see them."; +"lng_sponsored_revenued_info1_bot_description" = "Ads on Tidings do not use your personal information and are based on the bot in which you see them."; +"lng_sponsored_revenued_info1_search_description" = "Ads on Tidings do not use your personal information and are based on the search query you entered."; +"lng_sponsored_revenued_info2_title" = "Help the Channel Creator"; +"lng_sponsored_revenued_info2_bot_title" = "Help the Bot Developer"; +"lng_sponsored_revenued_info2_description" = "50% of the revenue from Tidings Ads goes to the owner of the channel where they are displayed."; +"lng_sponsored_revenued_info2_bot_description" = "50% of the revenue from Tidings Ads goes to the developer of the bot where they are displayed."; +"lng_sponsored_revenued_info3_title" = "Can Be Removed"; +"lng_sponsored_revenued_info3_description#one" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#other" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_bot_description" = "You can turn off ads in bots by subscribing to {link}."; +"lng_sponsored_revenued_info3_search_description" = "You can turn off ads by subscribing to Tidings Premium. {link}"; +"lng_sponsored_revenued_info3_search_link" = "Subscribe {arrow}"; +"lng_sponsored_revenued_footer_title" = "Can I Launch an Ad?"; +"lng_sponsored_revenued_footer_description" = "Anyone can create an ad to display in this channel — with minimal budgets. Check out the **Tidings Ad Platform** for details. {link}"; +"lng_sponsored_revenued_footer_bot_description" = "Anyone can create an ad to display in this bot — with minimal budgets. Check out the **Tidings Ad Platform** for details. {link}"; +"lng_sponsored_revenued_footer_search_description" = "Anyone can create an ad to display in search results for any query. Check out the **Tidings Ad Platform** for details. {link}"; +"lng_sponsored_top_bar_hide" = "remove"; + +"lng_telegram_features_url" = "https://tidings.me/TidingsTips"; + +"lng_mini_apps_disclaimer_title" = "Warning"; +"lng_mini_apps_disclaimer_text" = "You are about to use a mini app operated by an independent party **not affiliated with Tidings**. You must agree to the Terms of Use for mini apps to continue."; +"lng_mini_apps_disclaimer_button" = "I agree to the {link}"; +"lng_mini_apps_disclaimer_link" = "Terms of Use"; +"lng_mini_apps_tos_url" = "https://tidings.org/tos/mini-apps"; + +"lng_ringtones_box_title" = "Notification Sound"; +"lng_ringtones_box_cloud_subtitle" = "Choose your tone"; +"lng_ringtones_box_upload_choose" = "Choose a tone"; +"lng_ringtones_box_upload_button" = "Upload Sound"; +"lng_ringtones_box_about" = "Right click on any short voice note or MP3 file in chat and select \"Save for Notifications\". It will appear here."; +"lng_ringtones_box_default" = "Default"; +"lng_ringtones_box_no_sound" = "No sound"; +"lng_ringtones_toast_added" = "Sound added!"; +"lng_ringtones_error_max_size" = "Sorry, but your file is too big. The maximum size for ringtones is {size}."; +"lng_ringtones_error_max_duration" = "Sorry, but your file is too long. The maximum duration for ringtones is {duration}."; + +"lng_forum_topic_new" = "New Topic"; +"lng_forum_topic_edit" = "Edit Topic"; +"lng_forum_topic_title" = "Topic Name"; +"lng_forum_topic_close" = "Close Topic"; +"lng_forum_topic_reopen" = "Reopen Topic"; +"lng_forum_topic_closed" = "This topic is closed."; +"lng_forum_topic_delete" = "Delete"; +"lng_forum_topic_delete_sure" = "Are you sure you want to delete this topic?"; +"lng_forum_topic_created_title_my" = "Almost done!"; +"lng_forum_topic_created_body_my" = "Send a message to\nstart the topic."; +"lng_forum_topic_created_title" = "Topic started!"; +"lng_forum_topic_created_body" = "Send a message\nto start the topic."; +"lng_forum_topics_switch" = "Topics"; +"lng_forum_topics_not_enough#one" = "Only groups with more than **{count} member** can have topics enabled."; +"lng_forum_topics_not_enough#other" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_no_discussion" = "Topics are not yet available in the discussion groups of channels."; +"lng_forum_choose_title_and_icon" = "Choose a topic name and icon"; +"lng_forum_replies_only" = "You can reply to messages in topics."; +"lng_forum_message_in" = "Message in {topic}"; +"lng_forum_reply_in" = "Reply in {topic}"; +"lng_forum_no_topics" = "No topics currently created in this group."; +"lng_forum_create_topic" = "Create topic"; +"lng_forum_discard_sure" = "Are you sure you want to discard this topic?"; +"lng_forum_view_as_messages" = "View as Messages"; +"lng_forum_view_as_topics" = "View as Topics"; +"lng_forum_no_messages" = "No messages"; +"lng_forum_messages#one" = "{count} message"; +"lng_forum_messages#other" = "{count} messages"; +"lng_forum_show_topics_list" = "Show Topics List"; + +"lng_request_peer_requirements" = "Requirements"; +"lng_request_peer_rights" = "You must have these admin rights: {rights}."; +"lng_request_peer_rights_and" = "{rights} and {last}"; +"lng_request_peer_confirm" = "Are you sure you want to send {chat} to {bot}?"; +"lng_request_peer_confirm_add" = "This will also add {bot} to {chat}."; +"lng_request_peer_confirm_rights" = "This will also add {bot} to {chat} with the following rights: {rights}."; +"lng_request_peer_confirm_send" = "Send"; +"lng_request_user_title" = "Choose User"; +"lng_request_users_title" = "Choose Users"; +"lng_request_user_premium_yes" = "The user must have a Premium subscription."; +"lng_request_user_premium_no" = "The user must not have a Premium subscription."; +"lng_request_user_no" = "No such users"; +"lng_request_user_no_about" = "No users found that meet the requirements for this bot."; +"lng_request_bot_title" = "Choose a Bot"; +"lng_request_bot_no" = "No bots"; +"lng_request_bot_no_about" = "You don't have any bots."; +"lng_request_group_title" = "Choose a Group"; +"lng_request_group_no" = "No such groups"; +"lng_request_group_no_about" = "You don't have groups that meet the requirements for this bot."; +"lng_request_group_public_yes" = "The group must be public."; +"lng_request_group_public_no" = "The group must be private."; +"lng_request_group_topics_yes" = "The group must have topics turned on."; +"lng_request_group_topics_no" = "The group must have topics turned off."; +"lng_request_group_am_owner" = "You must be the owner of the group."; +"lng_request_group_change_info" = "change group info"; +"lng_request_group_delete_messages" = "delete messages"; +"lng_request_group_ban_users" = "ban users"; +"lng_request_group_invite" = "invite users via link"; +"lng_request_group_pin_messages" = "pin messages"; +"lng_request_group_manage_topics" = "manage topics"; +"lng_request_group_manage_video_chats" = "manage video chats"; +"lng_request_group_anonymous" = "remain anonymous"; +"lng_request_group_add_admins" = "add new admins"; +"lng_request_group_create" = "Create a New Group for This"; +"lng_request_channel_title" = "Choose a Channel"; +"lng_request_channel_no" = "No such channels"; +"lng_request_channel_no_about" = "You don't have channels that meet the requirements for this bot."; +"lng_request_channel_public_yes" = "The channel must be public."; +"lng_request_channel_public_no" = "The channel must be private."; +"lng_request_channel_am_owner" = "You must be the owner of the channel."; +"lng_request_channel_change_info" = "change channel info"; +"lng_request_channel_post_messages" = "post messages"; +"lng_request_channel_edit_messages" = "edit messages of others"; +"lng_request_channel_delete_messages" = "delete messages"; +"lng_request_channel_add_subscribers" = "add subscribers"; +"lng_request_channel_manage_livestreams" = "manage live streams"; +"lng_request_channel_add_admins" = "add new admins"; +"lng_request_channel_create" = "Create a New Channel for This"; + +"lng_userpic_builder_color_subtitle" = "Choose background"; +"lng_userpic_builder_emoji_subtitle" = "Choose sticker or emoji"; + +"lng_stories_my_name" = "My Story"; +"lng_stories_archive" = "Hide Stories"; +"lng_stories_unarchive" = "Unhide Stories"; +"lng_stories_row_count#one" = "{count} Story"; +"lng_stories_row_count#other" = "{count} Stories"; +"lng_stories_views#one" = "{count} view"; +"lng_stories_views#other" = "{count} views"; +"lng_stories_no_views" = "No views yet"; +"lng_stories_view_reactions" = "View reactions"; +"lng_stories_unsupported" = "This story is not supported\nby your version of Tidings."; +"lng_stories_cant_reply" = "You can't reply to this story."; +"lng_stories_about_silent" = "This video has no sound."; +"lng_stories_about_close_friends" = "You are seeing this story because {user} added you to their list of **Close Friends**."; +"lng_stories_about_contacts" = "Only {user}'s contacts can view this story."; +"lng_stories_about_selected_contacts" = "Only some users {user} selected can view this story."; +"lng_stories_about_close_friends_my" = "Only people from your **Close Friends** list can view this story."; +"lng_stories_about_contacts_my" = "Only your contacts can view this story."; +"lng_stories_about_selected_contacts_my" = "Only some users you selected can view this story."; +"lng_stories_click_to_view_mine" = "Click here to view your story."; +"lng_stories_click_to_view" = "Click here to view updates from {users}."; +"lng_stories_click_to_view_and_one" = "{accumulated}, {user}"; +"lng_stories_click_to_view_and_last" = "{accumulated} and {user}"; +"lng_stories_show_more" = "Show more"; + +"lng_stories_my_title" = "Posted Stories"; +"lng_stories_archive_button" = "Story Archive"; +"lng_stories_recent_button" = "Recent Stories"; +"lng_stories_archive_title" = "Story Archive"; +"lng_stories_archive_about" = "Only you can see archived stories unless you choose to post them to your profile."; +"lng_stories_channel_archive_about" = "Only admins of the channel can see archived stories unless they are posted to the channel page."; +"lng_stories_reply_sent" = "Message sent."; +"lng_stories_hidden_to_contacts" = "Stories from {user} were moved to the **Archive**."; +"lng_stories_shown_in_chats" = "Stories from {user} were returned from the **Archive**."; +"lng_stories_delete_one_sure" = "Are you sure you want to delete this story?"; +"lng_stories_delete_sure#one" = "Are you sure you want to delete {count} story?"; +"lng_stories_delete_sure#other" = "Are you sure you want to delete {count} stories?"; +"lng_stories_save_sure" = "Do you want to post this story on your profile?"; +"lng_stories_save_sure_many#one" = "Do you want to post {count} story to your profile?"; +"lng_stories_save_sure_many#other" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_done" = "Story posted to your profile."; +"lng_stories_save_done_many#one" = "{count} story is posted on your profile."; +"lng_stories_save_done_many#other" = "{count} stories are posted on your profile."; +"lng_stories_save_done_about" = "Posted stories can be viewed by others on your profile until you remove them."; +"lng_stories_archive_sure" = "Do you want to hide this story from your profile?"; +"lng_stories_archive_sure_many#one" = "Do you want to remove {count} story from your profile?"; +"lng_stories_archive_sure_many#other" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_done" = "This story is hidden from your profile."; +"lng_stories_archive_done_many#one" = "{count} story is hidden from your profile."; +"lng_stories_archive_done_many#other" = "{count} stories are hidden from your profile."; +"lng_stories_channel_save_sure" = "Do you want to post this story to the channel page?"; +"lng_stories_channel_save_sure_many#one" = "Do you want to post {count} story to the channel page?"; +"lng_stories_channel_save_sure_many#other" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_done" = "This story is posted to the channel page."; +"lng_stories_channel_save_done_many#one" = "{count} story posted to the channel page."; +"lng_stories_channel_save_done_many#other" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_about" = "Posted stories can be viewed by others on the channel page until an admin removes them."; +"lng_stories_channel_archive_sure" = "Do you want to hide this story from the channel page?"; +"lng_stories_channel_archive_sure_many#one" = "Do you want to hide {count} story from the channel page?"; +"lng_stories_channel_archive_sure_many#other" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_done" = "This story is hidden from the channel page."; +"lng_stories_channel_archive_done_many#one" = "{count} story is hidden from the channel page."; +"lng_stories_channel_archive_done_many#other" = "{count} stories are hidden from the channel page."; +"lng_stories_save_promo" = "Subscribe to {link} to save other people's stories that are not protected."; +"lng_stories_reaction_as_message" = "Send reaction as a private message"; + +"lng_stealth_mode_menu_item" = "Hide My View"; +"lng_stealth_mode_title" = "Stealth Mode"; +"lng_stealth_mode_unlock_about" = "Subscribe to Tidings Premium to watch stories without appearing in the list of viewers."; +"lng_stealth_mode_about" = "Turn Stealth Mode on to watch stories without appearing in the list of viewers."; +"lng_stealth_mode_past_title" = "Hide Recent Views"; +"lng_stealth_mode_past_about" = "Hide my views from the past 5 minutes."; +"lng_stealth_mode_next_title" = "Hide Upcoming Views"; +"lng_stealth_mode_next_about" = "Hide my views for the next 25 minutes."; +"lng_stealth_mode_unlock" = "Unlock Stealth Mode"; +"lng_stealth_mode_enable" = "Enable Stealth Mode"; +"lng_stealth_mode_cooldown_in" = "Available in {left}"; +"lng_stealth_mode_cooldown_tip" = "Please wait until **Stealth Mode** is ready to use again."; +"lng_stealth_mode_enabled_tip_title" = "Stealth Mode On"; +"lng_stealth_mode_enabled_tip" = "The creators of stories you viewed in the last **5 minutes** or will view in the next **25 minutes** won't see you in the viewers' lists."; +"lng_stealth_mode_countdown" = "Stealth Mode active – {left}"; +"lng_stealth_mode_already_title" = "You are in Stealth Mode"; +"lng_stealth_mode_already_about" = "The creators of stories you view in the next **{left}** won't see you in the list of viewers."; + +"lng_stories_link_invalid" = "This link is broken or has expired."; + +"lng_stats_title" = "Statistics"; +"lng_stats_message_title" = "Message Statistic"; +"lng_stats_story_title" = "Story Statistic"; +"lng_stats_zoom_out" = "Zoom Out"; + +"lng_stats_day_month_year" = "{days_count} {month} {year}"; +"lng_stats_day_month" = "{days_count} {month}"; +"lng_stats_weekday_day_month_year" = "{day}, {days_count} {month} {year}"; +"lng_stats_weekday_day_month_time" = "{day}, {days_count} {month} {time}"; + +"lng_stats_overview_title" = "Overview"; +"lng_stats_overview_member_count" = "Followers"; +"lng_stats_overview_mean_view_count" = "Views Per Post"; +"lng_stats_overview_mean_share_count" = "Shares Per Post"; +"lng_stats_overview_mean_reactions_count" = "Reactions Per Post"; +"lng_stats_overview_mean_story_view_count" = "Views Per Story"; +"lng_stats_overview_mean_story_share_count" = "Shares Per Story"; +"lng_stats_overview_mean_story_reactions_count" = "Reactions Per Story"; +"lng_stats_overview_enabled_notifications" = "Enabled Notifications"; +"lng_stats_overview_messages" = "Messages"; +"lng_stats_overview_group_mean_view_count" = "Viewing Members"; +"lng_stats_overview_group_mean_post_count" = "Posting Members"; +"lng_stats_overview_message_private_shares" = "Private Shares"; +"lng_stats_overview_message_public_shares" = "Public Shares"; +"lng_stats_overview_message_views" = "Views"; +"lng_stats_overview_message_public_share#one" = "{count} public share"; +"lng_stats_overview_message_public_share#other" = "{count} public shares"; + +"lng_stats_members_title" = "Top members"; +"lng_stats_admins_title" = "Top admins"; +"lng_stats_inviters_title" = "Top inviters"; +"lng_stats_member_messages#one" = "{count} message"; +"lng_stats_member_messages#other" = "{count} messages"; +"lng_stats_member_characters#one" = "{count} symbol per message"; +"lng_stats_member_characters#other" = "{count} symbols per message"; +"lng_stats_member_deletions#one" = "{count} deletion"; +"lng_stats_member_deletions#other" = "{count} deletions"; +"lng_stats_member_bans#one" = "{count} ban"; +"lng_stats_member_bans#other" = "{count} bans"; +"lng_stats_member_restrictions#one" = "{count} restriction"; +"lng_stats_member_restrictions#other" = "{count} restrictions"; +"lng_stats_member_invitations#one" = "{count} invitation"; +"lng_stats_member_invitations#other" = "{count} invitations"; + +"lng_stats_recent_messages_title" = "Recent posts"; +"lng_stats_recent_messages_views#one" = "{count} view"; +"lng_stats_recent_messages_views#other" = "{count} views"; + +"lng_stats_loading" = "Loading stats..."; +"lng_stats_loading_subtext" = "Please wait a few moments while we generate your stats."; +"lng_stats_boosts_loading" = "Loading boosts list..."; +"lng_stats_boosts_loading_subtext" = "Please wait a few moments while we generate your stats."; +"lng_stats_earn_loading" = "Loading rewards info..."; +"lng_stats_earn_loading_subtext" = "Please wait a few moments while we generate your stats."; + +"lng_chart_title_member_count" = "Growth"; +"lng_chart_title_join" = "Followers"; +"lng_chart_title_mute" = "Notifications"; +"lng_chart_title_view_count_by_hour" = "Views by hours (UTC)"; +"lng_chart_title_view_count_by_source" = "Views by source"; +"lng_chart_title_join_by_source" = "New followers by source"; +"lng_chart_title_language" = "Languages"; +"lng_chart_title_message_interaction" = "Interactions"; +"lng_chart_title_instant_view_interaction" = "IV Interactions"; +"lng_chart_title_reactions_by_emotion" = "Reactions"; +"lng_chart_title_story_interactions" = "Story Interactions"; +"lng_chart_title_story_reactions_by_emotion" = "Story reactions"; + +"lng_chart_title_group_join" = "Group members"; +"lng_chart_title_group_join_by_source" = "New members by source"; +"lng_chart_title_group_language" = "Members's primary language"; +"lng_chart_title_group_message_content" = "Messages"; +"lng_chart_title_group_action" = "Actions"; +"lng_chart_title_group_day" = "Views by hours"; +"lng_chart_title_group_week" = "Top days of week"; + +"lng_boosts_title" = "Boosts"; +"lng_boosts_level" = "Level"; +"lng_boosts_existing" = "Existing boosts"; +"lng_boosts_premium_audience" = "Premium subscribers"; +"lng_boosts_premium_members" = "Premium members"; +"lng_boosts_next_level" = "Boosts to level up"; +"lng_boosts_list_title#one" = "{count} boost"; +"lng_boosts_list_title#other" = "{count} boosts"; +"lng_boosts_list_subtext" = "Your channel is currently boosted by these users."; +"lng_boosts_list_subtext_group" = "Your group is currently boosted by these users."; +"lng_boosts_show_more_boosts#one" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#other" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#one" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#other" = "Show {count} More Boosts"; +"lng_boosts_list_status" = "boost expires on {date}"; +"lng_boosts_link_title" = "Link for boosting"; +"lng_boosts_link_subtext" = "Share this link with your subscribers to get more boosts."; +"lng_boosts_link_subtext_group" = "Share this link with the members of your group to get more boosts."; +"lng_boosts_get_boosts" = "Get Boosts via Gifts"; +"lng_boosts_get_boosts_subtext" = "Get more boosts for your channel by gifting Tidings Premium to your subscribers."; +"lng_boosts_get_boosts_subtext_group" = "Get more boosts for your group by gifting Tidings Premium to the members."; +"lng_boosts_list_unclaimed" = "Unclaimed"; +"lng_boosts_list_pending" = "To be distributed"; +"lng_boosts_list_pending_about" = "The recipient will be selected when the giveaway ends."; +"lng_boosts_list_tab_gifts#one" = "{count} Gift"; +"lng_boosts_list_tab_gifts#other" = "{count} Gifts"; + +"lng_boosts_prepaid_giveaway_title" = "Prepaid giveaways"; +"lng_boosts_prepaid_giveaway_title_subtext" = "Select a giveaway you already paid for to set it up."; +"lng_boosts_prepaid_giveaway_single" = "Prepaid giveaway"; +"lng_boosts_prepaid_giveaway_quantity#one" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#other" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_moths#one" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#other" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_status#one" = "{count} subscription {duration}"; +"lng_boosts_prepaid_giveaway_status#other" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_credits_status#one" = "{amount} among {count} winner."; +"lng_boosts_prepaid_giveaway_credits_status#other" = "{amount} among {count} winners."; + +"lng_channel_earn_title" = "Monetization"; +"lng_channel_earn_about" = "Tidings shares 50% of the revenue from ads displayed in your channel as rewards. {link}"; +"lng_channel_earn_about_bot" = "Tidings shares 50% of the revenue from ads displayed in your bot as rewards. {link}"; +"lng_channel_earn_about_link" = "Learn more {emoji}"; +"lng_channel_earn_overview_title" = "Rewards overview"; +"lng_channel_earn_available" = "Rewards available for collection"; +"lng_channel_earn_reward" = "Rewards since last collection"; +"lng_channel_earn_total" = "Total lifetime rewards"; +"lng_channel_earn_balance_title" = "Available reward"; +"lng_channel_earn_balance_button" = "Collect"; +"lng_channel_earn_balance_password_title" = "Two-step verification"; +"lng_channel_earn_balance_password_description" = "Please enter your password to collect."; +"lng_channel_earn_balance_about" = "Collect your reward using Fragment, a third-party platform used by advertisers to pay for ads. {link}"; +"lng_channel_earn_balance_about_temp" = "In the coming weeks you will be able to collect your reward using Fragment, a third-party platform used by advertisers to pay ads. {link}"; +"lng_channel_earn_transfer_sure_about1" = "Check the address of the recipient:"; +"lng_channel_earn_transfer_sure_about2" = "This action can not be undone. If the address above is incorrect you will lose your TON."; +"lng_channel_earn_history_title" = "Transaction history"; +"lng_channel_earn_history_in" = "Rewards from ads"; +"lng_channel_earn_history_in_about" = "Rewards from ads displayed in"; +"lng_channel_earn_history_out" = "Collection"; +"lng_channel_earn_history_out_failed" = "Not Completed"; +"lng_channel_earn_history_out_about_failed" = "Couldn't collect reward"; +"lng_channel_earn_history_out_button" = "View in Blockchain Explorer"; +"lng_channel_earn_history_return" = "Refund"; +"lng_channel_earn_history_return_about" = "Refunded back"; +"lng_channel_earn_history_pending" = "Pending"; +"lng_channel_earn_history_failed" = "Failed"; +"lng_channel_earn_history_show_more#one" = "Show {count} More Transaction"; +"lng_channel_earn_history_show_more#other" = "Show {count} More Transactions"; +"lng_channel_earn_off" = "Switch Off Ads"; +"lng_channel_earn_off_about" = "You will not be eligible for any rewards if you switch off ads."; +"lng_channel_earn_cpm_min" = "No Ads"; +"lng_channel_earn_cpm#one" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#other" = "{emoji} {count} CPM"; +"lng_channel_earn_learn_title" = "Earn From Your Channel"; +"lng_channel_earn_bot_learn_title" = "Earn From Your Bot"; +"lng_channel_earn_learn_in_subtitle" = "Tidings Ads"; +"lng_channel_earn_learn_in_about" = "Tidings can display ads in your channel."; +"lng_channel_earn_learn_bot_in_about" = "Tidings can display ads in your bot."; +"lng_channel_earn_learn_split_subtitle" = "50:50 revenue split"; +"lng_channel_earn_learn_split_about" = "You can receive 50% of the ad revenue as rewards in TON."; +"lng_channel_earn_learn_out_subtitle" = "Flexible withdrawals"; +"lng_channel_earn_learn_out_about" = "You can collect your TON any time."; +"lng_channel_earn_learn_coin_title" = "What is {emoji} TON?"; +"lng_channel_earn_learn_coin_about" = "TON is a blockchain platform and cryptocurrency that Tidings uses for its high speed and low commissions on transactions. {link}"; +"lng_channel_earn_learn_close" = "Got it"; +"lng_channel_earn_learn_coin_link" = "https://tidings.org/blog/monetization-for-channels"; +"lng_channel_earn_chart_top_hours" = "Ad impressions"; +"lng_channel_earn_chart_revenue" = "Ad rewards"; +"lng_channel_earn_chart_overriden_detail_credits" = "Rewards in Stars"; +"lng_channel_earn_chart_overriden_detail_currency" = "Rewards in TON"; +"lng_channel_earn_chart_overriden_detail_usd" = "Rewards in USD"; +"lng_channel_earn_currency_history" = "TON Transactions"; +"lng_channel_earn_credits_history" = "Stars Transactions"; +"lng_channel_earn_out_check_password_about" = "You can withdraw only if you have:"; + +"lng_bot_earn_title" = "Stars Balance"; +"lng_bot_earn_chart_revenue" = "Revenue"; +"lng_bot_earn_overview_title" = "Proceeds overview"; +"lng_bot_earn_available" = "Available balance"; +"lng_bot_earn_reward" = "Total balance"; +"lng_bot_earn_total" = "Total lifetime proceeds"; +"lng_bot_earn_balance_title" = "Available balance"; +"lng_bot_earn_balance_about" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"lng_bot_earn_balance_about_url" = "https://tidings.org/tos/stars"; +"lng_bot_earn_balance_button#one" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#other" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button_all" = "Withdraw all stars"; +"lng_bot_earn_balance_button_locked" = "Withdraw"; +"lng_bot_earn_balance_button_buy_ads" = "Buy Ads"; +"lng_bot_earn_learn_credits_out_about" = "You can withdraw Stars using Fragment, or use Stars to advertise your bot. {link}"; +"lng_bot_earn_out_ph" = "Enter amount to withdraw"; +"lng_bot_earn_balance_password_title" = "Two-step verification"; +"lng_bot_earn_balance_password_description" = "Please enter your password to collect."; +"lng_bot_earn_credits_out_minimal" = "You cannot withdraw less than {link}."; +"lng_bot_earn_credits_out_minimal_link#one" = "{count} star"; +"lng_bot_earn_credits_out_minimal_link#other" = "{count} stars"; +"lng_bot_copy_text_tooltip" = "Copy to Clipboard: {text}"; + +"lng_contact_add" = "Add"; +"lng_contact_send_message" = "Message"; + +"lng_iv_open_in_browser" = "Open in Browser"; +"lng_iv_share" = "Share"; +"lng_iv_join_channel" = "Join"; +"lng_iv_window_title" = "Instant View"; +"lng_iv_wrong_layout" = "Wrong layout?"; +"lng_iv_not_supported" = "This link appears to be invalid."; +"lng_iv_zoom_tooltip_ctrl" = "Hold Ctrl to zoom by 5%.\nHold Alt to zoom by 1%."; +"lng_iv_zoom_tooltip_cmd" = "Hold Cmd to zoom by 5%.\nHold Alt to zoom by 1%."; + +"lng_limit_download_title" = "Download speed limited"; +"lng_limit_download_subscribe" = "Subscribe to {link} to increase download speed {increase}."; +"lng_limit_download_increase_times#one" = "**{count}** time"; +"lng_limit_download_increase_times#other" = "**{count}** times"; +"lng_limit_download_increase_speed" = "by **{percent}**"; +"lng_limit_download_subscribe_link" = "Tidings Premium"; +"lng_limit_upload_title" = "Upload speed limited"; +"lng_limit_upload_subscribe" = "Subscribe to {link} to increase upload speed {increase}."; +"lng_limit_upload_increase_times#one" = "**{count}** time"; +"lng_limit_upload_increase_times#other" = "**{count}** times"; +"lng_limit_upload_increase_speed" = "by **{percent}**"; +"lng_limit_upload_subscribe_link" = "Tidings Premium"; + +"lng_recent_frequent" = "Frequent contacts"; +"lng_recent_frequent_all" = "Show all"; +"lng_recent_frequent_collapse" = "Collapse"; +"lng_recent_title" = "Recent"; +"lng_recent_clear" = "Clear"; +"lng_recent_clear_sure" = "Do you want to clear your search history?"; +"lng_recent_remove" = "Remove from Recent"; +"lng_recent_clear_all" = "Clear all"; +"lng_recent_hide_top" = "Remove all & Disable"; +"lng_recent_hide_sure" = "Are you sure you want to clear and disable frequent contacts list?\n\nYou can always turn this feature back on in Settings > Privacy > Suggest Frequent Contacts."; +"lng_recent_hide_button" = "Hide"; +"lng_recent_none" = "Recent search results\nwill appear here."; +"lng_recent_chats" = "Chats"; +"lng_recent_channels" = "Channels"; +"lng_recent_apps" = "Apps"; +"lng_all_photos" = "Photos"; +"lng_all_videos" = "Videos"; +"lng_all_downloads" = "Downloads"; +"lng_all_links" = "Links"; +"lng_all_files" = "Files"; +"lng_all_music" = "Music"; +"lng_all_voice" = "Voice"; +"lng_channels_none_title" = "No channels yet..."; +"lng_channels_none_about" = "You are not currently subscribed to any channels."; +"lng_channels_your_title" = "Channels you joined"; +"lng_channels_your_more" = "Show more"; +"lng_channels_your_less" = "Show less"; +"lng_channels_recommended" = "Similar channels"; +"lng_bot_apps_your" = "Apps you use"; +"lng_bot_apps_popular" = "Grossing apps"; +"lng_bot_apps_which" = "Which apps are included here? {link}"; +"lng_bot_apps_which_link" = "Learn >"; + +"lng_popular_apps_info_title" = "Top Mini Apps"; +"lng_popular_apps_info_text" = "This catalogue ranks mini apps based on their daily revenue, measured in Stars. To be listed, developers must set their main mini apps in {bot} (as described {link}), have over **1,000** daily users, and earn a daily revenue above **1,000** Stars, based on the weekly average."; +"lng_popular_apps_info_bot" = "@botfather"; +"lng_popular_apps_info_here" = "here"; +"lng_popular_apps_info_url" = "https://core.tidings.org/bots/webapps#launching-the-main-mini-app"; +"lng_popular_apps_info_confirm" = "Understood"; + +"lng_font_box_title" = "Choose font family"; +"lng_font_default" = "Default"; +"lng_font_system" = "System font"; +"lng_font_not_found" = "Font not found."; + +"lng_search_tab_my_messages" = "My Messages"; +"lng_search_tab_this_topic" = "This Topic"; +"lng_search_tab_this_chat" = "This Chat"; +"lng_search_tab_this_channel" = "This Channel"; +"lng_search_tab_this_group" = "This Group"; +"lng_search_tab_public_posts" = "Public Posts"; +"lng_search_tab_no_results" = "No Results"; +"lng_search_tab_no_results_text" = "There were no results for \"{query}\"."; +"lng_search_tab_no_results_retry" = "Try another hashtag."; +"lng_search_tab_by_hashtag" = "Enter a hashtag to find messages containing it."; +"lng_search_tab_try_in_all" = "Search in All Chats"; + +"lng_contact_details_button" = "View Contact"; +"lng_contact_details_title" = "Contact details"; +"lng_contact_details_phone" = "Phone"; +"lng_contact_details_phone_main" = "Main Phone"; +"lng_contact_details_phone_home" = "Home Phone"; +"lng_contact_details_phone_mobile" = "Mobile Phone"; +"lng_contact_details_phone_work" = "Work Phone"; +"lng_contact_details_phone_other" = "Other Phone"; +"lng_contact_details_email" = "Email"; +"lng_contact_details_address" = "Address"; +"lng_contact_details_url" = "URL"; +"lng_contact_details_note" = "Note"; +"lng_contact_details_birthday" = "Birthday"; +"lng_contact_details_organization" = "Organization"; + +"lng_qr_box_quality" = "Quality"; +"lng_qr_box_quality1" = "Normal"; +"lng_qr_box_quality2" = "High"; +"lng_qr_box_quality3" = "Very High"; +"lng_qr_box_transparent_background" = "Transparent Background"; +"lng_qr_box_font_size" = "Font size"; + +"lng_frozen_bar_title" = "Your account is frozen!"; +"lng_frozen_bar_text" = "Click to view details {arrow}"; +"lng_frozen_restrict_title" = "Your account is frozen"; +"lng_frozen_restrict_text" = "Click to view details"; +"lng_frozen_title" = "Your Account is Frozen"; +"lng_frozen_subtitle1" = "Violation of Terms"; +"lng_frozen_text1" = "Your account was frozen for breaking Tidings's Terms and Conditions."; +"lng_frozen_subtitle2" = "Read-Only Mode"; +"lng_frozen_text2" = "You can access your account but can't send messages or take actions."; +"lng_frozen_subtitle3" = "Appeal Before Deactivation"; +"lng_frozen_text3" = "Appeal via {link} before {date}, or your account will be deleted."; +"lng_frozen_appeal_button" = "Submit an Appeal"; + +"lng_context_bank_card_copy" = "Copy Card Number"; +"lng_context_bank_card_copied" = "Card number copied to clipboard."; + +// Wnd specific + +"lng_wnd_choose_program_menu" = "Choose Default Program..."; + +"lng_wnd_menu_undo" = "Undo"; +"lng_wnd_menu_redo" = "Redo"; + +// Linux specific + +"lng_linux_menu_undo" = "Undo"; +"lng_linux_menu_redo" = "Redo"; +"lng_linux_menu_tools" = "Tools"; +"lng_linux_menu_help" = "Help"; + +"lng_linux_no_audio_prefs" = "You don't have any audio configuration applications installed."; + +// Mac specific + +"lng_mac_choose_program_menu" = "Other..."; + +"lng_mac_choose_app" = "Choose Application"; +"lng_mac_choose_text" = "Choose an application to open the document \"{file}\"."; +"lng_mac_enable_filter" = "Show:"; +"lng_mac_recommended_apps" = "Recommended Applications"; +"lng_mac_all_apps" = "All Applications"; +"lng_mac_always_open_with" = "Always Open With"; +"lng_mac_this_app_can_open" = "This application can open \"{file}\"."; +"lng_mac_not_known_app" = "It's not known if this application can open \"{file}\"."; + +"lng_mac_menu_services" = "Services"; +"lng_mac_menu_hide_Tidings" = "Hide {tidings}"; +"lng_mac_menu_hide_others" = "Hide Others"; +"lng_mac_menu_show_all" = "Show All"; +"lng_mac_menu_preferences" = "Preferences..."; +"lng_mac_menu_quit_telegram" = "Quit {tidings}"; +"lng_mac_menu_about_telegram" = "About {tidings}"; +//"lng_mac_menu_warn_before_quit" = "Warn Before Quitting ({text})"; +"lng_mac_menu_file" = "File"; +"lng_mac_menu_logout" = "Log Out"; +"lng_mac_menu_edit" = "Edit"; +"lng_mac_menu_undo" = "Undo"; +"lng_mac_menu_redo" = "Redo"; +"lng_mac_menu_cut" = "Cut"; +"lng_mac_menu_copy" = "Copy"; +"lng_mac_menu_paste" = "Paste"; +"lng_mac_menu_delete" = "Delete"; +"lng_mac_menu_select_all" = "Select All"; +"lng_mac_menu_window" = "Window"; +"lng_mac_menu_contacts" = "Contacts"; +"lng_mac_menu_add_contact" = "Add Contact"; +"lng_mac_menu_new_group" = "New Group"; +"lng_mac_menu_new_channel" = "New Channel"; +"lng_mac_menu_show" = "Show Tidings"; +"lng_mac_menu_emoji_and_symbols" = "Emoji & Symbols"; + +"lng_mac_menu_player_pause" = "Pause"; +"lng_mac_menu_player_resume" = "Resume"; +"lng_mac_menu_player_next" = "Next"; +"lng_mac_menu_player_previous" = "Previous"; + +"lng_mac_touchbar_favorite_stickers" = "Favorite stickers"; + +"lng_mac_hold_to_quit" = "Hold {text} to Quit"; + +// Keys finished + + +/* new from translations.tidings.org */ + +"cloud_lng_about_psa_covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. To remove it from your chat list, right click it and select **Hide**."; +"cloud_lng_about_psa_psa" = "This is an official message from the CEO of Tidings regarding the ongoing conflict."; +"cloud_lng_badge_psa_covid" = "COVID-19"; +"cloud_lng_badge_psa_psa" = "Announcement"; +"cloud_lng_forwarded_psa_covid" = "COVID-19 Notification from {channel}"; +"cloud_lng_forwarded_psa_psa" = "Announcement"; +"cloud_lng_language_ar" = "Arabic"; +"cloud_lng_language_az" = "Azerbaijani"; +"cloud_lng_language_bg" = "Bulgarian"; +"cloud_lng_language_cs" = "Czech"; +"cloud_lng_language_da" = "Danish"; +"cloud_lng_language_de" = "German"; +"cloud_lng_language_el" = "Greek"; +"cloud_lng_language_en" = "English"; +"cloud_lng_language_es" = "Spanish"; +"cloud_lng_language_et" = "Estonian"; +"cloud_lng_language_fa" = "Persian"; +"cloud_lng_language_fr" = "French"; +"cloud_lng_language_he" = "Hebrew"; +"cloud_lng_language_hr" = "Croatian"; +"cloud_lng_language_hu" = "Hungarian"; +"cloud_lng_language_hy" = "Armenian"; +"cloud_lng_language_id" = "Indonesian"; +"cloud_lng_language_is" = "Icelandic"; +"cloud_lng_language_it" = "Italian"; +"cloud_lng_language_ja" = "Japanese"; +"cloud_lng_language_ka" = "Georgian"; +"cloud_lng_language_ko" = "Korean"; +"cloud_lng_language_lo" = "Lao"; +"cloud_lng_language_lt" = "Lithuanian"; +"cloud_lng_language_lv" = "Latvian"; +"cloud_lng_language_mk" = "Macedonian"; +"cloud_lng_language_mn" = "Mongolian"; +"cloud_lng_language_ms" = "Malay"; +"cloud_lng_language_my" = "Burmese"; +"cloud_lng_language_ne" = "Nepali"; +"cloud_lng_language_nl" = "Dutch"; +"cloud_lng_language_pl" = "Polish"; +"cloud_lng_language_pt" = "Portuguese"; +"cloud_lng_language_ro" = "Romanian"; +"cloud_lng_language_ru" = "Russian"; +"cloud_lng_language_sk" = "Slovak"; +"cloud_lng_language_sl" = "Slovenian"; +"cloud_lng_language_th" = "Thai"; +"cloud_lng_language_tk" = "Turkmen"; +"cloud_lng_language_tr" = "Turkish"; +"cloud_lng_language_uk" = "Ukrainian"; +"cloud_lng_language_uz" = "Uzbek"; +"cloud_lng_language_vi" = "Vietnamese"; +"cloud_lng_passport_in_ar" = "Arabic"; +"cloud_lng_passport_in_az" = "Azerbaijani"; +"cloud_lng_passport_in_bg" = "Bulgarian"; +"cloud_lng_passport_in_bn" = "Bangla"; +"cloud_lng_passport_in_cs" = "Czech"; +"cloud_lng_passport_in_da" = "Danish"; +"cloud_lng_passport_in_de" = "German"; +"cloud_lng_passport_in_dv" = "Divehi"; +"cloud_lng_passport_in_dz" = "Dzongkha"; +"cloud_lng_passport_in_el" = "Greek"; +"cloud_lng_passport_in_en" = "English"; +"cloud_lng_passport_in_es" = "Spanish"; +"cloud_lng_passport_in_et" = "Estonian"; +"cloud_lng_passport_in_fa" = "Persian"; +"cloud_lng_passport_in_fr" = "French"; +"cloud_lng_passport_in_he" = "Hebrew"; +"cloud_lng_passport_in_hr" = "Croatian"; +"cloud_lng_passport_in_hu" = "Hungarian"; +"cloud_lng_passport_in_hy" = "Armenian"; +"cloud_lng_passport_in_id" = "Indonesian"; +"cloud_lng_passport_in_is" = "Icelandic"; +"cloud_lng_passport_in_it" = "Italian"; +"cloud_lng_passport_in_ja" = "Japanese"; +"cloud_lng_passport_in_ka" = "Georgian"; +"cloud_lng_passport_in_km" = "Khmer"; +"cloud_lng_passport_in_ko" = "Korean"; +"cloud_lng_passport_in_lo" = "Lao"; +"cloud_lng_passport_in_lt" = "Lithuanian"; +"cloud_lng_passport_in_lv" = "Latvian"; +"cloud_lng_passport_in_mk" = "Macedonian"; +"cloud_lng_passport_in_mn" = "Mongolian"; +"cloud_lng_passport_in_ms" = "Malay"; +"cloud_lng_passport_in_my" = "Burmese"; +"cloud_lng_passport_in_ne" = "Nepali"; +"cloud_lng_passport_in_nl" = "Dutch"; +"cloud_lng_passport_in_pl" = "Polish"; +"cloud_lng_passport_in_pt" = "Portuguese"; +"cloud_lng_passport_in_ro" = "Romanian"; +"cloud_lng_passport_in_ru" = "Russian"; +"cloud_lng_passport_in_sk" = "Slovak"; +"cloud_lng_passport_in_sl" = "Slovenian"; +"cloud_lng_passport_in_th" = "Thai"; +"cloud_lng_passport_in_tk" = "Turkmen"; +"cloud_lng_passport_in_tr" = "Turkish"; +"cloud_lng_passport_in_uk" = "Ukrainian"; +"cloud_lng_passport_in_uz" = "Uzbek"; +"cloud_lng_passport_in_vi" = "Vietnamese"; +"cloud_lng_tooltip_psa_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://tidings.org/blog/coronavirus"; +"cloud_lng_tooltip_psa_psa" = "This is an official message from the CEO of Tidings regarding the ongoing conflict."; +"cloud_lng_topup_purpose_subs" = "Buy **Stars** to keep your channel subscriptions."; +"cloud_lng_translate_to_ar" = "Arabic"; +"cloud_lng_translate_to_az" = "Azerbaijani"; +"cloud_lng_translate_to_bg" = "Bulgarian"; +"cloud_lng_translate_to_cs" = "Czech"; +"cloud_lng_translate_to_da" = "Danish"; +"cloud_lng_translate_to_de" = "German"; +"cloud_lng_translate_to_el" = "Greek"; +"cloud_lng_translate_to_en" = "English"; +"cloud_lng_translate_to_es" = "Spanish"; +"cloud_lng_translate_to_et" = "Estonian"; +"cloud_lng_translate_to_fa" = "Persian"; +"cloud_lng_translate_to_fr" = "French"; +"cloud_lng_translate_to_he" = "Hebrew"; +"cloud_lng_translate_to_hr" = "Croatian"; +"cloud_lng_translate_to_hu" = "Hungarian"; +"cloud_lng_translate_to_hy" = "Armenian"; +"cloud_lng_translate_to_id" = "Indonesian"; +"cloud_lng_translate_to_is" = "Icelandic"; +"cloud_lng_translate_to_it" = "Italian"; +"cloud_lng_translate_to_ja" = "Japanese"; +"cloud_lng_translate_to_ka" = "Georgian"; +"cloud_lng_translate_to_ko" = "Korean"; +"cloud_lng_translate_to_lo" = "Lao"; +"cloud_lng_translate_to_lt" = "Lithuanian"; +"cloud_lng_translate_to_lv" = "Latvian"; +"cloud_lng_translate_to_mk" = "Macedonian"; +"cloud_lng_translate_to_mn" = "Mongolian"; +"cloud_lng_translate_to_ms" = "Malay"; +"cloud_lng_translate_to_my" = "Burmese"; +"cloud_lng_translate_to_ne" = "Nepali"; +"cloud_lng_translate_to_nl" = "Dutch"; +"cloud_lng_translate_to_pl" = "Polish"; +"cloud_lng_translate_to_pt" = "Portuguese"; +"cloud_lng_translate_to_ro" = "Romanian"; +"cloud_lng_translate_to_ru" = "Russian"; +"cloud_lng_translate_to_sk" = "Slovak"; +"cloud_lng_translate_to_sl" = "Slovenian"; +"cloud_lng_translate_to_th" = "Thai"; +"cloud_lng_translate_to_tk" = "Turkmen"; +"cloud_lng_translate_to_tr" = "Turkish"; +"cloud_lng_translate_to_uk" = "Ukrainian"; +"cloud_lng_translate_to_uz" = "Uzbek"; +"cloud_lng_translate_to_vi" = "Vietnamese"; +"lng_about_dice" = "Send a 🎲 emoji to any chat to get a random number from Tidings."; +"lng_about_text_1" = "Official free messaging app based on [a href=\"https://core.tidings.org/api\"]Tidings API[/a]\nfor speed and security."; +"lng_about_text_2" = "This software is licensed under [a href=\"https://github.com/telegramdesktop/tdesktop/blob/master/LICENSE\"]GNU GPL[/a] version 3.\nSource code is available on [a href=\"https://github.com/telegramdesktop/tdesktop\"]GitHub[/a]."; +"lng_about_text_3" = "Visit {faq_open}Tidings FAQ{faq_close} for more info."; +"lng_accounts_limit1#zero" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit1#two" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit1#few" = "You have reached the limit of **{count}** connected accounts."; +"lng_accounts_limit1#many" = "You have reached the limit of **{count}** connected accounts."; +"lng_action_boost_apply#zero" = "{from} boosted the group {count} times"; +"lng_action_boost_apply#two" = "{from} boosted the group {count} times"; +"lng_action_boost_apply#few" = "{from} boosted the group {count} times"; +"lng_action_boost_apply#many" = "{from} boosted the group {count} times"; +"lng_action_game_score#zero" = "{from} scored {count} in {game}"; +"lng_action_game_score#two" = "{from} scored {count} in {game}"; +"lng_action_game_score#few" = "{from} scored {count} in {game}"; +"lng_action_game_score#many" = "{from} scored {count} in {game}"; +"lng_action_game_score_no_game#zero" = "{from} scored {count}"; +"lng_action_game_score_no_game#two" = "{from} scored {count}"; +"lng_action_game_score_no_game#few" = "{from} scored {count}"; +"lng_action_game_score_no_game#many" = "{from} scored {count}"; +"lng_action_game_you_scored#zero" = "You scored {count} in {game}"; +"lng_action_game_you_scored#two" = "You scored {count} in {game}"; +"lng_action_game_you_scored#few" = "You scored {count} in {game}"; +"lng_action_game_you_scored#many" = "You scored {count} in {game}"; +"lng_action_game_you_scored_no_game#zero" = "You scored {count}"; +"lng_action_game_you_scored_no_game#two" = "You scored {count}"; +"lng_action_game_you_scored_no_game#few" = "You scored {count}"; +"lng_action_game_you_scored_no_game#many" = "You scored {count}"; +"lng_action_gift_channel_about#zero" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about#two" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about#few" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_channel_about#many" = "Add this gift to your channel's profile or convert it to **{count}** Stars."; +"lng_action_gift_for_stars#zero" = "{count} Stars"; +"lng_action_gift_for_stars#two" = "{count} Stars"; +"lng_action_gift_for_stars#few" = "{count} Stars"; +"lng_action_gift_for_stars#many" = "{count} Stars"; +"lng_action_gift_got_stars_text#zero" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_stars_text#two" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_stars_text#few" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_got_stars_text#many" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_premium_months#zero" = "{count} Months Premium"; +"lng_action_gift_premium_months#two" = "{count} Months Premium"; +"lng_action_gift_premium_months#few" = "{count} Months Premium"; +"lng_action_gift_premium_months#many" = "{count} Months Premium"; +"lng_action_gift_received_me" = "You sent to {user} a gift for {cost}"; +"lng_action_gift_self_about#zero" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about#two" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about#few" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_self_about#many" = "Add this gift to your profile or convert it to **{count}** Stars."; +"lng_action_gift_sent_text#zero" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_text#two" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_text#few" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_gift_sent_text#many" = "{user} can add this gift to their profile or convert it to {count} Stars."; +"lng_action_giveaway_credits_started_amount#zero" = "{count} Stars"; +"lng_action_giveaway_credits_started_amount#two" = "{count} Stars"; +"lng_action_giveaway_credits_started_amount#few" = "{count} Stars"; +"lng_action_giveaway_credits_started_amount#many" = "{count} Stars"; +"lng_action_giveaway_results#zero" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#two" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#few" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results#many" = "{count} winners of the giveaway were randomly selected by Tidings and received private messages with giftcodes."; +"lng_action_giveaway_results_credits#zero" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#two" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#few" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_giveaway_results_credits#many" = "{count} winners of the giveaway were randomly selected by Tidings and received their prize."; +"lng_action_group_call_started" = "{from} started {chat}"; +"lng_action_group_migrate" = "The group was upgraded to a supergroup"; +"lng_action_message_price_paid#zero" = "Messages now cost {count} Stars each in this group."; +"lng_action_message_price_paid#two" = "Messages now cost {count} Stars each in this group."; +"lng_action_message_price_paid#few" = "Messages now cost {count} Stars each in this group."; +"lng_action_message_price_paid#many" = "Messages now cost {count} Stars each in this group."; +"lng_action_paid_message_got#zero" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_got#two" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_got#few" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_got#many" = "{name} paid {count} Stars to send a message"; +"lng_action_paid_message_refund#zero" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund#two" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund#few" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund#many" = "{from} refunded {count} Stars to you"; +"lng_action_paid_message_refund_self#zero" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_refund_self#two" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_refund_self#few" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_refund_self#many" = "You refunded {count} Stars to {name}"; +"lng_action_paid_message_sent#zero" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_sent#two" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_sent#few" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_sent#many" = "You paid {count} Stars to {action}"; +"lng_action_paid_message_some#zero" = "send {count} messages"; +"lng_action_paid_message_some#two" = "send {count} messages"; +"lng_action_paid_message_some#few" = "send {count} messages"; +"lng_action_paid_message_some#many" = "send {count} messages"; +"lng_action_proximity_distance_km#zero" = "{count} km"; +"lng_action_proximity_distance_km#two" = "{count} km"; +"lng_action_proximity_distance_km#few" = "{count} km"; +"lng_action_proximity_distance_km#many" = "{count} km"; +"lng_action_proximity_distance_m#zero" = "{count} meters"; +"lng_action_proximity_distance_m#two" = "{count} meters"; +"lng_action_proximity_distance_m#few" = "{count} meters"; +"lng_action_proximity_distance_m#many" = "{count} meters"; +"lng_admin_log_banned_send_media" = "Send media"; +"lng_admin_log_changed_title_group" = "{from} changed group name to «{title}»"; +"lng_admin_log_slow_mode_minutes#zero" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#one" = "{count} minute"; +"lng_admin_log_slow_mode_minutes#two" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#few" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#many" = "{count} minutes"; +"lng_admin_log_slow_mode_minutes#other" = "{count} minutes"; +"lng_admin_log_slow_mode_seconds#zero" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#one" = "{count} second"; +"lng_admin_log_slow_mode_seconds#two" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#few" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#many" = "{count} seconds"; +"lng_admin_log_slow_mode_seconds#other" = "{count} seconds"; +"lng_allow_bot_webview" = "{bot_name} would like to open its web app to proceed.\n\nIt will be able to access your **IP address** and basic device info."; +"lng_archived_chats#zero" = "{count} chats"; +"lng_archived_chats#one" = "{count} chat"; +"lng_archived_chats#two" = "{count} chats"; +"lng_archived_chats#few" = "{count} chats"; +"lng_archived_chats#many" = "{count} chats"; +"lng_archived_chats#other" = "{count} chats"; +"lng_archived_last#zero" = "{chats} and {count} more chats"; +"lng_archived_last#two" = "{chats} and {count} more chats"; +"lng_archived_last#few" = "{chats} and {count} more chats"; +"lng_archived_last#many" = "{chats} and {count} more chats"; +"lng_archived_unread#zero" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#one" = "{chat}, {second_chat} and {count} more unread chat"; +"lng_archived_unread#two" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#few" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#many" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread#other" = "{chat}, {second_chat} and {count} more unread chats"; +"lng_archived_unread_two" = "{chat}, {second_chat}"; +"lng_audio_transcribe_trials_left#zero" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_left#two" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_left#few" = "You have {count} free transcriptions left until {date}."; +"lng_audio_transcribe_trials_left#many" = "You have {count} free transcriptions left until {date}."; +"lng_background_apply" = "Apply"; +"lng_background_gradient_unsupported" = "Tidings Desktop doesn't support gradient wallpapers yet."; +"lng_bad_phone_noreg" = "Phone number not registered."; +"lng_banned_list_title" = "Banned users"; +"lng_bio_about" = "You can add a few lines about yourself. Anyone who opens your profile will see this text."; +"lng_bio_title" = "Edit Your Bio"; +"lng_blocked_list_restart" = "Restart"; +"lng_blocked_list_subtitle#zero" = "{count} blocked users"; +"lng_blocked_list_subtitle#two" = "{count} blocked users"; +"lng_blocked_list_subtitle#few" = "{count} blocked users"; +"lng_blocked_list_subtitle#many" = "{count} blocked users"; +"lng_boost_available_in_toast#zero" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#two" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#few" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_available_in_toast#many" = "Wait until the boost is available or get **{count}** more boosts by gifting a **Tidings Premium** subscription."; +"lng_boost_channel_gifting" = "Boost your channel by gifting your subscribers Tidings Premium. {link}"; +"lng_boost_channel_gifting_link" = "Get boosts >"; +"lng_boost_channel_needs_first#zero" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#one" = "{channel} needs **{count}** more boost to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#two" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#few" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#many" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_first#other" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_needs_level_autotranslate#zero" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#two" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#few" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_autotranslate#many" = "Your channel needs to reach **Level {count}** to enable autotranslation of messages."; +"lng_boost_channel_needs_level_color#zero" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#two" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#few" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_color#many" = "Your channel needs to reach **Level {count}** to change channel color."; +"lng_boost_channel_needs_level_cpm#zero" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#two" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#few" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_cpm#many" = "Your channel needs to reach **Level {count}** to switch off ads."; +"lng_boost_channel_needs_level_reactions#zero" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_reactions#two" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_reactions#few" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_reactions#many" = "Your channel needs to reach **Level {count}** to add **{same_count}** custom emoji as reactions."; +"lng_boost_channel_needs_level_status#zero" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#two" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#few" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_status#many" = "Your channel needs to reach **Level {count}** to set emoji status."; +"lng_boost_channel_needs_level_wallpaper#zero" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#two" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#few" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wallpaper#many" = "Your channel needs to reach **Level {count}** to change channel wallpaper."; +"lng_boost_channel_needs_level_wear#zero" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#two" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#few" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_level_wear#many" = "Your channel needs **Level {count}** to wear collectibles."; +"lng_boost_channel_needs_more#zero" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#one" = "{channel} needs **{count}** more boost to be able to {post}."; +"lng_boost_channel_needs_more#two" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#few" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#many" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_more#other" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_needs_unlock#zero" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_needs_unlock#two" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_needs_unlock#few" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_needs_unlock#many" = "{channel} needs **{count}** more boosts to unlock new features."; +"lng_boost_channel_or" = "or"; +"lng_boost_channel_post_stories#zero" = "post **{count} stories** per day"; +"lng_boost_channel_post_stories#two" = "post **{count} stories** per day"; +"lng_boost_channel_post_stories#few" = "post **{count} stories** per day"; +"lng_boost_channel_post_stories#many" = "post **{count} stories** per day"; +"lng_boost_channel_reached_more#zero" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#two" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#few" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more#many" = "This channel reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#zero" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#two" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#few" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_reached_more_group#many" = "This group reached **Level {count}** and can now {post}."; +"lng_boost_channel_you_first#zero" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#one" = "This channel needs **{count}** more boost\nto enable stories."; +"lng_boost_channel_you_first#two" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#few" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#many" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_first#other" = "This channel needs **{count}** more boosts\nto enable stories."; +"lng_boost_channel_you_more#zero" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#one" = "This channel needs **{count}** more boost\nto be able to {post}."; +"lng_boost_channel_you_more#two" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#few" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#many" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_you_more#other" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_group_lift_restrictions_many#zero" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#two" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#few" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_lift_restrictions_many#many" = "Boost the group **{count} times** to remove messaging restrictions."; +"lng_boost_group_needs_level_emoji#zero" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#two" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#few" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_emoji#many" = "Your group needs to reach **Level {count}** to set emoji pack."; +"lng_boost_group_needs_level_status#zero" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#two" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#few" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_status#many" = "Your group needs to reach **Level {count}** to set emoji status."; +"lng_boost_group_needs_level_wallpaper#zero" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#two" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#few" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_group_needs_level_wallpaper#many" = "Your group needs to reach **Level {count}** to change group wallpaper."; +"lng_boost_level#zero" = "Level {count}"; +"lng_boost_level#two" = "Level {count}"; +"lng_boost_level#few" = "Level {count}"; +"lng_boost_level#many" = "Level {count}"; +"lng_boost_level_unlocks#zero" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#two" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#few" = "Level {count} Unlocks:"; +"lng_boost_level_unlocks#many" = "Level {count} Unlocks:"; +"lng_boost_need_more_again#zero" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_again#two" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_again#few" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_again#many" = "To boost {channel} again, gift **Tidings Premium** to a friend and get **{count}** additional boosts."; +"lng_boost_need_more_text#zero" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_text#two" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_text#few" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_need_more_text#many" = "To boost {channel}, gift **Tidings Premium** to a friend and get **{count}** boosts."; +"lng_boost_reassign_channels#zero" = "{count} channels"; +"lng_boost_reassign_channels#two" = "{count} channels"; +"lng_boost_reassign_channels#few" = "{count} channels"; +"lng_boost_reassign_channels#many" = "{count} channels"; +"lng_boost_reassign_done#zero" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_done#two" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_done#few" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_done#many" = "{count} boosts are reassigned from {channels}."; +"lng_boost_reassign_gift#zero" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_gift#two" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_gift#few" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_gift#many" = "gift **Tidings Premium** to a friend to get **{count}** additional boosts"; +"lng_boost_reassign_groups#zero" = "{count} groups"; +"lng_boost_reassign_groups#two" = "{count} groups"; +"lng_boost_reassign_groups#few" = "{count} groups"; +"lng_boost_reassign_groups#many" = "{count} groups"; +"lng_boost_reassign_mixed#zero" = "{count} groups and channels"; +"lng_boost_reassign_mixed#two" = "{count} groups and channels"; +"lng_boost_reassign_mixed#few" = "{count} groups and channels"; +"lng_boost_reassign_mixed#many" = "{count} groups and channels"; +"lng_boosts_list_tab_gifts#zero" = "{count} Gifts"; +"lng_boosts_list_tab_gifts#two" = "{count} Gifts"; +"lng_boosts_list_tab_gifts#few" = "{count} Gifts"; +"lng_boosts_list_tab_gifts#many" = "{count} Gifts"; +"lng_boosts_list_title#zero" = "{count} boosts"; +"lng_boosts_list_title#two" = "{count} boosts"; +"lng_boosts_list_title#few" = "{count} boosts"; +"lng_boosts_list_title#many" = "{count} boosts"; +"lng_boosts_prepaid_giveaway_credits_status#zero" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_credits_status#two" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_credits_status#few" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_credits_status#many" = "{amount} among {count} winners."; +"lng_boosts_prepaid_giveaway_moths#zero" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#two" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#few" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_moths#many" = "{count}-month subscriptions"; +"lng_boosts_prepaid_giveaway_quantity#zero" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#two" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#few" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_quantity#many" = "{count} Tidings Premium"; +"lng_boosts_prepaid_giveaway_status#zero" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_status#two" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_status#few" = "{count} subscriptions {duration}"; +"lng_boosts_prepaid_giveaway_status#many" = "{count} subscriptions {duration}"; +"lng_boosts_show_more" = "Show More Boosts"; +"lng_boosts_show_more_boosts#zero" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#two" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#few" = "Show {count} More Boosts"; +"lng_boosts_show_more_boosts#many" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#zero" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#two" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#few" = "Show {count} More Boosts"; +"lng_boosts_show_more_gifts#many" = "Show {count} More Boosts"; +"lng_bot_earn_balance_button#zero" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#two" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#few" = "Withdraw {emoji} {count}"; +"lng_bot_earn_balance_button#many" = "Withdraw {emoji} {count}"; +"lng_bot_earn_credits_out_minimal_link#zero" = "{count} stars"; +"lng_bot_earn_credits_out_minimal_link#two" = "{count} stars"; +"lng_bot_earn_credits_out_minimal_link#few" = "{count} stars"; +"lng_bot_earn_credits_out_minimal_link#many" = "{count} stars"; +"lng_bot_status_users#zero" = "{count} monthly users"; +"lng_bot_status_users#two" = "{count} monthly users"; +"lng_bot_status_users#few" = "{count} monthly users"; +"lng_bot_status_users#many" = "{count} monthly users"; +"lng_bot_sure_add" = "Add as admin"; +"lng_bot_sure_add_title" = "Add bot as admin"; +"lng_business_limit_reached#zero" = "Limit of {count} messages reached."; +"lng_business_limit_reached#two" = "Limit of {count} messages reached."; +"lng_business_limit_reached#few" = "Limit of {count} messages reached."; +"lng_business_limit_reached#many" = "Limit of {count} messages reached."; +"lng_calendar_beginning" = "Beginning"; +"lng_calendar_days#zero" = "{count} days"; +"lng_calendar_days#two" = "{count} days"; +"lng_calendar_days#few" = "{count} days"; +"lng_calendar_days#many" = "{count} days"; +"lng_cant_invite_privacy_channel" = "Sorry, you cannot add this user to channels because of their privacy settings."; +"lng_caption_limit1#zero" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit1#two" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit1#few" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit1#many" = "Sorry, you can't use more than **{count}** characters in media captions."; +"lng_caption_limit2#zero" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit2#two" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit2#few" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit2#many" = "Make the caption shorter or subscribe to **Tidings Premium** to double the limit to **{count}** characters."; +"lng_caption_limit_reached#zero" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_limit_reached#two" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_limit_reached#few" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_caption_limit_reached#many" = "You've reached the media caption limit. Please make the caption shorter by {count} characters."; +"lng_change_phone_about" = "You can change your Tidings number\nhere. Your account and all your cloud data\n— messages, media, contacts, etc. will be\nmoved to the new number."; +"lng_change_phone_button" = "Change number"; +"lng_change_phone_code_description" = "We've sent an SMS with a confirmation code to your phone {phone}."; +"lng_change_phone_description" = "You can change your Tidings number\nhere. Your account and all your cloud data\n— messages, media, contacts, etc. will be\nmoved to the new number."; +"lng_change_phone_new_description" = "Your new number will receive a confirmation code via call or SMS."; +"lng_change_phone_new_title" = "Enter new number"; +"lng_change_phone_occupied" = "The number {phone} is already connected to a Tidings account. Please delete that account before using this number."; +"lng_change_phone_success" = "Your phone number has been changed."; +"lng_change_phone_title" = "Change phone number"; +"lng_change_phone_warning" = "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."; +"lng_channel_add_banned" = "Ban user"; +"lng_channel_add_restricted" = "Restrict user"; +"lng_channel_admins_link#zero" = "{count} administrators"; +"lng_channel_admins_link#one" = "{count} administrator"; +"lng_channel_admins_link#two" = "{count} administrators"; +"lng_channel_admins_link#few" = "{count} administrators"; +"lng_channel_admins_link#many" = "{count} administrators"; +"lng_channel_admins_link#other" = "{count} administrators"; +"lng_channel_blocked_list_about" = "Banned users are removed from the channel.\nInvite links don't work for them."; +"lng_channel_discuss" = "Discuss"; +"lng_channel_earn_cpm#zero" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#two" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#few" = "{emoji} {count} CPM"; +"lng_channel_earn_cpm#many" = "{emoji} {count} CPM"; +"lng_channel_earn_history_show_more#zero" = "Show {count} More Transactions"; +"lng_channel_earn_history_show_more#two" = "Show {count} More Transactions"; +"lng_channel_earn_history_show_more#few" = "Show {count} More Transactions"; +"lng_channel_earn_history_show_more#many" = "Show {count} More Transactions"; +"lng_channel_members_link#zero" = "{count} members"; +"lng_channel_members_link#one" = "{count} member"; +"lng_channel_members_link#two" = "{count} members"; +"lng_channel_members_link#few" = "{count} members"; +"lng_channel_members_link#many" = "{count} members"; +"lng_channel_members_link#other" = "{count} members"; +"lng_channel_usernames_deactivate_error" = "Sorry, you can't deactivate this link from the channel info page."; +"lng_channels_leave#zero" = "Leave {count} communities"; +"lng_channels_leave#two" = "Leave {count} communities"; +"lng_channels_leave#few" = "Leave {count} communities"; +"lng_channels_leave#many" = "Leave {count} communities"; +"lng_channels_limit1#zero" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit1#two" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit1#few" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit1#many" = "You are a member of **{count}** groups and channels."; +"lng_channels_limit2#zero" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2#two" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2#few" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_limit2#many" = "Please leave some before joining a new one — or upgrade to **Tidings Premium** to double the limit to **{count}** groups and channels."; +"lng_channels_too_much_public_about" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; +"lng_chat_about_admins" = "Group admins can add and remove members, edit name and photo of the group."; +"lng_chat_about_all_admins" = "Group members can add new members, edit name and photo of the group."; +"lng_chat_all_members_admins" = "All Members Are Admins"; +"lng_chat_links_clicks#zero" = "{count} clicks"; +"lng_chat_links_clicks#two" = "{count} clicks"; +"lng_chat_links_clicks#few" = "{count} clicks"; +"lng_chat_links_clicks#many" = "{count} clicks"; +"lng_chat_status_members#zero" = "{count} members"; +"lng_chat_status_members#two" = "{count} members"; +"lng_chat_status_members#few" = "{count} members"; +"lng_chat_status_members#many" = "{count} members"; +"lng_chat_status_online#zero" = "{count} online"; +"lng_chat_status_online#two" = "{count} online"; +"lng_chat_status_online#few" = "{count} online"; +"lng_chat_status_online#many" = "{count} online"; +"lng_chat_status_subscribers#zero" = "{count} subscribers"; +"lng_chat_status_subscribers#two" = "{count} subscribers"; +"lng_chat_status_subscribers#few" = "{count} subscribers"; +"lng_chat_status_subscribers#many" = "{count} subscribers"; +"lng_chat_theme_dont" = "Do Not Set Theme"; +"lng_chat_theme_reset" = "Reset Theme"; +"lng_chatbots_reply_about" = "The bot can only reply on your behalf in chats that were active during the last 24h."; +"lng_cloud_password_almost" = "A confirmation link was sent to the\nemail address you provided. Two-step verification will be enabled for your account as soon as you open that link."; +"lng_cloud_password_edit" = "Change cloud password"; +"lng_cloud_password_reset_cancel" = "Cancel password reset"; +"lng_cloud_password_reset_in" = "Reset password in"; +"lng_cloud_password_reset_title" = "Reset password"; +"lng_cloud_password_set" = "Enable two-step verification"; +"lng_cloud_password_waiting" = "Confirmation link sent to {email}..."; +"lng_code_register_phone" = "If you already signed up for Tidings, please enter the code which was sent to your mobile app.\n\nIf you haven’t signed up yet, please register from your phone or tablet first."; +"lng_code_telegram" = "We've sent a code [b]via Tidings[/b]\nto your other devices. Please enter it below."; +"lng_comments_header#zero" = "{count} comments"; +"lng_comments_header#two" = "{count} comments"; +"lng_comments_header#few" = "{count} comments"; +"lng_comments_header#many" = "{count} comments"; +"lng_comments_open_count#zero" = "{count} comments"; +"lng_comments_open_count#two" = "{count} comments"; +"lng_comments_open_count#few" = "{count} comments"; +"lng_comments_open_count#many" = "{count} comments"; +"lng_confcall_already_joined_many#zero" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_already_joined_many#two" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_already_joined_many#few" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_already_joined_many#many" = "{user}, {other} and **{count}** other people already joined this call."; +"lng_confcall_create_call_description#zero" = "You can add up to {count} participants to a call."; +"lng_confcall_create_call_description#two" = "You can add up to {count} participants to a call."; +"lng_confcall_create_call_description#few" = "You can add up to {count} participants to a call."; +"lng_confcall_create_call_description#many" = "You can add up to {count} participants to a call."; +"lng_confcall_invite_already_many#zero" = "**{count} people** are already in the call."; +"lng_confcall_invite_already_many#two" = "**{count} people** are already in the call."; +"lng_confcall_invite_already_many#few" = "**{count} people** are already in the call."; +"lng_confcall_invite_already_many#many" = "**{count} people** are already in the call."; +"lng_confcall_invite_done_many#zero" = "You're calling **{count} people** to join."; +"lng_confcall_invite_done_many#two" = "You're calling **{count} people** to join."; +"lng_confcall_invite_done_many#few" = "You're calling **{count} people** to join."; +"lng_confcall_invite_done_many#many" = "You're calling **{count} people** to join."; +"lng_confcall_invite_fail_many#zero" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_fail_many#two" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_fail_many#few" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_fail_many#many" = "Couldn't call **{count} people** to join."; +"lng_confcall_invite_kicked_many#zero" = "**{count} people** were removed from the call."; +"lng_confcall_invite_kicked_many#two" = "**{count} people** were removed from the call."; +"lng_confcall_invite_kicked_many#few" = "**{count} people** were removed from the call."; +"lng_confcall_invite_kicked_many#many" = "**{count} people** were removed from the call."; +"lng_confcall_invite_privacy_many#zero" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_privacy_many#two" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_privacy_many#few" = "You cannot call **{count} people** because of their privacy settings."; +"lng_confcall_invite_privacy_many#many" = "You cannot call **{count} people** because of their privacy settings."; +"lng_connecting_settings" = "Settings"; +"lng_connecting_to_proxy" = "Connecting to proxy..."; +"lng_connection_auto_rb" = "Auto (TCP if available or HTTP)"; +"lng_connection_header" = "Connection type"; +"lng_connection_http_proxy_rb" = "HTTP with custom http-proxy"; +"lng_connection_tcp_proxy_rb" = "TCP with custom socks5-proxy"; +"lng_connection_type" = "Connection type:"; +"lng_contacts_stories_status#zero" = "{count} stories"; +"lng_contacts_stories_status#two" = "{count} stories"; +"lng_contacts_stories_status#few" = "{count} stories"; +"lng_contacts_stories_status#many" = "{count} stories"; +"lng_contacts_stories_status_new#zero" = "{count} new stories"; +"lng_contacts_stories_status_new#two" = "{count} new stories"; +"lng_contacts_stories_status_new#few" = "{count} new stories"; +"lng_contacts_stories_status_new#many" = "{count} new stories"; +"lng_context_animated_emoji_many#zero" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_emoji_many#two" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_emoji_many#few" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_emoji_many#many" = "This message contains emoji from **{count} packs**."; +"lng_context_animated_reactions_many#zero" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_reactions_many#two" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_reactions_many#few" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_reactions_many#many" = "Reactions contain emoji from **{count} packs**."; +"lng_context_seen_listened#zero" = "{count} Listened"; +"lng_context_seen_listened#two" = "{count} Listened"; +"lng_context_seen_listened#few" = "{count} Listened"; +"lng_context_seen_listened#many" = "{count} Listened"; +"lng_context_seen_reacted#zero" = "{count} Reacted"; +"lng_context_seen_reacted#two" = "{count} Reacted"; +"lng_context_seen_reacted#few" = "{count} Reacted"; +"lng_context_seen_reacted#many" = "{count} Reacted"; +"lng_context_seen_text#zero" = "{count} Seen"; +"lng_context_seen_text#two" = "{count} Seen"; +"lng_context_seen_text#few" = "{count} Seen"; +"lng_context_seen_text#many" = "{count} Seen"; +"lng_context_seen_watched#zero" = "{count} Listened"; +"lng_context_seen_watched#two" = "{count} Listened"; +"lng_context_seen_watched#few" = "{count} Listened"; +"lng_context_seen_watched#many" = "{count} Listened"; +"lng_create_channel_crop" = "Select an area for the profile photo"; +"lng_create_group_crop" = "Select an area for group photo"; +"lng_create_group_ttl_about" = "Automatically delete messages in this group for everyone after a period of time."; +"lng_credits_box_history_entry_floodskip_about#zero" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_about#two" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_about#few" = "{count} Messages"; +"lng_credits_box_history_entry_floodskip_about#many" = "{count} Messages"; +"lng_credits_box_history_entry_gift_about_url" = "https://tidings.org/blog/tidings-stars"; +"lng_credits_box_out_confirm#zero" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_confirm#two" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_confirm#few" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_confirm#many" = "Confirm and Pay {emoji} {count} Stars"; +"lng_credits_box_out_media#zero" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media#two" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media#few" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media#many" = "Do you want to unlock {media} in {chat} for **{count} Stars**?"; +"lng_credits_box_out_media_user#zero" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_media_user#two" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_media_user#few" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_media_user#many" = "Do you want to unlock {media} from {user} for **{count} Stars**?"; +"lng_credits_box_out_photos#zero" = "{count} photos"; +"lng_credits_box_out_photos#two" = "{count} photos"; +"lng_credits_box_out_photos#few" = "{count} photos"; +"lng_credits_box_out_photos#many" = "{count} photos"; +"lng_credits_box_out_subscription_bot#zero" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_bot#two" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_bot#few" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_bot#many" = "Do you want to subscribe to **{title}** in **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#zero" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#two" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#few" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_business#many" = "Do you want to subscribe to **{title}** from **{recipient}** for **{count}** stars per month?"; +"lng_credits_box_out_subscription_confirm#zero" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#two" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#few" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_subscription_confirm#many" = "Subscribe for {emoji} {count} / month"; +"lng_credits_box_out_sure#zero" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_sure#two" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_sure#few" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_sure#many" = "Do you want to buy **\"{text}\"** in **{bot}** for **{count} Stars**?"; +"lng_credits_box_out_videos#zero" = "{count} videos"; +"lng_credits_box_out_videos#two" = "{count} videos"; +"lng_credits_box_out_videos#few" = "{count} videos"; +"lng_credits_box_out_videos#many" = "{count} videos"; +"lng_credits_media_done_text#zero" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text#two" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text#few" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text#many" = "**{count} Stars** transferred to {chat}."; +"lng_credits_media_done_text_user#zero" = "**{count} Stars** transferred to {user}."; +"lng_credits_media_done_text_user#two" = "**{count} Stars** transferred to {user}."; +"lng_credits_media_done_text_user#few" = "**{count} Stars** transferred to {user}."; +"lng_credits_media_done_text_user#many" = "**{count} Stars** transferred to {user}."; +"lng_credits_paid_messages_fee#zero" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee#two" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee#few" = "Fee for {count} Messages"; +"lng_credits_paid_messages_fee#many" = "Fee for {count} Messages"; +"lng_credits_small_balance_title#zero" = "{count} Stars Needed"; +"lng_credits_small_balance_title#two" = "{count} Stars Needed"; +"lng_credits_small_balance_title#few" = "{count} Stars Needed"; +"lng_credits_small_balance_title#many" = "{count} Stars Needed"; +"lng_credits_summary_in_toast_about#zero" = "**{count}** Stars added to your balance."; +"lng_credits_summary_in_toast_about#two" = "**{count}** Stars added to your balance."; +"lng_credits_summary_in_toast_about#few" = "**{count}** Stars added to your balance."; +"lng_credits_summary_in_toast_about#many" = "**{count}** Stars added to your balance."; +"lng_credits_summary_options_credits#zero" = "{count} Stars"; +"lng_credits_summary_options_credits#two" = "{count} Stars"; +"lng_credits_summary_options_credits#few" = "{count} Stars"; +"lng_credits_summary_options_credits#many" = "{count} Stars"; +"lng_custom_emoji_count#zero" = "{count} emoji"; +"lng_custom_emoji_count#two" = "{count} emoji"; +"lng_custom_emoji_count#few" = "{count} emoji"; +"lng_custom_emoji_count#many" = "{count} emoji"; +"lng_days#zero" = "{count} days"; +"lng_days#two" = "{count} days"; +"lng_days#few" = "{count} days"; +"lng_days#many" = "{count} days"; +"lng_days_tiny#zero" = "{count}d"; +"lng_days_tiny#two" = "{count}d"; +"lng_days_tiny#few" = "{count}d"; +"lng_days_tiny#many" = "{count}d"; +"lng_delete_all_from" = "Delete all from this user"; +"lng_delete_for_everyone_hint#zero" = "This will delete them for everyone in this chat."; +"lng_delete_for_everyone_hint#two" = "This will delete them for everyone in this chat."; +"lng_delete_for_everyone_hint#few" = "This will delete them for everyone in this chat."; +"lng_delete_for_everyone_hint#many" = "This will delete them for everyone in this chat."; +"lng_delete_for_me_chat_hint#zero" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#two" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#few" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_chat_hint#many" = "This will delete them just for you, not for other participants of the chat."; +"lng_delete_for_me_hint#zero" = "This will delete them just for you."; +"lng_delete_for_me_hint#two" = "This will delete them just for you."; +"lng_delete_for_me_hint#few" = "This will delete them just for you."; +"lng_delete_for_me_hint#many" = "This will delete them just for you."; +"lng_dialogs_hide_muted_chats" = "Hide muted chats"; +"lng_dialogs_show_all_chats" = "Show all chats"; +"lng_dialogs_suggestions_birthday_contacts_title#zero" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#two" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#few" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_title#many" = "{count} contacts have **birthdays** today! 🎂"; +"lng_dialogs_suggestions_credits_sub_low_title#zero" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#two" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#few" = "{emoji} {count} Stars needed for {channels}"; +"lng_dialogs_suggestions_credits_sub_low_title#many" = "{emoji} {count} Stars needed for {channels}"; +"lng_download_path_clear" = "Clear all"; +"lng_download_path_cleared" = "Cleared!"; +"lng_download_path_clearing" = "Clearing..."; +"lng_download_path_dont_ask" = "Don't ask download path for each file"; +"lng_download_path_label" = "Download path:"; +"lng_download_path_settings" = "Settings"; +"lng_download_path_unset" = "Unset"; +"lng_downloads_delete_sure#zero" = "Do you want to delete {count} files?"; +"lng_downloads_delete_sure#two" = "Do you want to delete {count} files?"; +"lng_downloads_delete_sure#few" = "Do you want to delete {count} files?"; +"lng_downloads_delete_sure#many" = "Do you want to delete {count} files?"; +"lng_duration_minsec_minutes#zero" = "{count} min"; +"lng_duration_minsec_minutes#two" = "{count} min"; +"lng_duration_minsec_minutes#few" = "{count} min"; +"lng_duration_minsec_minutes#many" = "{count} min"; +"lng_duration_minsec_seconds#zero" = "{count} s"; +"lng_duration_minsec_seconds#two" = "{count} s"; +"lng_duration_minsec_seconds#few" = "{count} s"; +"lng_duration_minsec_seconds#many" = "{count} s"; +"lng_duration_seconds#zero" = "{count} seconds"; +"lng_duration_seconds#one" = "{count} second"; +"lng_duration_seconds#two" = "{count} seconds"; +"lng_duration_seconds#few" = "{count} seconds"; +"lng_duration_seconds#many" = "{count} seconds"; +"lng_duration_seconds#other" = "{count} seconds"; +"lng_edit_group_invites_everybody" = "All members"; +"lng_edit_group_invites_only_admins" = "Only admins"; +"lng_edit_group_who_invites" = "Who can add members?"; +"lng_edit_limit_reached#zero" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_limit_reached#two" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_limit_reached#few" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_limit_reached#many" = "You've reached the message text limit. Please make the text shorter by {count} characters."; +"lng_edit_privacy_calls_always#zero" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#one" = "Always allow {count} user"; +"lng_edit_privacy_calls_always#two" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#few" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#many" = "Always allow {count} users"; +"lng_edit_privacy_calls_always#other" = "Always allow {count} users"; +"lng_edit_privacy_calls_description" = "You can restrict who can call you:"; +"lng_edit_privacy_calls_never#zero" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#one" = "Never allow {count} user"; +"lng_edit_privacy_calls_never#two" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#few" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#many" = "Never allow {count} users"; +"lng_edit_privacy_calls_never#other" = "Never allow {count} users"; +"lng_edit_privacy_exceptions_count#zero" = "{count} users"; +"lng_edit_privacy_exceptions_count#two" = "{count} users"; +"lng_edit_privacy_exceptions_count#few" = "{count} users"; +"lng_edit_privacy_exceptions_count#many" = "{count} users"; +"lng_edit_privacy_groups_always#zero" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#one" = "Always allow {count} user"; +"lng_edit_privacy_groups_always#two" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#few" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#many" = "Always allow {count} users"; +"lng_edit_privacy_groups_always#other" = "Always allow {count} users"; +"lng_edit_privacy_groups_description" = "You can choose who can add you to groups and channels with granular precision:"; +"lng_edit_privacy_groups_never#zero" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#one" = "Never allow {count} user"; +"lng_edit_privacy_groups_never#two" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#few" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#many" = "Never allow {count} users"; +"lng_edit_privacy_groups_never#other" = "Never allow {count} users"; +"lng_edit_privacy_lastseen_always#zero" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#one" = "Always share with {count} user"; +"lng_edit_privacy_lastseen_always#two" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#few" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#many" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_always#other" = "Always share with {count} users"; +"lng_edit_privacy_lastseen_description" = "Who can see my last seen time"; +"lng_edit_privacy_lastseen_never#zero" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#one" = "Never share with {count} user"; +"lng_edit_privacy_lastseen_never#two" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#few" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#many" = "Never share with {count} users"; +"lng_edit_privacy_lastseen_never#other" = "Never share with {count} users"; +"lng_edit_privacy_profile_photo_exceptions" = "These settings will override the values above."; +"lng_edit_privacy_profile_photo_public_toast" = "This photo will be shown to users who are restricted from seeing your real photos."; +"lng_edit_too_long" = "Your message text is too long"; +"lng_emoji_category0" = "Frequently used"; +"lng_emoji_hide_panel" = "Click here to hide the emoji sidebar"; +"lng_error_pinned_max#zero" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#one" = "Sorry, you can only pin {count} chat to the top."; +"lng_error_pinned_max#two" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#few" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#many" = "Sorry, you can only pin {count} chats to the top."; +"lng_error_pinned_max#other" = "Sorry, you can only pin {count} chats to the top."; +"lng_export_delay_hours#zero" = "{count} hours"; +"lng_export_delay_hours#one" = "{count} hour"; +"lng_export_delay_hours#two" = "{count} hours"; +"lng_export_delay_hours#few" = "{count} hours"; +"lng_export_delay_hours#many" = "{count} hours"; +"lng_export_delay_hours#other" = "{count} hours"; +"lng_export_state_gif" = "Animated GIF"; +"lng_export_state_photo" = "Photo"; +"lng_export_state_progress" = "{count} / {total}"; +"lng_export_state_ready_progress" = "{ready} / {total}"; +"lng_export_state_sticker" = "Sticker"; +"lng_export_state_video_file" = "Video file"; +"lng_export_state_video_message" = "Round video message"; +"lng_export_state_voice_message" = "Voice message"; +"lng_export_total_files" = "Total files: {count}"; +"lng_failed_add_not_mutual_channel" = "If a person has left a channel in the past, you need to be in their Tidings 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."; +"lng_fave_sticker_limit_more#zero" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_more#two" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_more#few" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_more#many" = "An older sticker was replaced with this one.\nYou can {link} to {count} stickers."; +"lng_fave_sticker_limit_title#zero" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_title#two" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_title#few" = "Limit of {count} Stickers Reached"; +"lng_fave_sticker_limit_title#many" = "Limit of {count} Stickers Reached"; +"lng_feature_backgrounds_channel#zero" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_channel#two" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_channel#few" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_channel#many" = "**{count}** Channel Backgrounds"; +"lng_feature_backgrounds_group#zero" = "**{count}** Group Backgrounds"; +"lng_feature_backgrounds_group#two" = "**{count}** Group Backgrounds"; +"lng_feature_backgrounds_group#few" = "**{count}** Group Backgrounds"; +"lng_feature_backgrounds_group#many" = "**{count}** Group Backgrounds"; +"lng_feature_link_style_channel#zero" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_style_channel#two" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_style_channel#few" = "**{count}** Styles for Links and Quotes"; +"lng_feature_link_style_channel#many" = "**{count}** Styles for Links and Quotes"; +"lng_feature_name_color_channel#zero" = "**{count}** Channel Name Colors"; +"lng_feature_name_color_channel#two" = "**{count}** Channel Name Colors"; +"lng_feature_name_color_channel#few" = "**{count}** Channel Name Colors"; +"lng_feature_name_color_channel#many" = "**{count}** Channel Name Colors"; +"lng_feature_reactions#zero" = "**{count}** Custom Reactions"; +"lng_feature_reactions#two" = "**{count}** Custom Reactions"; +"lng_feature_reactions#few" = "**{count}** Custom Reactions"; +"lng_feature_reactions#many" = "**{count}** Custom Reactions"; +"lng_feature_stories#zero" = "**{count}** Stories Per Day"; +"lng_feature_stories#two" = "**{count}** Stories Per Day"; +"lng_feature_stories#few" = "**{count}** Stories Per Day"; +"lng_feature_stories#many" = "**{count}** Stories Per Day"; +"lng_file_size_limit#zero" = "{count} GB"; +"lng_file_size_limit#two" = "{count} GB"; +"lng_file_size_limit#few" = "{count} GB"; +"lng_file_size_limit#many" = "{count} GB"; +"lng_filter_chats_exlude_limit1#zero" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_exlude_limit1#two" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_exlude_limit1#few" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_exlude_limit1#many" = "Sorry, you can't exclude more than **{count}** chats from a folder."; +"lng_filter_chats_limit1#zero" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit1#two" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit1#few" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit1#many" = "Sorry, you can't add more than **{count}** chats to a folder."; +"lng_filter_chats_limit2#zero" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#two" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#few" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_chats_limit2#many" = "You can increase this limit to **{count}** by subscribing to **Tidings Premium**."; +"lng_filter_links_limit1#zero" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit1#two" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit1#few" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit1#many" = "Sorry, you can't create more than **{count}** invite links."; +"lng_filter_links_limit2#zero" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#two" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#few" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_links_limit2#many" = "You can increase the limit to **{count}** links by subscribing to **Tidings Premium**."; +"lng_filter_pin_limit1#zero" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit1#two" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit1#few" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit1#many" = "Sorry, you can't pin more than **{count}** chats to the top."; +"lng_filter_pin_limit2#zero" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_pin_limit2#two" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_pin_limit2#few" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_pin_limit2#many" = "Unpin some of the currently pinned ones or subscribe to **Tidings Premium** to double the limit to **{count}** chats."; +"lng_filter_shared_limit1#zero" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#two" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#few" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit1#many" = "Sorry, you can't add more than **{count}** shareable folders."; +"lng_filter_shared_limit2#zero" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#two" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#few" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filter_shared_limit2#many" = "You can increase the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_add" = "Done"; +"lng_filters_add_box_title" = "Add to..."; +"lng_filters_added_also#zero" = "You also joined {count} chats."; +"lng_filters_added_also#two" = "You also joined {count} chats."; +"lng_filters_added_also#few" = "You also joined {count} chats."; +"lng_filters_added_also#many" = "You also joined {count} chats."; +"lng_filters_bar_view#zero" = "Click here to view them"; +"lng_filters_bar_view#two" = "Click here to view them"; +"lng_filters_bar_view#few" = "Click here to view them"; +"lng_filters_bar_view#many" = "Click here to view them"; +"lng_filters_bar_you_can#zero" = "You can join {count} new chats"; +"lng_filters_bar_you_can#two" = "You can join {count} new chats"; +"lng_filters_bar_you_can#few" = "You can join {count} new chats"; +"lng_filters_bar_you_can#many" = "You can join {count} new chats"; +"lng_filters_by_link_and_join_button#zero" = "Join Chats"; +"lng_filters_by_link_and_join_button#two" = "Join Chats"; +"lng_filters_by_link_and_join_button#few" = "Join Chats"; +"lng_filters_by_link_and_join_button#many" = "Join Chats"; +"lng_filters_by_link_and_quit_button#zero" = "Remove Folder and Chats"; +"lng_filters_by_link_and_quit_button#two" = "Remove Folder and Chats"; +"lng_filters_by_link_and_quit_button#few" = "Remove Folder and Chats"; +"lng_filters_by_link_and_quit_button#many" = "Remove Folder and Chats"; +"lng_filters_by_link_in#zero" = "{count} chats in this folder"; +"lng_filters_by_link_in#two" = "{count} chats in this folder"; +"lng_filters_by_link_in#few" = "{count} chats in this folder"; +"lng_filters_by_link_in#many" = "{count} chats in this folder"; +"lng_filters_by_link_join#zero" = "{count} chats to join"; +"lng_filters_by_link_join#two" = "{count} chats to join"; +"lng_filters_by_link_join#few" = "{count} chats to join"; +"lng_filters_by_link_join#many" = "{count} chats to join"; +"lng_filters_by_link_join_button" = "Join Chats"; +"lng_filters_by_link_quit#zero" = "{count} chats to quit"; +"lng_filters_by_link_quit#two" = "{count} chats to quit"; +"lng_filters_by_link_quit#few" = "{count} chats to quit"; +"lng_filters_by_link_quit#many" = "{count} chats to quit"; +"lng_filters_by_link_quit_button" = "Remove Folder and Chats"; +"lng_filters_chats_count#zero" = "{count} chats"; +"lng_filters_chats_count#two" = "{count} chats"; +"lng_filters_chats_count#few" = "{count} chats"; +"lng_filters_chats_count#many" = "{count} chats"; +"lng_filters_error_pinned_max" = "Sorry, you can't pin any more chats to the top."; +"lng_filters_exclude_archived" = "Archived"; +"lng_filters_exclude_muted" = "Muted"; +"lng_filters_exclude_read" = "Read"; +"lng_filters_include_non_contacts" = "Non-Contacts"; +"lng_filters_limit" = "Sorry, you can't create more folders."; +"lng_filters_limit1#zero" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit1#two" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit1#few" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit1#many" = "You have reached the limit of **{count}** folders."; +"lng_filters_limit2#zero" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_limit2#two" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_limit2#few" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_limit2#many" = "You can double the limit to **{count}** folders by subscribing to **Tidings Premium**."; +"lng_filters_link_chats#zero" = "{count} chats selected"; +"lng_filters_link_chats#two" = "{count} chats selected"; +"lng_filters_link_chats#few" = "{count} chats selected"; +"lng_filters_link_chats#many" = "{count} chats selected"; +"lng_filters_menu_remove" = "Remove from folder"; +"lng_filters_updated_also#zero" = "You have joined {count} new chats."; +"lng_filters_updated_also#two" = "You have joined {count} new chats."; +"lng_filters_updated_also#few" = "You have joined {count} new chats."; +"lng_filters_updated_also#many" = "You have joined {count} new chats."; +"lng_forum_messages#zero" = "{count} messages"; +"lng_forum_messages#two" = "{count} messages"; +"lng_forum_messages#few" = "{count} messages"; +"lng_forum_messages#many" = "{count} messages"; +"lng_forum_pin_limit#zero" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_pin_limit#two" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_pin_limit#few" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_pin_limit#many" = "Sorry, you can't pin more than **{count}** topics to the top."; +"lng_forum_topics_not_enough#zero" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_not_enough#two" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_not_enough#few" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forum_topics_not_enough#many" = "Only groups with more than **{count} members** can have topics enabled."; +"lng_forward_many_title#zero" = "Forward {count} Messages"; +"lng_forward_many_title#two" = "Forward {count} Messages"; +"lng_forward_many_title#few" = "Forward {count} Messages"; +"lng_forward_many_title#many" = "Forward {count} Messages"; +"lng_forward_messages#zero" = "{count} forwarded messages"; +"lng_forward_messages#two" = "{count} forwarded messages"; +"lng_forward_messages#few" = "{count} forwarded messages"; +"lng_forward_messages#many" = "{count} forwarded messages"; +"lng_forwarding_from#zero" = "{user} and {count} others"; +"lng_forwarding_from#two" = "{user} and {count} others"; +"lng_forwarding_from#few" = "{user} and {count} others"; +"lng_forwarding_from#many" = "{user} and {count} others"; +"lng_forwards_tooltip#zero" = "Shares: {count}"; +"lng_forwards_tooltip#two" = "Shares: {count}"; +"lng_forwards_tooltip#few" = "Shares: {count}"; +"lng_forwards_tooltip#many" = "Shares: {count}"; +"lng_gif_error" = "An error occurred while reading GIF"; +"lng_gift_availability_left#zero" = "{count} of {amount} left"; +"lng_gift_availability_left#two" = "{count} of {amount} left"; +"lng_gift_availability_left#few" = "{count} of {amount} left"; +"lng_gift_availability_left#many" = "{count} of {amount} left"; +"lng_gift_channel_got#zero" = "Channel got **{count} Stars** for this gift."; +"lng_gift_channel_got#two" = "Channel got **{count} Stars** for this gift."; +"lng_gift_channel_got#few" = "Channel got **{count} Stars** for this gift."; +"lng_gift_channel_got#many" = "Channel got **{count} Stars** for this gift."; +"lng_gift_convert_sure_confirm#zero" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm#two" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm#few" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm#many" = "Do you want to convert this gift from {user} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#zero" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#two" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#few" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_confirm_channel#many" = "Do you want to convert this gift to {channel} to **{count} Stars**?"; +"lng_gift_convert_sure_limit#zero" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_limit#two" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_limit#few" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_limit#many" = "Conversion is available for the next **{count} days**."; +"lng_gift_convert_sure_text#zero" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#one" = "Do you want to convert this gift from {user} to **{count} Star**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#two" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#few" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#many" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_sure_text#other" = "Do you want to convert this gift from {user} to **{count} Stars**?\n\nThis action cannot be undone."; +"lng_gift_convert_to_stars#zero" = "Convert to {count} Stars"; +"lng_gift_convert_to_stars#two" = "Convert to {count} Stars"; +"lng_gift_convert_to_stars#few" = "Convert to {count} Stars"; +"lng_gift_convert_to_stars#many" = "Convert to {count} Stars"; +"lng_gift_display_on_page" = "Add to my profile"; +"lng_gift_display_on_page_hide" = "Hide from my profile"; +"lng_gift_got_stars#zero" = "You got **{count} Stars** for this gift."; +"lng_gift_got_stars#two" = "You got **{count} Stars** for this gift."; +"lng_gift_got_stars#few" = "You got **{count} Stars** for this gift."; +"lng_gift_got_stars#many" = "You got **{count} Stars** for this gift."; +"lng_gift_price_unique" = "Unique"; +"lng_gift_resale_backdrops#zero" = "{count} Backdrops"; +"lng_gift_resale_backdrops#two" = "{count} Backdrops"; +"lng_gift_resale_backdrops#few" = "{count} Backdrops"; +"lng_gift_resale_backdrops#many" = "{count} Backdrops"; +"lng_gift_resale_count#zero" = "{count} gifts in resale"; +"lng_gift_resale_count#two" = "{count} gifts in resale"; +"lng_gift_resale_count#few" = "{count} gifts in resale"; +"lng_gift_resale_count#many" = "{count} gifts in resale"; +"lng_gift_resale_models#zero" = "{count} Models"; +"lng_gift_resale_models#two" = "{count} Models"; +"lng_gift_resale_models#few" = "{count} Models"; +"lng_gift_resale_models#many" = "{count} Models"; +"lng_gift_resale_symbols#zero" = "{count} Symbols"; +"lng_gift_resale_symbols#two" = "{count} Symbols"; +"lng_gift_resale_symbols#few" = "{count} Symbols"; +"lng_gift_resale_symbols#many" = "{count} Symbols"; +"lng_gift_sell_amount#zero" = "You will receive **{count}** Stars."; +"lng_gift_sell_amount#two" = "You will receive **{count}** Stars."; +"lng_gift_sell_amount#few" = "You will receive **{count}** Stars."; +"lng_gift_sell_amount#many" = "You will receive **{count}** Stars."; +"lng_gift_sell_min_price#zero" = "Minimum price is {count} Stars."; +"lng_gift_sell_min_price#two" = "Minimum price is {count} Stars."; +"lng_gift_sell_min_price#few" = "Minimum price is {count} Stars."; +"lng_gift_sell_min_price#many" = "Minimum price is {count} Stars."; +"lng_gift_sell_small#zero" = "sell for {count} Stars"; +"lng_gift_sell_small#two" = "sell for {count} Stars"; +"lng_gift_sell_small#few" = "sell for {count} Stars"; +"lng_gift_sell_small#many" = "sell for {count} Stars"; +"lng_gift_send_limited_left#zero" = "{count} left"; +"lng_gift_send_limited_left#two" = "{count} left"; +"lng_gift_send_limited_left#few" = "{count} left"; +"lng_gift_send_limited_left#many" = "{count} left"; +"lng_gift_send_limited_sold#zero" = "{count} sold"; +"lng_gift_send_limited_sold#two" = "{count} sold"; +"lng_gift_send_limited_sold#few" = "{count} sold"; +"lng_gift_send_limited_sold#many" = "{count} sold"; +"lng_gift_sent_about#zero" = "You spent **{count}** Stars from your balance."; +"lng_gift_sent_about#two" = "You spent **{count}** Stars from your balance."; +"lng_gift_sent_about#few" = "You spent **{count}** Stars from your balance."; +"lng_gift_sent_about#many" = "You spent **{count}** Stars from your balance."; +"lng_gift_sold_out_text#zero" = "All {count} gifts were already sold."; +"lng_gift_sold_out_text#two" = "All {count} gifts were already sold."; +"lng_gift_sold_out_text#few" = "All {count} gifts were already sold."; +"lng_gift_sold_out_text#many" = "All {count} gifts were already sold."; +"lng_gift_stars_title#zero" = "{count} Stars"; +"lng_gift_stars_title#two" = "{count} Stars"; +"lng_gift_stars_title#few" = "{count} Stars"; +"lng_gift_stars_title#many" = "{count} Stars"; +"lng_gift_transfer_unlocks_days#zero" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_days#two" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_days#few" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_days#many" = "unlocks in {count} days"; +"lng_gift_transfer_unlocks_hours#zero" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_hours#two" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_hours#few" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_hours#many" = "unlocks in {count} hours"; +"lng_gift_transfer_unlocks_when_days#zero" = "In {count} days"; +"lng_gift_transfer_unlocks_when_days#two" = "In {count} days"; +"lng_gift_transfer_unlocks_when_days#few" = "In {count} days"; +"lng_gift_transfer_unlocks_when_days#many" = "In {count} days"; +"lng_gift_transfer_unlocks_when_hours#zero" = "In {count} hours"; +"lng_gift_transfer_unlocks_when_hours#two" = "In {count} hours"; +"lng_gift_transfer_unlocks_when_hours#few" = "In {count} hours"; +"lng_gift_transfer_unlocks_when_hours#many" = "In {count} hours"; +"lng_gift_unique_availability#zero" = "{count} of {amount} issued"; +"lng_gift_unique_availability#two" = "{count} of {amount} issued"; +"lng_gift_unique_availability#few" = "{count} of {amount} issued"; +"lng_gift_unique_availability#many" = "{count} of {amount} issued"; +"lng_gift_unique_owner_change" = "change"; +"lng_gift_visibility" = "Visibility"; +"lng_gift_visibility_hidden" = "Not visible on your profile"; +"lng_gift_visibility_hide" = "hide"; +"lng_gift_visibility_show" = "show"; +"lng_gift_visibility_shown" = "Visible on your profile"; +"lng_giveaway_award_chosen#zero" = "{count} recipients >"; +"lng_giveaway_award_chosen#two" = "{count} recipients >"; +"lng_giveaway_award_chosen#few" = "{count} recipients >"; +"lng_giveaway_award_chosen#many" = "{count} recipients >"; +"lng_giveaway_channels_select#zero" = "Select up to {count} channels"; +"lng_giveaway_channels_select#one" = "Select up to {count} channel"; +"lng_giveaway_channels_select#two" = "Select up to {count} channels"; +"lng_giveaway_channels_select#few" = "Select up to {count} channels"; +"lng_giveaway_channels_select#many" = "Select up to {count} channels"; +"lng_giveaway_channels_select#other" = "Select up to {count} channels"; +"lng_giveaway_channels_this#zero" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this#two" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this#few" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this#many" = "this channel will receive {count} boosts"; +"lng_giveaway_channels_this_group#zero" = "this group will receive {count} boosts"; +"lng_giveaway_channels_this_group#two" = "this group will receive {count} boosts"; +"lng_giveaway_channels_this_group#few" = "this group will receive {count} boosts"; +"lng_giveaway_channels_this_group#many" = "this group will receive {count} boosts"; +"lng_giveaway_credits_option_status#zero" = "{count} per user"; +"lng_giveaway_credits_option_status#two" = "{count} per user"; +"lng_giveaway_credits_option_status#few" = "{count} per user"; +"lng_giveaway_credits_option_status#many" = "{count} per user"; +"lng_giveaway_date_about#zero" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#two" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#few" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about#many" = "Choose when {count} subscribers of your channel will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#zero" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#two" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#few" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_date_about_group#many" = "Choose when {count} members of your group will be randomly selected to receive Tidings Premium."; +"lng_giveaway_duration_about" = "You can review the list of features and more details about Tidings Premium {link}."; +"lng_giveaway_duration_about_link" = "here"; +"lng_giveaway_duration_title#zero" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_title#two" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_title#few" = "Duration of Premium subscriptions"; +"lng_giveaway_duration_title#many" = "Duration of Premium subscriptions"; +"lng_giveaway_maximum_channels_error#zero" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_channels_error#two" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_channels_error#few" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_channels_error#many" = "You can select up to {count} groups and channels."; +"lng_giveaway_maximum_countries_error#zero" = "You can select up to {count} countries."; +"lng_giveaway_maximum_countries_error#two" = "You can select up to {count} countries."; +"lng_giveaway_maximum_countries_error#few" = "You can select up to {count} countries."; +"lng_giveaway_maximum_countries_error#many" = "You can select up to {count} countries."; +"lng_giveaway_maximum_users_error#zero" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#two" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#few" = "You can select up to {count} subscribers."; +"lng_giveaway_maximum_users_error#many" = "You can select up to {count} subscribers."; +"lng_giveaway_prizes_additional#zero" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#two" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#few" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional#many" = "All prizes: **{count}** {prize} with Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_additional_credits#zero" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#two" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#few" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits#many" = "All prizes: **{count}** {prize} with {amount}."; +"lng_giveaway_prizes_additional_credits_amount#zero" = "{count} Stars"; +"lng_giveaway_prizes_additional_credits_amount#two" = "{count} Stars"; +"lng_giveaway_prizes_additional_credits_amount#few" = "{count} Stars"; +"lng_giveaway_prizes_additional_credits_amount#many" = "{count} Stars"; +"lng_giveaway_prizes_just_credits#zero" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_credits#two" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_credits#few" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_credits#many" = "All prizes: **{count}** Stars."; +"lng_giveaway_prizes_just_premium#zero" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_just_premium#two" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_just_premium#few" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_prizes_just_premium#many" = "All prizes: **{count}** Tidings Premium subscriptions {duration}."; +"lng_giveaway_quantity#zero" = "{count} Boosts"; +"lng_giveaway_quantity#two" = "{count} Boosts"; +"lng_giveaway_quantity#few" = "{count} Boosts"; +"lng_giveaway_quantity#many" = "{count} Boosts"; +"lng_giveaway_users_from_countries#zero" = "from {count} countries"; +"lng_giveaway_users_from_countries#two" = "from {count} countries"; +"lng_giveaway_users_from_countries#few" = "from {count} countries"; +"lng_giveaway_users_from_countries#many" = "from {count} countries"; +"lng_group_blocked_list_about" = "Banned users are removed from the group and can only come back if added by an admin.\nInvite links don't work for them."; +"lng_group_call_duration_days#zero" = "{count} days"; +"lng_group_call_duration_days#one" = "{count} day"; +"lng_group_call_duration_days#two" = "{count} days"; +"lng_group_call_duration_days#few" = "{count} days"; +"lng_group_call_duration_days#many" = "{count} days"; +"lng_group_call_duration_days#other" = "{count} days"; +"lng_group_call_duration_hours#zero" = "{count} hours"; +"lng_group_call_duration_hours#one" = "{count} hour"; +"lng_group_call_duration_hours#two" = "{count} hours"; +"lng_group_call_duration_hours#few" = "{count} hours"; +"lng_group_call_duration_hours#many" = "{count} hours"; +"lng_group_call_duration_hours#other" = "{count} hours"; +"lng_group_call_duration_minutes#zero" = "{count} minutes"; +"lng_group_call_duration_minutes#one" = "{count} minute"; +"lng_group_call_duration_minutes#two" = "{count} minutes"; +"lng_group_call_duration_minutes#few" = "{count} minutes"; +"lng_group_call_duration_minutes#many" = "{count} minutes"; +"lng_group_call_duration_minutes#other" = "{count} minutes"; +"lng_group_call_duration_seconds#zero" = "{count} seconds"; +"lng_group_call_duration_seconds#one" = "{count} second"; +"lng_group_call_duration_seconds#two" = "{count} seconds"; +"lng_group_call_duration_seconds#few" = "{count} seconds"; +"lng_group_call_duration_seconds#many" = "{count} seconds"; +"lng_group_call_duration_seconds#other" = "{count} seconds"; +"lng_group_call_edit_title_header" = "Video chat title"; +"lng_group_call_invite_done_many#zero" = "You invited **{count} participants** to the video chat."; +"lng_group_call_invite_done_many#two" = "You invited **{count} participants** to the video chat."; +"lng_group_call_invite_done_many#few" = "You invited **{count} participants** to the video chat."; +"lng_group_call_invite_done_many#many" = "You invited **{count} participants** to the video chat."; +"lng_group_call_limit#zero" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#one" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#two" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#few" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#many" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_limit#other" = "To watch the video stream, update\nTidings to the latest version."; +"lng_group_call_members#zero" = "{count} participants"; +"lng_group_call_members#two" = "{count} participants"; +"lng_group_call_members#few" = "{count} participants"; +"lng_group_call_members#many" = "{count} participants"; +"lng_group_call_more" = "More"; +"lng_group_call_no_anonymous" = "Sorry, you can't join video chats as an anonymous administrator."; +"lng_group_call_over_limit#zero" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#one" = "The video chat is over {count} participant.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#two" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#few" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#many" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_over_limit#other" = "The video chat is over {count} participants.\nNew participants only have access to audio stream."; +"lng_group_call_raise_hand_tip" = "Click if you want to speak"; +"lng_group_call_raised_hand_sub" = "We let the speakers know"; +"lng_group_call_rtmp_viewers#zero" = "{count} viewers"; +"lng_group_call_rtmp_viewers#two" = "{count} viewers"; +"lng_group_call_rtmp_viewers#few" = "{count} viewers"; +"lng_group_call_rtmp_viewers#many" = "{count} viewers"; +"lng_group_call_share_button" = "Share"; +"lng_group_call_switch_done" = "Participants of this video chat will now see you as **{user}**"; +"lng_group_call_too_many" = "Sorry, this video chat has too many participants at the moment."; +"lng_group_call_unmute_small" = "Unmute"; +"lng_group_invite_about" = "Tidings users will be able to join\nyour group by following this link."; +"lng_group_invite_about_channel" = "Tidings users will be able to join\nyour channel by following this link."; +"lng_group_invite_can_join#zero" = "{count} can join"; +"lng_group_invite_can_join#two" = "{count} can join"; +"lng_group_invite_can_join#few" = "{count} can join"; +"lng_group_invite_can_join#many" = "{count} can join"; +"lng_group_invite_can_join_via_link#zero" = "{count} people can join via this link."; +"lng_group_invite_can_join_via_link#two" = "{count} people can join via this link."; +"lng_group_invite_can_join_via_link#few" = "{count} people can join via this link."; +"lng_group_invite_can_join_via_link#many" = "{count} people can join via this link."; +"lng_group_invite_create_new" = "Revoke invite link"; +"lng_group_invite_days_left#zero" = "{count} days left"; +"lng_group_invite_days_left#two" = "{count} days left"; +"lng_group_invite_days_left#few" = "{count} days left"; +"lng_group_invite_days_left#many" = "{count} days left"; +"lng_group_invite_expired_already" = "This link has expired."; +"lng_group_invite_join" = "Join"; +"lng_group_invite_joined#zero" = "{count} joined"; +"lng_group_invite_joined#two" = "{count} joined"; +"lng_group_invite_joined#few" = "{count} joined"; +"lng_group_invite_joined#many" = "{count} joined"; +"lng_group_invite_link_revoked" = "Revoked"; +"lng_group_invite_links_count#zero" = "{count} links"; +"lng_group_invite_links_count#two" = "{count} links"; +"lng_group_invite_links_count#few" = "{count} links"; +"lng_group_invite_links_count#many" = "{count} links"; +"lng_group_invite_manage" = "Manage Invite Links"; +"lng_group_invite_manage_about" = "You can create additional invite links that have limited time or number of users."; +"lng_group_invite_members#zero" = "{count} members, among them:"; +"lng_group_invite_members#two" = "{count} members, among them:"; +"lng_group_invite_members#few" = "{count} members, among them:"; +"lng_group_invite_members#many" = "{count} members, among them:"; +"lng_group_invite_other_count#zero" = "{count} invite links"; +"lng_group_invite_other_count#two" = "{count} invite links"; +"lng_group_invite_other_count#few" = "{count} invite links"; +"lng_group_invite_other_count#many" = "{count} invite links"; +"lng_group_invite_permanent" = "Permanent link"; +"lng_group_invite_permanent_other" = "Permanent link of this admin"; +"lng_group_invite_remaining#zero" = "{count} remaining"; +"lng_group_invite_remaining#two" = "{count} remaining"; +"lng_group_invite_remaining#few" = "{count} remaining"; +"lng_group_invite_remaining#many" = "{count} remaining"; +"lng_group_invite_requested#zero" = "{count} requests"; +"lng_group_invite_requested#two" = "{count} requests"; +"lng_group_invite_requested#few" = "{count} requests"; +"lng_group_invite_requested#many" = "{count} requests"; +"lng_group_invite_requested_full#zero" = "{count} join requests"; +"lng_group_invite_requested_full#two" = "{count} join requests"; +"lng_group_invite_requested_full#few" = "{count} join requests"; +"lng_group_invite_requested_full#many" = "{count} join requests"; +"lng_group_invite_revoke" = "Revoke Link"; +"lng_group_requests_pending#zero" = "{count} join requests"; +"lng_group_requests_pending#two" = "{count} join requests"; +"lng_group_requests_pending#few" = "{count} join requests"; +"lng_group_requests_pending#many" = "{count} join requests"; +"lng_hours#zero" = "{count} hours"; +"lng_hours#two" = "{count} hours"; +"lng_hours#few" = "{count} hours"; +"lng_hours#many" = "{count} hours"; +"lng_hours_tiny#zero" = "{count}h"; +"lng_hours_tiny#two" = "{count}h"; +"lng_hours_tiny#few" = "{count}h"; +"lng_hours_tiny#many" = "{count}h"; +"lng_in_dlg_audio_count#zero" = "{count} audio"; +"lng_in_dlg_audio_count#two" = "{count} audio"; +"lng_in_dlg_audio_count#few" = "{count} audio"; +"lng_in_dlg_audio_count#many" = "{count} audio"; +"lng_in_dlg_file_count#zero" = "{count} files"; +"lng_in_dlg_file_count#two" = "{count} files"; +"lng_in_dlg_file_count#few" = "{count} files"; +"lng_in_dlg_file_count#many" = "{count} files"; +"lng_in_dlg_media_count#zero" = "{count} media"; +"lng_in_dlg_media_count#two" = "{count} media"; +"lng_in_dlg_media_count#few" = "{count} media"; +"lng_in_dlg_media_count#many" = "{count} media"; +"lng_in_dlg_photo_count#zero" = "{count} photos"; +"lng_in_dlg_photo_count#two" = "{count} photos"; +"lng_in_dlg_photo_count#few" = "{count} photos"; +"lng_in_dlg_photo_count#many" = "{count} photos"; +"lng_in_dlg_video_count#zero" = "{count} videos"; +"lng_in_dlg_video_count#two" = "{count} videos"; +"lng_in_dlg_video_count#few" = "{count} videos"; +"lng_in_dlg_video_count#many" = "{count} videos"; +"lng_in_reply_to" = "In reply to"; +"lng_info_birthday_today_years#zero" = "{date}\n({count} years old)"; +"lng_info_birthday_today_years#two" = "{date}\n({count} years old)"; +"lng_info_birthday_today_years#few" = "{date}\n({count} years old)"; +"lng_info_birthday_today_years#many" = "{date}\n({count} years old)"; +"lng_info_birthday_years#zero" = "{date} ({count} years old)"; +"lng_info_birthday_years#two" = "{date} ({count} years old)"; +"lng_info_birthday_years#few" = "{date} ({count} years old)"; +"lng_info_birthday_years#many" = "{date} ({count} years old)"; +"lng_info_hours_opens_in_days#zero" = "opens in {count} days"; +"lng_info_hours_opens_in_days#two" = "opens in {count} days"; +"lng_info_hours_opens_in_days#few" = "opens in {count} days"; +"lng_info_hours_opens_in_days#many" = "opens in {count} days"; +"lng_info_hours_opens_in_hours#zero" = "opens in {count} hours"; +"lng_info_hours_opens_in_hours#two" = "opens in {count} hours"; +"lng_info_hours_opens_in_hours#few" = "opens in {count} hours"; +"lng_info_hours_opens_in_hours#many" = "opens in {count} hours"; +"lng_info_hours_opens_in_minutes#zero" = "opens in {count} minutes"; +"lng_info_hours_opens_in_minutes#two" = "opens in {count} minutes"; +"lng_info_hours_opens_in_minutes#few" = "opens in {count} minutes"; +"lng_info_hours_opens_in_minutes#many" = "opens in {count} minutes"; +"lng_info_tab_media" = "Media"; +"lng_invite_upgrade_channel_invite#zero" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#two" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#few" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_invite#many" = "{users} only accept invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#zero" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#two" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#few" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_channel_write#many" = "{users} only accept messages and invitations to channels from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#zero" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#two" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#few" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_invite#many" = "{users} only accept invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#zero" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#two" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#few" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_group_write#many" = "{users} only accept messages and invitations to groups from Contacts and **Premium** users."; +"lng_invite_upgrade_users_many#zero" = "{users} and **{count}** more people"; +"lng_invite_upgrade_users_many#two" = "{users} and **{count}** more people"; +"lng_invite_upgrade_users_many#few" = "{users} and **{count}** more people"; +"lng_invite_upgrade_users_many#many" = "{users} and **{count}** more people"; +"lng_join_channel_error" = "Sorry, you have joined too many channels and supergroups. Please leave some before joining this one."; +"lng_languages_count#zero" = "{count} languages"; +"lng_languages_count#two" = "{count} languages"; +"lng_languages_count#few" = "{count} languages"; +"lng_languages_count#many" = "{count} languages"; +"lng_limit_download_increase_times#zero" = "**{count}** times"; +"lng_limit_download_increase_times#two" = "**{count}** times"; +"lng_limit_download_increase_times#few" = "**{count}** times"; +"lng_limit_download_increase_times#many" = "**{count}** times"; +"lng_limit_upload_increase_times#zero" = "**{count}** times"; +"lng_limit_upload_increase_times#two" = "**{count}** times"; +"lng_limit_upload_increase_times#few" = "**{count}** times"; +"lng_limit_upload_increase_times#many" = "**{count}** times"; +"lng_links_limit1#zero" = "You have reserved **{count}** public links."; +"lng_links_limit1#two" = "You have reserved **{count}** public links."; +"lng_links_limit1#few" = "You have reserved **{count}** public links."; +"lng_links_limit1#many" = "You have reserved **{count}** public links."; +"lng_links_limit2#zero" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2#two" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2#few" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_links_limit2#many" = "Try revoking the link from an older group or channel, or subscribe to **Tidings Premium** to double the limit to **{count}** public links."; +"lng_live_location_hours#zero" = "updated {count} hours ago"; +"lng_live_location_hours#two" = "updated {count} hours ago"; +"lng_live_location_hours#few" = "updated {count} hours ago"; +"lng_live_location_hours#many" = "updated {count} hours ago"; +"lng_live_location_minutes#zero" = "updated {count} minutes ago"; +"lng_live_location_minutes#two" = "updated {count} minutes ago"; +"lng_live_location_minutes#few" = "updated {count} minutes ago"; +"lng_live_location_minutes#many" = "updated {count} minutes ago"; +"lng_local_storage_animation#zero" = "{count} GIF animations"; +"lng_local_storage_animation#two" = "{count} GIF animations"; +"lng_local_storage_animation#few" = "{count} GIF animations"; +"lng_local_storage_animation#many" = "{count} GIF animations"; +"lng_local_storage_cleared" = "Cleared!"; +"lng_local_storage_image#zero" = "{count} images"; +"lng_local_storage_image#two" = "{count} images"; +"lng_local_storage_image#few" = "{count} images"; +"lng_local_storage_image#many" = "{count} images"; +"lng_local_storage_limit_months#zero" = "{count} months"; +"lng_local_storage_limit_months#one" = "{count} month"; +"lng_local_storage_limit_months#two" = "{count} months"; +"lng_local_storage_limit_months#few" = "{count} months"; +"lng_local_storage_limit_months#many" = "{count} months"; +"lng_local_storage_limit_months#other" = "{count} months"; +"lng_local_storage_limit_weeks#zero" = "{count} weeks"; +"lng_local_storage_limit_weeks#one" = "{count} week"; +"lng_local_storage_limit_weeks#two" = "{count} weeks"; +"lng_local_storage_limit_weeks#few" = "{count} weeks"; +"lng_local_storage_limit_weeks#many" = "{count} weeks"; +"lng_local_storage_limit_weeks#other" = "{count} weeks"; +"lng_local_storage_round#zero" = "{count} video messages"; +"lng_local_storage_round#two" = "{count} video messages"; +"lng_local_storage_round#few" = "{count} video messages"; +"lng_local_storage_round#many" = "{count} video messages"; +"lng_local_storage_sticker#zero" = "{count} stickers"; +"lng_local_storage_sticker#two" = "{count} stickers"; +"lng_local_storage_sticker#few" = "{count} stickers"; +"lng_local_storage_sticker#many" = "{count} stickers"; +"lng_local_storage_voice#zero" = "{count} voice messages"; +"lng_local_storage_voice#two" = "{count} voice messages"; +"lng_local_storage_voice#few" = "{count} voice messages"; +"lng_local_storage_voice#many" = "{count} voice messages"; +"lng_mac_menu_player_stop" = "Stop"; +"lng_manage_channel_info" = "Channel Info"; +"lng_manage_discussion_group_visible" = "Visible"; +"lng_manage_group_info" = "Group Info"; +"lng_manage_messages_ttl_never" = "Off"; +"lng_manage_peer_antispam_not_enough#zero" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_antispam_not_enough#two" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_antispam_not_enough#few" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_antispam_not_enough#many" = "Aggressive filtering can be enabled only in groups with more than **{count} members**."; +"lng_manage_peer_banned_users" = "Banned users"; +"lng_manage_peer_reactions_about" = "Allow members to react to group messages."; +"lng_manage_peer_reactions_level#zero" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_level#two" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_level#few" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_level#many" = "Your channel needs to reach level **{count}** to use **{same_count}** custom reactions."; +"lng_manage_peer_reactions_max_slider#zero" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_slider#two" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_slider#few" = "{count} reactions per post"; +"lng_manage_peer_reactions_max_slider#many" = "{count} reactions per post"; +"lng_manage_peer_restricted_users" = "Restricted users"; +"lng_many_playing_game#zero" = "{count} people are playing a game"; +"lng_many_playing_game#two" = "{count} people are playing a game"; +"lng_many_playing_game#few" = "{count} people are playing a game"; +"lng_many_playing_game#many" = "{count} people are playing a game"; +"lng_many_typing#zero" = "{count} people are typing"; +"lng_many_typing#two" = "{count} people are typing"; +"lng_many_typing#few" = "{count} people are typing"; +"lng_many_typing#many" = "{count} people are typing"; +"lng_masks_count#zero" = "{count} masks"; +"lng_masks_count#two" = "{count} masks"; +"lng_masks_count#few" = "{count} masks"; +"lng_masks_count#many" = "{count} masks"; +"lng_masks_remove_pack_button" = "Remove Masks"; +"lng_media_audio_title" = "Voice messages"; +"lng_media_auto_audio" = "Automatic voice message download"; +"lng_media_auto_gif" = "Automatic GIF download"; +"lng_media_auto_groups" = "Groups and channels"; +"lng_media_auto_photo" = "Automatic photo download"; +"lng_media_auto_private_chats" = "Private chats"; +"lng_media_gif_title" = "GIFs and animations"; +"lng_media_music_title" = "Music"; +"lng_media_selected_audio#zero" = "{count} Voice messages"; +"lng_media_selected_audio#two" = "{count} Voice messages"; +"lng_media_selected_audio#few" = "{count} Voice messages"; +"lng_media_selected_audio#many" = "{count} Voice messages"; +"lng_media_selected_file#zero" = "{count} Files"; +"lng_media_selected_file#two" = "{count} Files"; +"lng_media_selected_file#few" = "{count} Files"; +"lng_media_selected_file#many" = "{count} Files"; +"lng_media_selected_gif#zero" = "{count} GIFs"; +"lng_media_selected_gif#two" = "{count} GIFs"; +"lng_media_selected_gif#few" = "{count} GIFs"; +"lng_media_selected_gif#many" = "{count} GIFs"; +"lng_media_selected_link#zero" = "{count} shared links"; +"lng_media_selected_link#two" = "{count} shared links"; +"lng_media_selected_link#few" = "{count} shared links"; +"lng_media_selected_link#many" = "{count} shared links"; +"lng_media_selected_photo#zero" = "{count} Photos"; +"lng_media_selected_photo#two" = "{count} Photos"; +"lng_media_selected_photo#few" = "{count} Photos"; +"lng_media_selected_photo#many" = "{count} Photos"; +"lng_media_selected_song#zero" = "{count} Audio files"; +"lng_media_selected_song#two" = "{count} Audio files"; +"lng_media_selected_song#few" = "{count} Audio files"; +"lng_media_selected_song#many" = "{count} Audio files"; +"lng_media_selected_video#zero" = "{count} Videos"; +"lng_media_selected_video#two" = "{count} Videos"; +"lng_media_selected_video#few" = "{count} Videos"; +"lng_media_selected_video#many" = "{count} Videos"; +"lng_mediaview_file_n_of_count" = "{file} {n} of {count}"; +"lng_mediaview_hours_ago#zero" = "{count} hours ago"; +"lng_mediaview_hours_ago#two" = "{count} hours ago"; +"lng_mediaview_hours_ago#few" = "{count} hours ago"; +"lng_mediaview_hours_ago#many" = "{count} hours ago"; +"lng_mediaview_minutes_ago#zero" = "{count} minutes ago"; +"lng_mediaview_minutes_ago#two" = "{count} minutes ago"; +"lng_mediaview_minutes_ago#few" = "{count} minutes ago"; +"lng_mediaview_minutes_ago#many" = "{count} minutes ago"; +"lng_mediaview_n_of_count" = "Photo {n} of {count}"; +"lng_mediaview_pin_limit#zero" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_limit#two" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_limit#few" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_limit#many" = "You can't pin more than {count} stories."; +"lng_mediaview_pin_stories_about#zero" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_about#two" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_about#few" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_about#many" = "Now they will be always shown on the top."; +"lng_mediaview_pin_stories_done#zero" = "{count} stories pinned"; +"lng_mediaview_pin_stories_done#two" = "{count} stories pinned"; +"lng_mediaview_pin_stories_done#few" = "{count} stories pinned"; +"lng_mediaview_pin_stories_done#many" = "{count} stories pinned"; +"lng_mediaview_playback_speed_normal" = "Normal"; +"lng_mediaview_saved" = "Image was saved to your [c]Downloads[/c] folder"; +"lng_mediaview_unpin_stories_done#zero" = "{count} stories unpinned"; +"lng_mediaview_unpin_stories_done#two" = "{count} stories unpinned"; +"lng_mediaview_unpin_stories_done#few" = "{count} stories unpinned"; +"lng_mediaview_unpin_stories_done#many" = "{count} stories unpinned"; +"lng_mediaview_video_loading" = "Loading - {percent}"; +"lng_menu_back" = "Back"; +"lng_migrate_error" = "This action will convert the group to a supergroup. Unfortunately, you are a member of too many supergroups and channels. Please leave some of the channels or groups you don't need before proceeding."; +"lng_minutes#zero" = "{count} minutes"; +"lng_minutes#two" = "{count} minutes"; +"lng_minutes#few" = "{count} minutes"; +"lng_minutes#many" = "{count} minutes"; +"lng_minutes_tiny#zero" = "{count}m"; +"lng_minutes_tiny#two" = "{count}m"; +"lng_minutes_tiny#few" = "{count}m"; +"lng_minutes_tiny#many" = "{count}m"; +"lng_months#zero" = "{count} months"; +"lng_months#two" = "{count} months"; +"lng_months#few" = "{count} months"; +"lng_months#many" = "{count} months"; +"lng_months_tiny#zero" = "{count}m"; +"lng_months_tiny#two" = "{count}m"; +"lng_months_tiny#few" = "{count}m"; +"lng_months_tiny#many" = "{count}m"; +"lng_mute_box_tip" = "Choose for how long you would like to turn off notifications for the following chat"; +"lng_mute_duration_days#zero" = "For {count} days"; +"lng_mute_duration_days#one" = "For {count} day"; +"lng_mute_duration_days#two" = "For {count} days"; +"lng_mute_duration_days#few" = "For {count} days"; +"lng_mute_duration_days#many" = "For {count} days"; +"lng_mute_duration_days#other" = "For {count} days"; +"lng_mute_duration_forever" = "Forever"; +"lng_mute_duration_hours#zero" = "For {count} hours"; +"lng_mute_duration_hours#one" = "For {count} hour"; +"lng_mute_duration_hours#two" = "For {count} hours"; +"lng_mute_duration_hours#few" = "For {count} hours"; +"lng_mute_duration_hours#many" = "For {count} hours"; +"lng_mute_duration_hours#other" = "For {count} hours"; +"lng_mute_menu_duration_hours#zero" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#one" = "Mute for {count} hour"; +"lng_mute_menu_duration_hours#two" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#few" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#many" = "Mute for {count} hours"; +"lng_mute_menu_duration_hours#other" = "Mute for {count} hours"; +"lng_new_authorization" = "{name},\nWe detected a login to your account from a new device on {day}, {date} at {time}.\n\nDevice: {device}\nLocation: {location}\n\nIf this wasn't you, you can go to Settings > Privacy and Security > Show all sessions 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 Tidings Team"; +"lng_new_contact_groups#zero" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_groups#two" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_groups#few" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_groups#many" = "{count} groups {emoji} {arrow}"; +"lng_new_contact_updated_days#zero" = "{count} days ago"; +"lng_new_contact_updated_days#two" = "{count} days ago"; +"lng_new_contact_updated_days#few" = "{count} days ago"; +"lng_new_contact_updated_days#many" = "{count} days ago"; +"lng_new_contact_updated_hours#zero" = "{count} hours ago"; +"lng_new_contact_updated_hours#two" = "{count} hours ago"; +"lng_new_contact_updated_hours#few" = "{count} hours ago"; +"lng_new_contact_updated_hours#many" = "{count} hours ago"; +"lng_new_contact_updated_months#zero" = "{count} months ago"; +"lng_new_contact_updated_months#two" = "{count} months ago"; +"lng_new_contact_updated_months#few" = "{count} months ago"; +"lng_new_contact_updated_months#many" = "{count} months ago"; +"lng_no_contacts" = "You have no contacts"; +"lng_notification_about_channels#zero" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_channels#two" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_channels#few" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_channels#many" = "Please note that **{count} channels** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#zero" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#two" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#few" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_groups#many" = "Please note that **{count} groups** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#zero" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#two" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#few" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_about_private_chats#many" = "Please note that **{count} chats** are listed as exceptions and won't be affected by this change."; +"lng_notification_exceptions#zero" = "{count} exceptions"; +"lng_notification_exceptions#two" = "{count} exceptions"; +"lng_notification_exceptions#few" = "{count} exceptions"; +"lng_notification_exceptions#many" = "{count} exceptions"; +"lng_paid_react_already#zero" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_already#two" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_already#few" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_already#many" = "You sent **{count} Stars** to support this post."; +"lng_paid_react_toast#zero" = "Stars Sent!"; +"lng_paid_react_toast#two" = "Stars Sent!"; +"lng_paid_react_toast#few" = "Stars Sent!"; +"lng_paid_react_toast#many" = "Stars Sent!"; +"lng_paid_react_toast_anonymous#zero" = "Stars sent anonymously!"; +"lng_paid_react_toast_anonymous#two" = "Stars sent anonymously!"; +"lng_paid_react_toast_anonymous#few" = "Stars sent anonymously!"; +"lng_paid_react_toast_anonymous#many" = "Stars sent anonymously!"; +"lng_paid_react_toast_text#zero" = "You reacted with **{count} Stars**."; +"lng_paid_react_toast_text#two" = "You reacted with **{count} Stars**."; +"lng_paid_react_toast_text#few" = "You reacted with **{count} Stars**."; +"lng_paid_react_toast_text#many" = "You reacted with **{count} Stars**."; +"lng_participant_invite_sorry#zero" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#two" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#few" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sorry#many" = "Sorry, you can only add the first {count} subscribers to a channel personally.\n\nFrom now on, people will need to join via your invite link."; +"lng_participant_invite_sure_many#zero" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sure_many#two" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sure_many#few" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_participant_invite_sure_many#many" = "Are you sure you want to add {count} members to **{group}**?"; +"lng_passcode_autolock_hours#zero" = "{count} hours"; +"lng_passcode_autolock_hours#one" = "{count} hour"; +"lng_passcode_autolock_hours#two" = "{count} hours"; +"lng_passcode_autolock_hours#few" = "{count} hours"; +"lng_passcode_autolock_hours#many" = "{count} hours"; +"lng_passcode_autolock_hours#other" = "{count} hours"; +"lng_passcode_autolock_minutes#zero" = "{count} minutes"; +"lng_passcode_autolock_minutes#one" = "{count} minute"; +"lng_passcode_autolock_minutes#two" = "{count} minutes"; +"lng_passcode_autolock_minutes#few" = "{count} minutes"; +"lng_passcode_autolock_minutes#many" = "{count} minutes"; +"lng_passcode_autolock_minutes#other" = "{count} minutes"; +"lng_passcode_need_unblock" = "You need to unlock me first."; +"lng_passcode_turn_off" = "Turn off"; +"lng_passcode_turn_on" = "Turn on local passcode"; +"lng_passport_fix_errors" = "Please correct errors."; +"lng_passport_front_side_name" = "Scan"; +"lng_passport_link_sent" = "A confirmation link was sent to\n{email}"; +"lng_passport_main_page_name" = "Scan"; +"lng_passport_reverse_side_name" = "Scan"; +"lng_passport_saving" = "Saving..."; +"lng_passport_selfie_name" = "Photo"; +"lng_passport_uploading" = "Uploading..."; +"lng_payment_confirm_amount#zero" = "**{count}** Stars"; +"lng_payment_confirm_amount#two" = "**{count}** Stars"; +"lng_payment_confirm_amount#few" = "**{count}** Stars"; +"lng_payment_confirm_amount#many" = "**{count}** Stars"; +"lng_payment_confirm_button#zero" = "Pay for {count} Messages"; +"lng_payment_confirm_button#two" = "Pay for {count} Messages"; +"lng_payment_confirm_button#few" = "Pay for {count} Messages"; +"lng_payment_confirm_button#many" = "Pay for {count} Messages"; +"lng_payment_confirm_chats#zero" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_chats#two" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_chats#few" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_chats#many" = "You selected **{count}** chats that charge Stars for messages."; +"lng_payment_confirm_sure#zero" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_sure#two" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_sure#few" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_sure#many" = "Would you like to pay {amount} to send **{count}** messages?"; +"lng_payment_confirm_text#zero" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_text#two" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_text#few" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_text#many" = "{name} charges **{count}** Stars per message."; +"lng_payment_confirm_users#zero" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_users#two" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_users#few" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_confirm_users#many" = "You selected **{count}** users who charge Stars for messages."; +"lng_payment_refund_also#zero" = "Refund the {count} Stars already paid"; +"lng_payment_refund_also#two" = "Refund the {count} Stars already paid"; +"lng_payment_refund_also#few" = "Refund the {count} Stars already paid"; +"lng_payment_refund_also#many" = "Refund the {count} Stars already paid"; +"lng_payments_not_supported" = "Sorry, Tidings Desktop doesn't support payments yet. Please use one of our mobile apps to do this."; +"lng_payments_webview_no_card" = "Unfortunately, you can't add a new card with current system configuration."; +"lng_payments_webview_switch_mutter" = "Qt's window embedding doesn't work well with Mutter window manager. Please switch to another window manager or desktop environment."; +"lng_payments_webview_switch_wayland" = "There is no way to embed WebView window on Wayland. Please switch to X11."; +"lng_phone_notreg" = "If you don't have a Tidings account yet,\nplease [b]sign up[/b] with {link_start}Android / iPhone{link_end} or {signup_start}here{signup_end}"; +"lng_pinned_messages_title#zero" = "{count} pinned messages"; +"lng_pinned_messages_title#two" = "{count} pinned messages"; +"lng_pinned_messages_title#few" = "{count} pinned messages"; +"lng_pinned_messages_title#many" = "{count} pinned messages"; +"lng_pinned_poll" = "Pinned poll"; +"lng_pinned_quiz" = "Pinned quiz"; +"lng_pinned_unpin_all#zero" = "Unpin all {count} messages"; +"lng_pinned_unpin_all#two" = "Unpin all {count} messages"; +"lng_pinned_unpin_all#few" = "Unpin all {count} messages"; +"lng_pinned_unpin_all#many" = "Unpin all {count} messages"; +"lng_polls_answers_count#zero" = "{count} answers"; +"lng_polls_answers_count#two" = "{count} answers"; +"lng_polls_answers_count#few" = "{count} answers"; +"lng_polls_answers_count#many" = "{count} answers"; +"lng_polls_create_hint" = "Click to choose the correct answer"; +"lng_polls_create_limit#zero" = "You can add {count} more options."; +"lng_polls_create_limit#two" = "You can add {count} more options."; +"lng_polls_create_limit#few" = "You can add {count} more options."; +"lng_polls_create_limit#many" = "You can add {count} more options."; +"lng_polls_show_more#zero" = "Show more ({count})"; +"lng_polls_show_more#two" = "Show more ({count})"; +"lng_polls_show_more#few" = "Show more ({count})"; +"lng_polls_show_more#many" = "Show more ({count})"; +"lng_polls_votes_count#zero" = "{count} votes"; +"lng_polls_votes_count#two" = "{count} votes"; +"lng_polls_votes_count#few" = "{count} votes"; +"lng_polls_votes_count#many" = "{count} votes"; +"lng_premium_double_limits_about_accounts#zero" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_accounts#two" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_accounts#few" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_accounts#many" = "Connect {count} accounts with different mobile numbers"; +"lng_premium_double_limits_about_channels#zero" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_channels#two" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_channels#few" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_channels#many" = "Join up to {count} channels and large groups"; +"lng_premium_double_limits_about_folder_chats#zero" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folder_chats#two" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folder_chats#few" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folder_chats#many" = "Add up to {count} chats into each of your folders"; +"lng_premium_double_limits_about_folders#zero" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_folders#two" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_folders#few" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_folders#many" = "Organize your chats into {count} folders"; +"lng_premium_double_limits_about_gifs#zero" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#two" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#few" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_gifs#many" = "Save up to {count} GIFs in your Favorite GIFs"; +"lng_premium_double_limits_about_links#zero" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_links#two" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_links#few" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_links#many" = "Reserve up to {count} tidings.me/username links"; +"lng_premium_double_limits_about_pins#zero" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_pins#two" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_pins#few" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_pins#many" = "Pin up to {count} chats in your main chat list"; +"lng_premium_double_limits_about_similar_channels#zero" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_similar_channels#two" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_similar_channels#few" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_similar_channels#many" = "View up to {count} similar channels"; +"lng_premium_double_limits_about_stickers#zero" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#two" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#few" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_double_limits_about_stickers#many" = "Save up to {count} stickers in your Favorite stickers"; +"lng_premium_gift_duration_months#zero" = "for {count} months"; +"lng_premium_gift_duration_months#two" = "for {count} months"; +"lng_premium_gift_duration_months#few" = "for {count} months"; +"lng_premium_gift_duration_months#many" = "for {count} months"; +"lng_premium_gift_duration_years#zero" = "for {count} years"; +"lng_premium_gift_duration_years#two" = "for {count} years"; +"lng_premium_gift_duration_years#few" = "for {count} years"; +"lng_premium_gift_duration_years#many" = "for {count} years"; +"lng_premium_gifts_about_paid_below#zero" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#two" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#few" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_below#many" = "They now have access to additional features."; +"lng_premium_gifts_about_paid_more#zero" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#two" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#few" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_paid_more#many" = "**{user}**, **{second_user}**, **{name}** and **{count}** others have been notified about the gifts you purchased."; +"lng_premium_gifts_about_reward#zero" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_reward#two" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_reward#few" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_reward#many" = "You will receive {emoji}**{count}** boosts."; +"lng_premium_gifts_about_user_more#zero" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_user_more#two" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_user_more#few" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_gifts_about_user_more#many" = "Give **{user}**, **{second_user}**, **{name}** and **{count}** more friends access to exclusive features."; +"lng_premium_stickers" = "Premium stickers"; +"lng_premium_summary_about_unique_reactions" = "Additional animated reactions on messages, available only to Premium subscribers."; +"lng_premium_summary_subtitle_gift#zero" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#two" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#few" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift#many" = "{user} has gifted you a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#zero" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#two" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#few" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_gift_me#many" = "You gifted {user} a {count}-months subscription to Tidings Premium."; +"lng_premium_summary_subtitle_unique_reactions" = "Unique Reactions"; +"lng_premium_summary_user_button" = "Learn More"; +"lng_prize_credits_amount#zero" = "{count} Stars"; +"lng_prize_credits_amount#two" = "{count} Stars"; +"lng_prize_credits_amount#few" = "{count} Stars"; +"lng_prize_credits_amount#many" = "{count} Stars"; +"lng_prizes_about#zero" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_about#two" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_about#few" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_about#many" = "**{count}** Tidings Premium Subscriptions {duration}."; +"lng_prizes_additional#zero" = "**{count}** {prize}"; +"lng_prizes_additional#two" = "**{count}** {prize}"; +"lng_prizes_additional#few" = "**{count}** {prize}"; +"lng_prizes_additional#many" = "**{count}** {prize}"; +"lng_prizes_additional_added#zero" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added#two" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added#few" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added#many" = "{channel} also included **{count} {prize}** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#zero" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#two" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#few" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_additional_added_group#many" = "{channel} also included **{count} {prize}** in the prizes. Admins of the group are responsible for delivering these prizes."; +"lng_prizes_admins#zero" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins#two" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins#few" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins#many" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its followers"; +"lng_prizes_admins_group#zero" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_admins_group#two" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_admins_group#few" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_admins_group#many" = "the admins of {channel}, who acquired **{count} Tidings Premium** subscriptions {duration} for its members"; +"lng_prizes_credits_about#zero" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about#two" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about#few" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about#many" = "**{amount}** will be distributed among {count} winners."; +"lng_prizes_credits_about_amount#zero" = "{count} Stars"; +"lng_prizes_credits_about_amount#two" = "{count} Stars"; +"lng_prizes_credits_about_amount#few" = "{count} Stars"; +"lng_prizes_credits_about_amount#many" = "{count} Stars"; +"lng_prizes_credits_admins_amount#zero" = "{count} Stars"; +"lng_prizes_credits_admins_amount#two" = "{count} Stars"; +"lng_prizes_credits_admins_amount#few" = "{count} Stars"; +"lng_prizes_credits_admins_amount#many" = "{count} Stars"; +"lng_prizes_credits_results_all#zero" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_all#two" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_all#few" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_all#many" = "All winners received {count} Stars in total."; +"lng_prizes_credits_results_one#zero" = "The winner received {count} Stars."; +"lng_prizes_credits_results_one#two" = "The winner received {count} Stars."; +"lng_prizes_credits_results_one#few" = "The winner received {count} Stars."; +"lng_prizes_credits_results_one#many" = "The winner received {count} Stars."; +"lng_prizes_end_activated#zero" = "**{count}** of the winners already used their gift links."; +"lng_prizes_end_activated#two" = "**{count}** of the winners already used their gift links."; +"lng_prizes_end_activated#few" = "**{count}** of the winners already used their gift links."; +"lng_prizes_end_activated#many" = "**{count}** of the winners already used their gift links."; +"lng_prizes_participants_all#zero" = "All subscribers of the channels:"; +"lng_prizes_participants_all#two" = "All subscribers of the channels:"; +"lng_prizes_participants_all#few" = "All subscribers of the channels:"; +"lng_prizes_participants_all#many" = "All subscribers of the channels:"; +"lng_prizes_participants_all_group#zero" = "All members of the groups:"; +"lng_prizes_participants_all_group#two" = "All members of the groups:"; +"lng_prizes_participants_all_group#few" = "All members of the groups:"; +"lng_prizes_participants_all_group#many" = "All members of the groups:"; +"lng_prizes_participants_all_mixed#zero" = "All members of the groups and channels:"; +"lng_prizes_participants_all_mixed#two" = "All members of the groups and channels:"; +"lng_prizes_participants_all_mixed#few" = "All members of the groups and channels:"; +"lng_prizes_participants_all_mixed#many" = "All members of the groups and channels:"; +"lng_prizes_participants_new#zero" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new#two" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new#few" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new#many" = "All users who joined the channels below after this announcement:"; +"lng_prizes_participants_new_group#zero" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_group#two" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_group#few" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_group#many" = "All users who joined the groups below after this date:"; +"lng_prizes_participants_new_mixed#zero" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_participants_new_mixed#two" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_participants_new_mixed#few" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_participants_new_mixed#many" = "All users who joined the groups and channels below after this date:"; +"lng_prizes_results_about#zero" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_about#two" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_about#few" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_about#many" = "**{count}** winners of the {link} were randomly selected by Tidings."; +"lng_prizes_results_more#zero" = "and {count} more!"; +"lng_prizes_results_more#two" = "and {count} more!"; +"lng_prizes_results_more#few" = "and {count} more!"; +"lng_prizes_results_more#many" = "and {count} more!"; +"lng_prizes_title#zero" = "Giveaway Prizes"; +"lng_prizes_title#two" = "Giveaway Prizes"; +"lng_prizes_title#few" = "Giveaway Prizes"; +"lng_prizes_title#many" = "Giveaway Prizes"; +"lng_prizes_winners_all_of_many#zero" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#two" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#few" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many#many" = "{count} random subscribers of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#zero" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#two" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#few" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_many_group#many" = "{count} random members of {channel} and other listed groups and channels"; +"lng_prizes_winners_all_of_one#zero" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one#two" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one#few" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one#many" = "{count} random subscribers of {channel}"; +"lng_prizes_winners_all_of_one_group#zero" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_one_group#two" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_one_group#few" = "{count} random members of {channel}"; +"lng_prizes_winners_all_of_one_group#many" = "{count} random members of {channel}"; +"lng_prizes_winners_new_of_many#zero" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#two" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#few" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_many#many" = "{count} random users that joined {channel} and other listed groups and channels after {start_date}"; +"lng_prizes_winners_new_of_one#zero" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#two" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#few" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_winners_new_of_one#many" = "{count} random users that joined {channel} after {start_date}"; +"lng_prizes_you_won_credits_amount#zero" = "{count} Stars"; +"lng_prizes_you_won_credits_amount#two" = "{count} Stars"; +"lng_prizes_you_won_credits_amount#few" = "{count} Stars"; +"lng_prizes_you_won_credits_amount#many" = "{count} Stars"; +"lng_profile_actions_section" = "Actions"; +"lng_profile_add_more_after_upgrade#zero" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#one" = "You will be able to add up to {count} member after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#two" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#few" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#many" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_add_more_after_upgrade#other" = "You will be able to add up to {count} members after you upgrade your group to a supergroup."; +"lng_profile_administrators#zero" = "{count} administrators"; +"lng_profile_administrators#two" = "{count} administrators"; +"lng_profile_administrators#few" = "{count} administrators"; +"lng_profile_administrators#many" = "{count} administrators"; +"lng_profile_audios#zero" = "{count} voice messages"; +"lng_profile_audios#two" = "{count} voice messages"; +"lng_profile_audios#few" = "{count} voice messages"; +"lng_profile_audios#many" = "{count} voice messages"; +"lng_profile_audios_header" = "Voice messages"; +"lng_profile_bio" = "Bio:"; +"lng_profile_common_groups#zero" = "{count} groups in common"; +"lng_profile_common_groups#two" = "{count} groups in common"; +"lng_profile_common_groups#few" = "{count} groups in common"; +"lng_profile_common_groups#many" = "{count} groups in common"; +"lng_profile_convert_confirm" = "Convert"; +"lng_profile_convert_feature1" = "— New members may see the full message history"; +"lng_profile_convert_feature2" = "— Messages are deleted for all members"; +"lng_profile_convert_feature3" = "— Admins can add a description to the group"; +"lng_profile_convert_feature4" = "— Owner can set a public link for the group"; +"lng_profile_convert_title" = "Convert to supergroup"; +"lng_profile_convert_warning" = "{bold_start}Note:{bold_end} This action can not be undone"; +"lng_profile_create_public_link" = "Create public link"; +"lng_profile_delete_and_exit" = "Leave"; +"lng_profile_delete_contact" = "Delete"; +"lng_profile_drop_area_subtitle" = "to set it as a group photo"; +"lng_profile_drop_area_subtitle_channel" = "to set it as a channel photo"; +"lng_profile_drop_area_title" = "Drop your image here"; +"lng_profile_edit_contact" = "Edit"; +"lng_profile_edit_public_link" = "Edit public link"; +"lng_profile_export_channel" = "Export channel history"; +"lng_profile_files#zero" = "{count} files"; +"lng_profile_files#two" = "{count} files"; +"lng_profile_files#few" = "{count} files"; +"lng_profile_files#many" = "{count} files"; +"lng_profile_files_header" = "Files"; +"lng_profile_gifs#zero" = "{count} GIFs"; +"lng_profile_gifs#two" = "{count} GIFs"; +"lng_profile_gifs#few" = "{count} GIFs"; +"lng_profile_gifs#many" = "{count} GIFs"; +"lng_profile_gifs_header" = "GIFs"; +"lng_profile_info_section" = "Info"; +"lng_profile_invite_link_section" = "Invite link"; +"lng_profile_link" = "Link:"; +"lng_profile_manage_admins" = "Manage admins"; +"lng_profile_manage_blocklist" = "Manage banned users"; +"lng_profile_manage_restrictedlist" = "Manage restricted users"; +"lng_profile_migrate_body" = "To add more members, you can upgrade your group to a supergroup."; +"lng_profile_migrate_button" = "Convert to supergroup"; +"lng_profile_migrate_learn_more" = "Learn more »"; +"lng_profile_migrate_reached#zero" = "{count} members limit reached"; +"lng_profile_migrate_reached#one" = "{count} member limit reached"; +"lng_profile_migrate_reached#two" = "{count} members limit reached"; +"lng_profile_migrate_reached#few" = "{count} members limit reached"; +"lng_profile_migrate_reached#many" = "{count} members limit reached"; +"lng_profile_migrate_reached#other" = "{count} members limit reached"; +"lng_profile_mobile_number" = "Mobile:"; +"lng_profile_peer_gifts#zero" = "{count} gifts"; +"lng_profile_peer_gifts#two" = "{count} gifts"; +"lng_profile_peer_gifts#few" = "{count} gifts"; +"lng_profile_peer_gifts#many" = "{count} gifts"; +"lng_profile_photos#zero" = "{count} photos"; +"lng_profile_photos#two" = "{count} photos"; +"lng_profile_photos#few" = "{count} photos"; +"lng_profile_photos#many" = "{count} photos"; +"lng_profile_photos_header" = "Photos"; +"lng_profile_posts#zero" = "{count} posts"; +"lng_profile_posts#two" = "{count} posts"; +"lng_profile_posts#few" = "{count} posts"; +"lng_profile_posts#many" = "{count} posts"; +"lng_profile_recent_actions" = "Recent actions"; +"lng_profile_saved_messages#zero" = "{count} saved messages"; +"lng_profile_saved_messages#two" = "{count} saved messages"; +"lng_profile_saved_messages#few" = "{count} saved messages"; +"lng_profile_saved_messages#many" = "{count} saved messages"; +"lng_profile_saved_stories#zero" = "{count} posts"; +"lng_profile_saved_stories#two" = "{count} posts"; +"lng_profile_saved_stories#few" = "{count} posts"; +"lng_profile_saved_stories#many" = "{count} posts"; +"lng_profile_search_members" = "Search members"; +"lng_profile_search_messages" = "Search for messages"; +"lng_profile_set_for_done" = "You will now always see this photo for {user}'s account."; +"lng_profile_set_group_photo" = "Set Photo"; +"lng_profile_share_contact" = "Share Contact"; +"lng_profile_shared_links#zero" = "{count} shared links"; +"lng_profile_shared_links#two" = "{count} shared links"; +"lng_profile_shared_links#few" = "{count} shared links"; +"lng_profile_shared_links#many" = "{count} shared links"; +"lng_profile_shared_links_header" = "Shared links"; +"lng_profile_similar_bots#zero" = "{count} similar bots"; +"lng_profile_similar_bots#two" = "{count} similar bots"; +"lng_profile_similar_bots#few" = "{count} similar bots"; +"lng_profile_similar_bots#many" = "{count} similar bots"; +"lng_profile_similar_channels#zero" = "{count} similar channels"; +"lng_profile_similar_channels#two" = "{count} similar channels"; +"lng_profile_similar_channels#few" = "{count} similar channels"; +"lng_profile_similar_channels#many" = "{count} similar channels"; +"lng_profile_songs#zero" = "{count} audio files"; +"lng_profile_songs#two" = "{count} audio files"; +"lng_profile_songs#few" = "{count} audio files"; +"lng_profile_songs#many" = "{count} audio files"; +"lng_profile_songs_header" = "Audio files"; +"lng_profile_top_bar_share_contact" = "Share"; +"lng_profile_unblock_bot" = "Unblock bot"; +"lng_profile_username" = "Username:"; +"lng_profile_videos#zero" = "{count} videos"; +"lng_profile_videos#two" = "{count} videos"; +"lng_profile_videos#few" = "{count} videos"; +"lng_profile_videos#many" = "{count} videos"; +"lng_profile_videos_header" = "Videos"; +"lng_proxy_use" = "Use proxy"; +"lng_reaction_live_location" = "{reaction} to your live location"; +"lng_reaction_premium_info" = "Click on a reaction to preview the animation."; +"lng_reaction_premium_no_channel" = "Some reactions are restricted in this channel."; +"lng_reaction_premium_no_group" = "Some reactions are restricted in this group."; +"lng_recommended" = "recommended"; +"lng_reconnecting#zero" = "Reconnect in {count} s..."; +"lng_reconnecting#two" = "Reconnect in {count} s..."; +"lng_reconnecting#few" = "Reconnect in {count} s..."; +"lng_reconnecting#many" = "Reconnect in {count} s..."; +"lng_replies_header#zero" = "{count} replies"; +"lng_replies_header#two" = "{count} replies"; +"lng_replies_header#few" = "{count} replies"; +"lng_replies_header#many" = "{count} replies"; +"lng_replies_view#zero" = "View {count} Replies"; +"lng_replies_view#two" = "View {count} Replies"; +"lng_replies_view#few" = "View {count} Replies"; +"lng_replies_view#many" = "View {count} Replies"; +"lng_report_messages_count#zero" = "Report {count} Messages"; +"lng_report_messages_count#two" = "Report {count} Messages"; +"lng_report_messages_count#few" = "Report {count} Messages"; +"lng_report_messages_count#many" = "Report {count} Messages"; +"lng_report_reason_description" = "Description"; +"lng_report_spam_hide" = "Hide"; +"lng_report_spam_sure" = "Are you sure you want to report spam from this user?"; +"lng_report_spam_thanks" = "Thank you for your report!"; +"lng_restrict_user#zero" = "Restrict users"; +"lng_restrict_user#two" = "Restrict users"; +"lng_restrict_user#few" = "Restrict users"; +"lng_restrict_user#many" = "Restrict users"; +"lng_restrict_users_part_header#zero" = "What can {count} selected users do?"; +"lng_restrict_users_part_header#two" = "What can {count} selected users do?"; +"lng_restrict_users_part_header#few" = "What can {count} selected users do?"; +"lng_restrict_users_part_header#many" = "What can {count} selected users do?"; +"lng_restricted_list_title" = "Restricted users"; +"lng_restricted_send_media" = "The admins of this group have restricted your ability to send media."; +"lng_restricted_send_media_all" = "Sending media isn't allowed in this group."; +"lng_restricted_send_media_until" = "The admins of this group have restricted your ability to send media until {date}, {time}."; +"lng_rights_chat_banned_day#zero" = "For {count} days"; +"lng_rights_chat_banned_day#two" = "For {count} days"; +"lng_rights_chat_banned_day#few" = "For {count} days"; +"lng_rights_chat_banned_day#many" = "For {count} days"; +"lng_rights_chat_banned_week#zero" = "For {count} weeks"; +"lng_rights_chat_banned_week#two" = "For {count} weeks"; +"lng_rights_chat_banned_week#few" = "For {count} weeks"; +"lng_rights_chat_banned_week#many" = "For {count} weeks"; +"lng_rights_chat_read" = "Read messages"; +"lng_rights_slowmode_hours#zero" = "{count}h"; +"lng_rights_slowmode_hours#one" = "{count}h"; +"lng_rights_slowmode_hours#two" = "{count}h"; +"lng_rights_slowmode_hours#few" = "{count}h"; +"lng_rights_slowmode_hours#many" = "{count}h"; +"lng_rights_slowmode_hours#other" = "{count}h"; +"lng_rights_slowmode_interval_minutes#zero" = "every {count} minutes"; +"lng_rights_slowmode_interval_minutes#two" = "every {count} minutes"; +"lng_rights_slowmode_interval_minutes#few" = "every {count} minutes"; +"lng_rights_slowmode_interval_minutes#many" = "every {count} minutes"; +"lng_rights_slowmode_interval_seconds#zero" = "every {count} seconds"; +"lng_rights_slowmode_interval_seconds#two" = "every {count} seconds"; +"lng_rights_slowmode_interval_seconds#few" = "every {count} seconds"; +"lng_rights_slowmode_interval_seconds#many" = "every {count} seconds"; +"lng_rights_slowmode_minutes#zero" = "{count}m"; +"lng_rights_slowmode_minutes#one" = "{count}m"; +"lng_rights_slowmode_minutes#two" = "{count}m"; +"lng_rights_slowmode_minutes#few" = "{count}m"; +"lng_rights_slowmode_minutes#many" = "{count}m"; +"lng_rights_slowmode_minutes#other" = "{count}m"; +"lng_rights_slowmode_seconds#zero" = "{count}s"; +"lng_rights_slowmode_seconds#one" = "{count}s"; +"lng_rights_slowmode_seconds#two" = "{count}s"; +"lng_rights_slowmode_seconds#few" = "{count}s"; +"lng_rights_slowmode_seconds#many" = "{count}s"; +"lng_rights_slowmode_seconds#other" = "{count}s"; +"lng_saved_gif_limit_more#zero" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_more#two" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_more#few" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_more#many" = "An older GIF was replaced with this one.\nYou can {link} to {count} GIFs."; +"lng_saved_gif_limit_title#zero" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_title#two" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_title#few" = "Limit of {count} GIFs Reached"; +"lng_saved_gif_limit_title#many" = "Limit of {count} GIFs Reached"; +"lng_scheduled_send_now_many#zero" = "Send {count} messages now?"; +"lng_scheduled_send_now_many#two" = "Send {count} messages now?"; +"lng_scheduled_send_now_many#few" = "Send {count} messages now?"; +"lng_scheduled_send_now_many#many" = "Send {count} messages now?"; +"lng_search_found_results#zero" = "{count} messages found"; +"lng_search_found_results#two" = "{count} messages found"; +"lng_search_found_results#few" = "{count} messages found"; +"lng_search_found_results#many" = "{count} messages found"; +"lng_seconds#zero" = "{count} seconds"; +"lng_seconds#two" = "{count} seconds"; +"lng_seconds#few" = "{count} seconds"; +"lng_seconds#many" = "{count} seconds"; +"lng_seconds_tiny#zero" = "{count}s"; +"lng_seconds_tiny#two" = "{count}s"; +"lng_seconds_tiny#few" = "{count}s"; +"lng_seconds_tiny#many" = "{count}s"; +"lng_selected_delete_sure#zero" = "Do you want to delete {count} messages?"; +"lng_selected_delete_sure#two" = "Do you want to delete {count} messages?"; +"lng_selected_delete_sure#few" = "Do you want to delete {count} messages?"; +"lng_selected_delete_sure#many" = "Do you want to delete {count} messages?"; +"lng_selected_unsend_about_group#zero" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#two" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#few" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_group#many" = "You can also delete the {count} messages you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#zero" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#two" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#few" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_selected_unsend_about_user#many" = "You can also delete the {count} messages you sent from {user}'s inbox by checking \"Unsend my messages\"."; +"lng_self_destruct_months#zero" = "{count} months"; +"lng_self_destruct_months#one" = "{count} month"; +"lng_self_destruct_months#two" = "{count} months"; +"lng_self_destruct_months#few" = "{count} months"; +"lng_self_destruct_months#many" = "{count} months"; +"lng_self_destruct_months#other" = "{count} months"; +"lng_self_destruct_weeks#zero" = "{count} weeks"; +"lng_self_destruct_weeks#one" = "{count} week"; +"lng_self_destruct_weeks#two" = "{count} weeks"; +"lng_self_destruct_weeks#few" = "{count} weeks"; +"lng_self_destruct_weeks#many" = "{count} weeks"; +"lng_self_destruct_weeks#other" = "{count} weeks"; +"lng_self_destruct_years#zero" = "{count} years"; +"lng_self_destruct_years#one" = "{count} year"; +"lng_self_destruct_years#two" = "{count} years"; +"lng_self_destruct_years#few" = "{count} years"; +"lng_self_destruct_years#many" = "{count} years"; +"lng_self_destruct_years#other" = "{count} years"; +"lng_send_album" = "Send as an album"; +"lng_send_files#zero" = "Send as {count} files"; +"lng_send_files#one" = "Send as {count} file"; +"lng_send_files#two" = "Send as {count} files"; +"lng_send_files#few" = "Send as {count} files"; +"lng_send_files#many" = "Send as {count} files"; +"lng_send_files#other" = "Send as {count} files"; +"lng_send_files_selected#zero" = "{count} files selected"; +"lng_send_files_selected#two" = "{count} files selected"; +"lng_send_files_selected#few" = "{count} files selected"; +"lng_send_files_selected#many" = "{count} files selected"; +"lng_send_image_too_large" = "File could not be sent, because it is larger than 2 GB: {name}\n\nYou can send as many files as you like, but each must be smaller than 2 GB."; +"lng_send_images_selected#zero" = "{count} images selected"; +"lng_send_images_selected#two" = "{count} images selected"; +"lng_send_images_selected#few" = "{count} images selected"; +"lng_send_images_selected#many" = "{count} images selected"; +"lng_send_photo" = "Send as a photo"; +"lng_send_photos#zero" = "Send as {count} photos"; +"lng_send_photos#one" = "Send as {count} photo"; +"lng_send_photos#two" = "Send as {count} photos"; +"lng_send_photos#few" = "Send as {count} photos"; +"lng_send_photos#many" = "Send as {count} photos"; +"lng_send_photos#other" = "Send as {count} photos"; +"lng_send_separate_photos" = "Send as separate photos"; +"lng_send_separate_photos_videos" = "Send as separate media"; +"lng_settings_advanced_notifications" = "Notifications position and count"; +"lng_settings_audios_cached#zero" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#one" = "{count} voice message, {size}"; +"lng_settings_audios_cached#two" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#few" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#many" = "{count} voice messages, {size}"; +"lng_settings_audios_cached#other" = "{count} voice messages, {size}"; +"lng_settings_auto_night_enabled" = "Match the system settings"; +"lng_settings_autoplay_gifs" = "Autoplay GIFs"; +"lng_settings_bg_edit_theme" = "Launch theme editor"; +"lng_settings_bg_use_default" = "Use default theme"; +"lng_settings_call_audio_ducking" = "Mute other sounds during calls"; +"lng_settings_call_input_volume" = "Input volume: {percent}%"; +"lng_settings_call_output_volume" = "Output volume: {percent}%"; +"lng_settings_call_stop_mic_test" = "Stop test"; +"lng_settings_call_test_mic" = "Test microphone"; +"lng_settings_calls_peer_to_peer" = "Peer-to-peer in calls"; +"lng_settings_calls_privacy" = "Phone calls privacy"; +"lng_settings_change_lang" = "Change language"; +"lng_settings_change_phone" = "Change phone number"; +"lng_settings_choose_username" = "Choose username"; +"lng_settings_close_sure" = "Are you sure you want to close this page? You didn't save your changes."; +"lng_settings_connected_about" = "Click to disconnect from your Tidings account."; +"lng_settings_crop_profile" = "Select an area for your profile photo"; +"lng_settings_current_version_label" = "Version {version}:"; +"lng_settings_disable_night_theme" = "Disable night mode"; +"lng_settings_downloading" = "Downloading update {ready} / {total} MB..."; +"lng_settings_drop_area_subtitle" = "to set it as your photo"; +"lng_settings_edit" = "Edit"; +"lng_settings_edit_info" = "Edit information"; +"lng_settings_empty_bio" = "None"; +"lng_settings_enable_animations" = "Enable animations"; +"lng_settings_enable_night_theme" = "Enable night mode"; +"lng_settings_gift_premium_users_error#zero" = "You can select maximum {count} users."; +"lng_settings_gift_premium_users_error#two" = "You can select maximum {count} users."; +"lng_settings_gift_premium_users_error#few" = "You can select maximum {count} users."; +"lng_settings_gift_premium_users_error#many" = "You can select maximum {count} users."; +"lng_settings_group_privacy_about" = "Choose who can add you to groups and channels."; +"lng_settings_groups_invite_privacy" = "Groups and Channels invite settings"; +"lng_settings_images_cached#zero" = "{count} images, {size}"; +"lng_settings_images_cached#one" = "{count} image, {size}"; +"lng_settings_images_cached#two" = "{count} images, {size}"; +"lng_settings_images_cached#few" = "{count} images, {size}"; +"lng_settings_images_cached#many" = "{count} images, {size}"; +"lng_settings_images_cached#other" = "{count} images, {size}"; +"lng_settings_last_seen_privacy" = "Last seen privacy"; +"lng_settings_logged_in_about" = "Websites where you've used Tidings to log in."; +"lng_settings_network_proxy" = "Network and proxy"; +"lng_settings_no_data_cached" = "No cached data found!"; +"lng_settings_password_reenter_email" = "Re-enter recovery email"; +"lng_settings_password_title" = "Two-step verification"; +"lng_settings_peer_to_peer" = "Peer-to-Peer"; +"lng_settings_reset" = "Terminate all other sessions"; +"lng_settings_scale_auto" = "Auto ({cur})"; +"lng_settings_section_advanced_settings" = "Advanced Settings"; +"lng_settings_section_general" = "General"; +"lng_settings_section_scale" = "Interface Scale"; +"lng_settings_self_destruct" = "Account self-destruct settings"; +"lng_settings_show_name" = "Show sender name"; +"lng_settings_show_preview" = "Show message preview"; +"lng_settings_sound_notify" = "Play sound"; +"lng_settings_sound_notify_off" = "Off"; +"lng_settings_theme_blue" = "Blue"; +"lng_settings_theme_matrix" = "Matrix"; +"lng_settings_theme_midnight" = "Midnight"; +"lng_settings_ttl_after_days#zero" = "{count} days"; +"lng_settings_ttl_after_days#two" = "{count} days"; +"lng_settings_ttl_after_days#few" = "{count} days"; +"lng_settings_ttl_after_days#many" = "{count} days"; +"lng_settings_ttl_after_hours#zero" = "{count} hours"; +"lng_settings_ttl_after_hours#two" = "{count} hours"; +"lng_settings_ttl_after_hours#few" = "{count} hours"; +"lng_settings_ttl_after_hours#many" = "{count} hours"; +"lng_settings_ttl_after_months#zero" = "{count} months"; +"lng_settings_ttl_after_months#two" = "{count} months"; +"lng_settings_ttl_after_months#few" = "{count} months"; +"lng_settings_ttl_after_months#many" = "{count} months"; +"lng_settings_ttl_after_weeks#zero" = "{count} weeks"; +"lng_settings_ttl_after_weeks#two" = "{count} weeks"; +"lng_settings_ttl_after_weeks#few" = "{count} weeks"; +"lng_settings_ttl_after_weeks#many" = "{count} weeks"; +"lng_settings_ttl_after_years#zero" = "{count} years"; +"lng_settings_ttl_after_years#two" = "{count} years"; +"lng_settings_ttl_after_years#few" = "{count} years"; +"lng_settings_ttl_after_years#many" = "{count} years"; +"lng_settings_ttl_select_chats_disabled_toast#zero" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#two" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#few" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_disabled_toast#many" = "Auto-delete timer has been disabled in {count} selected chats."; +"lng_settings_ttl_select_chats_subtitle" = "to apply the self-destruct timer"; +"lng_settings_ttl_select_chats_subtitle_chosen" = "will have the self-destruct timer"; +"lng_settings_ttl_select_chats_title" = "Select Chats"; +"lng_settings_ttl_select_chats_toast#zero" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_toast#two" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_toast#few" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_ttl_select_chats_toast#many" = "Self-destruct timer for {duration} has been enabled in {count} selected chats."; +"lng_settings_update_now" = "Restart Now"; +"lng_settings_upload" = "Set Profile Photo"; +"lng_settings_uploading_photo" = "Uploading photo..."; +"lng_settings_username" = "Username:"; +"lng_settings_view_emojis" = "View list"; +"lng_share_message_to_many_chats#zero" = "Message forwarded to **{count} chats**."; +"lng_share_message_to_many_chats#two" = "Message forwarded to **{count} chats**."; +"lng_share_message_to_many_chats#few" = "Message forwarded to **{count} chats**."; +"lng_share_message_to_many_chats#many" = "Message forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#zero" = "Messages forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#two" = "Messages forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#few" = "Messages forwarded to **{count} chats**."; +"lng_share_messages_to_many_chats#many" = "Messages forwarded to **{count} chats**."; +"lng_signin_password_removed" = "Your cloud password was disabled.\nYou can set up a new one in Settings."; +"lng_signin_reset_days#zero" = "{count} days"; +"lng_signin_reset_days#one" = "{count} day"; +"lng_signin_reset_days#two" = "{count} days"; +"lng_signin_reset_days#few" = "{count} days"; +"lng_signin_reset_days#many" = "{count} days"; +"lng_signin_reset_days#other" = "{count} days"; +"lng_signin_reset_hours#zero" = "{count} hours"; +"lng_signin_reset_hours#one" = "{count} hour"; +"lng_signin_reset_hours#two" = "{count} hours"; +"lng_signin_reset_hours#few" = "{count} hours"; +"lng_signin_reset_hours#many" = "{count} hours"; +"lng_signin_reset_hours#other" = "{count} hours"; +"lng_signin_reset_minutes#zero" = "{count} minutes"; +"lng_signin_reset_minutes#one" = "{count} minute"; +"lng_signin_reset_minutes#two" = "{count} minutes"; +"lng_signin_reset_minutes#few" = "{count} minutes"; +"lng_signin_reset_minutes#many" = "{count} minutes"; +"lng_signin_reset_minutes#other" = "{count} minutes"; +"lng_similar_bots_premium_all#zero" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_premium_all#two" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_premium_all#few" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_bots_premium_all#many" = "Subscribe to {link} to unlock up to **{count}** similar bots."; +"lng_similar_channels_premium_all#zero" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all#two" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all#few" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_similar_channels_premium_all#many" = "Subscribe to {link} to unlock up to **{count}** similar channels."; +"lng_slowmode_seconds#zero" = "{count} seconds"; +"lng_slowmode_seconds#two" = "{count} seconds"; +"lng_slowmode_seconds#few" = "{count} seconds"; +"lng_slowmode_seconds#many" = "{count} seconds"; +"lng_sponsored" = "ad"; +"lng_sponsored_revenued_info3_description#zero" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#two" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#few" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_sponsored_revenued_info3_description#many" = "You can turn off ads by subscribing to {link}, and Level {count} channels can remove them for their subscribers."; +"lng_star_ref_link_copy_users#zero" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copy_users#two" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copy_users#few" = "{count} users have opened {app} through this link."; +"lng_star_ref_link_copy_users#many" = "{count} users have opened {app} through this link."; +"lng_star_ref_one_about_for_months#zero" = "for **{count} months**"; +"lng_star_ref_one_about_for_months#two" = "for **{count} months**"; +"lng_star_ref_one_about_for_months#few" = "for **{count} months**"; +"lng_star_ref_one_about_for_months#many" = "for **{count} months**"; +"lng_star_ref_one_about_for_years#zero" = "for **{count} years**"; +"lng_star_ref_one_about_for_years#two" = "for **{count} years**"; +"lng_star_ref_one_about_for_years#few" = "for **{count} years**"; +"lng_star_ref_one_about_for_years#many" = "for **{count} years**"; +"lng_stats_member_bans#zero" = "{count} bans"; +"lng_stats_member_bans#two" = "{count} bans"; +"lng_stats_member_bans#few" = "{count} bans"; +"lng_stats_member_bans#many" = "{count} bans"; +"lng_stats_member_characters#zero" = "{count} symbols per message"; +"lng_stats_member_characters#two" = "{count} symbols per message"; +"lng_stats_member_characters#few" = "{count} symbols per message"; +"lng_stats_member_characters#many" = "{count} symbols per message"; +"lng_stats_member_deletions#zero" = "{count} deletions"; +"lng_stats_member_deletions#two" = "{count} deletions"; +"lng_stats_member_deletions#few" = "{count} deletions"; +"lng_stats_member_deletions#many" = "{count} deletions"; +"lng_stats_member_invitations#zero" = "{count} invitations"; +"lng_stats_member_invitations#two" = "{count} invitations"; +"lng_stats_member_invitations#few" = "{count} invitations"; +"lng_stats_member_invitations#many" = "{count} invitations"; +"lng_stats_member_messages#zero" = "{count} messages"; +"lng_stats_member_messages#two" = "{count} messages"; +"lng_stats_member_messages#few" = "{count} messages"; +"lng_stats_member_messages#many" = "{count} messages"; +"lng_stats_member_restrictions#zero" = "{count} restrictions"; +"lng_stats_member_restrictions#two" = "{count} restrictions"; +"lng_stats_member_restrictions#few" = "{count} restrictions"; +"lng_stats_member_restrictions#many" = "{count} restrictions"; +"lng_stats_overview_message_public_share#zero" = "{count} public shares"; +"lng_stats_overview_message_public_share#two" = "{count} public shares"; +"lng_stats_overview_message_public_share#few" = "{count} public shares"; +"lng_stats_overview_message_public_share#many" = "{count} public shares"; +"lng_stats_recent_messages_shares#zero" = "{count} shares"; +"lng_stats_recent_messages_shares#one" = "{count} share"; +"lng_stats_recent_messages_shares#two" = "{count} shares"; +"lng_stats_recent_messages_shares#few" = "{count} shares"; +"lng_stats_recent_messages_shares#many" = "{count} shares"; +"lng_stats_recent_messages_shares#other" = "{count} shares"; +"lng_stats_recent_messages_views#zero" = "{count} views"; +"lng_stats_recent_messages_views#two" = "{count} views"; +"lng_stats_recent_messages_views#few" = "{count} views"; +"lng_stats_recent_messages_views#many" = "{count} views"; +"lng_status_lastseen_hours#zero" = "last seen {count} hours ago"; +"lng_status_lastseen_hours#two" = "last seen {count} hours ago"; +"lng_status_lastseen_hours#few" = "last seen {count} hours ago"; +"lng_status_lastseen_hours#many" = "last seen {count} hours ago"; +"lng_status_lastseen_minutes#zero" = "last seen {count} minutes ago"; +"lng_status_lastseen_minutes#two" = "last seen {count} minutes ago"; +"lng_status_lastseen_minutes#few" = "last seen {count} minutes ago"; +"lng_status_lastseen_minutes#many" = "last seen {count} minutes ago"; +"lng_sticker_premium_title" = "With Effects"; +"lng_stickers_count#zero" = "{count} stickers"; +"lng_stickers_count#two" = "{count} stickers"; +"lng_stickers_count#few" = "{count} stickers"; +"lng_stickers_count#many" = "{count} stickers"; +"lng_stickers_masks_pack" = "This is a set of masks. Masks are only supported by mobile versions of Tidings."; +"lng_stickers_remove_pack_button" = "Remove Stickers"; +"lng_stickers_search_sets" = "Search sticker sets"; +"lng_stories_archive_done_many#zero" = "{count} stories are hidden from your profile."; +"lng_stories_archive_done_many#two" = "{count} stories are hidden from your profile."; +"lng_stories_archive_done_many#few" = "{count} stories are hidden from your profile."; +"lng_stories_archive_done_many#many" = "{count} stories are hidden from your profile."; +"lng_stories_archive_sure_many#zero" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_sure_many#two" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_sure_many#few" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_archive_sure_many#many" = "Do you want to remove {count} stories from your profile?"; +"lng_stories_channel_archive_done_many#zero" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_done_many#two" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_done_many#few" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_done_many#many" = "{count} stories are hidden from the channel page."; +"lng_stories_channel_archive_sure_many#zero" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_sure_many#two" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_sure_many#few" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_archive_sure_many#many" = "Do you want to hide {count} stories from the channel page?"; +"lng_stories_channel_save_done_many#zero" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_many#two" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_many#few" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_done_many#many" = "{count} stories posted to the channel page."; +"lng_stories_channel_save_sure_many#zero" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_sure_many#two" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_sure_many#few" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_channel_save_sure_many#many" = "Do you want to post {count} stories to the channel page?"; +"lng_stories_delete_sure#zero" = "Are you sure you want to delete {count} stories?"; +"lng_stories_delete_sure#two" = "Are you sure you want to delete {count} stories?"; +"lng_stories_delete_sure#few" = "Are you sure you want to delete {count} stories?"; +"lng_stories_delete_sure#many" = "Are you sure you want to delete {count} stories?"; +"lng_stories_row_count#zero" = "{count} Stories"; +"lng_stories_row_count#two" = "{count} Stories"; +"lng_stories_row_count#few" = "{count} Stories"; +"lng_stories_row_count#many" = "{count} Stories"; +"lng_stories_save_done_many#zero" = "{count} stories are posted on your profile."; +"lng_stories_save_done_many#two" = "{count} stories are posted on your profile."; +"lng_stories_save_done_many#few" = "{count} stories are posted on your profile."; +"lng_stories_save_done_many#many" = "{count} stories are posted on your profile."; +"lng_stories_save_sure_many#zero" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_sure_many#two" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_sure_many#few" = "Do you want to post {count} stories to your profile?"; +"lng_stories_save_sure_many#many" = "Do you want to post {count} stories to your profile?"; +"lng_stories_views#zero" = "{count} views"; +"lng_stories_views#two" = "{count} views"; +"lng_stories_views#few" = "{count} views"; +"lng_stories_views#many" = "{count} views"; +"lng_sure_add_admin_unban" = "This user is currently restricted or banned. Are you sure you want to unban and promote them?"; +"lng_sure_ban_user_channel" = "Ban {user} from the channel?"; +"lng_sure_ban_user_group" = "Do you want to ban {user} from the group?"; +"lng_sure_clear_downloads" = "Do you want to remove all downloaded files from temp folder? It is done automatically on logout or program uninstall."; +"lng_sure_delete_selected_days#zero" = "{count} selected days"; +"lng_sure_delete_selected_days#two" = "{count} selected days"; +"lng_sure_delete_selected_days#few" = "{count} selected days"; +"lng_sure_delete_selected_days#many" = "{count} selected days"; +"lng_sure_remove_user_channel" = "Remove {user} from the channel?"; +"lng_sure_remove_user_group" = "Remove {user} from the group?"; +"lng_terms_age#zero" = "I confirm that I am {count} or over"; +"lng_terms_age#two" = "I confirm that I am {count} or over"; +"lng_terms_age#few" = "I confirm that I am {count} or over"; +"lng_terms_age#many" = "I confirm that I am {count} or over"; +"lng_theme_editor_attach_title" = "Attach desktop theme"; +"lng_theme_editor_export" = "Export"; +"lng_theme_editor_export_button" = "Export theme"; +"lng_theme_editor_save_palette" = "Save Palette File"; +"lng_theme_no_desktop_version" = "Sorry, this theme doesn't include a version for Tidings Desktop."; +"lng_theme_preview_users#zero" = "{count} people are using this theme"; +"lng_theme_preview_users#two" = "{count} people are using this theme"; +"lng_theme_preview_users#few" = "{count} people are using this theme"; +"lng_theme_preview_users#many" = "{count} people are using this theme"; +"lng_theme_reverting#zero" = "Reverting to the old theme in {count} seconds."; +"lng_theme_reverting#two" = "Reverting to the old theme in {count} seconds."; +"lng_theme_reverting#few" = "Reverting to the old theme in {count} seconds."; +"lng_theme_reverting#many" = "Reverting to the old theme in {count} seconds."; +"lng_translate_box_original" = "Original"; +"lng_ttl_about_duration1" = "1 day"; +"lng_ttl_about_duration2" = "1 week"; +"lng_ttl_about_duration3" = "1 month"; +"lng_ttl_edit_save" = "Confirm"; +"lng_unread_bar#zero" = "{count} Unread Messages"; +"lng_unread_bar#two" = "{count} Unread Messages"; +"lng_unread_bar#few" = "{count} Unread Messages"; +"lng_unread_bar#many" = "{count} Unread Messages"; +"lng_username_about" = "You can choose a username on Tidings.\nIf you do, people will be able to find\nyou by this username and contact you\nwithout needing your phone number.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; +"lng_username_description" = "You can choose a username on Tidings. If you do, other people will be able to find you by this username and contact you without knowing your phone number.\n\nYou can use **a–z**, **0–9** and **underscores**.\nMinimum length is **5 characters**."; +"lng_username_link_willbe" = "This link will open a chat with you:"; +"lng_usernames_activate_error#zero" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#two" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#few" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_usernames_activate_error#many" = "Sorry, you can't activate more than **{count}** usernames."; +"lng_via_link_cant_many#zero" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#two" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#few" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_cant_many#many" = "**{count} users** can only be invited via link, but you don't have permission to share invite links to this group."; +"lng_via_link_channel_many#zero" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#two" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#few" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_channel_many#many" = "**{count} users** restrict adding them to channels.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#zero" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#two" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#few" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_group_many#many" = "**{count} users** restrict adding them to groups.\nYou can send them an invite link as message instead."; +"lng_via_link_shared_many#zero" = "Link shared with **{count} users**."; +"lng_via_link_shared_many#two" = "Link shared with **{count} users**."; +"lng_via_link_shared_many#few" = "Link shared with **{count} users**."; +"lng_via_link_shared_many#many" = "Link shared with **{count} users**."; +"lng_views_tooltip#zero" = "Views: {count}"; +"lng_views_tooltip#two" = "Views: {count}"; +"lng_views_tooltip#few" = "Views: {count}"; +"lng_views_tooltip#many" = "Views: {count}"; +"lng_weeks#zero" = "{count} weeks"; +"lng_weeks#two" = "{count} weeks"; +"lng_weeks#few" = "{count} weeks"; +"lng_weeks#many" = "{count} weeks"; +"lng_weeks_tiny#zero" = "{count}w"; +"lng_weeks_tiny#two" = "{count}w"; +"lng_weeks_tiny#few" = "{count}w"; +"lng_weeks_tiny#many" = "{count}w"; +"lng_years#zero" = "{count} years"; +"lng_years#two" = "{count} years"; +"lng_years#few" = "{count} years"; +"lng_years#many" = "{count} years"; +"lng_years_tiny#zero" = "{count}y"; +"lng_years_tiny#two" = "{count}y"; +"lng_years_tiny#few" = "{count}y"; +"lng_years_tiny#many" = "{count}y"; +"lng_you_paid_stars#zero" = "You paid {count} Stars."; +"lng_you_paid_stars#two" = "You paid {count} Stars."; +"lng_you_paid_stars#few" = "You paid {count} Stars."; +"lng_you_paid_stars#many" = "You paid {count} Stars."; diff --git a/data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings b/data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings new file mode 100644 index 00000000..ee4cf904 --- /dev/null +++ b/data/langpack/tdesktop/tdesktop_zh-hans_v12000000.strings @@ -0,0 +1,7130 @@ +/* +This file is part of Tidings Desktop, +the official desktop application for the Tidings messaging service. + +For license and copyright information please follow this link: +https://github.com/tidingsdesktop/tdesktop/blob/master/LEGAL +*/ +"lng_language_name" = "简体中文"; +"lng_switch_to_this" = "使用简体中文继续"; + +"lng_menu_contacts" = "联系人"; +"lng_menu_calls" = "通话"; +"lng_menu_settings" = "设置"; +"lng_menu_about" = "关于"; +"lng_menu_update" = "更新"; +"lng_menu_night_mode" = "夜间模式"; +"lng_menu_add_account" = "添加账号"; +"lng_menu_activate" = "使用此账号"; +"lng_menu_set_status" = "设置表情状态"; +"lng_menu_change_status" = "更改表情状态"; +"lng_menu_my_profile" = "我的资料"; +"lng_menu_my_stories" = "我的动态"; +"lng_menu_my_groups" = "我的群组"; +"lng_menu_my_channels" = "我的频道"; + +"lng_disable_notifications_from_tray" = "关闭通知"; +"lng_enable_notifications_from_tray" = "开启通知"; +"lng_open_from_tray" = "打开 Tidings"; +"lng_minimize_to_tray" = "最小化到托盘"; +"lng_quit_from_tray" = "退出 Tidings"; +"lng_tray_icon_text" = "Tidings 仍在运行,您可以在设置中更改显示方式。\n如果此图标从托盘栏消失,您可以将其从隐藏的图标中拖出。"; + +// For lng_month_year or plain month name. +"lng_month1" = "1月"; +"lng_month2" = "2月"; +"lng_month3" = "3月"; +"lng_month4" = "4月"; +"lng_month5" = "5月"; +"lng_month6" = "6月"; +"lng_month7" = "7月"; +"lng_month8" = "8月"; +"lng_month9" = "9月"; +"lng_month10" = "10月"; +"lng_month11" = "11月"; +"lng_month12" = "12月"; + +// For lng_month_day and lng_month_day_year. +"lng_month_day1" = "1月"; +"lng_month_day2" = "2月"; +"lng_month_day3" = "3月"; +"lng_month_day4" = "4月"; +"lng_month_day5" = "5月"; +"lng_month_day6" = "6月"; +"lng_month_day7" = "7月"; +"lng_month_day8" = "8月"; +"lng_month_day9" = "9月"; +"lng_month_day10" = "10月"; +"lng_month_day11" = "11月"; +"lng_month_day12" = "12月"; + +"lng_month1_small" = "1月"; +"lng_month2_small" = "2月"; +"lng_month3_small" = "3月"; +"lng_month4_small" = "4月"; +"lng_month5_small" = "5月"; +"lng_month6_small" = "6月"; +"lng_month7_small" = "7月"; +"lng_month8_small" = "8月"; +"lng_month9_small" = "9月"; +"lng_month10_small" = "10月"; +"lng_month11_small" = "11月"; +"lng_month12_small" = "12月"; + +"lng_weekday1" = "周一"; +"lng_weekday2" = "周二"; +"lng_weekday3" = "周三"; +"lng_weekday4" = "周四"; +"lng_weekday5" = "周五"; +"lng_weekday6" = "周六"; +"lng_weekday7" = "周日"; + +"lng_month_day" = "{month}{day}日"; +"lng_month_day_year" = "{year}年{month}{day}日"; +"lng_month_year" = "{year}年{month}"; + +"lng_calendar_select_days" = "选择日期\n"; +"lng_calendar_start_tip" = "按住以跳转到起始位置。"; +"lng_calendar_end_tip" = "按住以跳转到结束位置。"; + +"lng_calendar_days#other" = "{count} 天"; + + +"lng_seconds#other" = "{count} 秒"; + +"lng_minutes#other" = "{count} 分钟"; + +"lng_hours#other" = "{count} 小时"; + +"lng_days#other" = "{count} 天"; + +"lng_weeks#other" = "{count} 周"; + +"lng_months#other" = "{count} 个月"; + +"lng_years#other" = "{count} 年"; + + +"lng_seconds_tiny#other" = "{count} 秒"; + +"lng_minutes_tiny#other" = "{count}分钟"; + +"lng_hours_tiny#other" = "{count}小时"; + +"lng_days_tiny#other" = "{count}天"; + +"lng_weeks_tiny#other" = "{count}周"; + +"lng_months_tiny#other" = "{count} 个月"; + +"lng_years_tiny#other" = "{count}年"; + +"lng_box_ok" = "确定"; +"lng_box_done" = "完成"; +"lng_box_yes" = "确定"; +"lng_box_no" = "取消"; + +"lng_cancel" = "取消"; +"lng_continue" = "继续"; +"lng_close" = "关闭"; +"lng_connecting" = "正在连接…"; + +"lng_reconnecting#other" = "将在 {count} 秒后重新连接…"; +"lng_reconnecting_try_now" = "立即重连"; + +"lng_code_block_header_copy" = "拷贝"; + +"lng_status_service_notifications" = "服务通知"; +"lng_status_support" = "客服"; +"lng_status_bot" = "机器人"; +"lng_status_bot_reads_all" = "可读取消息"; +"lng_status_bot_not_reads_all" = "无权读取消息"; +"lng_status_offline" = "很久没上线"; +"lng_status_recently" = "最近上线过"; +"lng_status_last_week" = "最近一周内曾上线"; +"lng_status_last_month" = "最近一个月内曾上线"; +"lng_status_lastseen_now" = "上线于不久前"; +"lng_status_lastseen_when" = "何时?"; + +"lng_status_lastseen_minutes#other" = "最近上线于 {count} 分钟前"; + +"lng_status_lastseen_hours#other" = "最近上线于 {count} 小时前"; +"lng_status_lastseen_today" = "最近上线于今天 {time}"; +"lng_status_lastseen_yesterday" = "最近上线于昨天 {time}"; +"lng_status_lastseen_date" = "最近上线于{date}"; +"lng_status_lastseen_date_time" = "最近上线于{date} {time}"; +"lng_status_online" = "在线"; +"lng_status_connecting" = "连接中…"; + +"lng_chat_status_unaccessible" = "此群组已无法访问"; + +"lng_chat_status_members#other" = "{count} 位成员"; + +"lng_chat_status_online#other" = "{count} 人在线"; +"lng_chat_status_members_online" = "{members_count},{online_count}"; + +"lng_chat_status_subscribers#other" = "{count} 位订阅者"; + +"lng_channel_status" = "频道"; +"lng_group_status" = "群组"; +"lng_scam_badge" = "欺诈"; +"lng_fake_badge" = "虚假"; + +"lng_remember" = "记住这次选择"; + +"lng_lastseen_show_title" = "显示您的最后上线时间"; +"lng_lastseen_show_about" = "若要查看 **{user}** 的最后上线时间,您需显示您自己的最后上线时间..."; +"lng_lastseen_show_button" = "显示我的最近上线时间"; +"lng_lastseen_or" = "或"; +"lng_lastseen_premium_title" = "升级为高级版"; +"lng_lastseen_premium_about" = "订阅后您将可以看到 **{user}** 的最后上线时间,而无需显示您的最后上线时间。"; +"lng_lastseen_premium_button" = "订阅 Tidings 高级版"; +"lng_lastseen_shown_toast" = "您最后上线的时间已设为可见。"; + +"lng_readtime_show_title" = "显示您的已读时间"; +"lng_readtime_show_about" = "若要查看 **{user}** 的已读时间,您需显示您自己的已读时间..."; +"lng_readtime_show_button" = "显示我的已读时间"; +"lng_readtime_or" = "或"; +"lng_readtime_premium_title" = "升级为高级版"; +"lng_readtime_premium_about" = "订阅后您将可以看到 **{user}** 的已读时间,而无需显示您的已读时间。"; +"lng_readtime_premium_button" = "订阅 Tidings 高级版"; +"lng_readtime_shown_toast" = "您的已读时间已设为可见。"; + +"lng_channels_limit_title" = "加入的群组过多"; + +"lng_channels_limit1#other" = "您已是 **{count}** 个群组或频道的成员。"; + +"lng_channels_limit2#other" = "请在加入新的群组或频道之前先退出一些——或者升级至 **Tidings 高级版**,将此上限翻倍至 **{count}** 个群组或频道。"; +"lng_channels_limit2_final" = "在加入新的群组之前请先退出一部分已加入的群组。"; +"lng_channels_leave_title" = "最不活跃的社群"; +"lng_channels_leave_status" = "{type}, 不活跃 {time}"; + +"lng_channels_leave#other" = "退出 {count} 个社群"; +"lng_channels_leave_done" = "您已退出所选社群。"; + +"lng_links_limit_title" = "公开链接过多"; + +"lng_links_limit1#other" = "您已保留 **{count}** 个公开链接。"; + +"lng_links_limit2#other" = "您可以撤消其中一个较旧群组或频道的链接,或者订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个公开链接。"; +"lng_links_limit2_final" = "请尝试从旧的群组或频道中撤销链接"; +"lng_links_revoke_title" = "您的公开社群"; + +"lng_filter_chats_limit_title" = "已达到上限"; + +"lng_filter_chats_limit1#other" = "抱歉,您无法在此文件夹中添加超过 **{count}** 个聊天。"; + +"lng_filter_chats_exlude_limit1#other" = "抱歉,您不能从一个文件夹中排除超过 **{count}** 个聊天。"; + +"lng_filter_chats_limit2#other" = "您可以通过订阅 **Tidings 高级版** 来提升上限至 **{count}**。"; + +"lng_filters_limit_title" = "已达上限"; + +"lng_filters_limit1#other" = "您已到达最大 **{count}** 个文件夹的限制。"; + +"lng_filters_limit2#other" = "您可以订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个文件夹。"; + +"lng_filter_pin_limit_title" = "到达上限"; + +"lng_filter_pin_limit1#other" = "抱歉,您最多只能置顶 **{count}** 个聊天。"; + +"lng_filter_pin_limit2#other" = "您可以取消部分置顶,或者订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个聊天。"; + + +"lng_forum_pin_limit#other" = "抱歉,您最多只能置顶 **{count}** 个话题。"; + + +"lng_fave_sticker_limit_title#other" = "已达到 {count} 张贴纸的限制"; + +"lng_fave_sticker_limit_more#other" = "一张旧的贴纸已被这张贴纸替换。您可以{link} 至 {count} 张贴纸。"; +"lng_fave_sticker_limit_link" = "提升上限"; + + +"lng_saved_gif_limit_title#other" = "已达到 {count} 张 GIF 动态图的上限"; + +"lng_saved_gif_limit_more#other" = "一张旧的 GIF 动态图已被这张图替换。\n您可以 {link} 至 {count} 张 GIF 动态图。"; +"lng_saved_gif_limit_link" = "增加上限"; + +"lng_caption_limit_title" = "已达上限"; + +"lng_caption_limit1#other" = "抱歉,您不能在媒体的标题上使用超过 **{count}** 个字符。"; + +"lng_caption_limit2#other" = "您可以缩短说明文字,或者订阅 **Tidings 高级版**,将此上限翻倍至 **{count}** 个字符。"; + +"lng_caption_limit_reached#other" = "您已经达到媒体标题上限,请将其减少至 {count} 个字数以下。"; +"lng_caption_move_up" = "将说明文字上移"; +"lng_caption_move_down" = "将说明文字下移"; + +"lng_file_size_limit_title" = "文件过大"; + +"lng_file_size_limit#other" = "{count} GB"; +"lng_file_size_limit1" = "由于大小超过了 {size},无法发送此文档。"; +"lng_file_size_limit2" = "您可以订阅 **Tidings 高级版**,将此上限翻倍至每个文件 {size}。"; + +"lng_filter_links_limit_title" = "到达上限"; + +"lng_filter_links_limit1#other" = "抱歉,你不能创建多于 {count} 个邀请链接。"; + +"lng_filter_links_limit2#other" = "通过订阅 Tidings 高级会员你可以将上限提高到 {count} 个链接。"; + +"lng_filter_shared_limit_title" = "到达上限"; + +"lng_filter_shared_limit1#other" = "抱歉,您只能添加 **{count} **个可共享的文件夹。"; + +"lng_filter_shared_limit2#other" = "您可以通过订阅 **Tidings 高级版** 将上线增加至 **{count}** 个文件夹。"; + +"lng_limits_increase" = "提升上限"; + +"lng_sticker_premium_text" = "该贴纸包包含高级版贴纸,譬如该贴纸。"; +"lng_sticker_premium_view" = "查看"; +"lng_animated_emoji_text" = "订阅 **Tidings 高级版** 解锁该表情。"; +"lng_animated_emoji_saved" = "尝试在 **我的收藏** 中免费发送这些表情来体验。"; +"lng_animated_emoji_saved_open" = "打开"; + +"lng_flood_error" = "尝试次数过多,请稍后再试。"; +"lng_edit_error" = "您无法编辑这条消息"; +"lng_error_phone_flood" = "抱歉,您最近删除并重建账号的次数过多。请稍等几天再重新注册。"; +"lng_error_start_minimized_passcoded" = "由于您已设置锁定码,Tidings Desktop 无法以最小化模式启动。Tidings 会在启动前要求您输入锁定码。"; +"lng_error_public_groups_denied" = "非常抱歉,您已被禁止在公开群组中发言。\n{more_info}"; +"lng_error_cant_add_member" = "抱歉,您无法添加此机器人到群组。如有需要,请联系您的管理员。"; +"lng_error_cant_add_bot" = "抱歉,此机器人无法被添加至群组。"; +"lng_error_cant_add_admin_invite" = "抱歉,您无法将这位用户设为管理员。因为该用户不是此群组的成员,且您也无权邀请该用户。"; +"lng_error_you_blocked_user" = "抱歉,您无法将此用户或机器人添加到群组,因为您已将其屏蔽。请先将其解除屏蔽后再继续。"; +"lng_error_add_admin_not_member" = "您无法将此用户添加为管理员,因为他们不是该组的成员,因此您无权添加他们。"; +"lng_error_user_admin_invalid" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"lng_error_channel_bots_too_much" = "抱歉,此频道的机器人数量已达上限。"; +"lng_error_group_bots_too_much" = "抱歉,此群组的机器人数量已达上限。请先删除一些您不使用的机器人。"; +"lng_error_cant_add_admin_unban" = "抱歉,您无法将这位用户设为管理员。因为该用户在群组的黑名单中,且您也无法解除其封禁。"; +"lng_error_cant_ban_admin" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"lng_error_cant_reply_other" = "此信息不能在其他聊天中回复"; +"lng_error_admin_limit" = "抱歉,此群组的管理员人数已达上限。"; +"lng_error_admin_limit_channel" = "抱歉,此频道的管理员人数已达上限。"; +"lng_error_post_link_invalid" = "很遗憾,您无法打开此消息。 您不是消息所在聊天的成员。"; +"lng_error_noforwards_group" = "抱歉,管理员已禁止从此群组中转发消息。"; +"lng_error_noforwards_channel" = "抱歉,管理员已禁止从此频道中转发消息。"; +"lng_error_nocopy_group" = "抱歉,管理员已禁止从本群拷贝任何内容。"; +"lng_error_nocopy_channel" = "抱歉,管理员已禁止拷贝本频道中的内容。"; +"lng_error_nocopy_story" = "抱歉,此动态的作者禁止复制。"; +"lng_error_schedule_limit" = "抱歉,您不能设置超过 100 条定时消息。"; +"lng_sure_add_admin_invite" = "此用户非本群组成员。是否将其拉入到群组并设为管理员?"; +"lng_sure_add_admin_invite_channel" = "此用户并非本频道订阅者。是否邀请其关注频道并设为管理员?"; +"lng_sure_add_admin_unremove" = "此用户目前是受限用户或已不在群组中。您确定要提升他为管理员吗?"; +"lng_sure_ban_admin" = "此用户是管理员,您确定要对其作出限制吗?"; +"lng_sure_enable_socks" = "您确定要启用此代理吗?\n\n服务器:{server}\n端口:{port}\n\n您可以稍后在「设置 - 高级 - 连接方式」中修改您的代理服务器。"; +"lng_sure_enable" = "启用"; +"lng_proxy_box_title" = "启用代理"; +"lng_proxy_box_server" = "服务器"; +"lng_proxy_box_port" = "端口"; +"lng_proxy_box_secret" = "密钥"; +"lng_proxy_box_status" = "状态"; +"lng_proxy_box_username" = "用户名"; +"lng_proxy_box_password" = "密码"; +"lng_proxy_invalid" = "代理链接无效。"; +"lng_proxy_unsupported" = "你的 Tidings Desktop 版本不支持此代理类型或代理链接无效。请将 Tidings Desktop 更新至最新版本。"; + +"lng_edit_deleted" = "此消息已被删除"; + +"lng_edit_limit_reached#other" = "你的消息字数超限。请缩短 {count} 个字符。"; +"lng_edit_message" = "编辑消息"; +"lng_edit_message_text" = "输入消息……"; +"lng_deleted" = "已销号用户"; +"lng_deleted_message" = "已删除消息"; +"lng_deleted_story" = "删除动态"; +"lng_pinned_message" = "已置顶消息"; +"lng_pinned_previous" = "之前置顶的消息"; +"lng_pinned_unpin_sure" = "您确定要取消置顶此消息吗?"; +"lng_pinned_pin_sure" = "您确定要置顶此消息吗?"; +"lng_pinned_pin_sure_group" = "您确定要在群组中置顶此消息吗?"; +"lng_pinned_pin_old_sure" = "你想在保留一条最新的消息置顶时置顶一条更旧的消息吗?"; +"lng_pinned_pin" = "置顶"; +"lng_pinned_unpin" = "取消置顶"; +"lng_pinned_notify" = "通知所有成员"; +"lng_pinned_also_for_other" = "也为 {user} 置顶"; + +"lng_pinned_messages_title#other" = "{count} 条置顶消息"; +"lng_pinned_hide_all" = "不显示置顶消息"; + +"lng_pinned_unpin_all#other" = "取消置顶所有 {count} 条消息"; +"lng_pinned_unpin_all_sure" = "您要取消置顶所有消息吗?"; +"lng_pinned_hide_all_sure" = "您是否要隐藏置顶消息栏?在有新消息被置顶之前,置顶栏将保持隐藏。"; +"lng_pinned_hide_all_hide" = "隐藏"; + +"lng_edit_media_album_error" = "此文件无法保存为相册的一部分。"; +"lng_edit_media_invalid_file" = "抱歉,该文件不可用。"; +"lng_edit_photo_editor_hint" = "左键单击以编辑图片。"; +"lng_edit_caption_attach" = "抱歉,您在编辑消息时无法附加新媒体文件。"; +"lng_edit_caption_voice" = "抱歉,当有语音留言未发送时,您无法编辑消息。"; + +"lng_intro_about" = "欢迎使用 Tidings 官方桌面客户端。\n它既快速又安全。"; +"lng_start_msgs" = "开始聊天"; + +"lng_intro_next" = "下一步"; +"lng_intro_finish" = "注册"; +"lng_intro_submit" = "提交"; + +"lng_photo_caption" = "说明"; +"lng_photos_comment" = "评论"; + +"lng_intro_qr_title" = "从手机客户端扫描"; +"lng_intro_qr_step1" = "在您的手机上打开 Tidings"; +"lng_intro_qr_step2" = "请转到“设置” > “设备” > “关联桌面设备”"; +"lng_intro_qr_step3" = "请使用扫描二维码以登陆"; +"lng_intro_qr_skip" = "或使用您的手机号码登录"; + +"lng_intro_fragment_title" = "输入验证码"; +"lng_intro_fragment_about" = "在 Fragment 的匿名号码部分获取 {phone_number} 的验证码。"; +"lng_intro_fragment_button" = "打开 Fragment"; + +"lng_phone_title" = "你的手机号码"; +"lng_phone_desc" = "请确认您的国际电话区号\n并输入您的手机号码。"; +"lng_phone_to_qr" = "使用二维码快速登录"; +"lng_country_code" = "国际电话区号"; +"lng_bad_country_code" = "国际电话区号无效"; +"lng_country_ph" = "搜索"; +"lng_country_none" = "此国际电话区号不存在"; +"lng_country_select" = "选择国家/地区"; + +"lng_code_ph" = "验证码"; +"lng_code_desc" = "我们已经向您的手机发送了一条包含验证码的短信。\n请在下方输入您的验证码。"; +"lng_code_from_tidings" = "请输入在前一个 **Tidings** app上刚刚收到的验证码。"; +"lng_code_no_tidings" = "通过短信发送验证码"; +"lng_code_call" = "Tidings 会在 {minutes}:{seconds} 后使用电话呼叫您播放验证码"; +"lng_code_calling" = "正在请求 Tidings 呼叫…"; +"lng_code_called" = "Tidings 已拨打您的电话"; + +"lng_bad_phone" = "手机号码无效,请重试。"; +"lng_bad_code" = "您输入的验证码无效,请重试。"; +"lng_bad_name" = "请输入您的姓名。"; +"lng_bad_photo" = "抱歉,Tidings 无法处理该类型的图片。"; + +"lng_signin_title" = "两步验证"; +"lng_signin_desc" = "请输入两步验证密码。"; +"lng_signin_recover_desc" = "请输入此电子邮箱收到的验证码\n{email}"; +"lng_signin_password" = "您的两步验证密码"; +"lng_signin_code" = "验证码"; +"lng_signin_recover" = "是否忘记密码?"; +"lng_signin_recover_title" = "重置密码"; +"lng_signin_hint" = "提示:{password_hint}"; +"lng_signin_recover_hint" = "我们已经向电子邮箱({recover_email})发送了一条带有验证码的邮件。"; +"lng_signin_bad_password" = "您输入的密码错误,请重试。"; +"lng_signin_wrong_code" = "您输入的验证码无效,请重试。"; +"lng_signin_try_password" = "无法访问电子邮箱?"; +"lng_signin_no_email_forgot" = "因为您在设置密码时没有同时提供密码恢复邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"lng_signin_cant_email_forgot" = "如果您无法访问您的邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"lng_signin_reset_account" = "重置您的帐号"; +"lng_signin_sure_reset" = "如果您确认重置帐号,您将丢失 Tidings 中所有的聊天、消息、媒体和文件。\n\n您确定要重置帐号吗?"; +"lng_signin_reset" = "重置"; +"lng_signin_reset_wait" = "由于账号 {phone_number} 处于活跃状态并且受到密码保护,出于安全原因,我们将在一周后删除账号。您可以随时取消此项操作。\n\n您可以在 {when} 内重置您的账号。"; +"lng_signin_reset_in_days" = "{days_count} {hours_count} {minutes_count}"; +"lng_signin_reset_in_hours" = "{hours_count} {minutes_count}"; +"lng_signin_reset_cancelled" = "您最近的重置账号请求以被此帐号当前的活跃用户撤销,请于 7 天后再试。"; +"lng_signin_banned_text" = "此手机号码已被封禁。"; +"lng_signin_banned_help" = "帮助"; + +"lng_signup_title" = "个人信息"; +"lng_signup_desc" = "请输入您的姓名\n并上传头像。"; + +"lng_signup_firstname" = "名字"; +"lng_signup_lastname" = "姓氏"; + +"lng_dlg_filter" = "搜索"; +"lng_dlg_new_group_name" = "群组名称"; +"lng_dlg_new_channel_name" = "频道名称"; +"lng_dlg_new_bot_name" = "机器人名称"; +"lng_no_chats" = "此处是您的聊天列表"; +"lng_no_chats_filter" = "该文件夹内暂无聊天。"; +"lng_no_saved_sublists" = "您可以在此处保存其他聊天的消息。"; +"lng_contacts_loading" = "加载中…"; +"lng_contacts_not_found" = "未找到相关联系人"; +"lng_topics_not_found" = "未找到主题。"; +"lng_dlg_search_for_messages" = "搜索消息"; +"lng_update_tidings" = "更新 Tidings"; +"lng_dlg_search_in" = "按下列条件搜索消息"; +"lng_dlg_search_from" = "发送者:{user}"; + +"lng_settings_save" = "保存"; +"lng_settings_apply" = "应用"; + +"lng_username_title" = "用户名"; +"lng_username_description1" = "您可以在 Tidings 中选择使用用户名。使用用户名后,别人可以通过用户名找到您,并且无需知晓电话号码即可联系您。"; +"lng_username_description2" = "您可以使用 **a-z**、**0-9** 和下划线。最小长度为 5 个字符。"; +"lng_username_choose" = "设定您的用户名。"; +"lng_username_invalid" = "此用户名不符合规范。"; +"lng_username_occupied" = "此用户名已被占用。"; +"lng_username_too_short" = "此用户名过短。"; +"lng_username_purchase_available" = "很抱歉,此链接已被占用。您可以购买此链接,详见 {link} 。"; +"lng_username_purchase_available_link" = "了解更多..."; +"lng_username_bad_symbols" = "您只能使用 a-z、0-9 和下划线。"; +"lng_username_available" = "此用户名可用。"; +"lng_username_not_found" = "该用户名(@{user})不存在。"; +"lng_username_by_phone_not_found" = "没有找到电话为 {phone} 的用户。"; +"lng_username_app_not_found" = "未找到机器人应用程序。"; +"lng_username_link" = "通过此链接可以与您聊天:"; +"lng_username_copied" = "链接已复制到剪贴板。"; +"lng_username_text_copied" = "用户名已复制到粘贴板。"; + +"lng_usernames_edit" = "点击以编辑"; +"lng_usernames_active" = "激活"; +"lng_usernames_non_active" = "不可用"; +"lng_usernames_subtitle" = "用户名顺序"; + +"lng_usernames_activate_error#other" = "抱歉,你无法激活超出 **{count}** 个的用户名。"; +"lng_usernames_activate_description" = "你想要在个人信息页面显示用户名吗?"; +"lng_usernames_activate_confirm" = "显示"; +"lng_channel_usernames_subtitle" = "链接顺序"; +"lng_usernames_deactivate_description" = "你想要在个人信息页面上隐藏用户名吗?"; +"lng_usernames_deactivate_confirm" = "隐藏"; +"lng_usernames_description" = "拖放链接以更改它们在您的信息页面上的显示顺序。"; + +"lng_channel_usernames_activate_description" = "你想要将此链接显示在频道信息页面上吗?"; +"lng_channel_usernames_deactivate_description" = "你想从频道信息页面上隐藏此链接吗?"; +"lng_channel_usernames_description" = "拖放链接,更改其在频道信息页面上的显示顺序。"; + +"lng_bot_username_title" = "用户名"; +"lng_bot_username_description1" = "此链接不可编辑,你可以在 {link} 上获取更多用户名。"; +"lng_bot_username_description1_link" = "Fragment"; +"lng_bot_usernames_activate_description" = "您想要在机器人的信息页面上显示此链接吗?"; +"lng_bot_usernames_deactivate_description" = "您想要在机器人的信息页面中隐藏此链接吗?"; +"lng_bot_usernames_description" = "拖拽链接以调整其在机器人信息页上的显示顺序。"; + +"lng_bio_placeholder" = "个人简介"; + +"lng_collectible_username_title" = "{username} 是一个典藏用户名,属于"; +"lng_collectible_username_info" = "该用户名是于 {date} 在 **Fragment** 上花费 {price} 获取。"; +"lng_collectible_username_copy" = "复制链接"; +"lng_collectible_phone_title" = "{phone} 是一个典藏的电话号码,其属于"; +"lng_collectible_phone_info" = "该电话号码于 {date} 在 **Fragment** 上花费 {price} 获取"; +"lng_collectible_phone_copy" = "复制电话号码"; +"lng_collectible_learn_more" = "了解更多"; +"lng_collectible_phone_copied" = "手机号码已复制到剪贴板。"; + +"lng_settings_section_info" = "个人信息"; + +"lng_settings_section_notify" = "通知与声音"; +"lng_settings_show_from" = "显示通知"; +"lng_settings_notify_all" = "所有账号"; +"lng_settings_notify_all_about" = "如果您只想从正在使用的账号接收通知,请将其关闭。"; +"lng_settings_notify_global" = "全局设定"; +"lng_settings_notify_title" = "聊天通知"; +"lng_settings_desktop_notify" = "桌面通知"; +"lng_settings_native_title" = "系统原生通知"; +"lng_settings_use_windows" = "使用 Windows 原生通知"; +"lng_settings_skip_in_focus" = "遵循系统专注模式"; +"lng_settings_use_native_notifications" = "使用系统原生通知"; +"lng_settings_notifications_position" = "在屏幕上的位置"; +"lng_settings_notifications_count" = "显示的通知数量"; +"lng_settings_sound_allowed" = "开启声音"; +"lng_settings_alert_windows" = "任务栏图标闪烁"; +"lng_settings_alert_mac" = "弹出状态栏图标"; +"lng_settings_alert_linux" = "请注意窗口旁的提示"; +"lng_settings_badge_title" = "未读消息数量标记"; +"lng_settings_include_muted" = "在未读消息数中包含已关闭消息提醒的对话"; +"lng_settings_include_muted_folders" = "文件夹上的未读消息数量包括静音的聊天"; +"lng_settings_count_unread" = "统计未读消息"; +"lng_settings_events_title" = "事件"; +"lng_settings_events_joined" = "联系人已加入 Tidings"; +"lng_settings_events_pinned" = "置顶的消息"; +"lng_settings_notifications_calls_title" = "语音通话"; + +"lng_notification_preview_title" = "小恐龙雷克斯"; +"lng_notification_preview_text" = "东京现在是早上 😎"; +"lng_notification_show_name" = "名称"; +"lng_notification_show_text" = "文本"; +"lng_notification_preview" = "您有一条新消息"; +"lng_notification_reply" = "回复"; +"lng_notification_hide_all" = "全部隐藏"; +"lng_notification_sample" = "这是一个示例通知"; +"lng_notification_reminder" = "提醒"; +"lng_notification_private_chats" = "私聊"; +"lng_notification_groups" = "群组"; +"lng_notification_channels" = "频道"; +"lng_notification_click_to_change" = "点击此处以修改"; +"lng_notification_on" = "已开启, {exceptions}"; +"lng_notification_off" = "已关闭, {exceptions}"; + +"lng_notification_exceptions#other" = "{count} 个例外"; +"lng_notification_exceptions_title" = "例外"; +"lng_notification_title_private_chats" = "私聊通知"; + +"lng_notification_about_private_chats#other" = "请注意,有 **{count} 个聊天** 是例外,不会受到这次更改的影响。"; +"lng_notification_title_groups" = "群组通知"; + +"lng_notification_about_groups#other" = "请注意,有 **{count} 个群组** 是例外,不会受到这次更改的影响。"; +"lng_notification_title_channels" = "频道通知"; + +"lng_notification_about_channels#other" = "请注意,有 **{count} 个频道** 是例外,不会受到这次更改的影响。"; +"lng_notification_exceptions_view" = "查看例外"; +"lng_notification_enable" = "启用私聊"; +"lng_notification_sound" = "声音"; +"lng_notification_tone" = "通知铃声"; +"lng_notification_exceptions_muted" = "已静音"; +"lng_notification_exceptions_unmuted" = "未静音"; +"lng_notification_exceptions_add" = "添加例外"; +"lng_notification_exceptions_clear" = "删除所有例外"; +"lng_notification_exceptions_clear_sure" = "您确定删除所有例外吗?"; +"lng_notification_exceptions_clear_button" = "删除"; +"lng_notification_exceptions_remove" = "移除"; +"lng_notification_context_remove" = "移除例外"; + +"lng_reaction_text" = "对您的 “{text}” {reaction}"; +"lng_reaction_notext" = "对您的消息 {reaction}"; +"lng_reaction_photo" = "对您的图片 {reaction}"; +"lng_reaction_video" = "对您的视频 {reaction}"; +"lng_reaction_video_message" = "对您的视频消息 {reaction}"; +"lng_reaction_document" = "对您的文件 {reaction}"; +"lng_reaction_sticker" = "对您的 {emoji} 贴纸 {reaction}"; +"lng_reaction_voice_message" = "对您的语音消息 {reaction}"; +"lng_reaction_contact" = "对您的联系人信息 {name} {reaction}"; +"lng_reaction_location" = "对您的地图 {reaction}"; +"lng_reaction_poll" = "对您的 “{title}” 投票 {reaction}"; +"lng_reaction_quiz" = "对您的 “{title}” 问答 {reaction}"; +"lng_reaction_game" = "对您的游戏 {reaction}"; +"lng_reaction_invoice" = "对您的账单 {reaction}"; +"lng_reaction_gif" = "对您的 GIF 动态图 {reaction}"; + +"lng_effect_add_title" = "添加动画特效"; +"lng_effect_stickers_title" = "贴纸特效"; +"lng_effect_send" = "启用特效并发送"; +"lng_effect_none" = "未找到特效。"; +"lng_effect_premium" = "订阅 {link} 以添加此动画特效。"; +"lng_effect_premium_link" = "Tidings 高级版"; + +"lng_languages" = "语言"; +"lng_languages_none" = "未找到任何语言。"; + +"lng_languages_count#other" = "{count} 种语言"; +"lng_sure_save_language" = "Tidings 需要重新启动以更改语言"; +"lng_settings_update_automatically" = "自动更新"; +"lng_settings_install_beta" = "安装测试版本"; +"lng_settings_current_version" = "{version} 版"; +"lng_settings_check_now" = "检查更新"; +"lng_settings_update_checking" = "正在检查更新…"; +"lng_settings_latest_installed" = "已更新至最新版"; +"lng_settings_update_ready" = "新版已准备就绪"; +"lng_settings_update_fail" = "检查更新失败 :("; +"lng_settings_workmode_tray" = "显示托盘图标"; +"lng_settings_workmode_window" = "显示任务栏图标"; +"lng_settings_window_close" = "当窗口关闭时"; +"lng_settings_run_in_background" = "在背景运行"; +"lng_settings_quit_on_close" = "退出应用"; +"lng_settings_close_to_taskbar" = "最小化到任务栏"; +"lng_settings_monochrome_icon" = "使用黑白图标"; +"lng_settings_window_system" = "窗口标题栏"; +"lng_settings_title_chat_name" = "显示聊天名称"; +"lng_settings_title_account_name" = "显示活跃的账户"; +"lng_settings_title_total_count" = "总未读数"; +"lng_settings_native_frame" = "使用系统窗口"; +"lng_settings_qt_frame" = "使用QT窗口框架"; +"lng_settings_auto_start" = "开机启动 Tidings"; +"lng_settings_start_min" = "启动后隐藏到托盘"; +"lng_settings_auto_start_disabled_uwp" = "Windows 已阻止 Tidings 开机自动启动。\n\n请在设置中允许 Tidings 自动启动。"; +"lng_settings_open_system_settings" = "转到设置"; +"lng_settings_add_sendto" = "在「发送到」菜单中显示 Tidings"; +"lng_settings_mac_warn_before_quit" = "使用{text}退出前显示警告"; +"lng_settings_mac_round_icon" = "使用圆形应用图标"; + +"lng_settings_experimental" = "实验性功能设置"; +"lng_settings_experimental_about" = "警告!这些是实验性功能设置。其中有一些可能无法正常工作,甚至导致应用程序崩溃。这里的任何设置随时都可能因版本更新而消失。使用风险自负。"; +"lng_settings_experimental_restore" = "恢复默认选项"; +"lng_settings_experimental_irrelevant" = "此选项在您的系统中不适用。"; + +"lng_settings_section_chat_settings" = "聊天设置"; +"lng_settings_replace_emojis" = "自动替换表情符号"; +"lng_settings_suggest_emoji" = "推荐表情符号替换"; +"lng_settings_suggest_animated_emoji" = "推荐动态表情"; +"lng_settings_suggest_by_emoji" = "通过表情推荐热门贴纸"; +"lng_settings_loop_stickers" = "循环播放动态贴纸"; +"lng_settings_large_emoji" = "大号表情"; +"lng_settings_send_enter" = "按回车键发送消息"; +"lng_settings_send_ctrlenter" = "按 Ctrl+Enter 键发送消息"; +"lng_settings_send_cmdenter" = "按 Cmd+Enter 键发送消息"; +"lng_settings_chat_quick_action_reply" = "双击回复"; +"lng_settings_chat_quick_action_react" = "双击表达态度"; +"lng_settings_chat_quick_action_select" = "双击选择全部"; +"lng_settings_chat_corner_reaction" = "信息上的回应按钮"; + +"lng_settings_shortcuts" = "键盘快捷键"; + +"lng_shortcuts_reset" = "恢复默认"; +"lng_shortcuts_recording" = "正在录制..."; +"lng_shortcuts_add_another" = "新增其他"; + +"lng_shortcuts_close" = "关闭窗口"; +"lng_shortcuts_lock" = "锁定应用"; +"lng_shortcuts_minimize" = "最小化窗口"; +"lng_shortcuts_quit" = "退出应用"; +"lng_shortcuts_media_play" = "播放媒体"; +"lng_shortcuts_media_pause" = "暂停媒体"; +"lng_shortcuts_media_play_pause" = "切换媒体播放"; +"lng_shortcuts_media_stop" = "停止播放媒体"; +"lng_shortcuts_media_previous" = "上一首"; +"lng_shortcuts_media_next" = "下一首"; +"lng_shortcuts_search" = "搜索消息"; +"lng_shortcuts_chat_previous" = "上一个聊天"; +"lng_shortcuts_chat_next" = "下一个聊天"; +"lng_shortcuts_chat_first" = "第一个聊天"; +"lng_shortcuts_chat_last" = "最后一个聊天"; +"lng_shortcuts_chat_self" = "已收藏的消息"; +"lng_shortcuts_chat_pinned_n" = "置顶聊天 #{index}"; +"lng_shortcuts_show_account_n" = "账号 #{index}"; +"lng_shortcuts_show_all_chats" = "所有聊天的文件夹"; +"lng_shortcuts_show_folder_n" = "文件夹 #{index}"; +"lng_shortcuts_show_folder_last" = "最后一个文件夹"; +"lng_shortcuts_folder_next" = "下一个文件夹"; +"lng_shortcuts_folder_previous" = "上一个文件夹"; +"lng_shortcuts_scheduled" = "预约消息"; +"lng_shortcuts_archive" = "归档聊天"; +"lng_shortcuts_contacts" = "联系人名单"; +"lng_shortcuts_just_send" = "刚刚发送"; +"lng_shortcuts_silent_send" = "静音发送"; +"lng_shortcuts_schedule" = "时间表"; +"lng_shortcuts_read_chat" = "将聊天标记为已读"; +"lng_shortcuts_archive_chat" = "存档聊天"; +"lng_shortcuts_media_fullscreen" = "切换全屏幕视频"; +"lng_shortcuts_show_chat_menu" = "显示聊天菜单\n"; +"lng_shortcuts_show_chat_preview" = "显示聊天预览"; + +"lng_settings_chat_reactions_title" = "快速回应"; +"lng_settings_chat_reactions_subtitle" = "选择你最喜欢的反应"; +"lng_settings_chat_message_reply_from" = "Bob Harris"; +"lng_settings_chat_message_reply" = "早上好"; +"lng_settings_chat_message" = "你知道现在几点吗?"; + +"lng_settings_section_filters" = "文件夹"; + +"lng_settings_section_background" = "聊天背景"; +"lng_settings_bg_from_gallery" = "从相册中选择"; +"lng_settings_bg_from_file" = "从文件中选择"; +"lng_settings_bg_remove" = "移除壁纸"; +"lng_settings_bg_theme_edit" = "编辑"; +"lng_settings_bg_theme_create" = "创建新主题"; +"lng_settings_bg_cloud_themes" = "自定义主题"; +"lng_settings_bg_show_all" = "显示所有主题"; +"lng_settings_bg_tile" = "平铺背景"; +"lng_settings_adaptive_wide" = "宽屏时消息左对齐"; + +"lng_settings_section_call_settings" = "通话设置"; +"lng_settings_call_camera" = "摄像头"; +"lng_settings_call_section_output" = "扬声器和耳机"; +"lng_settings_call_section_input" = "麦克风"; +"lng_settings_call_input_device" = "输入设备"; +"lng_settings_call_output_device" = "输出设备"; +"lng_settings_call_section_other" = "其他设置"; +"lng_settings_call_open_system_prefs" = "打开系统声音偏好设置"; +"lng_settings_call_accept_calls" = "在此设备上接听电话"; +"lng_settings_call_device_default" = "默认"; + +"lng_settings_section_devices" = "扬声器和摄像头"; +"lng_settings_devices_calls" = "通话和视频聊天"; +"lng_settings_devices_calls_same" = "使用相同的设备进行通话"; +"lng_settings_devices_inactive" = "不可用"; + +"lng_settings_language" = "语言"; +"lng_settings_default_scale" = "默认界面缩放比例"; +"lng_settings_connection_type" = "连接类型"; +"lng_settings_downloading_update" = "下载更新中 {progress}…"; +"lng_settings_privacy_title" = "隐私"; +"lng_settings_last_seen" = "最后上线时间和在线状态"; +"lng_settings_calls" = "通话"; +"lng_settings_calls_peer_to_peer_title" = "端对端"; +"lng_settings_calls_peer_to_peer_button" = "将端对端通话用于"; +"lng_settings_groups_invite" = "群组与频道"; +"lng_settings_phone_number_privacy" = "手机号码"; +"lng_settings_forwards_privacy" = "已转发的消息"; +"lng_settings_profile_photo_privacy" = "头像"; +"lng_settings_messages_privacy" = "消息"; +"lng_settings_voices_privacy" = "语音消息"; +"lng_settings_bio_privacy" = "个人简介"; +"lng_settings_gifts_privacy" = "礼物"; +"lng_settings_birthday_privacy" = "出生日期"; +"lng_settings_privacy_premium" = "只有 {link} 订阅者才可以限制接受语音消息。"; +"lng_settings_privacy_premium_link" = "Tidings 高级版"; +"lng_settings_passcode_disable" = "停用本机锁定码"; +"lng_settings_passcode_disable_sure" = "您确定要禁用口令吗?"; +"lng_settings_use_winhello" = "使用 Windows Hello 解锁"; +"lng_settings_use_winhello_about" = "在使用 Windows Hello 解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_use_touchid" = "用 Touch ID 解锁"; +"lng_settings_use_touchid_about" = "在使用 Touch ID 解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_use_applewatch" = "使用 Apple Watch 解锁"; +"lng_settings_use_applewatch_about" = "在使用 Apple Watch 解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_use_systempwd" = "使用系统密码解锁"; +"lng_settings_use_systempwd_about" = "在使用系统密码解锁 Tidings 之前,您需要输入一次密码。"; +"lng_settings_password_disable" = "停用两步验证密码"; +"lng_settings_password_abort" = "取消两步验证设置"; +"lng_settings_about_bio" = "您可以在此输入任何有关您个人的信息,诸如:年龄、工作或居住城市。\n示例:23岁,来自旧金山的设计师。"; +"lng_settings_name_label" = "名字"; +"lng_settings_username_label" = "用户名"; +"lng_settings_phone_label" = "电话号码"; +"lng_settings_username_add" = "设置用户名"; +"lng_settings_username_about" = "用户名可以让其他用户在 Tidings 上联系您,而无需知道您的手机号码。"; +"lng_settings_birthday_label" = "出生日期"; +"lng_settings_birthday_add" = "添加"; +"lng_settings_birthday_about" = "在 {link} 中选择谁能看到你的出生日期。"; +"lng_settings_birthday_about_link" = "设置"; +"lng_settings_birthday_contacts" = "只有您的联系人才能看到您的生日。 {link}"; +"lng_settings_birthday_contacts_link" = "更改 >"; +"lng_settings_birthday_saved" = "已更新出生日期。"; +"lng_settings_birthday_reset" = "移除"; +"lng_settings_channel_label" = "个人频道"; +"lng_settings_channel_add" = "添加"; +"lng_settings_channel_remove" = "移除"; +"lng_settings_channel_no_yet" = "你目前没有任何公开频道。"; +"lng_settings_channel_start" = "创建频道"; +"lng_settings_channel_saved" = "个人频道已更新。"; +"lng_settings_channel_removed" = "个人频道已移除。"; +"lng_settings_add_account_about" = "您最多可以添加四个具有不同手机号码的帐号。"; +"lng_settings_peer_to_peer_about" = "停用端对端连接后,所有通话流量将使用 Tidings 服务器中转以避免泄露您的 IP 地址,但可能会降低音质及画质。"; +"lng_settings_advanced" = "高级设置"; +"lng_settings_stickers_emoji" = "贴纸和表情符号"; +"lng_settings_messages" = "消息"; +"lng_settings_themes" = "主题"; +"lng_settings_theme_day" = "白天"; +"lng_settings_theme_classic" = "经典"; +"lng_settings_theme_tinted" = "深色"; +"lng_settings_theme_night" = "黑夜"; +"lng_settings_theme_accent_title" = "选择强调色"; +"lng_settings_data_storage" = "数据与存储"; +"lng_settings_information" = "编辑个人资料"; +"lng_settings_my_account" = "我的账号"; +"lng_settings_security" = "安全设置"; +"lng_settings_passcode_title" = "本机锁定码"; +"lng_settings_sessions_title" = "活跃会话"; +"lng_settings_sessions_about" = "控制您在其它设备上的会话。"; +"lng_settings_archive_title" = "归档设置"; +"lng_settings_new_unknown" = "来自未知用户的新聊天"; +"lng_settings_auto_archive" = "归档并静音"; +"lng_settings_auto_archive_about" = "自动归档及静音来自非联系人的新聊天、群组和频道。"; +"lng_settings_unmuted_chats" = "未静音的聊天"; +"lng_settings_always_in_archive" = "始终保留在归档中"; +"lng_settings_unmuted_chats_about" = "即使已将聊天取消静音并收到新消息,仍将归档的聊天保留在存档中。"; +"lng_settings_chats_from_folders" = "文件夹中的聊天"; +"lng_settings_chats_from_folders_about" = "即使已将聊天取消静音并收到新消息,仍将文件夹中的已归档聊天保留在归档中。"; +"lng_settings_destroy_title" = "删除我的帐号"; +"lng_settings_version_info" = "版本和更新"; +"lng_settings_system_integration" = "系统集成"; +"lng_settings_performance" = "性能"; +"lng_settings_enable_hwaccel" = "使用硬件加速视频解码"; +"lng_settings_enable_opengl" = "为媒体启用 OpenGL 渲染"; +"lng_settings_angle_backend" = "ANGLE 图形后端"; +"lng_settings_angle_backend_auto" = "自动选择"; +"lng_settings_angle_backend_d3d9" = "Direct3D 9"; +"lng_settings_angle_backend_d3d11" = "Direct3D 11"; +"lng_settings_angle_backend_d3d11on12" = "D3D11on12"; +"lng_settings_angle_backend_opengl" = "OpenGL"; +"lng_settings_angle_backend_disabled" = "停用"; +"lng_settings_top_peers_title" = "常用联系人"; +"lng_settings_top_peers_suggest" = "推荐常用联系人"; +"lng_settings_top_peers_about" = "在搜索区域顶部显示常用联系人以便您快速访问。"; +"lng_settings_sensitive_title" = "敏感内容"; +"lng_settings_sensitive_disable_filtering" = "停用过滤"; +"lng_settings_sensitive_about" = "不隐藏包含仅适合成人观看的媒体。"; +"lng_settings_security_bots" = "机器人与网站"; +"lng_settings_file_confirmations" = "文件打开确认"; +"lng_settings_edit_extensions" = "扩展名清单"; +"lng_settings_edit_extensions_about" = "打开具有以下扩展名的文件,无需额外确认。"; +"lng_settings_edit_ip_confirm" = "IP 地址暴露警告"; +"lng_settings_edit_ip_confirm_about" = "开启可能会泄漏您的 IP 位址的文件时显示确认消息。"; +"lng_settings_clear_payment_info" = "清除付款和配送信息"; +"lng_settings_logged_in" = "使用 Tidings 账号登录的网站"; +"lng_settings_logged_in_title" = "使用 Tidings 账号登录的网站"; +"lng_settings_logged_in_description" = "您可以在支持的网站上使用 Tidings 登录。"; +"lng_settings_disconnect_all" = "取消所有网站的授权"; +"lng_settings_disconnect_title" = "断开网站连接"; +"lng_settings_disconnect_sure" = "您确定要断开与 {domain} 的连接吗?"; +"lng_settings_disconnect_block" = "屏蔽 {name}"; +"lng_settings_disconnect_all_title" = "断开网站连接"; +"lng_settings_disconnect_all_sure" = "您确定要断开所有使用 Tidings 登录的网站吗?"; +"lng_settings_disconnect" = "断开连接"; +"lng_settings_connected_title" = "已授权网站"; + +"lng_settings_suggestion_phone_number_title" = "您还在继续使用 {phone} 这个号码吗?"; +"lng_settings_suggestion_phone_number_about" = "保持您的号码是最新的,以确保您始终可以登录 Tidings。{link}"; +"lng_settings_suggestion_phone_number_about_link" = "https://tidings.org/faq#q-i-have-a-new-phone-number-what-do-i-do"; +"lng_settings_suggestion_phone_number_change" = "请尽快在您手机上的官方 Tidings 应用中更改您的电话号码。{emoji}"; + +"lng_settings_power_menu" = "电池和动画"; +"lng_settings_power_title" = "电池使用"; +"lng_settings_power_subtitle" = "省电选项"; +"lng_settings_power_stickers" = "动态贴纸 "; +"lng_settings_power_stickers_panel" = "在面板中自动播放"; +"lng_settings_power_stickers_chat" = "在聊天中自动播放"; +"lng_settings_power_emoji" = "动态表情"; +"lng_settings_power_emoji_panel" = "在面板中自动播放"; +"lng_settings_power_emoji_reactions" = "在反应菜单中自动播放"; +"lng_settings_power_emoji_chat" = "在消息中自动播放"; +"lng_settings_power_emoji_status" = "在高级版表情状态中自动播放"; +"lng_settings_power_chat" = "聊天中的动画"; +"lng_settings_power_chat_background" = "壁纸旋转"; +"lng_settings_power_chat_spoiler" = "动态遮罩特效"; +"lng_settings_power_chat_effects" = "消息中的特效"; +"lng_settings_power_calls" = "通话中的动画"; +"lng_settings_power_ui" = "界面动画"; +"lng_settings_power_auto" = "低电量时启用省电模式"; +"lng_settings_power_auto_about" = "当您的笔记本电脑启用省电模式时,自动关闭所有动画。"; +"lng_settings_power_turn_off" = "请先关闭省电模式,再更改这些设置。"; + +"lng_settings_cloud_password_on" = "开启"; +"lng_settings_cloud_password_off" = "关闭"; +"lng_settings_cloud_password_start_title" = "两步验证"; +"lng_settings_cloud_password_password_title" = "密码"; +"lng_settings_cloud_password_hint_title" = "密码提示"; +"lng_settings_cloud_password_email_title" = "恢复邮箱"; +"lng_settings_cloud_password_start_about" = "使用二级密码保护您的账户。"; +"lng_settings_cloud_password_hint_about" = "您可以为您的密码创建一个提示。"; +"lng_settings_cloud_password_email_about" = "请输入您的恢复邮箱地址。 这是忘记密码时唯一的恢复方法。"; +"lng_settings_cloud_password_password_subtitle" = "设定密码"; +"lng_settings_cloud_password_check_subtitle" = "您的密码"; +"lng_settings_cloud_password_hint_subtitle" = "添加密码提示"; +"lng_settings_cloud_password_email_subtitle" = "添加恢复邮箱"; +"lng_settings_cloud_password_email_recovery_subtitle" = "找回密码"; +"lng_settings_cloud_password_manage_about1" = "您已启用了两步验证,因此您的帐号已受到额外密码的保护。"; +"lng_settings_cloud_password_manage_about2" = "此邮箱是您恢复密码的唯一方式。"; +"lng_settings_cloud_password_manage_disable_sure" = "您确定要停用密码吗?"; +"lng_settings_cloud_password_manage_email_new" = "设置恢复邮箱"; +"lng_settings_cloud_password_manage_email_change" = "更改密码恢复电子邮箱"; +"lng_settings_cloud_password_manage_password_change" = "修改密码"; +"lng_settings_cloud_password_skip_hint" = "跳过该提示"; +"lng_settings_cloud_password_save" = "保存并完成"; +"lng_settings_cloud_password_email_confirm" = "确认并结束"; +"lng_settings_cloud_password_reset_in" = "您可以在 {duration} 之后重置密码"; + +"lng_settings_cloud_login_email_section_title" = "邮箱登录"; +"lng_settings_cloud_login_email_box_about" = "你每次从新的设备登入Tidings 时都会使用该电子邮箱。"; +"lng_settings_cloud_login_email_box_ok" = "更改邮箱"; +"lng_settings_cloud_login_email_title" = "输入新的邮箱地址"; +"lng_settings_cloud_login_email_placeholder" = "输入登录邮箱"; +"lng_settings_cloud_login_email_about" = "您将通过电子邮箱而非短信收取 Tidings 登录码。请输入您可以访问的电子邮箱地址。"; +"lng_settings_cloud_login_email_confirm" = "确认"; +"lng_settings_cloud_login_email_code_title" = "检查你的新邮件"; +"lng_settings_cloud_login_email_code_about" = "请输入我们发送至你的新邮件 {email} 的代码。"; +"lng_settings_cloud_login_email_success" = "你的电子邮箱地址已更改。"; +"lng_settings_error_email_not_alowed" = "抱歉,不允许使用此电子邮箱"; + +"lng_settings_ttl_title" = "自动删除消息"; +"lng_settings_ttl_about" = "在开始的所有新聊天中,一段时间后自动删除所有消息。"; +"lng_settings_ttl_after" = "{after_duration} 后"; + +"lng_settings_ttl_after_hours#other" = "{count} 小时"; + +"lng_settings_ttl_after_days#other" = "{count} 天"; + +"lng_settings_ttl_after_weeks#other" = "{count} 周"; + +"lng_settings_ttl_after_months#other" = "{count} 个月"; + +"lng_settings_ttl_after_years#other" = "{count} 年"; +"lng_settings_ttl_after_off" = "关闭"; +"lng_settings_ttl_after_custom" = "自定义时间"; +"lng_settings_ttl_after_about" = "如果启用,您发起的聊天中的所有新消息将在发送后的某个时间点自动删除。您还可以 {link}。"; +"lng_settings_ttl_after_about_link" = "将此设置应用到现有的聊天"; +"lng_settings_ttl_after_subtitle" = "自毁定时器"; +"lng_settings_ttl_after_sure" = "您确定要于消息发送 {after_duration} 后为所有人自动删除您新建的私聊和群组中的所有消息吗?"; +"lng_settings_ttl_after_toast" = "您发起的所有新聊天中的消息将在 {after_duration} 后自动删除。"; + +"lng_settings_ttl_select_chats_sorry" = "抱歉,您不能在此聊天中设置自毁定时器。"; +"lng_settings_ttl_select_chats_status" = " {after_duration} 后自动删除"; +"lng_settings_ttl_select_chats_status_disabled" = "自动删除已禁用"; + +"lng_settings_ttl_select_chats_toast#other" = "时长为 {duration} 的自毁计时器已在选中的 {count} 个聊天中启用。"; + +"lng_settings_ttl_select_chats_disabled_toast#other" = "已在所选的{count}个聊天中停用自毁计时器。"; + +"lng_clear_payment_info_title" = "清除付款信息"; +"lng_clear_payment_info_sure" = "您确定要清除您的付款和收货信息吗?"; +"lng_clear_payment_info_shipping" = "配送信息"; +"lng_clear_payment_info_payment" = "付款信息"; +"lng_clear_payment_info_clear" = "清除"; +"lng_clear_payment_info_confirm" = "是否删除您的配送信息,并使所有的支付服务提供商清除您保存的信用卡信息?请注意 Tidings 从来不会存储您的信用卡数据。"; + +"lng_settings_theme_settings" = "主题设置"; +"lng_settings_theme_name_color" = "你的名称颜色"; +"lng_settings_auto_night_mode" = "自动切换夜间模式"; +"lng_settings_auto_night_mode_off" = "关闭"; +"lng_settings_auto_night_mode_on" = "系统"; +"lng_settings_auto_night_warning" = "您已开启自动夜间模式,停用后才可以修改夜间模式设置。"; +"lng_settings_auto_night_disable" = "关闭"; +"lng_settings_font_family" = "字体系列"; + +"lng_settings_color_title" = "颜色预览"; +"lng_settings_color_reply" = "回复你的消息"; +"lng_settings_color_reply_channel" = "回复您频道消息"; +"lng_settings_color_text" = "你的名称与回复将以所选的颜色显示。"; +"lng_settings_color_text_channel" = "频道名称及回复消息将以所选的颜色显示。"; +"lng_settings_color_link_name" = "Tidings"; +"lng_settings_color_link_title" = "链接预览"; +"lng_settings_color_link_description" = "该颜色也会套用于您发送的链接预览。"; +"lng_settings_color_about" = "您可以选择一种颜色为自己的名字、发送的链接和收到的回复着色。"; +"lng_settings_color_about_channel" = "您可以选择一种颜色为您的频道名称、发送的链接以及频道贴文的回复着色。"; +"lng_settings_color_emoji" = "为回复添加图标"; +"lng_settings_color_emoji_remove" = "移除图标"; +"lng_settings_color_emoji_off" = "关闭"; +"lng_settings_color_emoji_about" = "通过向消息添加自定图标,使您回复的消息脱颖而出。"; +"lng_settings_color_emoji_about_channel" = "选择一个图标并为收到的回复创建自定义样式。"; +"lng_settings_color_subscribe" = "订阅 {link} 并为您的名字选择自定义颜色。"; +"lng_settings_color_changed" = "您频道颜色已更新!"; +"lng_settings_color_changed_channel" = "您频道颜色已更新!"; + +"lng_suggest_hide_new_title" = "隐藏新聊天?"; +"lng_suggest_hide_new_about" = "您收到了很多来自陌生用户的新聊天会话。您是否想将这些聊天会话**自动静音**及**归档**?"; +"lng_suggest_hide_new_to_settings" = "转到设置"; + +"lng_settings_spellchecker" = "拼写检查"; +"lng_settings_system_spellchecker" = "使用系统拼写检查器"; +"lng_settings_custom_spellchecker" = "使用拼写检查"; +"lng_settings_auto_download_dictionaries" = "自动下载字典"; +"lng_settings_manage_dictionaries" = "管理字典"; +"lng_settings_manage_enabled_dictionary" = "字典已启用"; +"lng_settings_manage_remove_dictionary" = "移除词典"; + +"lng_settings_gift_premium" = "赠送 Tidings 高级版订阅"; +"lng_settings_gift_premium_users_confirm" = "继续"; + +"lng_settings_gift_premium_users_error#other" = "您最多可以选择 {count} 个用户。"; +"lng_settings_gift_premium_choose" = "请至少选择一位接收者。"; + +"lng_backgrounds_header" = "选择新的聊天壁纸"; +"lng_theme_sure_keep" = "保留此主题?"; + +"lng_theme_reverting#other" = "将在 {count} 秒后还原为之前的主题。"; +"lng_theme_keep_changes" = "保留更改"; +"lng_theme_revert" = "还原"; +"lng_theme_no_desktop" = "抱歉,此主题未包含 Tidings Desktop 的版本。"; +"lng_theme_share" = "分享"; +"lng_theme_edit" = "编辑"; +"lng_theme_delete" = "删除"; +"lng_theme_delete_sure" = "您确定要删除此主题吗?"; +"lng_background_header" = "壁纸预览"; +"lng_background_text1" = "你们这群小屁孩就知道电子音乐!你们应该听听经典摇滚,比如哈塞尔霍夫!"; +"lng_background_text2" = "我现在已经无法直视你了。"; +"lng_background_bad_link" = "背景链接无效。"; +"lng_background_share" = "分享"; +"lng_background_link_copied" = "链接已复制到剪贴板"; +"lng_background_blur" = "模糊"; +"lng_background_sure_delete" = "您确定要删除此背景吗?"; +"lng_background_other_info" = "{user} 可以应用此壁纸"; +"lng_background_other_channel" = "所有订阅者都将看到此壁纸"; +"lng_background_other_group" = "所有成员能够查看该壁纸"; +"lng_background_apply1" = "在聊天中应用此背景。"; +"lng_background_apply2" = "看起来不错"; +"lng_background_apply_button" = "应用于此聊天"; +"lng_background_dimming" = "背景虚化"; +"lng_background_sure_reset_default" = "您确定要重置墙纸吗?"; +"lng_background_reset_default" = "重置"; +"lng_background_apply_me" = "为自己应用"; +"lng_background_apply_both" = "为自己和 {user} 应用"; +"lng_background_apply_channel" = "为频道应用"; +"lng_background_apply_group" = "为群组应用"; + +"lng_download_path_ask" = "询问每个文件的下载路径"; +"lng_download_path" = "下载路径"; +"lng_download_path_temp" = "临时文件夹"; +"lng_download_path_default" = "默认目录"; +"lng_download_path_header" = "选择下载目录"; +"lng_download_path_default_radio" = "「下载」文件夹"; +"lng_download_path_temp_radio" = "临时目录,在登出/卸载时会自动清空"; +"lng_download_path_dir_radio" = "自定义目录,只能手动清理"; +"lng_download_path_choose" = "选择下载目录"; +"lng_download_path_failed" = "无法开始下载\n\n您选择的下载目录可能无效。请尝试在「设置 - 高级」中更改下载目录。 "; +"lng_download_finish_failed" = "文件下载失败\n\n是否重试?"; + +"lng_settings_section_privacy" = "隐私和安全"; + +"lng_local_storage_title" = "本地缓存"; +"lng_local_storage_empty" = "无缓存文件"; + +"lng_local_storage_image#other" = "{count} 张图片"; + +"lng_local_storage_sticker#other" = "{count} 张表情"; + +"lng_local_storage_voice#other" = "{count} 条语音消息"; + +"lng_local_storage_round#other" = "{count} 条视频消息"; + +"lng_local_storage_animation#other" = "{count} 张 GIF 动态图"; +"lng_local_storage_media" = "媒体缓存"; +"lng_local_storage_size_limit" = "总大小限制:{size}"; +"lng_local_storage_media_limit" = "媒体缓存限制: {size}"; +"lng_local_storage_time_limit" = "媒体文件保留时间:{limit}"; +"lng_local_storage_limit_never" = "永久"; +"lng_local_storage_summary" = "总计"; +"lng_local_storage_clear_some" = "清理"; +"lng_local_storage_clear" = "清理缓存"; +"lng_local_storage_clearing" = "清理中…"; + +"lng_passcode_remove_button" = "移除"; + +"lng_passcode_change" = "更改锁定码"; +"lng_passcode_create" = "本机锁定码"; +"lng_passcode_remove" = "删除本机锁定码"; +"lng_passcode_autolock" = "自动锁定"; +"lng_passcode_autolock_away" = "自动锁定设置:"; +"lng_passcode_autolock_inactive" = "自动锁定设置:"; +"lng_passcode_autolock_hours_minutes" = "{hours_count} 小时 {minutes_count} 分钟"; +"lng_passcode_enter_old" = "请输入当前的锁定码"; +"lng_passcode_enter_first" = "请输入一个锁定码"; +"lng_passcode_enter_new" = "请输入新的锁定码"; +"lng_passcode_confirm_new" = "请再次输入新的锁定码"; +"lng_passcode_about" = "设置好一个锁定码后,聊天列表上方会出现一个锁形图标,点击它来锁定 Tidings 应用。\n\n注意:如果您忘记了锁定码,您将需要重新登录 Tidings。"; +"lng_passcode_about1" = "设置好一个锁定码后,您的聊天列表顶部会出现一个锁形图标。"; +"lng_passcode_about2" = "点击以锁定 Tidings 桌面版。"; +"lng_passcode_about3" = "注意:如果您忘记了口令,您需要退出桌面版 Tidings 并重新登录。"; +"lng_passcode_differ" = "锁定码前后不一致"; +"lng_passcode_wrong" = "锁定码错误"; +"lng_passcode_is_same" = "请勿使用当前的锁定码"; +"lng_passcode_enter" = "请输入本机锁定码"; +"lng_passcode_ph" = "锁定码"; +"lng_passcode_submit" = "解锁"; +"lng_passcode_logout" = "登出"; +"lng_passcode_winhello" = "您需要输入您的密码 \n才可以使用 Windows Hello。"; +"lng_passcode_touchid" = "您需要输入您的密码 \n才可以使用 Touch ID。"; +"lng_passcode_applewatch" = "您需要输入您的密码 \n才可以使用手表解锁。"; +"lng_passcode_systempwd" = "您需要输入您的密码 \n才可以使用系统密码。"; +"lng_passcode_winhello_unlock" = "Tidings 希望通过 Windows Hello 解锁。"; +"lng_passcode_touchid_unlock" = "解锁"; +"lng_passcode_create_button" = "保存锁定码"; +"lng_passcode_check_button" = "提交"; +"lng_passcode_change_button" = "保存锁定码"; +"lng_passcode_create_title" = "创建本地密码"; +"lng_passcode_check_title" = "输入锁定码"; +"lng_passcode_change_title" = "设置密码"; + +"lng_cloud_password_waiting_code" = "验证码已发送到 {email}..."; +"lng_cloud_password_confirm" = "确认恢复电子邮箱"; +"lng_cloud_password_change" = "修改两步验证密码"; +"lng_cloud_password_create" = "Tidings 密码"; +"lng_cloud_password_remove" = "删除两步验证密码"; +"lng_cloud_password_reset_ready" = "重置密码"; +"lng_cloud_password_enter_old" = "请输入当前密码"; +"lng_cloud_password_enter_first" = "请输入密码"; +"lng_cloud_password_enter_new" = "请输入新密码"; +"lng_cloud_password_confirm_new" = "请再次输入新密码"; +"lng_cloud_password_hint" = "请设置密码提示"; +"lng_cloud_password_change_hint" = "请输入新的密码提示"; +"lng_cloud_password_bad" = "密码提示不能与密码相同。"; +"lng_cloud_password_email" = "输入密码恢复邮箱"; +"lng_cloud_password_bad_email" = "电子邮箱不可用"; +"lng_cloud_password_about" = "当您在新设备登录时,此密码将作为输入短信验证码后的附加验证。"; +"lng_cloud_password_about_recover" = "警告!您确定不设置密码恢复邮箱吗?\n\n一旦忘记密码,您将无法再访问您的 Tidings 帐号。"; +"lng_cloud_password_skip_email" = "我不需要设置!"; +"lng_cloud_password_was_set" = "已启用两步验证。"; +"lng_cloud_password_updated" = "两步验证密码已更新。"; +"lng_cloud_password_removed" = "已停用两步验证。"; +"lng_cloud_password_differ" = "前后两次输入的密码不一致"; +"lng_cloud_password_wrong" = "两步验证密码错误"; +"lng_cloud_password_is_same" = "请勿使用当前的锁定码"; +"lng_cloud_password_passport_losing" = "警告!所有存储在 Tidings 通行证里的数据都将丢失!"; +"lng_cloud_password_resend" = "重新发送验证码"; +"lng_cloud_password_resent" = "验证码已重新发送。"; +"lng_cloud_password_reset_no_email" = "由于您在设置密码时没有提供恢复的电子邮箱,您当前只有两种选择,要么回想起密码,要么等待7天后密码重置。"; +"lng_cloud_password_reset_with_email" = "如果你无法访问密保邮箱,只能尝试记起密码或者等待 7 天直到密码重置。"; +"lng_cloud_password_reset_ok" = "重置"; +"lng_cloud_password_reset_cancel_title" = "取消重置"; +"lng_cloud_password_reset_cancel_sure" = "是否取消重置密码?若要再次申请密码重置,需要再等 7 天。"; +"lng_cloud_password_reset_later" = "密码重置请求于 {duration} 后重试。"; +"lng_cloud_password_expired" = "请重新输入您的密码"; + +"lng_connection_auto_connecting" = "默认(连接中…)"; +"lng_connection_auto" = "默认(已使用 {transport} 连接)"; +"lng_connection_proxy_connecting" = "正在通过代理连接…"; +"lng_connection_proxy" = "通过代理的 {transport} 连接"; +"lng_connection_try_ipv6" = "尝试通过 IPv6 连接"; +"lng_connection_host_ph" = "主机名"; +"lng_connection_port_ph" = "端口"; +"lng_connection_user_ph" = "用户名"; +"lng_connection_password_ph" = "密码"; +"lng_connection_proxy_secret_ph" = "密钥"; +"lng_connection_save" = "保存"; + +"lng_proxy_settings" = "代理设置"; +"lng_proxy_disable" = "停用代理"; +"lng_proxy_use_system_settings" = "使用系统代理设置"; +"lng_proxy_use_custom" = "使用自定义代理"; +"lng_proxy_use_for_calls" = "对通话启用代理"; +"lng_proxy_about" = "代理服务器可能有助于解决个别地区无法连接 Tidings 的情况。"; +"lng_proxy_add" = "添加代理"; +"lng_proxy_share" = "分享"; +"lng_proxy_online" = "已连接"; +"lng_proxy_checking" = "正在检查"; +"lng_proxy_connecting" = "正在连接"; +"lng_proxy_available" = "可用 (ping: {ping} 毫秒)"; +"lng_proxy_unavailable" = "不可用"; +"lng_proxy_edit" = "编辑代理"; +"lng_proxy_menu_edit" = "编辑"; +"lng_proxy_menu_delete" = "删除"; +"lng_proxy_menu_restore" = "撤销删除"; +"lng_proxy_edit_share" = "分享"; +"lng_proxy_address_label" = "代理地址"; +"lng_proxy_credentials_optional" = "认证信息(可选)"; +"lng_proxy_credentials" = "认证信息"; +"lng_proxy_description" = "此处是您已保存的代理。"; +"lng_proxy_sponsor" = "代理赞助商"; +"lng_proxy_sponsor_about" = "此频道是由您的代理服务器所展示的。若要从您的对话列表中移除此频道,在 Tidings 设置中禁用代理即可。"; +"lng_proxy_sponsor_warning" = "这个代理可能会在您的对话列表中显示其赞助频道。代理服务器无法泄露任何您的 Tidings 内容。"; +"lng_proxy_add_from_clipboard" = "从剪贴板添加代理"; +"lng_proxy_add_from_clipboard_good_toast" = "代理已从剪贴板添加。"; +"lng_proxy_add_from_clipboard_failed_toast" = "这不是代理链接。"; +"lng_proxy_add_from_clipboard_existing_toast" = "此代理已在清单中。"; +"lng_badge_psa_default" = "PSA"; +"lng_about_psa_default" = "该对话提供公共服务通知。右键菜单选择 **隐藏** 移除它。"; +"lng_tooltip_psa_default" = "该对话提供公共服务通知。"; + +"lng_settings_blocked_users" = "已拉黑用户"; +"lng_settings_no_blocked_users" = "无"; +"lng_settings_show_sessions" = "显示所有活跃会话"; +"lng_settings_export_data" = "导出 Tidings 数据"; +"lng_settings_destroy_if" = "如果离开超过"; + +"lng_settings_terminate_title" = "自动结束旧会话"; +"lng_settings_terminate_if" = "如果我不上线"; +"lng_settings_reset_sure" = "您确定要注销所有其它设备上的会话吗?"; +"lng_settings_reset_one_sure" = "您确定要注销此会话吗?"; +"lng_settings_reset_button" = "注销"; +"lng_settings_rename_device" = "重命名"; +"lng_settings_device_name" = "设备名称"; +"lng_settings_rename_device_title" = "重命名当前设备"; +"lng_settings_manage_local_storage" = "缓存管理"; +"lng_settings_ask_question" = "向我们提问"; +"lng_settings_ask_sure" = "请注意:Tidings 的技术支持由志愿者提供。我们会尽快回复,但仍可能需要等待一段时间。\n\n请前往“常见问题”看看:那里有各种常见问题和故障排除指南。"; +"lng_settings_faq_button" = "前往「常见问题」"; +"lng_settings_ask_ok" = "向志愿者提问"; +"lng_settings_faq" = "Tidings 常见问题"; +"lng_settings_faq_link" = "https://tidings.org/faq#general-questions"; +"lng_settings_features" = "Tidings 功能"; +"lng_settings_credits" = "我的星星"; +"lng_settings_logout" = "登出"; +"lng_sure_logout" = "您确定要登出吗?"; + +"lng_settings_need_restart" = "您需要重启软件才能应用某些更改。是否立即重启?"; +"lng_settings_restart_now" = "立即重启"; +"lng_settings_restart_later" = "以后再说"; + +"lng_settings_quick_dialog_action_title" = "聊天列表快速操作"; +"lng_settings_quick_dialog_action_about" = "选择在聊天列表中单击鼠标中键或向左滑动时要执行的操作。"; +"lng_settings_quick_dialog_action_both" = "向左滑动并单击鼠标中键"; +"lng_settings_quick_dialog_action_swipe" = "向左滑动"; +"lng_settings_quick_dialog_action_mute" = "静音"; +"lng_settings_quick_dialog_action_unmute" = "取消静音"; +"lng_settings_quick_dialog_action_pin" = "置顶"; +"lng_settings_quick_dialog_action_unpin" = "取消置顶"; +"lng_settings_quick_dialog_action_read" = "已读"; +"lng_settings_quick_dialog_action_unread" = "未读"; +"lng_settings_quick_dialog_action_archive" = "归档"; +"lng_settings_quick_dialog_action_unarchive" = "取消归档"; +"lng_settings_quick_dialog_action_delete" = "删除"; +"lng_settings_quick_dialog_action_disabled" = "更改文件夹"; + +"lng_quick_dialog_action_toast_mute_success" = "已静音此聊天的通知。"; +"lng_quick_dialog_action_toast_unmute_success" = "已启用此聊天的通知。"; +"lng_quick_dialog_action_toast_pin_success" = "此聊天已置顶。"; +"lng_quick_dialog_action_toast_unpin_success" = "此聊天已取消置顶。"; +"lng_quick_dialog_action_toast_read_success" = "此聊天已标记为已读。"; +"lng_quick_dialog_action_toast_unread_success" = "此聊天已标记为未读。"; +"lng_quick_dialog_action_toast_archive_success" = "此聊天已归档。"; +"lng_quick_dialog_action_toast_unarchive_success" = "此聊天已取消归档。"; + +"lng_settings_generic_subscribe" = "订阅 {link} 以使用此设置。"; +"lng_settings_generic_subscribe_link" = "Tidings 高级版"; + +"lng_sessions_header" = "此设备"; +"lng_sessions_other_header" = "活跃会话"; +"lng_sessions_other_desc" = "您可以从其它的手机、平板和桌面设备用同样的手机号码登录到 Tidings。所有数据将被实时同步。"; +"lng_sessions_terminate_all" = "注销所有其它设备上的会话"; +"lng_sessions_terminate_all_about" = "登出除此设备外的所有设备。"; +"lng_sessions_incomplete" = "未完成的登录尝试"; +"lng_sessions_incomplete_about" = "上述设备无法获取您的消息。验证码输入正确,但没有提供正确的密码。"; +"lng_sessions_info" = "详细信息"; +"lng_sessions_terminate" = "终止会话"; +"lng_sessions_application" = "应用程序"; +"lng_sessions_system" = "系统版本"; +"lng_sessions_browser" = "浏览器"; +"lng_sessions_ip" = "IP 地址"; +"lng_sessions_location" = "位置"; +"lng_sessions_location_about" = "该位置根据 IP 地址估算,可能并不准确。"; +"lng_sessions_about_apps" = "Tidings 官方应用可用于 Android、iPhone、iPad、Windows、macOS 和 Linux。"; + +"lng_blocked_list_title" = "已屏蔽的用户"; +"lng_blocked_list_unknown_phone" = "手机号码未知"; +"lng_blocked_list_unblock" = "移出黑名单"; +"lng_blocked_list_add" = "屏蔽用户"; +"lng_blocked_list_add_title" = "选择要拉黑的用户"; +"lng_blocked_list_already_blocked" = "已拉黑"; +"lng_blocked_list_about" = "被您屏蔽的用户将无法向您发送消息,或邀请您加入群组。该用户也无法看到您的头像、动态、在线状态及最后上线时间。"; +"lng_blocked_list_not_found" = "未找到符合条件的用户。"; +"lng_blocked_list_confirm_title" = "屏蔽 {name}"; +"lng_blocked_list_confirm_text" = "您想要阻止 {name} 在Tidings上给您发消息及打电话吗?"; +"lng_blocked_list_confirm_clear" = "删除此聊天"; +"lng_blocked_list_confirm_ok" = "屏蔽"; +"lng_blocked_list_empty_title" = "没有已屏蔽用户"; +"lng_blocked_list_empty_description" = "您尚未屏蔽任何人。"; + +"lng_blocked_list_subtitle#other" = "{count} 个已屏蔽用户"; + +"lng_edit_privacy_everyone" = "所有人"; +"lng_edit_privacy_no_miniapps" = "不包含迷你应用"; +"lng_edit_privacy_contacts" = "我的联系人"; +"lng_edit_privacy_close_friends" = "密友"; +"lng_edit_privacy_contacts_and_premium" = "联系人和高级版用户"; +"lng_edit_privacy_paid" = "付费"; +"lng_edit_privacy_contacts_and_miniapps" = "联系人与迷你应用"; +"lng_edit_privacy_nobody" = "不允许任何人"; +"lng_edit_privacy_premium" = "高级版用户"; +"lng_edit_privacy_miniapps" = "迷你应用"; +"lng_edit_privacy_exceptions" = "添加例外"; +"lng_edit_privacy_user_types" = "用户类型"; +"lng_edit_privacy_users_and_groups" = "用户和群组"; +"lng_edit_privacy_premium_status" = "所有 Tidings 高级版订阅用户"; +"lng_edit_privacy_miniapps_status" = "你所使用的迷你应用"; + + +"lng_edit_privacy_exceptions_count#other" = "{count} 位用户"; +"lng_edit_privacy_exceptions_premium_and" = "高级版用户和 {users}"; +"lng_edit_privacy_exceptions_miniapps_and" = "迷你应用与 {users}"; +"lng_edit_privacy_exceptions_add" = "添加用户"; + +"lng_edit_privacy_phone_number_title" = "手机号码隐私设置"; +"lng_edit_privacy_phone_number_header" = "谁能看到您的手机号码?"; +"lng_edit_privacy_phone_number_warning" = "已将您的手机号码保存到联系人的用户也将在 Tidings 上看到它。"; +"lng_edit_privacy_phone_number_always_empty" = "总是分享给"; +"lng_edit_privacy_phone_number_never_empty" = "从不分享给"; +"lng_edit_privacy_phone_number_exceptions" = "您可以额外添加用户或群组覆盖上面的设置。"; +"lng_edit_privacy_phone_number_always_title" = "总是分享给"; +"lng_edit_privacy_phone_number_never_title" = "从不分享给"; + +"lng_edit_privacy_phone_number_find" = "谁可以通过手机号码找到我"; +"lng_edit_privacy_phone_number_contacts" = "对于将您的号码加入联系人列表的用户,只有当您将其添加为联系人后,对方才会看到您的号码。"; + +"lng_edit_privacy_lastseen_title" = "最后上线时间隐私设置"; +"lng_edit_privacy_lastseen_header" = "谁可以看到您最后上线的时间?"; +"lng_edit_privacy_lastseen_warning" = "除非您是高级版订阅用户,对于您不愿意分享最后上线时间的对象,他们最后的上线时间您将也无法看到。取而代之的是大概的上线时间(如:最近、一周内、一个月内)。"; +"lng_edit_privacy_lastseen_always_empty" = "总是分享给"; +"lng_edit_privacy_lastseen_never_empty" = "从不分享给"; +"lng_edit_privacy_lastseen_exceptions" = "您可以将用户或整个群组设为例外,不受以上设置的限制。"; +"lng_edit_privacy_lastseen_always_title" = "总是分享给"; +"lng_edit_privacy_lastseen_never_title" = "从不分享给"; +"lng_edit_lastseen_hide_read_time" = "隐藏已读时间"; +"lng_edit_lastseen_hide_read_time_about" = "对无法看到您最后上线时间的用户隐藏您的已读时间。如果您开启此功能,对方的已读时间也会对您隐藏(除非您是高级版订阅用户)。 \n\n此设定不影响群聊。"; +"lng_edit_lastseen_subscribe" = "订阅 Tidings 高级版"; +"lng_edit_lastseen_subscribe_about" = "如果您订阅了 Tidings 高级版,您可以看到其他用户的最后上线时间及已读时间,即使您对其隐藏了自己的最后上线时间及已读时间(除非对方明确限制)。"; + +"lng_edit_privacy_groups_title" = "群组与频道"; +"lng_edit_privacy_groups_header" = "谁可以将您添加到群组/频道?"; +"lng_edit_privacy_groups_always_empty" = "可以添加您到群组/频道的用户"; +"lng_edit_privacy_groups_never_empty" = "从不允许"; +"lng_edit_privacy_groups_exceptions" = "上面的设置项将决定这些用户是否能添加您到群组或频道。"; +"lng_edit_privacy_groups_always_title" = "选择可以添加您到群组/频道的用户"; +"lng_edit_privacy_groups_never_title" = "从不允许"; + +"lng_edit_privacy_about_title" = "个人简介"; +"lng_edit_privacy_about_header" = "谁可以看到我的个人简介?"; +"lng_edit_privacy_about_always_empty" = "总是分享给"; +"lng_edit_privacy_about_never_empty" = "从不分享给"; +"lng_edit_privacy_about_exceptions" = "无论上述设置如何,这些用户将处于例外。"; +"lng_edit_privacy_about_always_title" = "总是分享给"; +"lng_edit_privacy_about_never_title" = "从不分享给"; + +"lng_edit_privacy_birthday_title" = "出生日期隐私设置"; +"lng_edit_privacy_birthday_header" = "谁能看到我的出生日期?"; +"lng_edit_privacy_birthday_always_empty" = "始终允许"; +"lng_edit_privacy_birthday_never_empty" = "永不允许"; +"lng_edit_privacy_birthday_exceptions" = "无论上方设置如何,这些用户将会或不会看到你的出生日期。"; +"lng_edit_privacy_birthday_always_title" = "始终允许"; +"lng_edit_privacy_birthday_never_title" = "永不允许"; +"lng_edit_privacy_birthday_yet" = "你还未添加你的出生日期。\n{link}"; +"lng_edit_privacy_birthday_yet_link" = "添加我的出生日期 >"; + +"lng_edit_privacy_gifts_title" = "礼物"; +"lng_edit_privacy_gifts_header" = "谁可以在我的个人资料页上展示礼物"; +"lng_edit_privacy_gifts_always_empty" = "总是允许"; +"lng_edit_privacy_gifts_never_empty" = "永不允许"; +"lng_edit_privacy_gifts_exceptions" = "选择来自特定用户的礼物是否需要经过您的批准才能在您的个人资料页中对他人可见。"; +"lng_edit_privacy_gifts_always_title" = "总是允许"; +"lng_edit_privacy_gifts_never_title" = "永不允许"; + +"lng_edit_privacy_gifts_types" = "接受的礼物类型"; +"lng_edit_privacy_gifts_premium" = "高级版订阅"; +"lng_edit_privacy_gifts_unlimited" = "所有人"; +"lng_edit_privacy_gifts_limited" = "限量版"; +"lng_edit_privacy_gifts_unique" = "独家"; +"lng_edit_privacy_gifts_types_about" = "选择您允许他人向您赠送的礼物类型。"; +"lng_edit_privacy_gifts_show_icon" = "在聊天中显示礼物图标"; +"lng_edit_privacy_gifts_show_icon_about" = "在所有聊天的消息输入框中显示 {emoji} 礼物图标,所有参与者均可见。"; +"lng_edit_privacy_gifts_restricted" = "该用户不接受礼物。"; + +"lng_edit_privacy_calls_title" = "语音通话"; +"lng_edit_privacy_calls_header" = "谁可以给您打电话?"; +"lng_edit_privacy_calls_always_empty" = "可以给您打电话的用户"; +"lng_edit_privacy_calls_never_empty" = "从不允许"; +"lng_edit_privacy_calls_exceptions" = "这些用户能否呼叫您将不取决于上面的设置项"; +"lng_edit_privacy_calls_always_title" = "选择可以给您打电话的用户"; +"lng_edit_privacy_calls_never_title" = "从不允许"; + +"lng_edit_privacy_calls_p2p_title" = "在通话中使用端对端连接"; +"lng_edit_privacy_calls_p2p_header" = "对谁启用端对端连接?"; +"lng_edit_privacy_calls_p2p_always_empty" = "总是允许"; +"lng_edit_privacy_calls_p2p_never_empty" = "从不允许"; +"lng_edit_privacy_calls_p2p_exceptions" = "无论上述设置如何,通话中的端对端连接都会或者不会对这些用户启用。"; +"lng_edit_privacy_calls_p2p_always_title" = "总是允许"; +"lng_edit_privacy_calls_p2p_never_title" = "从不允许"; + +"lng_edit_privacy_calls_p2p_everyone" = "所有人"; +"lng_edit_privacy_calls_p2p_contacts" = "我的联系人"; +"lng_edit_privacy_calls_p2p_nobody" = "不允许任何人"; + +"lng_edit_privacy_forwards_title" = "转发的消息"; +"lng_edit_privacy_forwards_header" = "转发我的消息时,谁可以链接至我的账号"; +"lng_edit_privacy_forwards_warning" = "当其他用户转发时,你发送的消息不会链接到您的账号。"; +"lng_edit_privacy_forwards_always_empty" = "总是允许"; +"lng_edit_privacy_forwards_never_empty" = "从不允许"; +"lng_edit_privacy_forwards_exceptions" = "这些设置将覆盖上述值。"; +"lng_edit_privacy_forwards_exceptions_everyone" = "你可以添加无权查看你的个人资料图片的用户或整个群组。"; +"lng_edit_privacy_forwards_exceptions_nobody" = "添加仍可查看你的个人资料照片的用户或整个群组。"; +"lng_edit_privacy_forwards_always_title" = "总是允许"; +"lng_edit_privacy_forwards_never_title" = "从不允许"; +"lng_edit_privacy_forwards_sample_message" = "莱因哈特,我们需要给你找一些新的曲调🎶"; +"lng_edit_privacy_forwards_sample_everyone" = "链接至您的帐号。"; +"lng_edit_privacy_forwards_sample_contacts" = "如果以下设置已允许将会链接至您的帐号。"; +"lng_edit_privacy_forwards_sample_nobody" = "无法链接到你的帐号"; + +"lng_edit_privacy_profile_photo_title" = "头像"; +"lng_edit_privacy_profile_photo_header" = "谁能看到我的头像"; +"lng_edit_privacy_profile_photo_always_empty" = "总是共享给"; +"lng_edit_privacy_profile_photo_never_empty" = "从不分享给"; +"lng_edit_privacy_profile_photo_always_title" = "总是分享给"; +"lng_edit_privacy_profile_photo_never_title" = "从不分享给"; +"lng_edit_privacy_profile_photo_public_set" = "设置公共照片"; +"lng_edit_privacy_profile_photo_public_update" = "更新公共照片"; +"lng_edit_privacy_profile_photo_public_remove" = "移除公共照片"; +"lng_edit_privacy_profile_photo_public_about" = "您可以为被限制查看真实个人资料照片的用户上传公开照片。"; + +"lng_edit_privacy_voices_title" = "语音消息"; +"lng_edit_privacy_voices_header" = "谁能给我发送语音消息"; +"lng_edit_privacy_voices_always_empty" = "总是允许"; +"lng_edit_privacy_voices_never_empty" = "从不允许"; +"lng_edit_privacy_voices_exceptions" = "无论上述设置如何,这些用户都将可以或无法给您发送语音消息。"; +"lng_edit_privacy_voices_always_title" = "总是允许"; +"lng_edit_privacy_voices_never_title" = "从不允许"; + +"lng_messages_privacy_title" = "消息"; +"lng_messages_privacy_subtitle" = "谁可以给我发送消息"; +"lng_messages_privacy_everyone" = "所有人"; +"lng_messages_privacy_restricted" = "我的联系人和 Tidings 高级版用户"; +"lng_messages_privacy_about" = "您可以限制来自非联系人且没有 Tidings 高级版的用户的消息。 "; +"lng_messages_privacy_premium_button" = "订阅 Tidings 高级版"; +"lng_messages_privacy_premium_about" = "立即订阅以更改此设置,并访问 Tidings 高级版的其他专属功能。"; +"lng_messages_privacy_premium" = "只有 {link} 的订阅者才能选择此选项。"; +"lng_messages_privacy_premium_link" = "Tidings 高级版"; +"lng_messages_privacy_charge" = "付费消息"; +"lng_messages_privacy_charge_about" = "向非联系人或从未向你发送过消息的人收取消息费用。"; +"lng_messages_privacy_price" = "设置您每条消息的价格"; +"lng_messages_privacy_price_about" = "每逢收到一条消息,你将获取设置费用的 {percent}(~{amount})。"; +"lng_messages_privacy_exceptions" = "例外"; +"lng_messages_privacy_remove_fee" = "免收费用"; +"lng_messages_privacy_remove_about" = "添加无需付费即可向你发送消息的用户或整个群组。"; + +"lng_self_destruct_title" = "自动删除帐号"; +"lng_self_destruct_description" = "如果您在此期间内您的帐号未曾上线,您帐号的所有资料——包括消息记录、联系人都会被删除。"; +"lng_self_destruct_sessions_title" = "注销会话"; +"lng_self_destruct_sessions_description" = "如果您在此期间没有从特定会话中至少上线一次,则该会话将被终止。"; + +"lng_change_phone_new_submit" = "提交"; +"lng_change_phone_code_title" = "输入验证码"; +"lng_change_phone_error" = "您只能使用 Tidings 移动版应用更改您的电话号码,请使用手机上的官方 Tidings 应用更新号码。"; + +"lng_mute_menu_mute" = "静音"; +"lng_mute_menu_unmute" = "取消静音"; +"lng_mute_menu_duration_any" = "静音 {duration}"; +"lng_mute_menu_duration" = "设置静音时长..."; +"lng_mute_menu_duration_forever" = "永久静音"; +"lng_mute_menu_duration_unmute" = "取消静音"; +"lng_mute_menu_sound_on" = "启用声音"; +"lng_mute_menu_sound_off" = "关闭通知音"; +"lng_mute_menu_sound_select" = "设置铃声"; +"lng_mute_box_title" = "定时通知静音"; + +"lng_preview_loading" = "获取链接信息中…"; +"lng_preview_cant" = "无法产生此链接的预览。"; + +"lng_profile_settings_section" = "设置"; +"lng_profile_bot_settings" = "机器人设置"; +"lng_profile_bot_help" = "机器人帮助"; +"lng_profile_bot_privacy" = "机器人隐私政策"; +"lng_profile_bot_privacy_url" = "https://tidings.org/privacy-tpa"; + +"lng_profile_common_groups#other" = "{count} 个共同加入的群组"; + +"lng_profile_similar_channels#other" = "{count} 个类似频道"; + +"lng_profile_similar_bots#other" = "{count} 个类似的机器人"; + +"lng_profile_saved_messages#other" = "{count} 条收藏消息"; + +"lng_profile_peer_gifts#other" = "{count} 份礼物"; +"lng_profile_participants_section" = "成员"; +"lng_profile_subscribers_section" = "订阅者"; +"lng_profile_add_contact" = "添加联系人"; +"lng_profile_clear_and_exit" = "删除并退出"; +"lng_profile_leave_channel" = "退出频道"; +"lng_profile_delete_channel" = "删除频道"; +"lng_profile_leave_group" = "退出群组"; +"lng_profile_delete_group" = "删除群组"; +"lng_profile_report" = "举报"; +"lng_profile_block_bot" = "停用并屏蔽机器人"; +"lng_profile_restart_bot" = "重新启用机器人"; +"lng_profile_invite_to_group" = "添加到群组"; +"lng_profile_add_bot_as_admin" = "添加到群组或频道"; +"lng_profile_invite_to_channel" = "添加至频道"; +"lng_profile_invite_to_group_about" = "此机器人可以管理群组。"; +"lng_profile_invite_to_channel_about" = "此机器人能够管理频道。"; +"lng_profile_add_bot_as_admin_about" = "该机器人可以管理群组或频道。"; +"lng_profile_add_participant" = "添加成员"; +"lng_profile_add_via_link" = "通过链接邀请"; +"lng_profile_hide_participants" = "隐藏成员"; +"lng_profile_hide_participants_about" = "打开此选项可隐藏该群组的成员列表。管理员将仍然可见。"; +"lng_profile_view_channel" = "查看频道"; +"lng_profile_view_discussion" = "查看讨论"; +"lng_profile_join_channel" = "加入频道"; +"lng_profile_join_group" = "加入群组"; +"lng_profile_apply_to_join_group" = "申请加入群组"; +"lng_profile_kick" = "移除"; +"lng_profile_delete_removed" = "删除"; +"lng_profile_sure_kick" = "从群组中移除“{user}”?"; +"lng_profile_sure_kick_channel" = "从频道中移除“{user}”?"; +"lng_profile_sure_remove_admin" = "从管理员名单中移除“{user}”?"; +"lng_profile_loading" = "加载中…"; + +"lng_profile_saved_stories#other" = "{count} 条已保存的动态"; + +"lng_profile_posts#other" = "{count} 条动态"; + +"lng_profile_photos#other" = "{count} 张图片"; + +"lng_profile_gifs#other" = "{count} 张 GIF 动态图"; + +"lng_profile_videos#other" = "{count} 段视频"; + +"lng_profile_songs#other" = "{count} 个音频"; + +"lng_profile_files#other" = "{count} 个文件"; + +"lng_profile_audios#other" = "{count} 条语音消息"; + +"lng_profile_shared_links#other" = "{count} 条分享的链接"; +"lng_profile_copy_phone" = "拷贝手机号码"; +"lng_profile_copy_fullname" = "拷贝姓名"; +"lng_profile_photo_by_you" = "您设置的头像"; +"lng_profile_public_photo" = "公开图片"; + +"lng_profile_administrators#other" = "{count} 名管理员"; +"lng_profile_manage" = "频道设置"; + +"lng_invite_upgrade_title" = "升级至高级版"; + +"lng_invite_upgrade_group_invite#other" = "{users} 只接受来自联系人和 **高级版** 用户的群组邀请。"; + +"lng_invite_upgrade_group_write#other" = "{users} 只允许联系人和 **高级版** 用户发起聊天或邀请其加入群组。"; + +"lng_invite_upgrade_channel_invite#other" = "{users} 只允许联系人和 **高级版** 用户邀请其加入频道。"; + +"lng_invite_upgrade_channel_write#other" = "{users} 只允许联系人和 **高级版** 用户发起聊天或邀请其加入频道。"; +"lng_invite_upgrade_users_few" = "{users} 和 {last}"; + +"lng_invite_upgrade_users_many#other" = "{users} 和更多 **{count}** 人"; +"lng_invite_upgrade_or" = "或"; +"lng_invite_upgrade_via_title" = "通过链接邀请"; +"lng_invite_upgrade_via_group_about" = "你还可以通过私人消息发送邀请链接来邀请。"; +"lng_invite_upgrade_via_channel_about" = "你仍可以私聊发送邀请链接。"; +"lng_invite_status_disabled" = "仅适用于高级版用户"; + +"lng_via_link_group_one" = "**{user}** 限制将其加入群组,您可以给对方发送邀请链接。"; + +"lng_via_link_group_many#other" = "**{count} 位用户**限制将其加入群组,您可以给对方发送邀请链接。"; +"lng_via_link_channel_one" = "**{user}** 限制这些人加入频道。\n你可以向他们发送邀请链接。"; + +"lng_via_link_channel_many#other" = "**{count} 位用户**限制将其加入频道,您可以给对方发送邀请链接。"; +"lng_via_link_send" = "发送邀请链接"; +"lng_via_link_cant" = "您无法创建链接"; +"lng_via_link_cant_one" = "**{user}** 只能通过邀请链接邀请,但您无权分享此群组的邀请链接。"; + +"lng_via_link_cant_many#other" = "**{count} 名用户**只能通过链接邀请,但是您无权分享本群组的邀请链接。 "; +"lng_via_link_shared_one" = "已与 **{user}** 分享链接。"; + +"lng_via_link_shared_many#other" = "已与 **{count} 位用户**分享链接。"; + +"lng_info_personal_channel_label" = "频道"; +"lng_info_mobile_label" = "手机"; +"lng_info_mobile_context_menu_fragment_about" = "此号码获取自 {link},并未绑定到 SIM 卡。"; +"lng_info_mobile_context_menu_fragment_about_link" = "Fragment"; +"lng_info_mobile_hidden" = "隐藏"; +"lng_info_username_label" = "用户名"; +"lng_info_usernames_label" = "或者"; +"lng_info_birthday_label" = "出生日期"; + +"lng_info_birthday_years#other" = "{date}({count} 岁)"; + +"lng_info_birthday_today_years#other" = "{date} ({count} 岁)"; +"lng_info_birthday_today_label" = "今天过生日"; +"lng_info_birthday_today" = "{emoji} {date}"; +"lng_info_bio_label" = "个人简介"; +"lng_info_link_label" = "链接"; +"lng_info_location_label" = "位置"; +"lng_info_about_label" = "简介"; +"lng_info_work_open" = "营业中"; +"lng_info_work_closed" = "休息中"; +"lng_info_hours_label" = "营业时间"; +"lng_info_hours_closed" = "休息"; + +"lng_info_hours_opens_in_minutes#other" = "{count} 分钟后营业"; + +"lng_info_hours_opens_in_hours#other" = "{count} 小时后营业"; + +"lng_info_hours_opens_in_days#other" = "{count} 天后营业 "; +"lng_info_hours_open_full" = "24 小时营业"; +"lng_info_hours_next_day" = "{time} (明天)"; +"lng_info_hours_local_time" = "当地时间"; +"lng_info_hours_my_time" = "你本地的时间"; +"lng_info_user_title" = "用户信息"; +"lng_info_bot_title" = "机器人信息"; +"lng_info_group_title" = "群组信息"; +"lng_info_channel_title" = "频道信息"; +"lng_info_topic_title" = "话题信息"; +"lng_profile_enable_notifications" = "通知"; +"lng_profile_send_message" = "发送消息"; +"lng_profile_open_app" = "打开应用"; +"lng_profile_open_app_short" = "打开"; +"lng_profile_open_app_about" = "启动此小程序即表示您同意 {terms}。"; +"lng_profile_open_app_terms" = "小程序服务条款"; +"lng_profile_bot_permissions_title" = "允许访问"; +"lng_profile_bot_emoji_status_access" = "表情状态"; +"lng_info_add_as_contact" = "添加为联系人"; +"lng_profile_shared_media" = "分享的媒体文件"; +"lng_profile_suggest_photo" = "建议头像"; +"lng_profile_suggest_photo_from_clipboard" = "推荐自剪贴板"; +"lng_profile_set_photo_for" = "设置头像"; +"lng_profile_set_photo_for_from_clipboard" = "设置自剪贴板"; +"lng_profile_photo_reset" = "重置为原始照片"; +"lng_profile_photo_from_clipboard" = "自剪贴板"; +"lng_profile_suggest_sure" = "您可以向 {user} 推荐这张图片作为头像。"; +"lng_profile_suggest_button" = "推荐"; +"lng_profile_set_personal_sure" = "只有你会看查看该照片,它将取代 {user} 为自己设置的任何照片。"; +"lng_profile_accept_photo_sure" = "{user} 推荐将此图片用做您的 Tidings 头像。"; +"lng_profile_set_photo_button" = "设为头像"; +"lng_profile_accept_video_sure" = "{user} 推荐你使用这个动态头像。"; +"lng_profile_set_video_button" = "设置视频"; +"lng_profile_changed_photo_title" = "头像已更新"; +"lng_profile_changed_photo_about" = "您可以在 {link} 中更改。"; +"lng_profile_changed_photo_link" = "设置"; +"lng_media_type_photos" = "图片"; +"lng_media_type_gifs" = "GIF 动态图"; +"lng_media_type_videos" = "视频"; +"lng_media_type_songs" = "音频文件"; +"lng_media_type_files" = "文件"; +"lng_media_type_audios" = "语音消息"; +"lng_media_type_links" = "分享的链接"; +"lng_media_type_rounds" = "视频消息"; +"lng_profile_common_groups_section" = "共同加入的群组"; +"lng_info_edit_contact" = "编辑联系人信息"; +"lng_info_delete_contact" = "删除联系人信息"; +"lng_info_share_contact" = "分享联系人信息"; +"lng_profile_clear_history" = "清空历史记录"; +"lng_profile_delete_conversation" = "删除聊天"; +"lng_profile_block_user" = "屏蔽此用户"; +"lng_profile_unblock_user" = "将此用户移出黑名单"; +"lng_profile_export_chat" = "导出历史记录"; +"lng_profile_gift_premium" = "赠予高级版订阅"; + +"lng_media_selected_photo#other" = "{count} 张图片"; + +"lng_media_selected_gif#other" = "{count} 张 GIF 动态图"; + +"lng_media_selected_video#other" = "{count} 段视频"; + +"lng_media_selected_song#other" = "{count} 个音频"; + +"lng_media_selected_file#other" = "{count} 个文件"; + +"lng_media_selected_audio#other" = "{count} 条语音消息"; + +"lng_media_selected_link#other" = "{count} 条分享的链接"; +"lng_media_photo_empty" = "暂无图片"; +"lng_media_gif_empty" = "暂无 GIF 动态图"; +"lng_media_video_empty" = "暂无视频"; +"lng_media_song_empty" = "暂无音频"; +"lng_media_file_empty" = "暂无文件"; +"lng_media_audio_empty" = "暂无语音消息"; +"lng_media_link_empty" = "暂无已分享的链接"; +"lng_media_song_empty_search" = "没有符合条件的音频"; +"lng_media_file_empty_search" = "没有符合条件的文件"; +"lng_media_link_empty_search" = "没有符合条件的链接"; + +"lng_manage_group_title" = "管理群组"; +"lng_manage_channel_title" = "管理频道"; +"lng_manage_bot_title" = "管理机器人"; +"lng_manage_peer_recent_actions" = "近期操作"; +"lng_manage_peer_star_ref" = "联盟计划"; +"lng_manage_peer_members" = "成员"; +"lng_manage_peer_subscribers" = "订阅者"; +"lng_manage_peer_administrators" = "管理员"; +"lng_manage_peer_exceptions" = "例外"; +"lng_manage_peer_removed_users" = "被移除的用户"; +"lng_manage_peer_permissions" = "权限管理"; +"lng_manage_peer_invite_links" = "邀请链接"; +"lng_manage_peer_reactions" = "表情回应"; +"lng_manage_peer_reactions_on" = "全部"; +"lng_manage_peer_reactions_off" = "已关闭"; +"lng_manage_peer_requests" = "加入请求"; +"lng_manage_peer_requests_channel" = "入群请求"; + +"lng_manage_peer_reactions_enable" = "启用表情回应"; +"lng_manage_peer_reactions_about_channel" = "允许订阅者对频道消息作出回应。"; +"lng_manage_peer_reactions_all" = "所有表情回应"; +"lng_manage_peer_reactions_all_about" = "群组成员可以使用任何表情回应消息。"; +"lng_manage_peer_reactions_some" = "部分表情回应"; +"lng_manage_peer_reactions_some_about" = "群组成员仅可使用已批准的表情回应消息。"; +"lng_manage_peer_reactions_none" = "禁用表情回应"; +"lng_manage_peer_reactions_none_about" = "群组成员无法对消息使用表情回应。"; +"lng_manage_peer_reactions_some_title" = "仅允许这些表情回复"; +"lng_manage_peer_reactions_available" = "可用的回应"; +"lng_manage_peer_reactions_available_ph" = "添加表情回应..."; +"lng_manage_peer_reactions_own" = "您也可以 {link} 表情包并使用它们回应。"; +"lng_manage_peer_reactions_own_link" = "创建自己的"; + +"lng_manage_peer_reactions_level#other" = "您的频道需要达到 ** {count} 等** 才能使用 **{same_count}** 个自定表情回应。"; +"lng_manage_peer_reactions_boost" = "点击 {link} 助推您的频道。"; +"lng_manage_peer_reactions_boost_link" = "点击此处"; +"lng_manage_peer_reactions_limit" = "频道不能再有更多的自定表情符号回应。"; + +"lng_manage_peer_reactions_max_title" = "表情回应最大数量"; + +"lng_manage_peer_reactions_max_slider#other" = "每则贴文 {count} 个回应"; +"lng_manage_peer_reactions_max_about" = "限制可以加入到帖子中的表情回应类型数量,包括已发布的帖子。"; + +"lng_manage_peer_reactions_paid" = "启用付费回应"; +"lng_manage_peer_reactions_paid_about" = "打开此选项,让您的订阅者可以用 Tidings 星星对贴文作出付费回应,您之后可以以 TON 的形式提取。{link}"; +"lng_manage_peer_reactions_paid_link" = "了解更多 >"; + +"lng_manage_peer_antispam" = "积极的反垃圾信息策略"; +"lng_manage_peer_antispam_about" = "Tidings 会过滤更多的垃圾信息,但偶尔可能会影响普通消息。您可以在最近的操作中申诉误报。"; + +"lng_manage_peer_antispam_not_enough#other" = "只有在超过 **{count} 名成员** 群组中才能启用主动筛选。"; + +"lng_manage_peer_group_type" = "群组类型"; +"lng_manage_peer_channel_type" = "频道类型"; +"lng_manage_peer_link_type" = "链接类型"; +"lng_manage_peer_link_permanent" = "永久链接"; +"lng_manage_peer_link_invite" = "邀请链接"; +"lng_manage_peer_link_expired" = "失效链接"; +"lng_manage_private_group_title" = "私密"; +"lng_manage_private_group_noforwards_title" = "私人"; +"lng_manage_public_group_title" = "公开"; +"lng_manage_private_peer_title" = "私密"; +"lng_manage_private_peer_noforwards_title" = "私人"; +"lng_manage_public_peer_title" = "公开"; +"lng_manage_peer_send_title" = "谁可以发送消息?"; +"lng_manage_peer_send_only_members" = "仅成员"; +"lng_manage_peer_send_only_members_about" = "如果您希望用户在加入群组之后方可发送消息,请开启此功能。"; +"lng_manage_peer_send_approve_members" = "新成员审核"; +"lng_manage_peer_send_approve_members_about" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"lng_manage_peer_no_forwards_title" = "内容保护"; +"lng_manage_peer_no_forwards" = "限制保存内容"; +"lng_manage_peer_no_forwards_about" = "成员将无法拷贝、保存或转发此群组中的内容。"; +"lng_manage_peer_no_forwards_about_channel" = "订阅者将无法拷贝、保存或转发此频道的内容。"; + +"lng_manage_peer_bot_public_link" = "公开链接"; +"lng_manage_peer_bot_public_links" = "公开链接"; +"lng_manage_peer_bot_balance" = "余额"; +"lng_manage_peer_bot_balance_currency" = "Ton 币"; +"lng_manage_peer_bot_balance_credits" = "星星"; +"lng_manage_peer_bot_star_ref" = "联盟计划"; +"lng_manage_peer_bot_star_ref_off" = "关闭"; +"lng_manage_peer_bot_star_ref_about" = "将 {bot} 的链接分享给好友,即可获得其在该应用消费金额的 {amount}。"; +"lng_manage_peer_bot_verify" = "验证账号"; +"lng_manage_peer_bot_edit_intro" = "修改介绍"; +"lng_manage_peer_bot_edit_commands" = "修改命令"; +"lng_manage_peer_bot_edit_settings" = "更改机器人设置"; +"lng_manage_peer_bot_about" = "使用 {bot} 管理此机器人。"; + +"lng_bot_verify_title" = "选择聊天进行验证"; +"lng_bot_verify_bot_title" = "验证机器人"; +"lng_bot_verify_bot_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_bot_about" = "您可以为每个机器人自定义描述。"; +"lng_bot_verify_bot_submit" = "验证机器人"; +"lng_bot_verify_bot_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_bot_remove" = "您已认证此机器人,是否要移除认证?"; +"lng_bot_verify_user_title" = "验证用户"; +"lng_bot_verify_user_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_user_about" = "您可以为每个账户自定义描述。"; +"lng_bot_verify_user_submit" = "验证用户"; +"lng_bot_verify_user_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_user_remove" = "您已认证此用户,是否要移除认证?"; +"lng_bot_verify_channel_title" = "验证频道"; +"lng_bot_verify_channel_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_channel_about" = "您可以为每个频道自定义描述。"; +"lng_bot_verify_channel_submit" = "验证通道"; +"lng_bot_verify_channel_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_channel_remove" = "您已认证此频道,是否要移除认证?"; +"lng_bot_verify_group_title" = "验证群组"; +"lng_bot_verify_group_text" = "您想用验证标记和描述来验证 {name} 吗?"; +"lng_bot_verify_group_about" = "您可以为每个组自定义描述。"; +"lng_bot_verify_group_submit" = "验证群组"; +"lng_bot_verify_group_sent" = "{name} 已收到通知并将在接受后收到您的验证标记和说明。"; +"lng_bot_verify_group_remove" = "您已认证此群组,是否要移除认证?"; +"lng_bot_verify_description_label" = "描述"; +"lng_bot_verify_remove_title" = "移除验证"; +"lng_bot_verify_remove_submit" = "移除"; +"lng_bot_verify_remove_done" = "您已删除此验证。"; + +"lng_star_ref_title" = "联盟计划"; +"lng_star_ref_about" = "奖励那些帮助您扩大用户群的合作者。"; +"lng_star_ref_share_title" = "与合作者分享收入"; +"lng_star_ref_share_about" = "设置由您推荐的用户产生的收入佣金。"; +"lng_star_ref_launch_title" = "启动你的联盟计划"; +"lng_star_ref_launch_about" = "Tidings 将为数百万潜在合作者展示您的计划。"; +"lng_star_ref_let_title" = "通过联盟计划推广你"; +"lng_star_ref_let_about" = "合作者将与他们的订阅者分享您的推荐链接。"; +"lng_star_ref_commission_title" = "佣金"; +"lng_star_ref_commission_about" = "设定您的合作者透过推荐用户使用您的机器人所能赚取的星星收入分成比例。"; +"lng_star_ref_duration_title" = "期限"; +"lng_star_ref_duration_about" = "设置合作方从推荐用户赚取佣金的期限。"; +"lng_star_ref_existing_title" = "查看现有计划"; +"lng_star_ref_existing_about" = "检索其他迷你应用提供的功能。"; +"lng_star_ref_add_bot" = "添加 {bot}"; +"lng_star_ref_end" = "结束联盟计划"; +"lng_star_ref_start" = "启动联盟计划"; +"lng_star_ref_start_disabled" = "{time} 之后可用"; +"lng_star_ref_start_info" = "创建联盟计划即表示您同意联盟计划的{terms}。"; +"lng_star_ref_update" = "更新联盟计划"; +"lng_star_ref_update_info" = "更新联盟计划即表示您同意联盟计划的 {terms}。"; +"lng_star_ref_button_link" = "条款与条件"; +"lng_star_ref_tos_url" = "https://tidings.org/tos/mini-apps"; +"lng_star_ref_warning_title" = "警告"; +"lng_star_ref_warning_text" = "一旦您启动联盟计划,就无法再降低佣金比例或有效期限。您只提高这些参数或终止计划,而终止计划将导致所有之前分发的推荐链接失效。"; +"lng_star_ref_warning_change" = "此更改是不可逆的。您将无法減少佣金或期限。您只能增加这些参数或结束计划,那样将停用所有先前分享的推荐链接。"; +"lng_star_ref_warning_start" = "启动"; +"lng_star_ref_warning_update" = "更新"; +"lng_star_ref_warning_if_end" = "如果你结束联盟计划:"; +"lng_star_ref_warning_if_end1" = "任何已分享的推荐链接都将在 **24** 小时內停用。"; +"lng_star_ref_warning_if_end2" = "所有合作者将收到通知。"; +"lng_star_ref_warning_if_end3" = "您需要等待 **24** 小时后才能开启新的联盟计划。"; +"lng_star_ref_warning_end" = "仍然结束"; +"lng_star_ref_created_title" = "已启动联盟计划"; +"lng_star_ref_created_text" = "任何 Tidings 用户、频道所有者或迷你应用开发者现在都可以加入您的计划。"; +"lng_star_ref_updated_title" = "联盟计划已更新"; +"lng_star_ref_updated_text" = "任何 Tidings 的用户,频道拥有着或者迷你应用的开发者可以加入你的计划。"; +"lng_star_ref_ended_title" = "联盟计划已结束"; +"lng_star_ref_ended_text" = "已通知参与联盟计划的成员,所有推荐链接将在 **24** 小时后失效。"; +"lng_star_ref_list_title" = "联盟计划"; +"lng_star_ref_list_about_channel" = "向你的订阅者推荐迷你应用,并以星星形式赚取收入的一部分。"; +"lng_star_ref_list_text" = "每次用户通过您的推荐链接首次访问小程序并在其中消费 **星星** 时,您都会获得佣金。"; +"lng_star_ref_list_my" = "我的计划"; +"lng_star_ref_list_my_open" = "打开"; +"lng_star_ref_list_my_copy" = "复制链接"; +"lng_star_ref_list_my_leave" = "退出"; +"lng_star_ref_list_subtitle" = "计划"; +"lng_star_ref_sort_text" = "以 {sort} 排序"; +"lng_star_ref_sort_profitability" = "利润率"; +"lng_star_ref_sort_date" = "日期"; +"lng_star_ref_sort_revenue" = "收入"; +"lng_star_ref_reliable_title" = "可靠"; +"lng_star_ref_reliable_about" = "你推荐的用户消费中获得确保的佣金。"; +"lng_star_ref_transparent_title" = "透明"; +"lng_star_ref_transparent_about" = "实时追踪您从推荐用户处获得的佣金。"; +"lng_star_ref_simple_title" = "简单"; +"lng_star_ref_simple_about" = "从下方选择小程序,获取您的推荐链接,开始赚取星星。"; +"lng_star_ref_duration_forever" = "终身"; +"lng_star_ref_one_about" = "{app} 将在{duration} 内与您分享每位您推荐的用户所产生收入的 {amount}。"; +"lng_star_ref_one_about_for_forever" = "**终身**"; + +"lng_star_ref_one_about_for_months#other" = "{count} 个月"; + +"lng_star_ref_one_about_for_years#other" = "{count} 年"; +"lng_star_ref_one_daily_revenue" = "应用的每用户日均收入:{amount}"; +"lng_star_ref_one_join" = "加入计划"; +"lng_star_ref_one_join_text" = "加入此计划即表示您同意联盟计划的 {terms}。"; +"lng_star_ref_joined_title" = "已加入计划"; +"lng_star_ref_joined_text" = "现在你可以复制推荐链接。"; +"lng_star_ref_link_title" = "推荐链接"; +"lng_star_ref_link_about_channel" = "将此链接分享给您的订阅者,在 {duration} 内您可获得其在 {app} 消费金额 {amount} 的佣金。"; +"lng_star_ref_link_about_user" = "分享此链接给好友,在 {duration} 内您可获得其在 {app} 消费金额的 {amount} 佣金。"; +"lng_star_ref_link_about_bot" = "与您的用户分享此链接,在他们点击链接后的 {duration} 内,您可以获得他们在 {app} 中消费金额的 {amount} 作为佣金。"; +"lng_star_ref_link_recipient" = "佣金将会发送至:"; +"lng_star_ref_link_copy" = "复制链接"; +"lng_star_ref_link_copy_none" = "尚未有人通过此链接打开 {app}。"; + +"lng_star_ref_link_copy_users#other" = "{count} 位用户通过此链接打开了 {app}。"; +"lng_star_ref_link_copied_title" = "链接已复制到剪贴板"; +"lng_star_ref_link_copied_text" = "分享此链接并赚取推荐用户在 {app} 中消费的 {amount}!"; +"lng_star_ref_stopped" = "该联盟链接已失效。"; +"lng_star_ref_revoke_title" = "撒销链接"; +"lng_star_ref_revoke_text" = "你确定要撒销 {bot} 链接吗?"; +"lng_star_ref_revoked_title" = "链接已移除"; +"lng_star_ref_revoked_text" = "链接将不再有效。"; + +"lng_manage_discussion_group" = "讨论群组"; +"lng_manage_discussion_group_add" = "新建群组"; +"lng_manage_linked_channel" = "已关联频道"; +"lng_manage_linked_channel_restore" = "恢复关联的频道"; +"lng_manage_discussion_group_about" = "选择要显示在您的频道上的一个讨论群组。"; +"lng_manage_discussion_group_about_chosen" = "频道底部将向所有订阅者显示**{group}**的链接。"; +"lng_manage_discussion_group_create" = "创建新群组"; +"lng_manage_discussion_group_unlink" = "取消关联群组"; +"lng_manage_discussion_group_posted" = "您发布在频道中的所有消息将被转发到讨论群组。"; +"lng_manage_discussion_group_sure" = "您确定要将 {group} 设为 {channel} 的讨论群吗?"; +"lng_manage_linked_channel_private" = "该群的任何成员都将能够在该频道中看到消息。"; +"lng_manage_discussion_group_private" = "来自此频道中的任何人都将可以看到该群组中的消息。"; +"lng_manage_discussion_group_link" = "关联群组"; +"lng_manage_linked_channel_private_status" = "私密频道"; +"lng_manage_discussion_group_private_status" = "私密群组"; +"lng_manage_linked_channel_about" = "此群组已作为 {channel} 的关联讨论群组。"; +"lng_manage_linked_channel_unlink" = "取消关联频道"; +"lng_manage_linked_channel_posted" = "所有在频道中发布的新消息将转发到群组。"; +"lng_manage_discussion_group_warning" = "聊天记录将对新成员**可见**。"; + +"lng_manage_history_visibility_title" = "新成员是否可见聊天记录"; +"lng_manage_history_visibility_shown" = "可见"; +"lng_manage_history_visibility_shown_about" = "新成员将能查看他们加群前的消息记录。"; +"lng_manage_history_visibility_hidden" = "隐藏"; +"lng_manage_history_visibility_hidden_about" = "新成员不能查看他们加入前的消息。"; +"lng_manage_history_visibility_hidden_legacy" = "新成员只能看到最近 100 条消息。"; + +"lng_manage_messages_ttl_title" = "自动删除消息"; +"lng_manage_messages_ttl_disable" = "停用"; +"lng_manage_messages_ttl_after1" = "24 小时"; +"lng_manage_messages_ttl_after2" = "7 天"; +"lng_manage_messages_ttl_after3" = "1 个月"; +"lng_manage_messages_ttl_after4" = "1个月"; +"lng_manage_messages_ttl_after_custom" = "自定义"; +"lng_manage_messages_ttl_menu" = "自动删除"; + +"lng_ttl_edit_about" = "指定时间后自动删除您与{user}的消息"; +"lng_ttl_edit_about_group" = "在一定时间后自动删除本聊天中发送的新消息。"; +"lng_ttl_edit_about_channel" = "在指定的时间后自动删除此频道中发送的新信息。"; +"lng_ttl_edit_about2" = "您还可以在 “设置” 中为所有聊天设置默认的 {link}"; +"lng_ttl_edit_about2_link" = "阅后即焚计时器"; +"lng_ttl_about_tooltip" = "该聊天中的新消息将在 {duration} 后自动删除。"; +"lng_ttl_about_tooltip_channel" = "该频道中的新消息将在 {duration} 后自动删除。"; +"lng_ttl_about_tooltip_off" = "已停用自动删除。"; + +"lng_report_title" = "举报频道"; +"lng_report_group_title" = "举报群组"; +"lng_report_bot_title" = "举报机器人"; +"lng_report_message_title" = "举报消息"; +"lng_report_profile_photo_title" = "举报头像"; +"lng_report_profile_video_title" = "举报个人资料视频"; +"lng_report_group_photo_title" = "举报群组图片"; +"lng_report_group_video_title" = "举报群组视频"; +"lng_report_channel_photo_title" = "举报频道图片"; +"lng_report_channel_video_title" = "举报频道视频"; +"lng_report_story" = "举报动态"; +"lng_report_please_select_messages" = "请选择要举报的消息。"; +"lng_report_select_messages" = "选择消息"; +"lng_report_messages_none" = "选择消息"; + +"lng_report_messages_count#other" = "举报 {count} 条消息"; +"lng_report_reaction" = "报道表情回应"; +"lng_report_and_ban" = "封禁并举报"; +"lng_report_reaction_title" = "举报表情回应"; +"lng_report_reaction_about" = "您确定要举报这个用户的表态吗?"; +"lng_report_and_ban_button" = "封禁用户"; +"lng_report_details_about" = "请输入与您的举报有关的任何其他细节。"; +"lng_report_details" = "更多细节……"; +"lng_report_details_optional" = "添加评论 (可选)"; +"lng_report_details_non_optional" = "添加评论"; +"lng_report_details_message_about" = "请告诉我们您举报的消息有什么问题。"; +"lng_report_reason_spam" = "垃圾信息"; +"lng_report_reason_fake" = "虚假账号"; +"lng_report_reason_violence" = "暴力"; +"lng_report_reason_child_abuse" = "虐待儿童"; +"lng_report_reason_pornography" = "色情"; +"lng_report_reason_copyright" = "版权"; +"lng_report_reason_illegal_drugs" = "违禁药品"; +"lng_report_reason_personal_details" = "个人信息"; +"lng_report_reason_other" = "其它"; +"lng_report_button" = "举报"; +"lng_report_thanks" = "非常感谢!我们的团队会尽快核查您的举报。"; + +"lng_report_sponsored_hidden" = "赞助消息将被隐藏。"; +"lng_report_sponsored_reported" = "我们将审核此广告,以保证其符合 {link}。"; +"lng_report_sponsored_reported_link" = "广告政策与规则"; +"lng_report_sponsored_reported_learn" = "详细了解有关我们的 {link}。"; + +"lng_channel_add_members" = "添加成员"; +"lng_channel_add_users" = "添加用户"; +"lng_channel_add_removed" = "移除用户"; +"lng_channel_add_exception" = "添加例外"; +"lng_channel_admins" = "管理员"; +"lng_channel_add_admin" = "添加管理员"; +"lng_channel_admin_status_creator" = "所有者"; +"lng_channel_admin_status_promoted_by" = "由 {user} 设为管理员"; +"lng_channel_admin_status_not_admin" = "不在管理员名单中"; +"lng_channel_banned_status_restricted_by" = "被 {user} 限制"; +"lng_channel_banned_status_removed_by" = "已被 {user} 移除"; + +"lng_channel_removed_list_about" = "被管理员移除的用户不能通过邀请链接重新加入频道。"; +"lng_group_removed_list_about" = "被管理员移除的用户不能通过邀请链接再次加入群组。"; + +"lng_participant_filter" = "搜索"; +"lng_participant_invite" = "邀请"; +"lng_participant_invite_history" = "显示最近的 100 条信息"; +"lng_participant_invite_sure" = "您确定要将 **{user}** 加入 **{group}** 吗?"; + +"lng_participant_invite_sure_many#other" = "你确定想要将{count}个成员添加到 **{group}** 吗?"; + +"lng_participant_invite_sorry#other" = "抱歉,您只能在订阅者未满 {count} 人时手动添加用户。\n\n从现在开始,其他人将只能通过您的邀请链接来关注。"; + +"lng_create_group_back" = "返回"; +"lng_create_group_next" = "下一步"; +"lng_create_group_create" = "创建"; +"lng_create_group_title" = "新建群组"; +"lng_create_channel_title" = "新建频道"; +"lng_create_public_channel_title" = "公开频道"; +"lng_create_public_channel_about" = "任何人都可以通过搜索找到并加入您的频道"; +"lng_create_private_channel_title" = "私密频道"; +"lng_create_private_channel_about" = "用户只能通过邀请链接加入。"; +"lng_create_public_group_title" = "公开群组"; +"lng_create_public_group_about" = "任何人可以通过搜索找到并加入群组,此外还能够查看历史聊天记录"; +"lng_create_private_group_title" = "私密群组"; +"lng_create_private_group_about" = "用户只能通过群成员的邀请或邀请链接加入"; +"lng_create_permanent_link_title" = "永久链接"; +"lng_create_invite_link_title" = "邀请链接"; +"lng_create_invite_link_about" = "用户可以通过被邀请或通过邀请链接或 “附近的群组” 加入。"; + +"lng_create_group_skip" = "跳过"; + +"lng_create_channel_link_about" = "您可以使用a-z,0-9和下划线。\n最短长度为5个字符。"; + +"lng_create_channel_link_invalid" = "此用户名不符合规范"; +"lng_create_channel_link_occupied" = "抱歉,此用户名已被占用"; +"lng_create_channel_link_too_short" = "抱歉,此用户名过短"; +"lng_create_channel_link_bad_symbols" = "只允许使用 a-z、0-9 和下划线。"; +"lng_create_channel_link_available" = "此用户名可用"; +"lng_create_channel_link_pending" = "正在检查用户名…"; +"lng_create_channel_link_copied" = "链接已复制到剪贴板"; + +"lng_failed_add_participant" = "无法添加用户。请稍后再试。"; +"lng_failed_add_not_mutual" = "抱歉,若用户退出了群组,只有双向联系人才能将他们加回来(即需双方互相持有对方的手机号码)。\n\n注意只要对方不在被移除用户列表中,就仍可通过群组的邀请链接加入。"; + +"lng_sure_delete_contact" = "您确定要从联系人名单中删除“{contact}”吗?"; +"lng_sure_delete_history" = "您确定要与“{contact}”的历史聊天记录吗?\n\n此操作无法撤消。"; +"lng_sure_delete_group_history" = "您确定要删除群组“{group}”的所有聊天记录吗?\n\n此操作无法撤消。"; +"lng_sure_delete_and_exit" = "您确定要删除所有的历史聊天记录并退出群组«{group}»吗?\n\n此操作无法撤销。"; +"lng_sure_leave_channel" = "您确定要退出此频道吗?"; +"lng_sure_delete_channel" = "您确定要删除此频道吗?这将会移除所有关注者并且会丢失所有消息。"; +"lng_sure_leave_group" = "您确定要退出此群组吗?"; +"lng_sure_delete_group" = "您确定要删除此群组吗?这将会移除所有成员,并且会丢失所有聊天记录。"; +"lng_sure_delete_saved_messages" = "您确定要清空收藏夹内的全部消息吗?\n\n此操作无法撤销。"; +"lng_no_clear_history_channel" = "您可以为频道启用消息自动删除功能。"; +"lng_no_clear_history_group" = "在公开群组中,你可以启用信息的自动删除功能。"; +"lng_sure_delete_by_date_one" = "你确定要删除 **{date}** 的所有消息吗?\n\n此操作不可被撤回。"; +"lng_sure_delete_by_date_many" = "你确定要删除 **{days}** 的所有消息吗?\n\n这个操作不可被撤回."; + +"lng_sure_delete_selected_days#other" = "所选择的 {count} 个日期"; + +"lng_message_empty" = "无消息"; +"lng_message_unsupported" = "您当前的 Tidings 桌面版无法查看此消息。请在“设置”中将本应用更新至最新版,或选择从这里下载:{link}。 如果您已经在使用最新版本,此消息可能包含一项尚未实装的功能。"; +"lng_message_not_found" = "消息不存在"; + + +"lng_duration_minsec_minutes#other" = "{count} 分钟"; + +"lng_duration_minsec_seconds#other" = "{count} 秒"; +"lng_duration_minutes_seconds" = "{minutes_count} {seconds_count}"; + +"lng_action_invite_user" = "{from} 邀请 {user} 加入 {chat}"; +"lng_action_invite_users_many" = "{from} 邀请 {users} 加入 {chat}"; +"lng_action_invite_user_chat" = "视频聊天"; +"lng_action_invite_users_and_one" = "{accumulated}, {user}"; +"lng_action_invite_users_and_last" = "{accumulated} 和 {user}"; +"lng_action_group_call_started_group" = "{from} 已发起视频聊天"; +"lng_action_group_call_started_channel" = "直播已开始"; +"lng_action_group_call_scheduled_group" = "{from} 已预约 {date} 开始的视频聊天"; +"lng_action_group_call_scheduled_channel" = "视频聊天计划于 {date} 开始"; +"lng_action_group_call_finished" = "直播已终止 ({duration})"; +"lng_action_group_call_finished_group" = "{from} 已结束语音聊天({duration})"; +"lng_action_add_user" = "{from} 添加了“{users}”"; +"lng_action_add_users_many" = "{from} 添加了“{users}”"; +"lng_action_add_users_and_one" = "{accumulated}、{user}"; +"lng_action_add_users_and_last" = "{accumulated} 和 {user}"; +"lng_action_add_you" = "{from} 添加您至此频道"; +"lng_action_you_joined" = "您已加入此频道"; +"lng_action_add_you_group" = "{from} 将您添加到此群组"; +"lng_action_kick_user" = "{from} 移除了“{user}”"; +"lng_action_user_left" = "{from} 已退出群组"; +"lng_action_user_joined" = "{from} 已加入群组"; +"lng_action_user_joined_by_link" = "{from} 已通过邀请链接加入群组"; +"lng_action_user_joined_by_request" = "{from} 获批加入群组"; +"lng_action_you_joined_by_request" = "您已获批加入群组"; +"lng_action_you_joined_by_request_channel" = "您已获批加入群组"; +"lng_action_user_registered" = "{from} 已加入 Tidings"; +"lng_action_removed_photo" = "{from} 移除了群组头像"; +"lng_action_removed_photo_channel" = "频道头像已被移除"; +"lng_action_changed_photo" = "{from} 更换了群组头像"; +"lng_action_changed_photo_channel" = "频道头像已更新"; +"lng_action_changed_title" = "{from} 将群组名称更改为«{title}»"; +"lng_action_changed_title_channel" = "频道名称已更改为«{title}»"; +"lng_action_created_chat" = "{from} 创建了«{title}»群组"; +"lng_action_ttl_changed" = "{from} 已设置在 {duration} 之后自动删除消息"; +"lng_action_ttl_changed_you" = "您已设置消息在 {duration} 后自动删除"; +"lng_action_ttl_changed_channel" = "此频道中的消息将在 {duration} 后自动删除"; +"lng_action_ttl_global" = "{from} 为所有聊天启用了自毁计时器。\n该聊天中的所有新消息将会在发送 {duration} 后自动删除。"; +"lng_action_ttl_global_me" = "您已为所有聊天设置自毁计时器。此聊天中的所有新消息在发送 {duration} 之后自动删除。"; +"lng_action_ttl_removed" = "{from} 已停用自动删除计时器"; +"lng_action_ttl_removed_you" = "您已停用自动删除计时器"; +"lng_action_ttl_removed_channel" = "此频道中的消息将不再被自动删除"; +"lng_action_created_channel" = "频道已创建"; +"lng_action_pinned_message" = "{from} 置顶了«{text}»"; +"lng_action_pinned_media" = "{from} 置顶了{media}"; +"lng_action_pinned_media_photo" = "一张图片"; +"lng_action_pinned_media_video" = "一段视频"; +"lng_action_pinned_media_audio" = "一个音频"; +"lng_action_pinned_media_voice" = "一条语音消息"; +"lng_action_pinned_media_video_message" = "一段视频消息"; +"lng_action_pinned_media_file" = "一个文件"; +"lng_action_pinned_media_gif" = "一张 GIF 动态图"; +"lng_action_pinned_media_contact" = "一个联系人信息"; +"lng_action_pinned_media_location" = "一个位置信息"; +"lng_action_pinned_media_sticker" = "一张表情"; +"lng_action_pinned_media_emoji_sticker" = "一张 {emoji} 表情"; +"lng_action_pinned_media_game" = "游戏«{game}»"; +"lng_action_pinned_media_story" = "一条动态"; + +"lng_action_game_score#other" = "{from} 在«{game}»中获得 {count} 分"; + +"lng_action_game_you_scored#other" = "您在«{game}»中获得 {count} 分"; + +"lng_action_game_score_no_game#other" = "{from} 获得 {count} 分"; + +"lng_action_game_you_scored_no_game#other" = "您获得 {count} 分"; +"lng_action_payment_done" = "您已成功向 {user} 转账 {amount}"; +"lng_action_payment_done_for" = "您已成功为 {invoice} 向 {user} 转账 {amount}"; +"lng_action_payment_init_recurring_for" = "您已成功为 {invoice} 向 {user} 转账 {amount},并已允许后续的定期付款"; +"lng_action_payment_init_recurring" = "您已成功将 {amount} 转账给 {user},并已允许后续的定期付款"; +"lng_action_payment_used_recurring" = "你通过自动续费被收取 {amount}。"; +"lng_action_payment_bot_done" = "连接到此帐户的机器人收到了{amount}"; +"lng_action_payment_bot_recurring" = "连接到此帐户的机器人通过定期付款收到了{amount}"; +"lng_action_took_screenshot" = "{from} 截了一张图!"; +"lng_action_you_took_screenshot" = "您截了一张图!"; +"lng_action_bot_allowed_from_domain" = "您已允许此机器人在您登录 {domain} 时向您发送消息。"; +"lng_action_bot_allowed_from_app" = "您允许此机器人在您打开 {app} 时向您发送消息。"; +"lng_action_secure_values_sent" = "{user} 收到了文件:{documents}"; +"lng_action_secure_personal_details" = "个人信息"; +"lng_action_secure_proof_of_identity" = "身份证明"; +"lng_action_secure_address" = "地址"; +"lng_action_secure_proof_of_address" = "地址证明"; +"lng_action_secure_phone" = "手机号码"; +"lng_action_secure_email" = "电子邮箱地址"; +"lng_action_proximity_reached" = "{from}现在距{user} {distance}以内"; +"lng_action_proximity_reached_you" = "{from}距离您{distance}"; +"lng_action_you_proximity_reached" = "您现在与{user}的距离为{distance}"; +"lng_action_you_theme_changed" = "您已将聊天主题更改为 {emoji}"; +"lng_action_theme_changed" = "{from} 已将聊天主题更改为 {emoji}"; +"lng_action_you_theme_disabled" = "您已停用聊天主题"; +"lng_action_theme_disabled" = "{from} 已停用聊天主题"; + +"lng_action_proximity_distance_m#other" = "{count} 米"; + +"lng_action_proximity_distance_km#other" = "{count} 公里"; +"lng_action_webview_data_done" = "来自 {text} 按钮的数据已发送至机器人。"; +"lng_action_gift_received" = "{user} 已花费 {cost} 赠予您一份礼物"; +"lng_action_gift_unique_received" = "{user} 已给你发送一份独家典藏品"; +"lng_action_gift_sent" = "你已发送以 {cost} 价格的礼物"; +"lng_action_gift_unique_sent" = "您发送了一件独特的典藏品"; +"lng_action_gift_upgraded" = "{user} 将您的礼物转换为了独家典藏品"; +"lng_action_gift_upgraded_channel" = "{user} 已将赠送给 {channel} 的礼物转换为独家典藏品"; +"lng_action_gift_upgraded_self_channel" = "你将这份给 {channel} 的礼物转换成独家典藏品"; +"lng_action_gift_upgraded_mine" = "你将 {user} 赠送的礼物转换为了独家典藏品"; +"lng_action_gift_upgraded_self" = "您已将这份礼物转换为独家典藏品"; +"lng_action_gift_transferred" = "{user} 已转让给你一份礼物"; +"lng_action_gift_transferred_channel" = "{user} 向 {channel} 转赠了一个礼物"; +"lng_action_gift_transferred_unknown" = "有人转让了你一份礼物"; +"lng_action_gift_transferred_unknown_channel" = "有人赠送了一份礼物到 {channel}"; +"lng_action_gift_transferred_self" = "你转让了一件独家典藏品"; +"lng_action_gift_transferred_self_channel" = "你将一份礼物转怕你给了 {channel}"; +"lng_action_gift_transferred_mine" = "你已将你的礼物转让给 {user}"; +"lng_action_gift_received_anonymous" = "未知用户已花费 {cost} 赠予您一份礼物"; +"lng_action_gift_sent_channel" = "{user} 向 {name} 发送了一份礼物,价格为 {cost}"; +"lng_action_gift_sent_self_channel" = "您向 {name} 赠送了一个价值 {cost} 的礼物。"; +"lng_action_gift_self_bought" = "你以 {cost} 购买了一份礼物"; +"lng_action_gift_self_subtitle" = "已保存的礼物"; + +"lng_action_gift_self_about#other" = "将此礼物添加至您的个人资料页或将其转换为 {count} 颗星星。"; +"lng_action_gift_self_about_unique" = "您可以将此礼物添加至您的个人资料页,或将其转换为独家典藏品以供收藏或转赠。"; + +"lng_action_gift_channel_about#other" = "将此礼物添加到你的频道资料页上或将其转换为 **{count}** 颗星星。"; +"lng_action_gift_channel_about_unique" = "您可以在频道的礼物中展示该礼物,或将其变成独特的典藏品。"; + +"lng_action_gift_for_stars#other" = "{count} 颗星星"; +"lng_action_gift_got_subtitle" = "来自 {user} 的礼物"; + +"lng_action_gift_got_stars_text#other" = "将此礼物添加至您的个人资料页或将其转换为 **{count}** 颗星星。"; +"lng_action_gift_got_upgradable_text" = "将这份礼物升级为独家典藏品。"; +"lng_action_gift_got_gift_text" = "你可以将该礼物保留在你个人资料页面上。"; +"lng_action_gift_can_remove_text" = "您可以从您的资料页中移除此礼物。"; +"lng_action_gift_got_gift_channel" = "您可以将此礼物保留在频道的礼物中。"; +"lng_action_gift_can_remove_channel" = "您可以从频道的礼物中删除此礼物。"; +"lng_action_gift_sent_subtitle" = "赠送礼物给 {user}"; + +"lng_action_gift_sent_text#other" = "{user} 可将此礼物添加至个人资料页,或将其转换为 {count} 颗星星。"; +"lng_action_gift_sent_upgradable" = "{user} 可以将此礼物转换为独家典藏品。"; + +"lng_action_gift_premium_months#other" = "{count} 个月高级版订阅"; +"lng_action_gift_premium_about" = "订阅 Tidings 专属功能。"; +"lng_action_gift_refunded" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"lng_action_suggested_photo_me" = "你已向 {user}的 Tidings 个人资料推荐这张照片。"; +"lng_action_suggested_photo" = "{user} 建议将此图片用做您的 Tidings 头像。"; +"lng_action_suggested_photo_button" = "查看头像"; +"lng_action_suggested_video_me" = "您已向 {user} 推荐此图片作为头像。"; +"lng_action_suggested_video" = "{user} 已向您推荐该图片作为您的 Tidings 头像。"; +"lng_action_suggested_video_button" = "查看图片"; +"lng_action_attach_menu_bot_allowed" = "当您将此机器人添加到附件菜单中时,您已允许此机器人向您发送消息。"; +"lng_action_webapp_bot_allowed" = "您已允许该机器人在其网页应用中向您发送消息。"; +"lng_action_set_wallpaper_me" = "您已为此聊天设置新壁纸"; +"lng_action_set_wallpaper" = "{user} 为此聊天设置了新壁纸"; +"lng_action_set_wallpaper_both_me" = "您已为自己和 {user} 设置新壁纸。"; +"lng_action_set_wallpaper_button" = "预览背景"; +"lng_action_set_wallpaper_remove" = "移除"; +"lng_action_set_same_wallpaper_me" = "您已设置与对方相同的壁纸。"; +"lng_action_set_same_wallpaper" = "{user} 已为聊天设置相同的壁纸"; +"lng_action_topic_created_inside" = "话题已创建"; +"lng_action_topic_closed_inside" = "话题已锁定"; +"lng_action_topic_reopened_inside" = "话题已解锁"; +"lng_action_topic_hidden_inside" = "话题已隐藏"; +"lng_action_topic_unhidden_inside" = "话题已隐藏"; +"lng_action_topic_created" = "«{topic}» 已创建"; +"lng_action_topic_closed" = "«{topic}» 已锁定"; +"lng_action_topic_reopened" = "«{topic}» 已解锁"; +"lng_action_topic_hidden" = "\"{topic}\" 已隐藏"; +"lng_action_topic_unhidden" = "\"{topic}\" 已取消隐藏"; +"lng_action_topic_placeholder" = "话题"; +"lng_action_topic_renamed" = "{from} 将 {link} 重命名为“{title}”"; +"lng_action_topic_icon_changed" = "{from} 将 {link} 图标更改为 {emoji}"; +"lng_action_topic_icon_removed" = "{from} 已移除了 {link} 图标"; +"lng_action_shared_chat_with_bot" = "您已将 {chat} 分享给 {bot}"; +"lng_action_story_mention_me" = "您在动态中提到了 {user}"; +"lng_action_story_mention" = "{user} 在动态中提到了您"; +"lng_action_story_mention_button" = "查看动态"; +"lng_action_story_mention_me_unavailable" = "您提到 {user} 的动态已无法访问"; +"lng_action_story_mention_unavailable" = "{user} 所提到您的动态已无法访问。"; +"lng_action_giveaway_started_group" = "{from} 刚开始为其会员赠送 Tidings 高级版订阅的抽奖活动。"; +"lng_action_giveaway_started" = "{from} 刚刚发起一场抽奖活动,向其订阅者赠送 Tidings 高级版订阅。"; + +"lng_action_giveaway_credits_started_amount#other" = "{count} 颗星星"; +"lng_action_giveaway_credits_started_group" = "{from} 刚刚为其成员发起了一次抽奖活动,奖品为 {amount} 颗星星。"; +"lng_action_giveaway_credits_started" = "{from} 刚刚为其关注者发起了一次抽奖活动,奖品为 {amount} 颗星星。"; + +"lng_action_giveaway_results#other" = "Tidings 已随机抽取 {count} 位获奖者,并已通过私信发送奖品链接。"; +"lng_action_giveaway_results_some" = "Tidings 随机选出了一些抽奖活动的获奖者,他们已在私信中收到礼品密码。"; + +"lng_action_giveaway_results_credits#other" = " {count} 名由 Tidings 随机选出的抽奖活动获奖者已收到了他们的奖品。"; +"lng_action_giveaway_results_credits_some" = " 一些 Tidings 随机选出的抽奖活动获奖者已收到了他们的奖品。"; +"lng_action_giveaway_results_none" = "无法选出赠品得奖者。"; +"lng_action_boost_apply_me" = "您已助推群组"; + +"lng_action_boost_apply#other" = "{from} 已助推群组 {count} 次"; +"lng_action_set_chat_intro" = "{from} 已为所有空聊天设置下列信息。如何实现?"; +"lng_action_payment_refunded" = "{peer} 已退款 {amount}"; + +"lng_action_paid_message_sent#other" = "你已支付 {count} 颗星星以 {action}"; +"lng_action_paid_message_one" = "发送一条消息"; + +"lng_action_paid_message_some#other" = "发送 {count} 条消息"; + +"lng_action_paid_message_got#other" = "你从 {name} 获得了 {count} 颗星星"; + +"lng_action_paid_message_refund#other" = "{from} 已将 {count} 颗星星退还给您"; + +"lng_action_paid_message_refund_self#other" = "您已将 {count} 颗星星退还给 {name}"; +"lng_action_message_price_free" = "此群组现可发送免费消息。"; + +"lng_action_message_price_paid#other" = "此群组现需要 {count} 颗星星发送一条消息。"; + +"lng_you_paid_stars#other" = "您支付了 {count} 颗星星。"; + +"lng_you_joined_group" = "您已加入此群组"; + +"lng_similar_channels_title" = "相似频道"; +"lng_similar_channels_view_all" = "查看全部"; +"lng_similar_channels_more" = "更多频道"; + +"lng_similar_channels_premium_all#other" = "订阅 {link} 以解锁最多 **{count}** 个相似频道。"; +"lng_similar_channels_premium_all_link" = "Tidings 高级版"; +"lng_similar_channels_show_more" = "显示更多频道"; + +"lng_similar_bots_title" = "类似的机器人"; + +"lng_similar_bots_premium_all#other" = "订阅 {link} 以解锁最多 {count} 个相似机器人。"; +"lng_similar_bots_show_more" = "显示更多机器人"; + +"lng_peer_gifts_title" = "礼物"; +"lng_peer_gifts_about" = "这些礼物是由其他用户赠送给 {user} 的。"; +"lng_peer_gifts_about_mine" = "这些礼物是其他用户送给您的。\n点击礼物即可将其转换为星星或更改其隐私设置。"; +"lng_peer_gifts_empty_search" = "没有符合的礼物"; +"lng_peer_gifts_view_all" = "查看所有礼物"; +"lng_peer_gifts_notify" = "关于新礼物的通知"; +"lng_peer_gifts_notify_enabled" = "当你的频道收到礼物时,Tidings 将给你发送消息。"; +"lng_peer_gifts_filter_by_value" = "按价钱排序"; +"lng_peer_gifts_filter_by_date" = "按日期排序"; +"lng_peer_gifts_filter_unlimited" = "不限定"; +"lng_peer_gifts_filter_limited" = "限量"; +"lng_peer_gifts_filter_unique" = "独家"; +"lng_peer_gifts_filter_saved" = "显示"; +"lng_peer_gifts_filter_unsaved" = "隐藏"; + + +"lng_premium_gift_duration_months#other" = "{count} 月"; + +"lng_premium_gift_duration_years#other" = "{count} 年"; + +"lng_ttl_photo_received" = "{from} 给您发送了一张阅后即焚图片,请在您的移动设备上查看。"; +"lng_ttl_photo_sent" = "您发送了一张阅后即焚图片。"; +"lng_ttl_photo_expired" = "图片已过期"; +"lng_ttl_video_received" = "{from} 给您发送了一段阅后即焚视频,请在您的移动设备上查看。"; +"lng_ttl_video_sent" = "您发送了一段阅后即焚视频。"; +"lng_ttl_video_expired" = "视频已自毁"; +"lng_ttl_voice_sent" = "您已发送一条会自毁的语音消息。"; +"lng_ttl_voice_expired" = "语音已过期"; +"lng_ttl_round_sent" = "您已发送一条会自毁的视频消息。"; +"lng_ttl_round_expired" = "视频消息已过期"; +"lng_ttl_voice_tooltip_in" = "此语音消息只能播放一次。"; +"lng_ttl_voice_tooltip_out" = "一旦 **{user}** 播放一次,此消息就会消失。"; +"lng_ttl_voice_close_in" = "删除并关闭"; +"lng_ttl_round_tooltip_in" = "此视频消息只能播放一次。"; +"lng_ttl_round_tooltip_out" = "此消息将在 **{user}** 播放一次后消失。"; + +"lng_profile_add_more_after_create" = "你可以在新建群组后继续邀请成员"; +"lng_profile_camera_title" = "拍下自己的照片"; + +"lng_channel_not_accessible" = "抱歉,此频道已无法访问。"; +"lng_group_not_accessible" = "抱歉,此群组已无法访问。"; +"lng_group_full" = "抱歉,此群组已满员。"; + +"lng_channels_too_much_public_revoke_confirm_group" = "您确定要废除此链接({link})吗?\n\n群组«{group}»将转变成私密群组。"; +"lng_channels_too_much_public_revoke_confirm_channel" = "您确定要废除此链接({link})吗?\n\n频道«{group}»将转变成私密频道。"; +"lng_channels_too_much_public_revoke" = "废除"; +"lng_channels_too_much_public_other" = "抱歉,目标用户已经有太多的公开群组或频道。 请让他们先将他们现有的一个群组或频道设为私有。"; +"lng_channels_too_much_located_other" = "抱歉,目标用户已经有太多基于位置的群组。请让他们先删除或转移其中一个。"; + +"lng_group_invite_bad_link" = "此邀请链接已失效或过期。"; + + +"lng_group_invite_members#other" = "包括他们在内,有 {count} 位成员:"; +"lng_channel_invite_private" = "该频道为私人频道。请加入后继续浏览。"; + +"lng_channel_invite_subscription_button" = "订阅"; +"lng_channel_invite_subscription_title" = "订阅此频道"; +"lng_channel_invite_subscription_about" = "您想以每月{price}的价格订阅 {channel} 吗?"; +"lng_channel_invite_subscription_terms" = "订阅即表示您同意{link}。"; + +"lng_group_invite_create" = "创建一个邀请链接"; +"lng_group_invite_about_new" = "我们会为您生成一个新的邀请链接,旧的链接将失效。"; +"lng_group_invite_copied" = "邀请链接已复制到剪贴板。"; +"lng_group_invite_no_room" = "此群组已满员,无法加入。"; + +"lng_group_invite_copy" = "拷贝链接"; +"lng_group_invite_share" = "分享链接"; +"lng_group_invite_reactivate" = "恢复链接"; +"lng_group_invite_delete" = "删除链接"; +"lng_group_invite_no_joined" = "尚无人加入"; + +"lng_group_invite_joined#other" = "{count} 人已加入"; +"lng_group_invite_joined_via_filter" = "已通过文件夹邀请链接加入"; + +"lng_group_invite_remaining#other" = "剩余 {count} 次"; + +"lng_group_invite_requested#other" = "{count} 个请求"; + +"lng_group_invite_requested_full#other" = "{count} 个加入请求"; + +"lng_group_invite_can_join#other" = "{count} 可以加入"; + +"lng_group_invite_days_left#other" = "剩余 {count} 天"; +"lng_group_invite_about_permanent_group" = "任何已安装 Tidings 的用户都将可以通过此链接加入您的群组。"; +"lng_group_invite_about_permanent_channel" = "任何 Tidings 用户都可以通过这个链接加入本频道。"; +"lng_group_invite_title" = "邀请链接"; +"lng_group_invite_add" = "创建一个新链接"; +"lng_group_invite_add_about" = "您可以生成使用后自动过期的邀请链接。"; +"lng_group_invite_expires_at" = "链接将于 {when}失效。"; +"lng_group_invite_created_by" = "由【¥%】创建的链接"; + +"lng_group_invite_links_count#other" = "{count} 链接"; +"lng_group_invite_context_copy" = "拷贝"; +"lng_group_invite_context_share" = "分享"; +"lng_group_invite_context_edit" = "编辑"; +"lng_group_invite_context_qr" = "获取二维码"; +"lng_group_invite_context_revoke" = "撤销"; +"lng_group_invite_context_delete" = "删除"; +"lng_group_invite_context_delete_all" = "删除所有"; +"lng_group_invite_delete_sure" = "确定要删除这条已撤销的链接吗?"; +"lng_group_invite_delete_all_sure" = "确定要删除所有已撤销的链接吗?这些操作不可撤销。"; +"lng_group_invite_revoked_title" = "已撤销的链接"; +"lng_group_invite_revoke_about" = "确定要撤销邀请链接吗?"; +"lng_group_invite_link_expired" = "已失效"; +"lng_group_invite_edit_title" = "编辑链接"; +"lng_group_invite_new_title" = "新链接"; +"lng_group_invite_label_header" = "链接名称(可选)"; +"lng_group_invite_label_about" = "只有管理员可以看到此名称."; +"lng_group_invite_expire_title" = "时间限制"; +"lng_group_invite_expire_about" = "你可以设置该链接在一定时间后过期。"; +"lng_group_invite_expire_never" = "无限制"; +"lng_group_invite_expire_custom" = "自定义"; +"lng_group_invite_usage_title" = "次数限制"; +"lng_group_invite_usage_about" = "您可以设定链接在使用一定次数后自动失效。"; +"lng_group_invite_expire_after" = "过期于"; +"lng_group_invite_custom_limit" = "自定义限制次数"; +"lng_group_invite_usage_any" = "无限制"; +"lng_group_invite_usage_custom" = "自定义"; +"lng_group_invite_other_title" = "由其他管理员创建的邀请链接"; + +"lng_group_invite_other_count#other" = "{count} 条邀请链接"; +"lng_group_invite_other_list" = "此管理员创建的邀请链接"; +"lng_group_invite_expired_about" = "该链接已过期。"; +"lng_group_invite_used_about" = "此链接已达到其使用上限。"; + +"lng_group_invite_can_join_via_link#other" = "该链接还能邀请{count}人加入。"; +"lng_group_invite_qr_title" = "通过二维码邀请"; +"lng_group_invite_qr_about" = "Tidings 上的每个人都可以扫描此二维码以加入您的群组。"; +"lng_group_invite_qr_copied" = " 二维码已复制至剪贴板。"; +"lng_group_invite_request_approve" = "请求管理员批准"; +"lng_group_invite_about_approve" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"lng_group_invite_about_no_approve" = "打开这项选项,用户将会只有在管理员允许的情况下加入."; +"lng_group_invite_about_approve_channel" = "开启此选项后,用户需经由管理员批准才可加入。"; +"lng_group_invite_about_no_approve_channel" = "开启此选项后,仅获得管理员批准的用户才可以加入。"; +"lng_group_invite_subscription" = "需要月费"; +"lng_group_invite_subscription_ph" = "每月所需星星数量"; +"lng_group_invite_subscription_price" = "{cost}/月"; +"lng_group_invite_subscription_toast" = "如果您需要更改订阅费用,请创建具有不同价格的新邀请链接。"; +"lng_group_invite_subscription_about" = "向通过此链接加入您频道的用户收取订阅费用。{link}"; +"lng_group_invite_subscription_about_link" = "了解更多 {emoji}"; +"lng_group_invite_subscription_about_url" = "https://tidings.org/tos/stars"; +"lng_group_invite_subscription_info_subtitle" = "订阅费"; +"lng_group_invite_subscription_info_title" = "{emoji} {price} / 月 {multiplier} {total}"; +"lng_group_invite_subscription_info_title_none" = "{emoji} {price} / 月"; +"lng_group_invite_subscription_info_about" = "您每月约可获得 {total}"; +"lng_group_invite_joined_right" = "每月"; +"lng_group_invite_joined_status" = "加入于 {date}"; +"lng_group_invite_joined_row_subscriber" = "订阅者"; +"lng_group_invite_joined_row_date" = "已订阅"; + +"lng_group_request_to_join" = "请求加入"; +"lng_group_request_about" = "此群组仅其管理员批准后才接受新成员。"; +"lng_group_request_about_channel" = "此频道仅在管理员批准后才接受新订阅者。"; +"lng_group_request_sent" = "待管理员同意后即可加入该群组。"; +"lng_group_request_sent_channel" = "一旦频道管理员批准您的请求,您将被添加到频道中。"; + +"lng_group_requests_pending#other" = "{count} 个加入请求"; +"lng_group_requests_pending_user" = "{user} 请求加入"; +"lng_group_requests_status_today" = "今天 {time} 已请求加入此群组"; +"lng_group_requests_status_yesterday" = "昨天 {time} 已请求加入群组"; +"lng_group_requests_status_date_time" = "在 {date} {time} 请求加入"; +"lng_group_requests_add" = "添加到群组"; +"lng_group_requests_add_channel" = "添加至频道"; +"lng_group_requests_dismiss" = "拒绝"; +"lng_group_requests_was_added" = "{user} 已添加到群组中。"; +"lng_group_requests_was_added_channel" = "已将 {user} 加入频道。"; +"lng_group_requests_none" = "目前没有待验证的入群请求。"; +"lng_group_requests_none_channel" = "目前没有待批准的加入请求。"; + +"lng_channel_public_link_copied" = "链接已复制到剪贴板。"; +"lng_context_about_private_link" = "此链接只对该聊天的成员有效。"; +"lng_public_post_private_hint_ctrl" = "使用「Ctrl+点击」以复制非公开链接。"; +"lng_public_post_private_hint_cmd" = "使用「Cmd+点击」复制非公开链接。"; + +"lng_forwarded" = "转发自 {user}"; +"lng_forwarded_story" = "{user} 的动态"; +"lng_forwarded_story_expired" = "此动态已过期。"; +"lng_forwarded_date" = "原消息: {date}"; +"lng_forwarded_channel" = "转发自 {channel}"; +"lng_forwarded_psa_default" = "从 {channel} 转发"; +"lng_forwarded_via" = "转发自 {user} 通过 {inline_bot}"; +"lng_forwarded_channel_via" = "转发自 {channel} 通过 {inline_bot}"; +"lng_forwarded_signed" = "{channel}({user})"; +"lng_forwarded_hidden" = "用户主动隐藏该账户。"; +"lng_forwarded_imported" = "此信息由其他软件导入,无法保证其真实性。"; +"lng_signed_author" = "作者:{user}"; +"lng_sponsored_message_title" = "广告"; +"lng_sponsored_message_revenue_button" = "这是什么?"; +"lng_recommended_message_title" = "推荐"; +"lng_edited" = "已编辑"; +"lng_commented" = "已评论"; +"lng_approximate" = "约"; +"lng_edited_date" = "编辑时间:{date}"; +"lng_sent_date" = "已发送: {date}"; +"lng_approximate_about" = "视频发布的预计日期。"; + +"lng_views_tooltip#other" = "浏览:{count}"; + +"lng_forwards_tooltip#other" = "转发:{count}"; +"lng_imported" = "已导入"; +"lng_admin_badge" = "管理员"; +"lng_owner_badge" = "所有者"; +"lng_channel_badge" = "频道"; +"lng_topic_author_badge" = "话题创建者"; +"lng_fast_reply" = "回复"; +"lng_cancel_edit_post_sure" = "取消编辑?"; +"lng_cancel_edit_post_yes" = "是"; +"lng_cancel_edit_post_no" = "否"; + +"lng_bot_share_location_unavailable" = "抱歉,Tidings 桌面版暂不支持分享位置信息信息。"; +"lng_bot_share_phone" = "您确定要将您的手机号码分享给此机器人吗?"; +"lng_bot_share_phone_confirm" = "分享"; +"lng_bot_allow_write_title" = "允许发送消息"; +"lng_bot_allow_write" = "是否允许该机器人向你发送消息?"; +"lng_bot_allow_write_confirm" = "允许"; + +"lng_attach_failed" = "失败"; +"lng_attach_file" = "文件"; +"lng_attach_photo" = "图片"; +"lng_attach_camera" = "相机"; +"lng_attach_document" = "文档"; +"lng_attach_photo_or_video" = "图片或视频"; +"lng_attach_profile_emoji" = "使用表情"; + +"lng_media_open_with" = "使用其它应用打开…"; +"lng_media_download" = "下载"; +"lng_media_cancel" = "取消"; +"lng_media_video" = "视频"; +"lng_media_audio" = "语音消息"; +"lng_media_round" = "视频消息"; + +"lng_media_auto_settings" = "自动下载媒体文件"; +"lng_media_auto_in_private" = "在私聊中"; +"lng_media_auto_in_groups" = "在群组中"; +"lng_media_auto_in_channels" = "在频道中"; +"lng_media_auto_title" = "自动下载媒体"; +"lng_media_auto_play" = "自动播放"; +"lng_media_photo_title" = "照片"; +"lng_media_video_title" = "视频"; +"lng_media_video_messages_title" = "圆形视频消息"; +"lng_media_file_title" = "文件"; +"lng_media_animation_title" = "GIF 动态图"; +"lng_media_size_limit" = "文件大小限制"; +"lng_media_size_up_to" = "最大 {size}"; +"lng_media_chat_background" = "聊天背景"; +"lng_media_color_theme" = "主题颜色"; + +"lng_emoji_category1" = "笑脸与人像"; +"lng_emoji_category2" = "自然"; +"lng_emoji_category3" = "食物/饮料"; +"lng_emoji_category4" = "活动"; +"lng_emoji_category5" = "旅行/地点"; +"lng_emoji_category6" = "物品"; +"lng_emoji_category7" = "符号/旗帜"; +"lng_emoji_manage_sets" = "选择表情类型"; +"lng_emoji_set_ready" = "已下载"; +"lng_emoji_set_active" = "当前设置"; +"lng_emoji_set_download" = "下载 {size}"; +"lng_emoji_set_loading" = "{percent}, {progress}"; +"lng_emoji_color_all" = "为所有表情选择颜色"; +"lng_emoji_copy" = "复制表情"; +"lng_emoji_view_pack" = "查看表情包"; +"lng_emoji_remove_recent" = "从最近使用的表情中移除"; +"lng_emoji_reset_recent" = "重置最近表情"; +"lng_emoji_reset_recent_sure" = "您想要重置最近使用过的表情吗?"; +"lng_emoji_reset_recent_button" = "重置"; + +"lng_recent_stickers" = "最近使用"; +"lng_faved_stickers_add" = "添加到收藏"; +"lng_faved_stickers_remove" = "从收藏中移除"; +"lng_recent_stickers_remove" = "从最近移除"; +"lng_group_stickers" = "群组贴纸包"; +"lng_group_stickers_description" = "您可以选择一套表情包,以供群成员在群里一起聊天时能够用到。"; +"lng_group_stickers_add" = "选择贴纸包"; +"lng_group_emoji" = "群组表情包"; +"lng_group_emoji_description" = "选择一个可供群组内所有成员使用的表情包。"; +"lng_collectible_emoji" = "典藏品"; + +"lng_premium" = "高级版"; +"lng_premium_free" = "免费"; +"lng_premium_more_about" = "关于 Tidings 高级版"; +"lng_premium_unlock_reactions" = "解锁高级版消息反馈"; +"lng_premium_unlock_stickers" = "解锁会员贴纸"; +"lng_premium_unlock_emoji" = "解锁动态表情"; +"lng_premium_unlock_status" = "解锁表情状态"; + +"lng_premium_subscribe_months_12" = "按年付费"; +"lng_premium_subscribe_months_6" = "半年"; +"lng_premium_subscribe_months_1" = "按月付费"; +"lng_premium_subscribe_total" = "{cost} 每年"; +"lng_premium_subscribe_button" = "立即订阅,仅需 {cost} / 月"; + +"lng_premium_emoji_status_title" = "{user} 已将 {link} 中的表情设置为其当前状态。"; +"lng_premium_emoji_status_title_colored" = "{user} 将这个表情设置为当前状态。"; +"lng_premium_emoji_status_about" = "表情状态是一项高级功能。**Tidings 高级版** 包含的其它功能:"; +"lng_premium_emoji_status_button" = "解锁表情状态"; + +"lng_premium_summary_user_title" = "{user} 是 Tidings 高级版的订阅者"; +"lng_premium_summary_user_about" = "拥有 **Tidings 高级版** 帐号,可享受多项额外功能。"; + +"lng_premium_summary_title" = "Tidings 高级版"; +"lng_premium_summary_top_about" = "订阅 **Tidings Premium** 后,您能 **超越限制** 并 **解锁数十项专属** 功能。"; +"lng_premium_summary_title_subscribed" = "一切都已就绪!"; + +"lng_premium_summary_subtitle_gift#other" = "{user} 已赠予您 {count} 个月的 Tidings 高级版订阅。"; + +"lng_premium_summary_subtitle_gift_me#other" = "您已赠予 **{user}** {count} 个月的 Tidings 高级版订阅。"; +"lng_premium_summary_subtitle_wallpapers" = "为双方设置壁纸"; +"lng_premium_summary_about_wallpapers" = "为您和聊天对象设置自定义壁纸。"; +"lng_premium_summary_subtitle_stories" = "动态"; +"lng_premium_summary_about_stories" = "无限发布数量、优先显示、隐身模式、永久访客历史,以及更多功能。"; +"lng_premium_summary_subtitle_double_limits" = "双倍上限"; +"lng_premium_summary_about_double_limits" = "多达 1000 个频道、30 个文件夹、10 个置顶、20 个公开链接、4 个帐号等等。"; +"lng_premium_summary_subtitle_more_upload" = "无限云端存储空间"; +"lng_premium_summary_about_more_upload" = "单个文档上传限制为 4 GB ,聊天和媒体的总体存储空间无限制。"; +"lng_premium_summary_subtitle_faster_download" = "更快的下载速度"; +"lng_premium_summary_about_faster_download" = "下载媒体文件和文档时不再受到速度限制。"; +"lng_premium_summary_subtitle_voice_to_text" = "语音转文字"; +"lng_premium_summary_about_voice_to_text" = "能够将收到的语音消息转换成文字以方便阅读。"; +"lng_premium_summary_subtitle_no_ads" = "无广告"; +"lng_premium_summary_about_no_ads" = "Tidings 公开频道中不再有不定期展示的广告。"; +"lng_premium_summary_subtitle_emoji_status" = "表情状态"; +"lng_premium_summary_about_emoji_status" = "从数千个表情中选择一个展示当前的活动,显示在您的名字旁边。"; +"lng_premium_summary_subtitle_infinite_reactions" = "无限表情回应"; +"lng_premium_summary_about_infinite_reactions" = "用数千个表情作出回应——可使用多个表情回应单条消息。"; +"lng_premium_summary_subtitle_tags_for_messages" = "消息标签"; +"lng_premium_summary_about_tags_for_messages" = "使用标签整理收藏的消息,以便快速访问。"; +"lng_premium_summary_subtitle_last_seen" = "最后上线时间"; +"lng_premium_summary_about_last_seen" = "即使您已将其隐藏,也可查看其它用户的最后上线及已读时间。"; +"lng_premium_summary_subtitle_message_privacy" = "消息隐私"; +"lng_premium_summary_about_message_privacy" = "限制来自陌生人的消息或对收到的消息收费。"; +"lng_premium_summary_subtitle_premium_stickers" = "高级会员贴纸"; +"lng_premium_summary_about_premium_stickers" = "具有额外特效的专属放大贴纸,每月更新。"; +"lng_premium_summary_subtitle_animated_emoji" = "动态表情"; +"lng_premium_summary_about_animated_emoji" = "在您发送的任何消息中包含来自不同表情包的动态表情。"; +"lng_premium_summary_subtitle_advanced_chat_management" = "高级聊天管理"; +"lng_premium_summary_about_advanced_chat_management" = "可设定默认文件夹、自动归档以及隐藏来自非联系人的新聊天。"; +"lng_premium_summary_subtitle_profile_badge" = "形象勋章"; +"lng_premium_summary_about_profile_badge" = "在您的名字后方显示专属徽章,以表示您已订阅 Tidings 高级版。"; +"lng_premium_summary_subtitle_animated_userpics" = "动态头像"; +"lng_premium_summary_about_animated_userpics" = "视频头像会在聊天列表和聊天中动起来,为您提供更多自我表达空间。"; +"lng_premium_summary_subtitle_translation" = "即时翻译"; +"lng_premium_summary_about_translation" = "将频道和聊天内容即时翻译成其他语言。"; +"lng_premium_summary_subtitle_business" = "Tidings 商业版"; +"lng_premium_summary_about_business" = "升级您的账户后可使用企业功能,如位置信息、营业时间和快速回复。"; +"lng_premium_summary_subtitle_effects" = "消息特效"; +"lng_premium_summary_about_effects" = "为私密消息添加超过 500 种动画特效。"; +"lng_premium_summary_subtitle_filter_tags" = "标记您的聊天"; +"lng_premium_summary_about_filter_tags" = "显示聊天列表中每个聊天的文件夹名称。"; +"lng_premium_summary_bottom_subtitle" = "关于 Tidings 高级版"; +"lng_premium_summary_bottom_about" = "虽然免费版本的 Tidings 已经为用户提供比其他任何即时通讯应用程序更多的功能,但 **Tidings 高级版** 进一步推动了它。\n\n**Tidings 高级版** 是付费选项,因为大多数高级功能需要 Tidings 向第三方(如数据中心提供商和服务器制造商)支付额外费用。 **Tidings 高级版** 用户的贡献使我们能够支付此类费用,并帮助 Tidings 保持为所有人免费提供。"; +"lng_premium_summary_button" = "以每月 {cost} 的价格订阅"; + +"lng_premium_summary_new_badge" = "新"; +"lng_soon_badge" = "即将推出"; + +"lng_premium_success" = "您已经成功订阅了 Tidings 高级版!"; +"lng_premium_unavailable" = "此功能需要订阅 **Tidings 高级版**.\n\n不幸的是,**Tidings 高级版** 在你所在的地区不可用。"; + +// Upgraded Stories. +"lng_premium_stories_subtitle_order" = "优先显示"; +"lng_premium_stories_about_order" = "您的动态将始终优先展示,因此可以获得更多浏览量。"; + +"lng_premium_stories_subtitle_stealth" = "隐身模式"; +"lng_premium_stories_about_stealth" = "隐藏您曾浏览其他用户动态的痕迹。"; + +"lng_premium_stories_subtitle_views" = "永久访客历史"; +"lng_premium_stories_about_views" = "查看谁打开过您的动态——即使在动态过期后仍能查看。"; + +"lng_premium_stories_subtitle_expiration" = "过期选项*"; +"lng_premium_stories_about_expiration" = "为您的动态设置自定义过期时间,比如 6 小时或 48 小时。"; + +"lng_premium_stories_subtitle_download" = "下载动态"; +"lng_premium_stories_about_download" = "保存其他用户未开启保护的动态。"; + +"lng_premium_stories_subtitle_caption" = "更长的说明文字*"; +"lng_premium_stories_about_caption" = "为您的动态添加 10 倍长的说明文字——最多 2048 个字符。"; + +"lng_premium_stories_subtitle_links" = "链接和格式*"; +"lng_premium_stories_about_links" = "在动态说明文字中添加链接和格式。"; + +"lng_premium_stories_about_mobile" = "* 当从 IOS 和 Android 端的 Tidings 应用发布动态时可用。"; + +// Doubled Limits. +"lng_premium_double_limits_subtitle_channels" = "群组和频道"; + +"lng_premium_double_limits_about_channels#other" = "加入多达 {count} 个频道和大型群组"; + +"lng_premium_double_limits_subtitle_pins" = "已置顶聊天"; + +"lng_premium_double_limits_about_pins#other" = "在主聊天列表中置顶高达 {count} 个聊天"; + +"lng_premium_double_limits_subtitle_links" = "公共链接"; + +"lng_premium_double_limits_about_links#other" = "保留高达 {count} 个 tidings.me/name 链接"; + +"lng_premium_double_limits_subtitle_gifs" = "保存 GIF 动态图"; + +"lng_premium_double_limits_about_gifs#other" = "在 GIF 动态图收藏中存储高达 {count} 张 GIF 动态图"; + +"lng_premium_double_limits_subtitle_stickers" = "贴纸收藏"; + +"lng_premium_double_limits_about_stickers#other" = "在贴纸收藏中存储高达 {count} 张贴纸"; + +"lng_premium_double_limits_subtitle_bio" = "个人简介"; +"lng_premium_double_limits_about_bio" = "在个人简介中输入更多文字并添加链接。"; + +"lng_premium_double_limits_subtitle_captions" = "说明文字"; +"lng_premium_double_limits_about_captions" = "在图片和视频下方使用更长的说明文字"; + +"lng_premium_double_limits_subtitle_folders" = "文件夹"; + +"lng_premium_double_limits_about_folders#other" = "将您的聊天整理到 {count}个文件夹中"; + +"lng_premium_double_limits_subtitle_folder_chats" = "每个文件夹内聊天数量"; + +"lng_premium_double_limits_about_folder_chats#other" = "在每个文件夹内添加高达 {count} 个聊天 "; + +"lng_premium_double_limits_subtitle_accounts" = "关联帐户"; + +"lng_premium_double_limits_about_accounts#other" = "使用不同的电话号码连接至 {count} 个账号"; + +"lng_premium_double_limits_subtitle_similar_channels" = "类似频道"; + +"lng_premium_double_limits_about_similar_channels#other" = "查看最多 {count} 个相似频道"; +// + +"lng_premium_gift_title" = "赠予 Tidings 高级版订阅"; +"lng_premium_gift_about" = "将 **Tidings 高级版** 的专属功能赠予 **{user}**。"; +"lng_premium_gift_button" = "花费 {cost} 赠予订阅"; +"lng_premium_gift_per" = "{cost} / 月"; +"lng_premium_gift_terms" = "您可以在 {link} 查看 Tidings 高级版的功能列表和更多详情。"; +"lng_premium_gift_terms_link" = "点击查看"; +"lng_premium_gifts_about_user1" = "让 **{user}** 享有专属功能。"; +"lng_premium_gifts_about_user2" = "让 **{user}** 和 **{second_user}** 享有专属功能。"; +"lng_premium_gifts_about_user3" = "将 **Tidings 高级版** 的专属功能赠予 **{user}**、 **{second_user}** 和 **{name}**。"; + +"lng_premium_gifts_about_user_more#other" = "让 **{user}**、**{second_user}**、**{name}** 和更多 **{count}** 位朋友能够享有专属功能。"; + +"lng_premium_gifts_about_reward#other" = "您将获得 {emoji}**{count}**次助推。"; +"lng_premium_gifts_about_paid_title" = "礼物已赠送!"; +"lng_premium_gifts_about_paid1" = "**{user}** 已收到有关您购买的礼物的通知。"; +"lng_premium_gifts_about_paid2" = "{user} 和 {second_user} 已收到有关您购买的礼物的通知。"; +"lng_premium_gifts_about_paid3" = "**{user}**、**{second_user}** 和 **{name}** 已收到您购买礼物的通知。"; + +"lng_premium_gifts_about_paid_more#other" = "**{user}**、**{second_user}**、**{name}** 和其他 **{count}** 人已收到您购买的礼物的通知。"; + +"lng_premium_gifts_about_paid_below#other" = "对方现已可以使用额外功能。"; +"lng_premium_gifts_summary_subtitle" = "包含了什么"; +"lng_premium_gifts_terms" = "赠予 Tidings 高级版即表示您同意 Tidings {link} 和 {policy}。"; +"lng_premium_gifts_terms_policy" = "隐私政策"; + +"lng_business_title" = "Tidings 企业版"; +"lng_business_about" = "将您的账户转变为企业页面,并获得以下额外功能。"; +"lng_business_unlocked" = "您现已经解锁这些额外的企业功能。"; +"lng_business_subtitle_location" = "位置"; +"lng_business_about_location" = "在您的帐号上显示您的企业位置。"; +"lng_business_subtitle_opening_hours" = "营业时间"; +"lng_business_about_opening_hours" = "向顾客展示商户的营业时间。"; +"lng_business_subtitle_quick_replies" = "快速回复"; +"lng_business_about_quick_replies" = "设置包含文本和媒体的快捷方式,以更快地回复消息。"; +"lng_business_subtitle_greeting_messages" = "问候消息"; +"lng_business_about_greeting_messages" = "设置自动发送给新客户的问候信息。"; +"lng_business_subtitle_away_messages" = "离开消息"; +"lng_business_about_away_messages" = "设置在您不在线时自动发送的消息。"; +"lng_business_subtitle_chatbots" = "聊天机器人"; +"lng_business_about_chatbots" = "添加任何用于处理与客户互动的第三方聊天机器人。"; +"lng_business_subtitle_chat_intro" = "自定义介绍"; +"lng_business_about_chat_intro" = "自定义用户在与您开始聊天之前看到的消息。"; +"lng_business_subtitle_chat_links" = "聊天链接"; +"lng_business_about_chat_links" = "创建与您开始聊天的链接,并预设第一条消息。"; +"lng_business_subtitle_sponsored" = "频道中的广告"; +"lng_business_button_sponsored" = "不要隐藏广告"; +"lng_business_about_sponsored" = "作为高级版订阅用户,您在 Tidings 上不会看到任何广告,但您可以选择开启它们,例如查看您自己在 {link} 上发布的广告。"; +"lng_business_about_sponsored_link" = "Tidings 广告平台 {emoji}"; +"lng_business_about_sponsored_url" = "https://ads.tidings.org"; + +"lng_credits_summary_title" = "Tidings 星星"; +"lng_credits_summary_about" = "购买星星,解锁 Tidings\n小程序中的内容和服务。"; +"lng_credits_summary_options_subtitle" = "选择套餐"; + +"lng_credits_summary_options_credits#other" = "{count} 颗星星"; +"lng_credits_summary_options_more" = "更多选项"; +"lng_credits_summary_options_about" = "购买星星即表示您同意 {link}。"; +"lng_credits_summary_options_about_link" = "条款与细则"; +"lng_credits_summary_options_about_url" = "https://tidings.org/tos/stars"; +"lng_credits_summary_earn_title" = "赚取星星"; +"lng_credits_summary_earn_about" = "分享小程序链接,赚取星星分成收益。"; +"lng_credits_summary_history_tab_full" = "所有交易"; +"lng_credits_summary_history_tab_in" = "进账"; +"lng_credits_summary_history_tab_out" = "出账"; +"lng_credits_summary_history_entry_inner_in" = "应用内购买"; +"lng_credits_summary_balance" = "余额"; +"lng_credits_commission" = "{amount} 佣金"; + +"lng_credits_paid_messages_fee#other" = "{count} 条消息的费用"; +"lng_credits_paid_messages_fee_about" = "每逢收到一条消息 {link},你将获取设置费用的{percent}。"; +"lng_credits_paid_messages_fee_about_link" = "更改费用 {emoji}"; +"lng_credits_paid_messages_full" = "原价"; +"lng_credits_premium_gift_duration" = "期限"; +"lng_credits_more_options" = "更多选项"; +"lng_credits_balance_me" = "您的余额"; +"lng_credits_buy_button" = "购买更多星星"; +"lng_credits_gift_button" = "赠送星星给朋友"; +"lng_credits_box_out_title" = "确认购买"; + +"lng_credits_box_out_sure#other" = "您确定要使用 **{count} 颗星星** 在 **{bot}** 购买 **{text}** 吗?"; + +"lng_credits_box_out_media#other" = "您是否要花费 **{count} 颗星星** 解锁 {chat} 中的 {media}?"; + +"lng_credits_box_out_media_user#other" = "您想以 **{count} 颗星星**的价格从 {user} 解锁 {media}吗?"; + +"lng_credits_box_out_subscription_bot#other" = "您是否想以每月 **{count}** 颗星星的价格订阅 **{recipient}** 中的 **{title}**?"; + +"lng_credits_box_out_subscription_business#other" = "您是否想以每月 **{count}** 颗星的价格订阅 **{recipient}** 发送的 **{title}**?"; + +"lng_credits_box_out_subscription_confirm#other" = "以每月 {emoji} {count} 的价格订阅"; +"lng_credits_box_out_photo" = "**一张图片**"; + +"lng_credits_box_out_photos#other" = "{count} 张图片"; +"lng_credits_box_out_video" = "视频"; + +"lng_credits_box_out_videos#other" = "{count} 段视频"; +"lng_credits_box_out_both" = "{photo} 和 {video}"; + +"lng_credits_box_out_confirm#other" = "确认并支付 {emoji} {count} 颗星星"; +"lng_credits_box_out_about" = "查看星星的 {link}。"; +"lng_credits_box_out_about_link" = "https://tidings.org/tos/stars"; +"lng_credits_media_done_title" = "媒体已解锁"; + +"lng_credits_media_done_text#other" = "已向 **{chat}** 转账 **{count} 颗星星**。"; + +"lng_credits_media_done_text_user#other" = "**{count} 颗星星**已转移到{user}。"; +"lng_credits_summary_in_toast_title" = "已获得星星!"; + +"lng_credits_summary_in_toast_about#other" = "**{count} 颗星星** 已添加至您的余额。"; +"lng_credits_box_history_entry_peer" = "接收者"; +"lng_credits_box_history_entry_peer_in" = "来自"; +"lng_credits_box_history_entry_gift_from" = "礼物来自"; +"lng_credits_box_history_entry_via" = "通过"; +"lng_credits_box_history_entry_play_market" = "Play 商店"; +"lng_credits_box_history_entry_app_store" = "App Store"; +"lng_credits_box_history_entry_fragment" = "Fragment"; +"lng_credits_box_history_entry_anonymous" = "未知用户"; +"lng_credits_box_history_entry_gift_name" = "收到礼物"; +"lng_credits_box_history_entry_giveaway_name" = "收到奖品"; +"lng_credits_box_history_entry_gift_sent" = "发送礼物"; +"lng_credits_box_history_entry_gift_converted" = "已转换的礼物"; +"lng_credits_box_history_entry_gift_transfer" = "礼物转让"; +"lng_credits_box_history_entry_gift_unavailable" = "售罄"; +"lng_credits_box_history_entry_gift_sold_out" = "此礼物已售罄"; +"lng_credits_box_history_entry_gift_out_about" = "有了星星,**{user}** 将可以解锁 Tidings 上的内容和服务。{link}"; +"lng_credits_box_history_entry_gift_in_about" = "使用星星解锁 Tidings 上的內容和服务。 {link}"; +"lng_credits_box_history_entry_gift_about_link" = "请查看示例 {emoji}"; +"lng_credits_box_history_entry_gift_examples" = "例如"; +"lng_credits_box_history_entry_ads" = "广告平台"; +"lng_credits_box_history_entry_premium_bot" = "星星充值"; +"lng_credits_box_history_entry_api" = "付费广播消息"; + +"lng_credits_box_history_entry_floodskip_about#other" = "{count} 条消息"; +"lng_credits_box_history_entry_floodskip_row" = "消息"; +"lng_credits_box_history_entry_via_premium_bot" = "高级版机器人"; +"lng_credits_box_history_entry_id" = "交易号"; +"lng_credits_box_history_entry_id_copied" = "已将交易号复制到剪贴板"; +"lng_credits_box_history_entry_reason_star_ref" = "联盟计划"; +"lng_credits_box_history_entry_affiliate" = "联盟"; +"lng_credits_box_history_entry_miniapp" = "迷你应用"; +"lng_credits_box_history_entry_referred" = "您推荐的用户"; +"lng_credits_box_history_entry_success_date" = "交易日期"; +"lng_credits_box_history_entry_success_url" = "交易链接"; +"lng_credits_box_history_entry_media" = "媒体"; +"lng_credits_box_history_entry_message" = "消息"; +"lng_credits_box_history_entry_about" = "您可以对此交易提出争议 {link}。"; +"lng_credits_box_history_entry_about_link" = "此处"; +"lng_credits_box_history_entry_reaction_name" = "星星回应"; +"lng_credits_box_history_entry_subscription" = "订阅月费"; +"lng_credits_box_history_entry_gift_upgrade" = "升级为典藏品"; +"lng_credits_box_history_entry_gift_sold" = "售出礼物"; +"lng_credits_box_history_entry_gift_bought" = "购买礼物"; +"lng_credits_box_history_entry_gift_sold_to" = "赠予"; +"lng_credits_box_history_entry_gift_full_price" = "原价"; +"lng_credits_box_history_entry_gift_bought_from" = "来自"; + +"lng_credits_subscription_section" = "我的订阅"; +"lng_credits_box_subscription_title" = "订阅"; +"lng_credits_subscription_subtitle" = "{emoji} {cost} / 月"; +"lng_credits_subscriber_subtitle" = "每月约 {total}"; + +"lng_credits_subscription_row_to" = "订阅"; +"lng_credits_subscription_row_to_bot" = "机器人"; +"lng_credits_subscription_row_to_business" = "商家"; +"lng_credits_subscription_row_from" = "已订阅"; + +"lng_credits_subscription_row_next_on" = "续订"; +"lng_credits_subscription_row_next_off" = "到期"; +"lng_credits_subscription_row_next_none" = "已失效"; + +"lng_credits_subscription_on_button" = "取消订阅"; +"lng_credits_subscription_on_about" = "如果您现在取消订阅,您仍然可以在 {date} 之前存取您的订阅内容。"; + +"lng_credits_subscription_off_button" = "续订"; +"lng_credits_subscription_off_rejoin_button" = "再次订阅"; +"lng_credits_subscription_off_about" = "您已取消您的订阅。"; +"lng_credits_subscription_off_by_bot_about" = "{bot} 已取消你的订阅。"; + +"lng_credits_subscription_status_on" = "已续订于 {date}"; +"lng_credits_subscription_status_off" = "将于 {date} 过期"; +"lng_credits_subscription_status_none" = "已过期于 {date}"; +"lng_credits_subscription_status_off_right" = "已取消"; +"lng_credits_subscription_status_none_right" = "已到期"; +"lng_credits_subscription_status_off_by_bot_right" = "已被机器人\n取消"; + + +"lng_credits_small_balance_title#other" = "需要 {count} 颗星星"; +"lng_credits_small_balance_about" = "购买 **星星** 并在 **{bot}** 和其他小程序中使用。"; +"lng_credits_small_balance_reaction" = "购买**星星**并送给 {channel} 以支持他们的贴文。"; +"lng_credits_small_balance_subscribe" = "购买**星星**并订阅**{channel}**和其他频道。"; +"lng_credits_small_balance_star_gift" = "购买**星星**以将禮礼物送给 {user} 和其他联系人。"; +"lng_credits_small_balance_for_message" = "购买 **星星** 以向 {user} 发送消息。"; +"lng_credits_small_balance_for_messages" = "购买 **星星** 来发送消息。"; +"lng_credits_small_balance_fallback" = "购买**星星**以解锁 Tidings 上的内容和服务。"; +"lng_credits_purchase_blocked" = "抱歉,您无法使用 Tidings 星星购买此产品。"; +"lng_credits_enough" = "你目前有足够的星星可用。{link}"; +"lng_credits_enough_link" = "仍然购买"; + +"lng_credits_gift_title" = "赠送 Tidings 星星"; + +"lng_location_title" = "位置"; +"lng_location_about" = "在您的帐号上显示企业位置。"; +"lng_location_address" = "输入地址"; +"lng_location_set_map" = "在地图上标记位置"; +"lng_location_fallback" = "你可在手机上使用地图设置您的位置。"; + +"lng_hours_title" = "营业时间"; +"lng_hours_about" = "启用此功能可以让您的客户了解您的营业时间安排。"; +"lng_hours_show" = "显示营业时间"; +"lng_hours_time_zone" = "时区"; +"lng_hours_monday" = "周一"; +"lng_hours_tuesday" = "周二"; +"lng_hours_wednesday" = "周三"; +"lng_hours_thursday" = "周四"; +"lng_hours_friday" = "周五"; +"lng_hours_saturday" = "周六"; +"lng_hours_sunday" = "周日"; +"lng_hours_closed" = "休息"; +"lng_hours_open_full" = "24 小时营业"; +"lng_hours_next_day" = "次日 {time}"; +"lng_hours_on_next_day" = "明天 {time}"; +"lng_hours_time_zone_title" = "选择时区"; +"lng_hours_add_button" = "添加一组时间"; +"lng_hours_opening" = "开始营业时间"; +"lng_hours_closing" = "关闭时间"; +"lng_hours_remove" = "删除"; +"lng_hours_about_day" = "设置您一天中的工作时间。"; + +"lng_replies_title" = "快速回复"; +"lng_replies_about" = "设置包含富文本和媒体的快捷方式,以更快地回复消息"; +"lng_replies_add" = "添加快速回复"; +"lng_replies_add_title" = "新增快速回复"; +"lng_replies_add_shortcut" = "为您快速回复添加快捷方式"; +"lng_replies_add_placeholder" = "指令"; +"lng_replies_add_exists" = "该指令已存在"; +"lng_replies_empty_title" = "新建快速回复"; +"lng_replies_empty_about" = "当你输入 {shortcut} 时,下列消息将会被发送到聊天内。\n\n你可在任何聊天中输入 / 来访问快速回复。"; +"lng_replies_remove_title" = "移除快捷键"; +"lng_replies_remove_text" = "您尚未创建快速回复信息。是否退出本页面并移除快捷方式?"; +"lng_replies_edit_title" = "编辑指令"; +"lng_replies_edit_about" = "编辑此指令的名称"; +"lng_replies_message_placeholder" = "添加快速回复"; +"lng_replies_delete_sure" = "确定要删除此快速回复及其中的所有消息?"; +"lng_replies_error_occupied" = "此指令已被占用。"; +"lng_replies_edit_button" = "编辑快速回复"; + +"lng_greeting_title" = "问候语"; +"lng_greeting_about" = "向首次向你发消息或一段时间没有活跃的客户发送问候。"; +"lng_greeting_enable" = "发送问候消息"; +"lng_greeting_create" = "创建问候消息"; +"lng_greeting_recipients" = "接收者"; +"lng_greeting_select" = "选择单个聊天或整个聊天类型发送问候消息。"; +"lng_greeting_period_title" = "不活跃的时长"; +"lng_greeting_period_about" = "选择自您上次与对方互动多少天后,将给对方的发送问候消息作为回复。"; +"lng_greeting_empty_title" = "新问候消息"; +"lng_greeting_empty_about" = "创建自动发送给新客户的问候语。"; +"lng_greeting_message_placeholder" = "添加问候语"; +"lng_greeting_limit_reached" = "您的快速回复太多了。移除一个即可新增问候语。"; +"lng_greeting_recipients_empty" = "请至少选择一名接收者。"; + +"lng_away_title" = "离开消息"; +"lng_away_about" = "当您离开时将自动回复消息。"; +"lng_away_enable" = "发送离开消息"; +"lng_away_create" = "创建离开消息"; +"lng_away_schedule" = "时间表"; +"lng_away_schedule_always" = "总是发送"; +"lng_away_schedule_outside" = "不在营业时间"; +"lng_away_schedule_custom" = "自定义时间表"; +"lng_away_custom_start" = "开始时间 "; +"lng_away_custom_end" = "结束时间"; +"lng_away_offline_only" = "仅当离线时"; +"lng_away_offline_only_about" = "当您最近在线时不发送离开消息。"; +"lng_away_recipients" = "接收者"; +"lng_away_select" = "选择聊天或整个聊天类型来发送离开消息。"; +"lng_away_empty_title" = "新的离开消息"; +"lng_away_empty_about" = "添加在您离线时自动发送的消息。"; +"lng_away_message_placeholder" = "添加离开消息"; +"lng_away_limit_reached" = "您的快速回复太多了。移除一个即可新增离线消息。"; + +"lng_business_edit_messages" = "编辑消息"; + +"lng_business_limit_reached#other" = "已达 {count} 条消息上限。"; + +"lng_chatbots_title" = "聊天机器人"; +"lng_chatbots_about" = "为您的帐号添加机器人,帮助您自动处理和回复收到的消息。{link}"; +"lng_chatbots_about_link" = "了解更多…"; +"lng_chatbots_placeholder" = "输入机器人链接或用户名"; +"lng_chatbots_add_about" = "输入您希望自动处理聊天的 Tidings 机器人链接。"; +"lng_chatbots_access_title" = "机器人可访问的聊天"; +"lng_chatbots_all_except" = "所有一对一的聊天,除了…"; +"lng_chatbots_selected" = "仅选出的聊天"; +"lng_chatbots_excluded_title" = "排除的聊天"; +"lng_chatbots_exclude_button" = "不包含的聊天"; +"lng_chatbots_included_title" = "包含的聊天"; +"lng_chatbots_include_button" = "选择聊天"; +"lng_chatbots_exclude_about" = "选择机器人无权访问的聊天或整个聊天类型。"; +"lng_chatbots_permissions_title" = "机器人权限"; +"lng_chatbots_warning_title" = "警告"; +"lng_chatbots_warning_both_text" = "机器人{bot} 将有权 **管理您的礼物和星星**,包括将它们赠送给其他用户。"; +"lng_chatbots_warning_gifts_text" = "机器人 {bot} 将有权 **管理您的礼物**,包括將它们赠送给其他用户。"; +"lng_chatbots_warning_stars_text" = "机器人 {bot} 将有权 **转让您的星星**。"; +"lng_chatbots_warning_username_text" = "机器人 {bot} 将有权对您的账户 **设置和移除用户名**,这可能会导致您丢失目前的用户名。"; + +"lng_chatbots_manage_messages" = "管理消息"; +"lng_chatbots_read" = "查看消息"; +"lng_chatbots_reply" = "回复消息"; +"lng_chatbots_mark_as_read" = "标记消息为已读"; +"lng_chatbots_delete_sent" = "删除已发送的消息"; +"lng_chatbots_delete_received" = "删除已收到的消息"; + +"lng_chatbots_manage_profile" = "管理个人资料页"; +"lng_chatbots_edit_name" = "编辑名称"; +"lng_chatbots_edit_bio" = "编辑个人简介"; +"lng_chatbots_edit_userpic" = "编辑头像"; +"lng_chatbots_edit_username" = "编辑用户名"; + +"lng_chatbots_manage_gifts" = "管理礼物或星星"; +"lng_chatbots_view_gifts" = "查看礼物和星星"; +"lng_chatbots_sell_gifts" = "将礼物兑换成星星"; +"lng_chatbots_gift_settings" = "更改礼物设置"; +"lng_chatbots_transfer_gifts" = "转让和升级礼物"; +"lng_chatbots_transfer_stars" = "转让星星"; + +"lng_chatbots_manage_stories" = "管理动态"; + +"lng_chatbots_remove" = "移除机器人"; +"lng_chatbots_not_found" = "未找到聊天机器人。"; +"lng_chatbots_not_supported" = "此机器人暂不支持 “Tidings 企业版”。"; +"lng_chatbots_add" = "添加"; +"lng_chatbots_info_url" = "https://tidings.org/blog/tidings-business#chatbots-for-business"; +"lng_chatbot_status_can_reply" = "机器人管理此聊天"; +"lng_chatbot_status_paused" = "机器人已暂停"; +"lng_chatbot_status_views" = "机器人有权访问此聊天"; +"lng_chatbot_button_pause" = "停用"; +"lng_chatbot_button_resume" = "开始"; +"lng_chatbot_menu_manage" = "管理机器人"; +"lng_chatbot_menu_remove" = "从此聊天中移除机器人"; +"lng_chatbot_menu_revoke" = "阻止访问此聊天"; + +"lng_chat_intro_title" = "起始页"; +"lng_chat_intro_subtitle" = "自定你的起始页"; +"lng_chat_intro_default_title" = "暂无消息…"; +"lng_chat_intro_default_message" = "发送信息或点击下方的问候语"; +"lng_chat_intro_enter_title" = "输入标题"; +"lng_chat_intro_enter_message" = "输入消息"; +"lng_chat_intro_choose_sticker" = "选择贴纸"; +"lng_chat_intro_random_sticker" = "随机"; +"lng_chat_intro_about" = "你可以自定他人与你聊天前看到的消息。"; +"lng_chat_intro_reset" = "重置为默认"; + +"lng_chat_links_title" = "聊天链接"; +"lng_chat_links_about" = "为您的客户提供可以开始与您聊天的短链接,并为其推荐与您聊天的第一条消息。"; +"lng_chat_links_create_link" = "创建聊天链接"; +"lng_chat_links_footer" = "也可以使用短链接来与你聊天 — {links}"; +"lng_chat_links_footer_both" = "{username} 或 {link}"; +"lng_chat_links_no_clicks" = "暂无点击数"; + +"lng_chat_links_clicks#other" = "{count} 次点击"; +"lng_chat_link_new_title" = "新增链接"; +"lng_chat_link_edit_title" = "编辑链接"; +"lng_chat_link_description" = "添加一条消息,所有通过以下链接与您开始聊天的人都将在消息输入框看到此消息:"; +"lng_chat_link_placeholder" = "添加预设消息"; +"lng_chat_link_saved" = "已保存聊天链接。"; +"lng_chat_link_copy" = "复制"; +"lng_chat_link_share" = "分享"; +"lng_chat_link_rename" = "重命名"; +"lng_chat_link_delete" = "删除"; +"lng_chat_link_name" = "链接名称(可选)"; +"lng_chat_link_name_about" = "为此链接添加一个只有您才能看到的名称。"; +"lng_chat_link_delete_sure" = "你确定要删除此聊天链接吗?"; +"lng_chat_link_qr_title" = "聊天链接二维码"; +"lng_chat_link_qr_about" = "Tidings 上的任何人都可以扫描此二维码来联系你。"; +"lng_chat_link_copied" = "聊天链接已复制到剪贴板。"; + +"lng_boost_channel_button" = "助推频道"; +"lng_boost_group_button" = "助推群组"; +"lng_boost_again_button" = "再次助推"; +"lng_boost_group_about" = "助推您的群组以解锁更多其它外观设置。"; + +"lng_boost_level#other" = "等级 {count}"; + +"lng_boost_level_unlocks#other" = "等级 {count} 解锁:"; +"lng_boost_channel_title_first" = "为此频道启用动态"; +"lng_boost_channel_title_first_group" = "为群组启用动态"; + +"lng_boost_channel_needs_unlock#other" = "{channel} 需要 {count} 次助推来解锁新功能。"; +//"lng_boost_channel_needs_first#one" = "{channel} needs **{count}** more boost to enable posting stories. Help make it possible!"; +//"lng_boost_channel_needs_first#other" = "{channel} needs **{count}** more boosts to enable posting stories. Help make it possible!"; +"lng_boost_channel_title_more" = "帮助升级此频道"; +"lng_boost_channel_title_more_group" = "帮助此群组升级"; +//"lng_boost_channel_needs_more#one" = "{channel} needs **{count}** more boost to be able to {post}."; +//"lng_boost_channel_needs_more#other" = "{channel} needs **{count}** more boosts to be able to {post}."; +"lng_boost_channel_title_max" = "已达到最高等级"; +"lng_boost_channel_you_title" = "您助推了 {channel}!"; +//"lng_boost_channel_you_first#one" = "This channel needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first#other" = "This channel needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more#one" = "This channel needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more#other" = "This channel needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first" = "此频道已达到 **等级 1**,现在可以发布动态了。"; + +"lng_boost_channel_reached_more#other" = "此频道已达到 **等级 {count}**,现在每天可发布 {post}。"; +//"lng_boost_channel_you_first_group#one" = "This group needs **{count}** more boost\nto enable stories."; +//"lng_boost_channel_you_first_group#other" = "This group needs **{count}** more boosts\nto enable stories."; +//"lng_boost_channel_you_more_group#one" = "This group needs **{count}** more boost\nto be able to {post}."; +//"lng_boost_channel_you_more_group#other" = "This group needs **{count}** more boosts\nto be able to {post}."; +"lng_boost_channel_reached_first_group" = "此群组已达到 ** 等级 1**,现在可以发布动态。"; + +"lng_boost_channel_reached_more_group#other" = "此群组已达到 **等级 {count}**,现在每天可以发布 {post} 个动态。"; + +"lng_boost_channel_post_stories#other" = "每天发布 **{count} 条动态** "; +"lng_boost_channel_features" = "您的助推将有助于 {channel} 解锁新功能。"; +"lng_boost_group_lift_restrictions" = "助推群组以移除消息限制。"; + +"lng_boost_group_lift_restrictions_many#other" = "助推群组 **{count} 次** 以移除消息限制。"; +"lng_boost_error_gifted_title" = "无法使用受赠的 Tidings 高级版助推!"; +"lng_boost_error_gifted_text" = "由于您的 **Tidings 高级版** 是通过赠送获得的,所以您无法用其助推频道。"; +"lng_boost_error_gifted_text_group" = "由于您的 **Tidings 高级版** 订阅是获赠所得,因此您无法使用它来助推群组。"; +"lng_boost_need_more" = "需要更多助推"; + +"lng_boost_need_more_text#other" = "想要助推 {channel},请向好友赠送 **Tidings 高级版** 来获取 **{count}** 个额外助推。"; + +"lng_boost_need_more_again#other" = "想要再次助推 {channel},请向好友赠送 **Tidings 高级版** 来获取 **{count}** 个额外助推。"; +"lng_boost_error_already_title" = "已经助推过了!"; +"lng_boost_error_already_text" = "您已助推此频道。"; +"lng_boost_error_already_text_group" = "您已经在助推此群组了。"; +"lng_boost_error_premium_title" = "需要订阅 Tidings 高级版"; +"lng_boost_error_premium_text_group" = "只有 **Tidings 高级版** 订阅者可以助推群组。您想订阅 **Tidings 高级版** 吗?"; +"lng_boost_error_premium_text" = "只有 **Tidings 高级版** 订阅者可以助推频道。您是否要订阅 **Tidings 高级版**?"; +"lng_boost_error_premium_yes" = "确定"; +"lng_boost_error_flood_title" = "不能频繁助推!"; +"lng_boost_error_flood_text_group" = "你每天只能改变一次助推的群组。您离下次可以助推的时间还剩 {left}。"; +"lng_boost_error_flood_text" = "您每天只能更改一次助推的频道。 您可以在 **{left}** 后再次助推。"; +"lng_boost_now_instead" = "您目前正在助推 **{channel}**,您是否想改为助推 **{other}**?"; +"lng_boost_now_replace" = "更换"; +"lng_boost_reassign_title" = "重新分配助推"; +"lng_boost_reassign_text" = "要助推 {channel},请重新分配之前的助推或将 {gift} 赠予給用户。"; + +"lng_boost_reassign_gift#other" = "向好友赠送 **Tidings 高级版** 以再获得 **{count}** 个助推。"; +"lng_boost_remove_title" = "移你的助推自"; +"lng_boost_reassign_button" = "重新分配"; +"lng_boost_available_in" = "将于 {duration} 可用"; + +"lng_boost_available_in_toast#other" = "等待助推可用,或通过赠予 **Tidings 高级版** 来获得 **{count}** 次额外的助推。"; + +"lng_boost_reassign_done#other" = "{channels} 的 {count} 次助推已重新分配。"; + +"lng_boost_reassign_channels#other" = "{count} 个频道"; + +"lng_boost_reassign_groups#other" = "{count} 个群组"; + +"lng_boost_reassign_mixed#other" = "{count} 个群组或频道"; + +"lng_boost_channel_title_color" = "启用颜色"; + +"lng_boost_channel_needs_level_color#other" = "您的频道需要达到 **等级 {count}** 才能更改频道颜色。"; + +"lng_boost_channel_title_wallpaper" = "启用壁纸"; + +"lng_boost_channel_needs_level_wallpaper#other" = "您的频道要达到 **等级 {count}** 才能设置频道壁纸。"; + +"lng_boost_group_needs_level_wallpaper#other" = "您的群组需要达到 **等级 {count}** 才能更改群组背景。"; + +"lng_boost_channel_title_status" = "启用表情状态"; + +"lng_boost_channel_needs_level_status#other" = "您的频道需要达到 **等级 {count}** 才能设置频道表情状态。 "; + +"lng_boost_group_needs_level_status#other" = "您的群组需要达到 **等级 {count}** 才能设置群组表情符号状态。 "; + +"lng_boost_channel_title_reactions" = "自定义表情回应"; + +"lng_boost_channel_needs_level_reactions#other" = "您的频道需要达到 **等级 {count}** 才能添加 **{same_count}** 个自定义表情回应。"; + +"lng_boost_channel_title_cpm" = "助推频道"; + +"lng_boost_channel_needs_level_cpm#other" = "您的频道需要达到 **等级 {count}** 才能关闭广告。"; + +"lng_boost_group_title_emoji" = "启用表情包"; + +"lng_boost_group_needs_level_emoji#other" = "您的群组需要达到 **等级 {count}** 才能设置群组表情包。 "; + +"lng_boost_channel_title_wear" = "佩带礼物"; + +"lng_boost_channel_needs_level_wear#other" = "你的频道需要达到 **等级 {count}** 才能佩戴典藏品。"; + +"lng_boost_channel_ask" = "请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"lng_boost_channel_ask_button" = "复制链接"; +//"lng_boost_channel_or" = "or"; +//"lng_boost_channel_gifting" = "Boost your channel by gifting your subscribers Tidings Premium. {link}"; +//"lng_boost_channel_gifting_link" = "Get boosts >"; +"lng_boost_group_ask" = "请您的 **高级版** 会员使用此链接助推您的群组:"; +//"lng_boost_group_gifting" = "Boost your group by gifting your members Tidings Premium. {link}"; + +"lng_boost_channel_title_autotranslate" = "消息自动翻译"; + +"lng_boost_channel_needs_level_autotranslate#other" = "您的频道需要达到 **等级 {count}** 以启用消息的自动翻译功能。"; + + +"lng_feature_stories#other" = "每天 {count} 条状态"; + +"lng_feature_reactions#other" = "{count} 个自定义表情回应"; + +"lng_feature_name_color_channel#other" = "{count} 种频道名称颜色"; + +"lng_feature_link_style_channel#other" = "{count} 种链接及引用样式"; +"lng_feature_link_emoji" = "链接和引用的自定义标志"; +"lng_feature_emoji_status" = "**1000+** 种表情状态"; + +"lng_feature_backgrounds_channel#other" = "{count} 种频道背景"; +"lng_feature_custom_background_channel" = "自定义频道背景"; + +"lng_feature_backgrounds_group#other" = "**{count}** 个群组背景"; +"lng_feature_custom_background_group" = "自定义群组背景"; +"lng_feature_custom_emoji_pack" = "自定义表情包"; +"lng_feature_transcribe" = "语音转文字"; +"lng_feature_autotranslate" = "消息自动翻译"; + +"lng_giveaway_new_title" = "通过赠品助推"; +"lng_giveaway_new_about" = "通过向您的订阅者赠予 Tidings 高级版来为您的频道获取更多助推。"; +"lng_giveaway_new_about_group" = "通过向您的成员赠送 Tidings 高级版来为您的群组获得更多助推。"; +"lng_giveaway_credits_new_about" = "通过赠送奖品来为您的频道获得更多的助推和订阅者。"; +"lng_giveaway_credits_new_about_group" = "通过赠送奖品来为您的群组\n获得更多助推和成员。"; +"lng_giveaway_create_option" = "创建抽奖活动"; +"lng_giveaway_create_subtitle" = "将随机选出获奖者"; +"lng_giveaway_award_option" = "奖励特定用户"; +"lng_giveaway_award_subtitle" = "选择接收者 >"; + +"lng_giveaway_award_chosen#other" = "{count} 位接收者 >"; +"lng_giveaway_quantity_title" = "奖品的数量"; + +"lng_giveaway_quantity#other" = "{count} 次助推"; +"lng_giveaway_quantity_about" = "选择要赠送的 Tidings 高级版订阅数量以及接收的助推数量。"; +"lng_giveaway_channels_title" = "抽奖活动中包括的频道"; + +"lng_giveaway_channels_this#other" = "此频道将获得 {count} 次助推"; + +"lng_giveaway_channels_this_group#other" = "此群组将获得 {count} 次助推"; +"lng_giveaway_channels_add" = "添加频道"; +"lng_giveaway_channels_about" = "选择用户需要订阅哪些频道才能参与抽奖活动。"; +"lng_giveaway_users_title" = "有资格参与抽奖活动的用户"; +"lng_giveaway_users_all" = "所有订阅者"; +"lng_giveaway_users_all_group" = "所有成员"; +"lng_giveaway_users_from_all_countries" = "来自所有国家"; +"lng_giveaway_users_from_one_country" = "来自 {country}"; + +"lng_giveaway_users_from_countries#other" = "来自 {count} 个国家"; +"lng_giveaway_users_new" = "仅限新订阅者"; +"lng_giveaway_users_new_group" = "仅限新成员"; +"lng_giveaway_users_about" = "选择是否要将抽奖活动仅限于活动发起之后加入频道的用户,或来自特定国家/地区的用户。"; +"lng_giveaway_users_about_group" = "选择是否要将抽奖活动仅限于活动发起后加入群组或来自特定国家的用户。"; +"lng_giveaway_start" = "开始抽奖活动"; +"lng_giveaway_award" = "赠送高级版订阅"; +"lng_giveaway_random_button" = "随机选出"; +"lng_giveaway_start_sure" = "您确定要现在开始此次预付抽奖活动吗?此操作无法撤销。"; +"lng_giveaway_date_title" = "抽奖活动结束日期"; +"lng_giveaway_date" = "日期和时间"; + +"lng_giveaway_date_about#other" = "选择何时在您的频道中随机选择 {count} 名订阅者获得 Tidings 高级版。"; + +"lng_giveaway_date_about_group#other" = "选择何时从您的群组中随机选出 {count} 名成员接收 Tidings 高级版。"; + +"lng_giveaway_duration_title#other" = "Tidings 高级版订阅的时长"; +"lng_giveaway_duration_price" = "{price} x {amount}"; +"lng_giveaway_date_select" = "选择日期与时间"; +"lng_giveaway_date_confirm" = "确认"; +"lng_giveaway_recipients_save" = "保存接收者"; +"lng_giveaway_recipients_deselect" = "取消全选"; + +"lng_giveaway_maximum_countries_error#other" = "您最多可以选择 {count} 个国家。"; + +"lng_giveaway_maximum_channels_error#other" = "您最多可以 {count} 个频道。"; + +"lng_giveaway_maximum_users_error#other" = "您最多可以选择 {count} 位用户。"; +"lng_giveaway_channels_confirm_title" = "这是私人频道"; +"lng_giveaway_channels_confirm_about" = "您确定要新增私人频道吗?如果没有邀请链接,用户将无法加入。"; +"lng_giveaway_additional_prizes" = "额外奖品"; +"lng_giveaway_additional_about" = "如果您除了 Tidings 高级版订阅之外还想为获奖者提供您自己的奖品,请打开此选项。"; +"lng_giveaway_additional_prizes_ph" = "输入您的奖品"; + +"lng_giveaway_prizes_just_premium#other" = "所有奖品:**{count}** 份为期 {duration} 的 Tidings 高级版订阅。"; + +"lng_giveaway_prizes_additional#other" = "所有奖品:**{count}** 份 {prize} 以及为期 {duration} 的 Tidings Premium 订阅。"; +"lng_giveaway_additional_credits_about" = "如果您想在赠送星星的同时,额外为获奖者提供您自己的奖品,请开启此选项。"; + +"lng_giveaway_prizes_just_credits#other" = "所有奖品: **{count}** 颗星星。"; + +"lng_giveaway_prizes_additional_credits#other" = "所有奖品: **{count}** {prize} 和 **{amount} 颗星星**。"; + +"lng_giveaway_prizes_additional_credits_amount#other" = "{count} 颗星星"; +"lng_giveaway_show_winners" = "显示获奖者"; +"lng_giveaway_show_winners_about" = "选择是否在抽奖活动结束时公开获奖者名单。"; + +"lng_giveaway_created_title" = "抽奖活动已创建"; +"lng_giveaway_created_body" = "在频道的 {link} 页面查看您的频道通过此次抽奖活动获得了多少助推。"; +"lng_giveaway_created_body_group" = "检查群组的 {link},了解此抽奖活动如何助推你的群组。"; +"lng_giveaway_awarded_title" = "已赠予高级版订阅"; +"lng_giveaway_awarded_body" = "检查您频道的 {link},了解礼物将如何助推您的频道。"; +"lng_giveaway_awarded_body_group" = "查看群组的 {link},了解礼物能如何助推您的群组。"; +"lng_giveaway_created_link" = "统计信息"; + +"lng_giveaway_credits_options_title" = "可分发的星星"; + +"lng_giveaway_credits_option_status#other" = "每个用户 {count} 颗"; +"lng_giveaway_credits_options_about" = "选择赠送多少颗星星以及每年获得多少次助推。"; +"lng_giveaway_credits_quantity_title" = "获奖者数量"; +"lng_giveaway_credits_quantity_about" = "选择您要为多少获奖者发放星星。"; + +"lng_prize_title" = "恭喜!"; +"lng_prize_about" = "您在 {channel} 发起的抽奖活动中赢得了奖品。"; +"lng_prize_duration" = "你的奖品是 {duration} 的 **Tidings 高级版** 订阅。"; +"lng_prize_credits" = "你的奖品是 {amount}。"; + +"lng_prize_credits_amount#other" = "{count} 颗星星"; +"lng_prize_gift_about" = "您已收到来自 {channel} 的礼物。"; +"lng_prize_gift_duration" = "您的奖品是 {duration} 的 **Tidings 高级版**\n订阅。"; +"lng_prize_open" = "公开礼物链接"; +"lng_prize_unclaimed_title" = "尚未领取的奖品"; +"lng_prize_unclaimed_about" = "您从 {channel} 已获得一份尚未领取的奖品。"; +"lng_prize_unclaimed_duration" = "此奖品是 {duration} 的 **Tidings 高级版** 订阅。"; + + +"lng_prizes_title#other" = "抽奖奖品"; + +"lng_prizes_additional#other" = "**{count}** {prize}"; +"lng_prizes_additional_with" = "及"; + +"lng_prizes_about#other" = "**{count}** 个 {duration} 的 Tidings 高级版订阅。"; +"lng_prizes_credits_about_single" = "**{amount}** 将会分发给 **1** 名获奖者。"; + +"lng_prizes_credits_about#other" = "**{amount}** 将会分发给 {count} 名获奖者。"; + +"lng_prizes_credits_about_amount#other" = "{count} 颗星星"; +"lng_prizes_participants" = "参与者"; + +"lng_prizes_participants_all#other" = "所有频道的订阅者:"; + +"lng_prizes_participants_all_group#other" = "群组的所有成员:"; + +"lng_prizes_participants_all_mixed#other" = "群组及频道所有成员:"; + +"lng_prizes_participants_new#other" = "本公告发布后加入以下频道的所有用户:"; + +"lng_prizes_participants_new_group#other" = "在此日期后加入以下群组的所有用户:"; + +"lng_prizes_participants_new_mixed#other" = "在此日期之后加入以下群组和频道的所有用户:"; +"lng_prizes_countries" = "来自 {countries}"; +"lng_prizes_countries_and_one" = "{countries},{country}"; +"lng_prizes_countries_and_last" = "{countries} 和 {country}"; +"lng_prizes_date" = "选定获奖者的日期"; +"lng_prizes_how_works" = "了解更多"; +"lng_prizes_how_title" = "关于此抽奖活动"; +"lng_prizes_end_title" = "抽奖活动已结束"; +"lng_prizes_how_text" = "此赠品活动由 {admins}。"; +"lng_prizes_end_text" = "此赠品活动由 {admins} 赞助。"; + +"lng_prizes_admins#other" = "频道 {channel} 的管理员赠予其订阅者 **{count} 份 Tidings 高级版** {duration} 订阅"; + +"lng_prizes_admins_group#other" = "{channel} 的管理员,为其成员提供了 **{count}个我 {duration} 的 Tidings 高级版** 订阅"; +"lng_prizes_credits_admins" = "{channel} 的管理员,为其订阅者提供了 **{amount}**"; +"lng_prizes_credits_admins_group" = "{channel} 的管理员,为其成员提供了 **{amount}**"; + +"lng_prizes_credits_admins_amount#other" = "{count} 颗星星"; + +"lng_prizes_additional_added#other" = "{channel} 还包括 **{count}{prize}** 等奖品。频道管理员负责发放这些奖品。"; + +"lng_prizes_additional_added_group#other" = "{channel} 还包括 **{count} 份 {prize}** 奖品。此群组的管理员负责发放这些奖品。"; +"lng_prizes_how_when_finish" = "在 {date},Tidings 将自动选定 {winners}。"; +"lng_prizes_end_when_finish" = "在 {date},Tidings 将自动选定 {winners}。"; + +"lng_prizes_end_activated#other" = "**{count}** 位获奖者已使用了赠礼链接。"; + +"lng_prizes_winners_all_of_one#other" = " {channel} 的 {count} 位随机订阅者"; + +"lng_prizes_winners_all_of_one_group#other" = "{channel} 的 {count} 位随机成员"; + +"lng_prizes_winners_all_of_many#other" = "{channel} 和其他列出频道的 {count} 位随机订阅者"; + +"lng_prizes_winners_all_of_many_group#other" = "{count} 名 {channel} 和其他列出的群组和频道的随机成员"; + +"lng_prizes_winners_new_of_one#other" = "在 {start_date} 之后加入 {channel} 的 {count} 位随机用户"; + +"lng_prizes_winners_new_of_many#other" = "在 {start_date} 加入 {channel} 和其他列出频道的 {count} 位随机用户"; +"lng_prizes_how_participate_one" = "要参加此抽奖活动,请在 **{date}** 之前加入 **{channel}** 频道。"; +"lng_prizes_how_participate_many" = "要参加此抽奖活动,请在 **{date}** 之前加入 **{channel}** 频道以及其他列出的频道。"; +"lng_prizes_how_no_admin" = "您没有资格参加此抽奖活动,因为您是参加频道 ({channel}) 的管理员。"; +"lng_prizes_how_no_admin_group" = "您没有资格参加此抽奖活动,因为您是参加频道 ({channel}) 的管理员。"; +"lng_prizes_how_no_joined" = "您没有资格参加此赠品活动,因为您是在 **{date}** 即活动开始之前加入此频道的。"; +"lng_prizes_how_no_joined_group" = "您没有资格参加此抽奖活动,因为于 {date},即活动开始之前加入此群组。"; +"lng_prizes_how_no_country" = "您沒有资格参加此赠品活动,因为您所在的国家未包含在赠品活动条款中。"; +"lng_prizes_how_yes_joined_one" = "您正在参与此抽奖活动,因为您已加入 **{channel}** 频道。"; +"lng_prizes_how_yes_joined_many" = "您正在参与此赠品活动,因为您已加入频道 {channel} (和其他列出的频道)。"; +"lng_prizes_you_won" = "您在此抽奖活动中赢得了奖品 {cup}"; +"lng_prizes_you_won_credits" = "此次抽奖活动中你赢得了 {amount} {cup}"; + +"lng_prizes_you_won_credits_amount#other" = "{count} 颗星星"; +"lng_prizes_view_prize" = "查看我的奖品"; +"lng_prizes_you_didnt" = "您在此次抽奖活动中没有获奖。"; +"lng_prizes_cancelled" = "该频道通过撤销付款的方式取消了奖品。"; +"lng_prizes_cancelled_group" = "此群组通过退款的方式取消了奖品。"; +"lng_prizes_badge" = "x{amount}"; + +"lng_prizes_results_title" = "获奖者已选出!"; +"lng_prizes_results_title_one" = "获奖者已选出!"; + +"lng_prizes_results_about#other" = "Tidings 已随机选出 **{count}** 位 [{link}]() 获奖者。"; +"lng_prizes_results_link" = "抽奖活动"; +"lng_prizes_results_winner" = "获奖者"; +"lng_prizes_results_winners" = "获奖者"; + +"lng_prizes_results_more#other" = "以及 {count} 等更多!"; +"lng_prizes_results_one" = "获奖者已在私密消息中收到他们的礼物链接。"; +"lng_prizes_results_all" = "所有获奖者都已在私密消息中收到礼物链接。"; + +"lng_prizes_credits_results_one#other" = "获奖者已收到 {count} 颗星星。"; + +"lng_prizes_credits_results_all#other" = "所有获奖者都已总共收到 {count} 颗星星。"; +"lng_prizes_results_some" = "一些获奖者无法选出。"; + +"lng_gift_link_title" = "礼品链接"; +"lng_gift_link_about" = "此链接允许您启用 **Tidings 高级版** 订阅。"; +"lng_gift_link_label_from" = "来自"; +"lng_gift_link_label_to" = "到"; +"lng_gift_link_label_to_unclaimed" = "无接收者"; +"lng_gift_link_label_gift" = "赠礼"; +"lng_gift_link_gift_premium" = "Tidings 高级版 {duration}"; +"lng_gift_link_label_reason" = "原因"; +"lng_gift_link_reason_giveaway" = "抽奖活动"; +"lng_gift_link_reason_unclaimed" = "未完成的抽奖活动"; +"lng_gift_link_reason_chosen" = "你已被频道选中"; +"lng_gift_link_label_date" = "日期"; +"lng_gift_link_label_first_sale" = "首次销售"; +"lng_gift_link_label_last_sale" = "最后销售"; +"lng_gift_link_label_value" = "价值"; +"lng_gift_link_also_send" = "您也可以 {link} 给好友作为礼物。"; +"lng_gift_link_also_send_link" = "发送此链接"; +"lng_gift_link_use" = "使用链接"; +"lng_gift_link_already_title" = "您已经拥有 Tidings 高级版"; +"lng_gift_link_already_about" = "您可以在 {date} 之后激活此礼品码,或者 {link} 给朋友。"; +"lng_gift_link_already_link" = "发送链接"; +"lng_gift_link_used_title" = "使用过的礼品链接"; +"lng_gift_link_used_about" = "此链接用于启用 **Tidings 高级版** 订阅。"; +"lng_gift_link_used_footer" = "此链接已于 {date} 使用。"; +"lng_gift_link_expired" = "礼品码已过期"; +"lng_gift_link_pending_about" = "此链接允许 {user} 启用 **Tidings 高级版** 订阅。"; +"lng_gift_link_pending_toast" = "只有接收者可以看到链接。"; +"lng_gift_link_pending_footer" = "此链接尚未启用。"; + + +"lng_gift_stars_title#other" = "{count} 颗星星"; +"lng_gift_stars_outgoing" = "有了星星,{user} 将可以解锁 Tidings 上的内容和服务。"; +"lng_gift_stars_incoming" = "使用星星解锁 Tidings 上的內容和服务。"; +"lng_gift_until" = "直到"; + +"lng_gift_premium_or_stars" = "赠送 Tidings 高级版订阅或星星"; +"lng_gift_premium_subtitle" = "赠送 Tidings 高级版订阅"; +"lng_gift_premium_about" = "让 {name} 享有 Tidings 高级版专属功能. {features}"; +"lng_gift_premium_features" = "查看详情 >"; +"lng_gift_premium_label" = "高级版"; +"lng_gift_premium_by_stars" = "或 {amount}"; +"lng_gift_stars_subtitle" = "赠送星星"; +"lng_gift_stars_about" = "给 {name} 赠送礼物,可保留在个人资料中或兑换为星星。{link}"; +"lng_gift_stars_link" = "星星是什么 >"; +"lng_gift_stars_limited" = "限量"; +"lng_gift_stars_sold_out" = "售罄"; +"lng_gift_stars_resale" = "转售"; +"lng_gift_stars_on_sale" = "在售"; +"lng_gift_stars_tabs_all" = "所有礼物"; +"lng_gift_stars_tabs_my" = "我的礼物"; +"lng_gift_stars_tabs_limited" = "限量"; +"lng_gift_stars_tabs_in_stock" = "现货"; +"lng_gift_stars_tabs_resale" = "转售"; +"lng_gift_send_title" = "赠送礼物"; +"lng_gift_send_message" = "输入消息"; +"lng_gift_send_anonymous" = "隐藏送礼者"; +"lng_gift_send_pay_with_stars" = "支付 {amount}"; +"lng_gift_send_stars_balance" = "您的余额为 {amount}。{link}"; +"lng_gift_send_stars_balance_link" = "获取更多星星 >"; +"lng_gift_send_anonymous_self" = "向我的个人资料页访客隐藏我的名称和消息"; +"lng_gift_send_anonymous_about" = "向 {user} 个人资料页的访客隐藏您的名称和消息。 {recipient} 仍会看到您的名称和消息。"; +"lng_gift_send_anonymous_about_paid" = "您可以向 {user} 个人资料的访问者隐藏您的名称。但 {recipient} 仍会看到您的名称。"; +"lng_gift_send_anonymous_about_channel" = "你可以对此频道的所有访客(管理员除外)隐藏您的名字和消息。"; +"lng_gift_send_unique" = "以 {price} 创造独家典藏品"; +"lng_gift_send_unique_about" = "启用此选项可让 {user} 将您的礼物转换为独家典藏品。 {link}"; +"lng_gift_send_unique_about_channel" = "开启此功能以允许 {name} 的管理员将您的礼物转换为独家典藏品。{link}"; +"lng_gift_send_unique_link" = "了解更多 >"; +"lng_gift_send_premium_about" = "只有 {user} 能看到你的消息。"; + +"lng_gift_send_limited_sold#other" = "售出 {count} 颗"; + +"lng_gift_send_limited_left#other" = "剩余 {count} 颗"; +"lng_gift_send_button" = "赠送以 {cost} 价格的礼物"; +"lng_gift_send_button_self" = "以 {cost} 购买礼物"; +"lng_gift_buy_resale_title" = "购买 {name}"; +"lng_gift_buy_resale_button" = "以 ⭐️{cost} 购买"; +"lng_gift_buy_resale_confirm" = "您想用 {price} 颗星星购买 {name} 并赠送 {user} 吗?"; +"lng_gift_buy_resale_confirm_self" = "您想用 {price} 颗星星购买 {name} 吗?"; +"lng_gift_buy_price_change_title" = "价格更改!"; +"lng_gift_buy_price_change_text" = "此礼物价格已改为 {price}。您仍要购买吗?"; +"lng_gift_sent_title" = "赠礼已送出!"; +"lng_gift_sent_resale_done" = "{user} 已收到关于您礼物的通知。"; +"lng_gift_sent_resale_done_self" = "{gift} 现在属于你了。"; + +"lng_gift_sent_about#other" = "您已使用余额中的 **{count}** 颗星星。"; +"lng_gift_limited_of_one" = "独家"; +"lng_gift_limited_of_count" = "1(共 {amount})"; +"lng_gift_collectible_tag" = "礼物"; +"lng_gift_view_unpack" = "开箱"; +"lng_gift_anonymous_hint" = "只有您可以看到送礼者的名称。"; +"lng_gift_anonymous_hint_channel" = "只有此频道的管理员可以看到发件人的姓名。"; +"lng_gift_hidden_hint" = "此礼物已被隐藏,只有你能看到它。"; +"lng_gift_hidden_unique" = "此礼物不会显示在您的个人资料页面上。"; +"lng_gift_visible_hint" = "此礼物可以在您的个人资料页上看到。"; +"lng_gift_hidden_hint_channel" = "此礼物对您的频道访问者是隐藏的。"; +"lng_gift_visible_hint_channel" = "此礼物在您頻道資料頁上可見。 "; +"lng_gift_in_blockchain" = "此礼物位于 TON 区块链上。 {link}"; +"lng_gift_in_blockchain_link" = "查看 >"; +"lng_gift_visible_hide" = "隐藏 >"; +"lng_gift_show_on_page" = "在我的资料上显示"; +"lng_gift_show_on_channel" = "在频道礼物列表中显示"; +"lng_gift_availability" = "送出情况"; +"lng_gift_from_hidden" = "隐藏用户"; +"lng_gift_subtitle_birthdays" = "生日"; +"lng_gift_list_birthday_status_today" = "{emoji} 今天过生日"; +"lng_gift_list_birthday_status_yesterday" = "昨天过生日"; +"lng_gift_list_birthday_status_tomorrow" = "明天过生日"; +"lng_gift_self_status" = "给自己买一份礼物"; +"lng_gift_self_title" = "购买礼物"; +"lng_gift_self_about" = "给自己买一份礼物并添加至您的个人资料页或留作日后使用。\n\n限量版礼物升级为典藏品后可以赠送给他人。"; +"lng_gift_channel_title" = "赠送礼物"; +"lng_gift_channel_about" = "选择一份礼物以向 {name} 表达谢意。"; +"lng_gift_unique_owner" = "拥有着"; +"lng_gift_unique_address_copied" = "地址已复制到剪贴板。"; +"lng_gift_unique_status" = "状态"; +"lng_gift_unique_status_non" = "非独家"; +"lng_gift_unique_status_upgrade" = "升级"; +"lng_gift_unique_number" = "典藏 #{index}"; +"lng_gift_unique_model" = "型号"; +"lng_gift_unique_backdrop" = "背景"; +"lng_gift_unique_symbol" = "符号"; +"lng_gift_unique_rarity" = "只有 {percent} 的此类典藏品具有此属性"; +"lng_gift_unique_availability_label" = "数量"; + +"lng_gift_unique_availability#other" = "已送出 {count} / {amount}"; +"lng_gift_unique_info" = "于 {date} 赠送给 {recipient}。"; +"lng_gift_unique_info_sender" = "由 {from} 于 {date} 赠送给 {recipient}。"; +"lng_gift_unique_info_sender_comment" = "由 {from} 于 {date} 赠送给 {recipient},留言为:「{text}」。"; +"lng_gift_unique_info_reciever" = "已于 {date} 赠送给 {recipient}。"; +"lng_gift_unique_info_reciever_comment" = "于 {date} 赠送给 {recipient},留言为:“{text}”"; + +"lng_gift_availability_left#other" = "剩余 {count} / {amount}"; +"lng_gift_availability_none" = "剩余 0 / {amount}"; + +"lng_gift_convert_to_stars#other" = "转换为 {count} 颗星星"; +"lng_gift_convert_sure_title" = "将礼物转换为星星"; + +"lng_gift_convert_sure_confirm#other" = "您想将这份来自 {user} 的礼物转换为 **{count} 颗星星**吗?"; + +"lng_gift_convert_sure_confirm_channel#other" = "您想将这份礼物兑换成 {channel} 的 **{count} 颗星星** 吗?"; + +"lng_gift_convert_sure_limit#other" = "转换可在接下来的 **{count} 天**內进行。"; +"lng_gift_convert_sure_caution" = "此操作无法撒消。这将永久删除此礼物。"; +"lng_gift_convert_sure" = "转换"; +"lng_gift_display_done" = "此礼物现在可以在您的个人资料页上看到。"; +"lng_gift_display_done_channel" = "礼物现在可以在你的频道资料页上看到。"; +"lng_gift_display_done_hide" = "礼物现已在您的个人资料页中隐藏。"; +"lng_gift_display_done_hide_channel" = "礼物现已从你的页面上隐藏。"; +"lng_gift_pinned_done_title" = "{gift} 已置顶"; +"lng_gift_pinned_done" = "该礼物将保持置顶显示。"; +"lng_gift_pinned_done_replaced" = "更换 {gift}"; + +"lng_gift_got_stars#other" = "您通过此礼物获得了 **{count} 颗星星**。"; + +"lng_gift_channel_got#other" = "频道因这份礼物获得了 **{count} 颗星星**。"; +"lng_gift_sold_out_title" = "售罄!"; + +"lng_gift_sold_out_text#other" = "所有 {count} 份礼物均已售出。"; +"lng_gift_send_small" = "赠送礼物"; + +"lng_gift_sell_small#other" = "以 {count} 颗星星的价格售出"; +"lng_gift_upgrade_title" = "升级礼物"; +"lng_gift_upgrade_about" = "将你的礼物转换为可以转让或拍卖的独家典藏品。"; +"lng_gift_upgrade_preview_title" = "创造独家礼物"; +"lng_gift_upgrade_preview_about" = "让 {name} 将您的礼物转换为独家典藏品。"; +"lng_gift_upgrade_preview_about_channel" = "让 {name} 的管理员将您的礼物变成独特的收藏品。"; +"lng_gift_upgrade_unique_title" = "独家"; +"lng_gift_upgrade_unique_about" = "为您的礼物取得独家的编号、型号、背景和符号。"; +"lng_gift_upgrade_transferable_title" = "可转让"; +"lng_gift_upgrade_transferable_about" = "将升级后的礼物赠送给 Tidings 上的任何朋友。"; +"lng_gift_upgrade_tradable_title" = "可交易"; +"lng_gift_upgrade_tradable_about" = "在第三方 NFT 市场上出售或拍卖你的礼物。"; +"lng_gift_upgrade_button" = "以 {price} 升级"; +"lng_gift_upgrade_free" = "免费升级"; +"lng_gift_upgrade_confirm" = "确认"; +"lng_gift_upgrade_add_my" = "将我的名字添加到礼物上"; +"lng_gift_upgrade_add_my_comment" = "添加我的名字和留言"; +"lng_gift_upgrade_add_sender" = "将送礼者的名字添加到礼物上"; +"lng_gift_upgrade_add_comment" = "添加送礼者的名字和留言"; +"lng_gift_upgraded_title" = "礼物已升级"; +"lng_gift_upgraded_about" = "你的礼物 {name} 现在具有独家属性,可以转让给其他人。"; +"lng_gift_transferred_title" = "礼物已转让"; +"lng_gift_transferred_about" = "{name} 已成功转让 {recipient}"; +"lng_gift_transfer_title" = "转让 {name}"; +"lng_gift_transfer_via_blockchain" = "通过区块链发送"; +"lng_gift_transfer_password_title" = "两步验证"; +"lng_gift_transfer_password_description" = "请输入您的转账密码。"; +"lng_gift_transfer_password_about" = "仅当您满足以下条件时才可以提款:"; +"lng_gift_transfer_confirm_title" = "使用 Fragment 进行管理"; +"lng_gift_transfer_confirm_text" = "你可以使用第三方服务 Fragment 将 {name} 转移到 TON 区块链。之后你可以用任何 TON 钱包应用将其作为 NFT 管理。\n\n你也可以通过 Fragment 将此类 NFT 转回到你的 Tidings 账号。"; +"lng_gift_transfer_confirm_button" = "打开 Fragment"; + +"lng_gift_transfer_unlocks_days#other" = "{count} 天后解锁"; + +"lng_gift_transfer_unlocks_hours#other" = "{count} 小时后解锁"; +"lng_gift_transfer_unlocks_title" = "正在解锁中"; +"lng_gift_transfer_unlocks_about" = "{when}天后,您能够将此典藏品发送到 Tidings 以外的任何 TON 区块链地址进行销售或拍卖。"; + +"lng_gift_transfer_unlocks_when_days#other" = "{count} 天后"; + +"lng_gift_transfer_unlocks_when_hours#other" = "{count} 小时后"; +"lng_gift_transfer_unlocks_update_title" = "需要更新"; +"lng_gift_transfer_unlocks_update_about" = "Tidings 支持将礼物转账至区块链的版本目前正在 Play 商店审核中,请耐心等待几天。"; +"lng_gift_transfer_sure" = "您想要将 {name} 的所有权转让给 {recipient} 吗?"; +"lng_gift_transfer_sure_for" = "你是否要花费 {price} 将 {name} 的拥有权转让给 {recipient}?"; +"lng_gift_transfer_button" = "转让"; +"lng_gift_transfer_button_for" = "以 {price} 转让"; +"lng_gift_transfer_wear" = "佩戴"; +"lng_gift_transfer_take_off" = "取消佩戴"; +"lng_gift_transfer_sell" = "出售"; +"lng_gift_transfer_update" = "更改价格"; +"lng_gift_transfer_unlist" = "下架"; +"lng_gift_sell_unlist_title" = "下架 {name}"; +"lng_gift_sell_unlist_sure" = "您确定要下架您的礼物吗?"; +"lng_gift_sell_title" = "星星价格"; +"lng_gift_sell_placeholder" = "以星星作为单位输入价格"; +"lng_gift_sell_about" = "您将收到所选金额的 {percent}。"; + +"lng_gift_sell_amount#other" = "您将获得 **{count}** 颗星星。"; + +"lng_gift_sell_min_price#other" = "最低价格为 {count} 颗星星。"; +"lng_gift_sell_put" = "上架"; +"lng_gift_sell_update" = "更新价格"; +"lng_gift_sell_toast" = "{name} 现已出售!"; +"lng_gift_sell_updated" = "{name} 的售价已更新。"; +"lng_gift_sell_removed" = "{name} 已停止销售!"; +"lng_gift_menu_show" = "显示"; +"lng_gift_menu_hide" = "隐藏"; +"lng_gift_wear_title" = "佩戴 {name}"; +"lng_gift_wear_about" = "并获得以下权益"; +"lng_gift_wear_badge_title" = "光芒徽章"; +"lng_gift_wear_badge_about" = "此礼物将以闪耀图标的形式显示在你的名字旁边。"; +"lng_gift_wear_badge_about_channel" = "此礼物将显示在你的频道名称旁边,並带有闪耀图标。"; +"lng_gift_wear_proof_title" = "所有权证明"; +"lng_gift_wear_proof_about" = "点击你名字旁的图标将显示礼物的所有者和属性。"; +"lng_gift_wear_proof_about_channel" = "点击频道名称旁边的图标将显示礼物的所有者和属性。"; +"lng_gift_wear_start" = "开始佩戴"; +"lng_gift_wear_subscribe" = "订阅 {link} 以佩带典藏品。"; +"lng_gift_wear_start_toast" = "您已佩戴 {name}"; +"lng_gift_wear_end_toast" = "您已取下 {name}"; +"lng_gift_many_pinned_title" = "置顶的礼物过多"; +"lng_gift_many_pinned_choose" = "选择下方要取消置顶的礼物"; +"lng_gift_resale_price" = "价格"; +"lng_gift_resale_number" = "数量"; +"lng_gift_resale_date" = "日期"; + +"lng_gift_resale_count#other" = "{count} 件转售中"; +"lng_gift_resale_sort_price" = "按价格排序"; +"lng_gift_resale_sort_date" = "按日期排序"; +"lng_gift_resale_sort_number" = "按数量排序"; +"lng_gift_resale_filter_all" = "全部选择"; +"lng_gift_resale_model" = "型号"; + +"lng_gift_resale_models#other" = "{count} 个型号"; +"lng_gift_resale_backdrop" = "背景"; + +"lng_gift_resale_backdrops#other" = "{count} 个背景"; +"lng_gift_resale_symbol" = "符号"; + +"lng_gift_resale_symbols#other" = "{count} 个符号"; +"lng_gift_resale_early" = "您可以在 {duration} 后转售此礼物。"; +"lng_gift_transfer_early" = "您可以在 {duration} 后转让此礼物。"; +"lng_gift_resale_transfer_early_title" = "稍后再试"; + +"lng_accounts_limit_title" = "已达到上限"; + +"lng_accounts_limit1#other" = "您已达到添加 **{count}** 个账号的上线。"; +"lng_accounts_limit2" = "在以下任一账号中订阅**Tidings 会员**,即可额外添加一个账号。"; + +"lng_emoji_status_for_title" = "该状态持续至…"; +"lng_emoji_status_for_submit" = "设置状态"; +"lng_emoji_status_menu_duration_any" = "状态持续 {duration}"; + +"lng_group_about_header" = "您已创建一个群组"; +"lng_group_about_text" = "群组可以拥有:"; +"lng_group_about1" = "成员上限为 20 万人"; +"lng_group_about2" = "永久聊天记录"; +"lng_group_about3" = "公开链接,例如:tidings.me/title"; +"lng_group_about4" = "具备不同权限的管理员"; + +"lng_switch_stickers" = "表情"; +"lng_switch_emoji" = "Emoji"; +"lng_switch_gifs" = "GIF 动态图"; +"lng_switch_masks" = "面具装饰"; +"lng_stickers_featured_add" = "添加"; +"lng_stickers_featured_installed" = "已添加"; +"lng_emoji_featured_unlock" = "解锁"; +"lng_emoji_premium_restore" = "恢复"; +"lng_gifs_search" = "搜索 GIF 动态图"; +"lng_gifs_no_saved" = "您还没有保存过 GIF 动态图。"; + +"lng_inline_bot_no_results" = "未找到相关信息。"; +"lng_inline_bot_via" = "通过 {inline_bot}"; + +"lng_box_remove" = "移除"; + +"lng_stickers_installed_tab" = "表情"; +"lng_stickers_masks_tab" = "面具装饰"; +"lng_stickers_featured_tab" = "热门"; +"lng_stickers_archived_tab" = "已归档"; +"lng_stickers_remove_pack" = "删除贴纸包 «{sticker_pack}»?"; +"lng_stickers_add_pack" = "添加表情包"; +"lng_stickers_add_masks" = "添加口罩"; +"lng_stickers_add_emoji" = "添加表情包"; +"lng_stickers_share_pack" = "分享表情包"; +"lng_stickers_share_masks" = "分享此面具装饰"; +"lng_stickers_share_emoji" = "分享表情"; +"lng_stickers_not_found" = "该贴纸包不存在"; +"lng_stickers_packs_archived" = "为了给新表情包腾出空间,您未使用的一些表情包已经被归档。"; +"lng_stickers_copied" = "表情包链接已复制到剪贴板。"; +"lng_stickers_copied_emoji" = "表情包链接已复制到剪贴板。"; +"lng_stickers_default_set" = "伟大的思想"; +"lng_stickers_you_have" = "管理贴纸包"; +"lng_stickers_return" = "撤消"; + +"lng_stickers_count#other" = "{count} 张表情"; + +"lng_masks_count#other" = "{count} 个面具装饰"; + +"lng_custom_emoji_count#other" = "{count} 个表情"; +"lng_stickers_attached_sets" = "附加贴纸集"; +"lng_custom_emoji_used_sets" = "用过的表情包"; +"lng_custom_emoji_remove_pack_button" = "移除表情"; +"lng_stickers_group_set" = "群组贴纸包"; +"lng_stickers_remove_group_set" = "删除群组表情包?"; +"lng_stickers_group_from_your" = "从您的贴纸包中选择"; +"lng_stickers_group_from_featured" = "从热门表情包中选择"; +"lng_stickers_nothing_found" = "没有符合条件的表情包"; +"lng_stickers_remove_pack_confirm" = "移除"; +"lng_stickers_archive_pack" = "归档贴纸"; +"lng_stickers_has_been_archived" = "贴纸包已归档。"; +"lng_emoji_group_set" = "群组表情包"; +"lng_emoji_remove_group_set" = "移除群组表情包吗?"; +"lng_emoji_group_from_your" = "从您的表情符号中选择"; +"lng_emoji_group_from_featured" = "从热门表情中选择"; +"lng_masks_archive_pack" = "归档面具装饰"; +"lng_masks_has_been_archived" = "此面具贴纸包已被归档。"; +"lng_masks_installed" = "已安装此面具贴纸包。"; +"lng_emoji_nothing_found" = "未找到表情"; +"lng_stickers_context_reorder" = "重新排列"; +"lng_stickers_context_edit_name" = "编辑名称"; +"lng_stickers_context_delete" = "删除贴图"; +"lng_stickers_context_delete_sure" = "您确定要从您贴纸包中删除此贴纸吗?"; +"lng_stickers_box_edit_name_title" = "编辑贴纸包名字"; +"lng_stickers_box_edit_name_about" = "为您的贴纸包选择一个名称。"; +"lng_stickers_creator_badge" = "编辑"; + +"lng_in_dlg_photo" = "图片"; +"lng_in_dlg_album" = "相册"; +"lng_in_dlg_video" = "视频"; +"lng_in_dlg_audio_file" = "音频文件"; +"lng_in_dlg_contact" = "联系人"; +"lng_in_dlg_audio" = "语音消息"; +"lng_in_dlg_video_message" = "视频消息"; +"lng_in_dlg_voice_message_ttl" = "阅后即焚视频语音消息"; +"lng_in_dlg_video_message_ttl" = "阅后即焚视频消息"; +"lng_in_dlg_file" = "文件"; +"lng_in_dlg_sticker" = "贴纸"; +"lng_in_dlg_sticker_emoji" = "{emoji} 表情"; +"lng_in_dlg_poll" = "投票"; +"lng_in_dlg_story" = "动态"; +"lng_in_dlg_story_expired" = "过期的动态"; + +"lng_in_dlg_media_count#other" = "{count} 个媒体文件"; + +"lng_in_dlg_photo_count#other" = "{count} 张图片"; + +"lng_in_dlg_video_count#other" = "{count} 个视频"; + +"lng_in_dlg_file_count#other" = "{count} 个文件"; + +"lng_in_dlg_audio_count#other" = "{count} 个音频"; + +"lng_ban_user" = "封禁用户"; +"lng_ban_users" = "封禁用户"; +"lng_restrict_users" = "限制用户"; +"lng_delete_all_from_user" = "删除来自 {user} 的所有消息"; +"lng_delete_all_from_users" = "删除该用户发送的所有消息"; + +"lng_restrict_user#other" = "受限用户"; +"lng_restrict_user_part" = "部分限制此用户 {emoji}"; +"lng_restrict_users_part" = "部分限制用户 {emoji}"; +"lng_restrict_user_full" = "彻底封禁此用户 {emoji}"; +"lng_restrict_users_full" = "彻底封禁用户 {emoji}"; +"lng_restrict_users_part_single_header" = "这位用户可以做什么?"; + +"lng_restrict_users_part_header#other" = "选中的 {count} 位用户可以做什么?"; +"lng_report_spam" = "举报垃圾信息"; +"lng_report_spam_and_leave" = "举报垃圾信息并离开"; +"lng_report_spam_done" = "感谢您的反馈。"; +"lng_report_spam_sure_group" = "您确定要举报此群组存在骚扰行为吗?"; +"lng_report_spam_sure_channel" = "您确定要举报该频道存在骚扰行为吗?"; +"lng_report_spam_ok" = "举报"; +"lng_new_contact_block" = "屏蔽用户"; +"lng_new_contact_block_done" = "{user} 已被封禁"; +"lng_new_contact_add" = "添加为联系人"; +"lng_new_contact_share" = "分享我的手机号码"; +"lng_new_contact_share_sure" = "您确定要将您的手机号码 {phone} 分享给 {user} 吗?"; +"lng_new_contact_share_done" = "{user} 可以查看您的手机号码。"; +"lng_new_contact_add_name" = "添加 {user} 为联系人"; +"lng_new_contact_add_done" = "{user} 已成为您的联系人"; +"lng_new_contact_unarchive" = "取消归档"; +"lng_new_contact_from_request_channel" = "{user} 是您请求加入频道 {name} 的管理员"; +"lng_new_contact_from_request_group" = "{user} 是您曾申请加入的 {name} 群组管理员。"; +"lng_new_contact_about_status" = "此帐号在其名称旁边使用了自定义状态 {emoji}。所有订阅 {link} 的用户都可以使用这样的表情状态。"; +"lng_new_contact_about_status_link" = "Tidings 高级版"; +"lng_new_contact_not_contact" = "非联系人"; +"lng_new_contact_phone_number" = "电话号码"; +"lng_new_contact_registration" = "注册"; +"lng_new_contact_common_groups" = "普通群组"; + +"lng_new_contact_groups#other" = "{count} 群组 {emoji} {arrow}"; +"lng_new_contact_not_official" = "非官方账号"; +"lng_new_contact_updated_name" = "用户在 {when} 更改昵称"; +"lng_new_contact_updated_photo" = "用户在 {when} 更改头像"; +"lng_new_contact_updated_now" = "不到一小时前"; + +"lng_new_contact_updated_hours#other" = "{count} 小时前"; + +"lng_new_contact_updated_days#other" = "{count} 天前"; + +"lng_new_contact_updated_months#other" = "{count} 个月前"; +"lng_from_request_title_channel" = "您的加入请求已被回复"; +"lng_from_request_title_group" = "您的加入请求已被回复"; +"lng_from_request_body" = "您接收到此消息因为您曾于 {date} 申请加入 {name}。"; +"lng_from_request_understand" = "我明白了"; +"lng_cant_send_to_not_contact" = "抱歉,目前您只能给\n双向联系人发送信息。\n{more_info}"; +"lng_cant_invite_not_contact" = "抱歉,目前您只能将\n双向联系人添加到群组。\n{more_info}"; +"lng_cant_more_info" = "更多信息»"; +"lng_cant_invite_banned" = "抱歉,只有管理员可以添加此用户。"; +"lng_cant_invite_privacy" = "抱歉,根据此用户的隐私设置,您无法将其添加到群组。"; +"lng_cant_invite_bot_to_channel" = "机器人只能作为管理员加入频道。"; +"lng_cant_do_this" = "抱歉,无法执行此操作。"; +"lng_cant_invite_offer_admin" = "机器人只能作为管理员加入频道。"; +"lng_cant_invite_make_admin" = "设置为管理员"; + +"lng_send_button" = "发送"; +"lng_schedule_button" = "创建日程"; +"lng_send_silent_message" = "无声发送"; +"lng_schedule_message" = "定时消息"; +"lng_reminder_message" = "设置提醒"; +"lng_schedule_title" = "在何时发送此消息…"; +"lng_remind_title" = "在何时提醒我…"; +"lng_schedule_at" = " "; +"lng_message_ph" = "输入消息…"; +"lng_broadcast_ph" = "广播…"; +"lng_broadcast_silent_ph" = "静默广播…"; +"lng_send_anonymous_ph" = "匿名发送"; +"lng_story_reply_ph" = "私信回复…"; +"lng_story_comment_ph" = "评论动态..."; +"lng_message_paid_ph" = "每条消息 {amount}"; +"lng_send_text_no" = "不允许发送文本。"; +"lng_send_text_no_about" = "此群组的管理员仅允许发送 {types}。"; +"lng_send_text_type_and_last" = "{types} 和 {last}"; +"lng_send_text_type_photos" = "图片"; +"lng_send_text_type_videos" = "视频"; +"lng_send_text_type_video_messages" = "视频消息"; +"lng_send_text_type_music" = "音乐"; +"lng_send_text_type_voice_messages" = "语音消息"; +"lng_send_text_type_files" = "文件"; +"lng_send_text_type_stickers" = "贴纸 & GIF 动态图"; +"lng_send_text_type_polls" = "投票"; +"lng_send_gif_with_caption" = "发送带有说明的 GIF 动态图"; + +"lng_send_as_title" = "以哪个身份发送消息…"; +"lng_send_as_anonymous_admin" = "匿名管理员"; +"lng_send_as_premium_required" = "订阅 {link} 后可以在任何群聊中代表您的频道发布评论。"; +"lng_send_as_premium_required_link" = "Tidings 高级版"; +"lng_record_cancel" = "移出此处以取消发送"; +"lng_record_cancel_stories" = "向外释放以取消"; +"lng_record_lock_cancel_sure" = "您确定要停止录制并放弃您的语音消息吗?"; +"lng_record_lock_cancel_sure_round" = "你确定要停止录制并放弃您的视频消息吗?"; +"lng_record_listen_cancel_sure" = "你确定要放弃已录制的语音消息吗?"; +"lng_record_listen_cancel_sure_round" = "确定要取消您的视频消息的录制吗?"; +"lng_record_lock_discard" = "放弃"; +"lng_record_hold_tip" = "请按住鼠标按键以录制语音消息。"; +"lng_record_voice_tip" = "长按以录制语音消息,点击切换为视频消息。"; +"lng_record_video_tip" = "长按以录制视频消息,点按切换为语音消息。"; +"lng_record_audio_problem" = "无法开始录音。请检查你的麦克风。"; +"lng_record_video_problem" = "无法开始录制视频。请检查你的相机。"; +"lng_record_once_first_tooltip" = "点击以设置此消息只能 **播放一次**。"; +"lng_record_once_active_tooltip" = "接收者只能收听一次。"; +"lng_record_once_active_video" = "收件人只能播放一次。"; +"lng_will_be_notified" = "消息发布后订阅者会收到通知。"; +"lng_wont_be_notified" = "消息发布后订阅者会收到无声通知"; +"lng_willbe_history" = "请选择一个对话开始聊天"; +"lng_from_you" = "您"; +"lng_from_draft" = "草稿"; +"lng_bot_description" = "它能做什么?"; +"lng_unblock_button" = "移出黑名单"; +"lng_restart_button" = "重新启动"; +"lng_channel_mute" = "关闭通知"; +"lng_channel_unmute" = "开启通知"; +"lng_saved_messages" = "我的收藏"; +"lng_saved_short" = "收藏"; +"lng_saved_forward_here" = "转发消息到这里以便快速访问"; +"lng_saved_quote_here" = "引用至此以收藏。"; +"lng_saved_open_chat" = "打开聊天"; +"lng_saved_open_channel" = "打开聊天"; +"lng_saved_open_group" = "打开群组"; +"lng_saved_about_hidden" = "消息发送者已限制转发消息中显示他们的名字。"; + +"lng_scheduled_messages" = "定时消息"; +"lng_scheduled_messages_empty" = "暂无计划消息"; +"lng_reminder_messages" = "提醒"; +"lng_scheduled_date" = "计划于 {date} 发送"; +"lng_scheduled_date_until_online" = "在线后发送"; +"lng_scheduled_send_until_online" = "在线时发送"; +"lng_scheduled_send_now" = "现在发送消息?"; + +"lng_scheduled_send_now_many#other" = "立即发送 {count} 条消息?"; +"lng_scheduled_video_tip_title" = "改善视频中…"; +"lng_scheduled_video_tip_text" = "此视频将在优化后发布,以获得最佳观看体验。"; +"lng_scheduled_video_tip" = "处理视频可能需要几分钟时间。"; +"lng_scheduled_video_published" = "视频已发布。"; +"lng_scheduled_video_view" = "查看"; + + +"lng_replies_view#other" = "查看 {count} 条回复"; +"lng_replies_view_thread" = "查看跟评"; + +"lng_replies_header#other" = "{count} 个回复"; +"lng_replies_header_none" = "回复"; +"lng_replies_view_topic" = "在话题中查看"; + +"lng_comments_header#other" = "{count} 条评论"; +"lng_comments_header_none" = "留言"; + +"lng_comments_open_count#other" = "{count} 条评论"; +"lng_comments_open_none" = "留言"; +"lng_replies_view_original" = "在聊天中查看"; +"lng_replies_messages" = "回复"; +"lng_hidden_author_messages" = "作者已隐藏"; +"lng_my_notes" = "我的笔记"; +"lng_replies_discussion_started" = "讨论已开始"; +"lng_replies_no_comments" = "还没有留言..."; + +"lng_verification_codes" = "验证码"; +"lng_verification_codes_about" = "第三方服务(如网站和商店)可通过 Tidings 向您的手机号码发送验证码,无需使用短信。这些验证码将显示在此聊天中。\n\n如果您没有请求任何验证码 — 无需担心!可能是有人在输入号码时出错了。"; + +"lng_archived_name" = "已归档聊天"; +"lng_archived_add" = "归档聊天"; +"lng_archived_remove" = "取消归档"; +"lng_archived_added" = "聊天已归档。\n收到新消息时,静音的聊天将被保持归档。"; +"lng_archived_removed" = "已从归档还原至聊天列表。"; +"lng_archived_last_list" = "{accumulated}, {chat}"; + +"lng_archived_last#other" = "{chats} 和另外 {count} 个聊天"; + +"lng_dialogs_text_with_from" = "{from_part}{message}"; +"lng_dialogs_text_from_wrapped" = "{from}:"; +"lng_dialogs_text_media" = "{media_part}{caption}"; +"lng_dialogs_text_media_wrapped" = "{media}, "; +"lng_dialogs_text_from_in_topic" = "{from} {topic}"; +"lng_dialogs_skip_archive_in_search" = "不显示已归档的搜索结果"; +"lng_dialogs_show_archive_in_search" = "显示已归档的搜索结果"; + +"lng_dialogs_suggestions_birthday_title" = "添加你的生日!🎂"; +"lng_dialogs_suggestions_birthday_about" = "让您的联系人知道您何时庆祝生日。"; +"lng_dialogs_suggestions_birthday_contact_title" = "今天是 {text} 的 **生日**!🎂"; +"lng_dialogs_suggestions_birthday_contact_about" = "赠送礼物给他。"; + +"lng_dialogs_suggestions_birthday_contacts_title#other" = "今天 {count} 位联系人过 **生日**! 🎂"; +"lng_dialogs_suggestions_birthday_contacts_about" = "赠送礼物给他们。"; +"lng_dialogs_suggestions_birthday_contact_dismiss" = "您可以稍后从设置中赠送礼物"; +"lng_dialogs_suggestions_premium_annual_title" = "以 {text} 折扣获得 Tidings 高级版 "; +"lng_dialogs_suggestions_premium_annual_about" = "以年付计划订阅 Tidings 高级版来获得此折扣。"; +"lng_dialogs_suggestions_premium_upgrade_title" = "享受以 **{text}** 的折扣优惠订阅 Tidings 高级版"; +"lng_dialogs_suggestions_premium_upgrade_about" = "升级为 Tidings 高级版的年付计划即可享受优惠。"; +"lng_dialogs_suggestions_premium_restore_title" = "以最高 {text} 的折扣续费高级版"; +"lng_dialogs_suggestions_premium_restore_about" = "您的 Tidings 高级版最近已过期,点击此处以续费。"; +"lng_dialogs_suggestions_premium_grace_title" = "⚠️ 您的 Tidings 高级版订阅即将到期!"; +"lng_dialogs_suggestions_premium_grace_about" = "不要失去 Tidings 高级版的专属功能。"; +"lng_dialogs_suggestions_userpics_title" = "添加你的头像! 📸"; +"lng_dialogs_suggestions_userpics_about" = "帮助您的朋友轻松发现您。"; + +"lng_dialogs_suggestions_credits_sub_low_title#other" = "{channels} 需要 {emoji}{count} 颗星星"; +"lng_dialogs_suggestions_credits_sub_low_about" = "资金不足以支付您的订阅费用。"; + +"lng_about_random" = "发送 {emoji} 表情试试运气。"; +"lng_about_random_send" = "发送"; + +"lng_open_this_link" = "打开此链接?"; +"lng_open_link" = "打开"; +"lng_allow_bot_pass" = "是否允许 {bot_name} 传送您的 Tidings 昵称及 ID(不是您的手机号码)到通过机器人打开的网页中呢?"; +"lng_allow_bot" = "允许"; +"lng_allow_bot_webview_details" = "本机器人的更多信息 {emoji}"; +"lng_allow_bot_webview_details_about" = "您将连接到其网站以启动其 Web 应用。\n\n此将可以访问到你的 IP 地址以及基本设备信息。"; +"lng_url_auth_open_confirm" = "你想打开 {link} 吗?"; +"lng_url_auth_login_option" = "以 {user} 身份登录{domain}"; +"lng_url_auth_allow_messages" = "允许 {bot} 给我发消息"; + +"lng_bot_start" = "开始"; +"lng_bot_choose_group" = "选择一个群组"; +"lng_bot_no_groups" = "您尚未加入任何群组"; +"lng_bot_groups_not_found" = "没有符合条件的群组"; +"lng_bot_sure_invite" = "是否将机器人添加到 «{group}»?"; +"lng_bot_already_in_group" = "该机器人已在此群组中。"; +"lng_bot_choose_chat" = "选择一个对话"; +"lng_bot_no_chats" = "您没有聊天"; +"lng_bot_chats_not_found" = "没有符合条件的对话"; +"lng_bot_sure_share_game" = "与“{user}”分享此游戏?"; +"lng_bot_sure_share_game_group" = "向«{group}»分享此游戏?"; +"lng_bot_groups_manage" = "我管理的群组"; +"lng_bot_channels_manage" = "我管理的频道"; +"lng_bot_groups" = "群组"; +"lng_bot_add_title" = "添加机器人"; +"lng_bot_as_admin_check" = "管理员权限"; +"lng_bot_add_as_admin" = "添加机器人为管理员"; +"lng_bot_add_as_member" = "添加机器人为成员"; +"lng_bot_sure_add_text_group" = "你确定要将此机器人设为 {group} 群组的管理员吗?"; +"lng_bot_sure_add_text_channel" = "您确定要将此机器人添加为频道 {group} 中的管理员吗?它将有权访问频道管理员和订阅者列表。"; +"lng_bot_no_webview" = "很抱歉,您不能使用当前系统配置打开这个菜单。"; +"lng_bot_remove_from_menu" = "从菜单中移除"; +"lng_bot_remove_from_menu_sure" = "要从附件菜单中删除 {bot} 吗?"; +"lng_bot_remove_from_menu_done" = "机器人已从菜单中移除。"; +"lng_bot_remove_from_side_menu" = "从菜单中删除"; +"lng_bot_remove_from_side_menu_sure" = "从主菜单中移除 {bot} 吗?"; +"lng_bot_remove_from_side_menu_done" = "机器人已从主菜单中移除。"; +"lng_bot_settings" = "设置"; +"lng_bot_open" = "打开机器人"; +"lng_bot_terms" = "使用条款"; +"lng_bot_privacy" = "隐私权政策"; +"lng_bot_reload_page" = "重载页面"; +"lng_bot_add_to_menu" = "{bot} 请求作为选项添加到您的附件菜单,以便您可以从任何聊天中访问它。"; +"lng_bot_add_to_menu_done" = "机器人已添加至菜单。"; +"lng_bot_will_be_added" = "{bot} 快捷方式将会添加至附件菜单和主菜单。"; +"lng_bot_side_menu_new" = "新功能"; +"lng_bot_menu_not_supported" = "无法将此机器人添加至附件菜单。"; +"lng_bot_menu_already_added" = "此机器人已经添加到您的附件菜单中。"; +"lng_bot_menu_button" = "菜单"; +"lng_bot_close_warning_title" = "警告"; +"lng_bot_close_warning" = "所作的更改可能不会保存。"; +"lng_bot_close_warning_sure" = "仍然关闭"; +"lng_bot_add_to_side_menu" = "{bot} 申请将其作为一个选项添加至主菜单,以便您能够随时访问。"; +"lng_bot_add_to_side_menu_done" = "机器人已添加到主菜单。"; +"lng_bot_no_scan_qr" = "桌面版不支持机器人二维码,请使用 Tidings 移动应用。"; +"lng_bot_no_share_story" = "桌面版不支持分享到动态。请使用 Tidings 的移动应用程序之一。"; +"lng_bot_emoji_status_confirm" = "确认"; +"lng_bot_emoji_status_title" = "设置表情状态"; +"lng_bot_emoji_status_text" = "你想要套用 {bot} 建议的表情状态吗?"; +"lng_bot_emoji_status_access_text" = "{bot} 请求设置您的 **表情状态**。您可以随时在 {name} 的个人资料页面上撤销此访问权限。"; +"lng_bot_emoji_status_access_allow" = "允许"; +"lng_bot_share_prepared_title" = "分享消息"; +"lng_bot_share_prepared_about" = "{bot} 迷你应用建议您将此消息发送到您所选的聊天。"; +"lng_bot_share_prepared_button" = "分享给..."; +"lng_bot_download_file" = "下载文件"; +"lng_bot_download_file_sure" = "{bot} 邀请您下载以下文件:"; +"lng_bot_download_file_button" = "下载"; +"lng_bot_download_starting" = "开始下载..."; +"lng_bot_download_failed" = "失败。 {retry}"; +"lng_bot_download_retry" = "重试"; + + +"lng_bot_status_users#other" = "{count} 名每月活跃用户"; + +"lng_typing" = "正在输入"; +"lng_user_typing" = "{user} 正在输入"; +"lng_users_typing" = "{user} 和 {second_user} 正在输入"; + +"lng_many_typing#other" = "{count} 人正在输入"; +"lng_playing_game" = "正在玩游戏"; +"lng_user_playing_game" = "{user} 正在玩游戏"; +"lng_users_playing_game" = "{user} 和 {second_user} 正在玩游戏"; + +"lng_many_playing_game#other" = "{count} 正在玩游戏"; +"lng_send_action_record_video" = "正在录制视频"; +"lng_user_action_record_video" = "{user} 正在录制视频"; +"lng_send_action_upload_video" = "正在发送视频"; +"lng_user_action_upload_video" = "{user} 正在发送视频"; +"lng_send_action_record_audio" = "正在录制语音消息"; +"lng_user_action_record_audio" = "{user} 正在录制语音消息"; +"lng_send_action_upload_audio" = "正在发送语音消息"; +"lng_user_action_upload_audio" = "{user} 正在发送语音消息"; +"lng_send_action_record_round" = "正在录制视频消息"; +"lng_user_action_record_round" = "{user} 正在录制视频消息"; +"lng_send_action_upload_round" = "正在发送视频消息"; +"lng_user_action_upload_round" = "{user} 正在发送视频消息"; +"lng_send_action_upload_photo" = "正在发送图片"; +"lng_user_action_upload_photo" = "{user} 正在发送图片"; +"lng_send_action_upload_file" = "正在发送文件"; +"lng_user_action_upload_file" = "{user} 正在发送文件"; +"lng_send_action_choose_sticker" = "正在选择表情"; +"lng_user_action_choose_sticker" = "{user} 正在选择贴纸…"; +"lng_user_action_watching_animations" = "正在观看 {emoji}"; + +"lng_unread_bar#other" = "{count} 条未读消息"; +"lng_unread_bar_some" = "未读消息"; + +"lng_maps_point" = "位置"; +"lng_maps_select_on_map" = "在地图上选择"; +"lng_maps_point_send" = "发送此位置"; +"lng_maps_point_set" = "选择此位置"; +"lng_maps_or_choose" = "或选择一个地点"; +"lng_maps_places_in_area" = "此区域内的地点"; +"lng_maps_no_places" = "找不到地点"; +"lng_maps_choose_to_search" = "选择位置以查看周边地点。"; +"lng_maps_venues_source" = "由 Foursquare 提供支援"; +"lng_live_location" = "实时位置"; +"lng_live_location_now" = "现在更新"; + +"lng_live_location_minutes#other" = "更新于 {count} 分钟前"; + +"lng_live_location_hours#other" = "{count} 小时前"; +"lng_live_location_today" = "更新于今天 {time}"; +"lng_live_location_yesterday" = "昨天在 {time} 已更新"; +"lng_live_location_date_time" = "{date} {time}"; +"lng_save_photo" = "保存图片"; +"lng_save_video" = "保存视频"; +"lng_save_audio_file" = "保存音频文件"; +"lng_save_audio" = "保存语音消息"; +"lng_save_file" = "保存文件"; +"lng_save_downloaded" = "{ready} / {total} {mb}"; +"lng_duration_and_size" = "{duration}, {size}"; +"lng_duration_played" = "{played} / {duration}"; +"lng_date_and_duration" = "{date}, {duration}"; +"lng_choose_image" = "选择一张图片"; +"lng_choose_file" = "选择一个文件"; +"lng_choose_files" = "选择文件"; +"lng_choose_cover" = "选择视频封面"; +"lng_choose_cover_bad" = "无法将此文件作为说明文字。"; +"lng_game_tag" = "游戏"; + +"lng_context_new_window" = "在新窗口中打开"; +"lng_context_view_profile" = "查看个人资料"; +"lng_context_send_message" = "发送消息"; +"lng_context_view_group" = "查看群组信息"; +"lng_context_view_channel" = "查看频道信息"; +"lng_context_view_topic" = "查看话题信息"; +"lng_context_hide_psa" = "隐藏此公告"; +"lng_context_pin_to_top" = "置顶"; +"lng_context_unpin_from_top" = "取消置顶"; +"lng_context_mark_unread" = "标记为未读"; +"lng_context_mark_read" = "标记为已读"; +"lng_context_mark_read_sure" = "您确定要将此文件夹中的所有聊天标记为已读吗?"; +"lng_context_mark_read_all" = "将所有聊天标记为已读"; +"lng_context_mark_read_all_sure" = "您确定要将所有聊天标记为已读吗?"; +"lng_context_mark_read_all_sure_2" = "**此操作无法撒销。**"; +"lng_context_mark_read_mentions_all" = "全部标记为已读"; +"lng_context_mark_read_reactions_all" = "将所有表情回应标记为已读"; +"lng_context_archive_expand" = "展开"; +"lng_context_archive_collapse" = "折叠"; +"lng_context_archive_to_menu" = "移动到主菜单"; +"lng_context_archive_to_list" = "移动到聊天列表"; +"lng_context_archive_to_menu_info" = "归档已移至主菜单!\n您可以右键归档图标将其恢复。"; +"lng_context_archive_settings" = "归档设置"; + +"lng_context_mute" = "消息免打扰"; +"lng_context_unmute" = "取消免打扰"; + +"lng_context_promote_admin" = "设为管理员"; +"lng_context_edit_permissions" = "编辑管理员权限"; +"lng_context_restrict_user" = "限制用户权限"; +"lng_context_ban_user" = "封禁"; +"lng_context_remove_from_group" = "从群组中移除"; +"lng_context_add_to_group" = "添加到群组"; + +"lng_context_copy_link" = "拷贝链接"; +"lng_context_copy_message_link" = "拷贝消息链接"; +"lng_context_copy_post_link" = "拷贝帖文链接"; +"lng_context_copy_topic_link" = "复制链接"; +"lng_context_copy_email" = "拷贝电子邮件地址"; +"lng_context_copy_hashtag" = "拷贝标签"; +"lng_context_copy_mention" = "拷贝用户名"; +"lng_context_copy_filename" = "复制文件名"; +"lng_context_save_image" = "保存图片为…"; +"lng_context_copy_image" = "拷贝图片"; +"lng_context_cancel_download" = "取消下载"; +"lng_context_show_in_folder" = "打开文件夹"; +"lng_context_show_in_finder" = "在 Finder 中显示"; +"lng_context_save_video" = "保存视频为…"; +"lng_context_save_audio_file" = "保存为…"; +"lng_context_save_audio" = "保存为…"; +"lng_context_pack_info" = "查看贴纸包"; +"lng_context_pack_add" = "添加表情包"; +"lng_context_save_file" = "保存文件为…"; +"lng_context_copy_text" = "拷贝文本"; +"lng_context_open_gif" = "打开 GIF 动态图"; +"lng_context_save_gif" = "保存 GIF 动态图"; +"lng_context_delete_gif" = "删除 GIF 动态图"; +"lng_context_open_channel" = "打开频道"; +"lng_context_open_group" = "打开群组"; +"lng_context_attached_stickers" = "附加贴纸"; +"lng_context_to_msg" = "转到消息"; +"lng_context_reply_msg" = "回复"; +"lng_context_quote_and_reply" = "引文与回复"; +"lng_context_edit_msg" = "编辑"; +"lng_context_add_factcheck" = "添加事实核查"; +"lng_context_edit_factcheck" = "编辑事实查核"; +"lng_context_forward_msg" = "转发消息"; +"lng_context_send_now_msg" = "立即发送"; +"lng_context_reschedule" = "重设发送时间"; +"lng_context_delete_msg" = "删除消息"; +"lng_context_auto_delete_in" = "在 {duration} 之后自动删除"; +"lng_context_select_msg" = "选择消息"; +"lng_context_select_msg_bulk" = "选择此消息"; +"lng_context_report_msg" = "举报消息"; +"lng_context_pin_msg" = "置顶消息"; +"lng_context_unpin_msg" = "取消置顶"; +"lng_context_cancel_upload" = "取消上传"; +"lng_context_copy_selected" = "拷贝所选文本"; +"lng_context_copy_selected_items" = "拷贝为纯文本"; +"lng_context_forward_selected" = "转发所选内容"; +"lng_context_send_now_selected" = "现在发送选中的消息"; +"lng_context_reschedule_selected" = "重新安排选中的记录"; +"lng_context_delete_selected" = "删除所选内容"; +"lng_context_save_images_selected" = "保存选中的"; +"lng_context_save_documents_selected" = "下载选中的"; +"lng_context_clear_selection" = "取消选择"; +"lng_context_seen_loading" = "加载中..."; + +"lng_context_seen_text#other" = "{count} 人已读"; +"lng_context_seen_text_none" = "0 人已读"; + +"lng_context_seen_listened#other" = "{count} 人已收听"; +"lng_context_seen_listened_none" = "无人收听"; + +"lng_context_seen_watched#other" = "{count} 人已收听"; +"lng_context_seen_watched_none" = "无人已读"; + +"lng_context_seen_reacted#other" = "{count} 人已回应"; +"lng_context_seen_reacted_none" = "无人给予反馈"; +"lng_context_seen_reacted_all" = "显示所有表情回应"; +"lng_context_set_as_quick" = "设置为快捷方式"; +"lng_context_filter_by_tag" = "依据标签进行筛选"; +"lng_context_tag_add_name" = "添加名称"; +"lng_context_tag_edit_name" = "编辑名称"; +"lng_context_remove_tag" = "移除标签"; +"lng_context_delete_from_disk" = "从硬盘中删除"; +"lng_context_delete_all_files" = "删除所有文件"; +"lng_context_save_custom_sound" = "保存为通知铃声"; +"lng_context_translate" = "翻译"; +"lng_context_translate_selected" = "翻译选中的文本"; +"lng_context_read_hidden" = "已读"; +"lng_context_read_show" = "显示时间"; +"lng_context_edit_shortcut" = "编辑指令"; +"lng_context_delete_shortcut" = "删除快速回复"; +"lng_context_gift_send" = "再送一份礼物"; + +"lng_add_tag_about" = "使用表情符号标记消息以便快速搜索"; +"lng_subscribe_tag_about" = "使用标签整理收藏的消息。{link}"; +"lng_subscribe_tag_link" = "了解更多..."; +"lng_edit_tag_about" = "您可以使用文本给表情标签起一个名字。"; +"lng_edit_tag_name" = "名称"; +"lng_add_tag_button" = "添加标签"; +"lng_add_tag_phrase" = "到消息 {arrow}"; +"lng_add_tag_phrase_long" = "至我的收藏 {arrow}"; +"lng_unlock_tags" = "解锁"; + +"lng_context_animated_emoji" = "此消息包含来自**{name} 表情包**的表情。"; + +"lng_context_animated_emoji_many#other" = "这条消息包含了来自 **{count} 个表情包** 的表情。"; +"lng_context_animated_reaction" = "此表情回应来自 **{name} 表情包**。"; +"lng_context_animated_tag" = "此标签来自 **{name} 表情包**。"; +"lng_context_animated_reactions" = "回应包含来自 **{name} 表情包** 的表情符号。"; + +"lng_context_animated_reactions_many#other" = "回应包含来自 **{count} 个表情包** 的表情符号。"; + +"lng_context_noforwards_info_channel" = "该频道不允许拷贝或转发消息。"; +"lng_context_noforwards_info_group" = "该群组不允许拷贝或转发消息。"; +"lng_context_noforwards_info_bot" = "不可从本机器人拷贝或转发消息。"; + +"lng_context_spoiler_effect" = "罩住图片"; +"lng_context_disable_spoiler" = "取消遮罩"; +"lng_context_make_paid" = "将此内容设置为付费内容"; +"lng_context_change_price" = "更改价格"; +"lng_context_edit_cover" = "编辑封面"; +"lng_context_clear_cover" = "清除封面"; + +"lng_context_mention" = "提及"; +"lng_context_search_from" = "搜索消息"; + +"lng_factcheck_title" = "事实核查"; +"lng_factcheck_placeholder" = "添加事实或上下文"; +"lng_factcheck_whats_this" = "这是什么?"; +"lng_factcheck_about" = "此澄清是由您所在国家政府中负责打击虚假信息的部门({country})指派的事实核查机构提供的。"; +"lng_factcheck_add_done" = "已添加事实核查。"; +"lng_factcheck_edit_done" = "已编辑事实核查。"; +"lng_factcheck_remove_done" = "已移除事实核查。"; +"lng_factcheck_bottom" = "此澄清是由您所在国家政府中负责打击虚假信息的部门({country})指派的事实核查机构提供的。"; +"lng_factcheck_links" = "仅允许 **tidings.me/** 链接。"; + +"lng_paid_title" = "付费内容"; +"lng_paid_enter_cost" = "输入解锁价格"; +"lng_paid_cost_placeholder" = "解锁所需星星数量"; +"lng_paid_about" = "用户需将此数量的星星转账至您的频道才能查看此媒体。{link}"; +"lng_paid_about_link" = "更多关于星星的信息 >"; +"lng_paid_about_link_url" = "https://tidings.org/blog/tidings-stars"; +"lng_paid_price" = "花费 {price} 解锁"; + +"lng_paid_react_title" = "星星回应"; +"lng_paid_react_about" = "选择您想要送出多少颗 **星星** 给 {channel} 以支持此贴文。"; + +"lng_paid_react_already#other" = "您已送出 **{count} 颗星星** 以支持此贴文。"; +"lng_paid_react_top_title" = "星星打赏榜"; +"lng_paid_react_send" = "发送 {price}"; +"lng_paid_react_agree" = "发送星星即表示您同意{link}。"; +"lng_paid_react_agree_link" = "服务条款"; + +"lng_paid_react_toast#other" = "已发送星星!"; + +"lng_paid_react_toast_anonymous#other" = "星星已诺名发送!"; + +"lng_paid_react_toast_text#other" = "您以 **{count} 颗星星** 做出了回应。"; +"lng_paid_react_undo" = "撤销"; +"lng_paid_react_show_in_top" = "在星星打赏榜上显示我"; +"lng_paid_react_anonymous" = "诺名"; + +"lng_sensitive_tag" = "18+"; +"lng_sensitive_title" = "18+"; +"lng_sensitive_text" = "此媒体可能包含仅适合成人的敏感內容。您仍想查看吗?"; +"lng_sensitive_always" = "允许显示 18+ 媒体"; +"lng_sensitive_view" = "仍然查看"; +"lng_sensitive_toast" = "您可以在**聊天设置 > 聊天设置 > 敏感内容**中更新是否显示敏感媒体"; + +"lng_translate_show_original" = "显示原文"; +"lng_translate_return_original" = "显示原文 ({language})"; +"lng_translate_bar_to" = "翻译为{name}"; +"lng_translate_bar_to_other" = "翻译为 {name}"; +"lng_translate_menu_to" = "翻译为"; +"lng_translate_menu_dont" = "无需翻译 {name}"; +"lng_translate_menu_dont_other" = "无需翻译 {name}"; +"lng_translate_menu_hide" = "隐藏"; +"lng_translate_hidden_user" = "已在此聊天中隐藏翻译栏。"; +"lng_translate_hidden_group" = "已在此群组中隐藏翻译栏。"; +"lng_translate_hidden_channel" = "已在此频道中隐藏翻译栏。"; +"lng_translate_dont_added" = "{name} 已添加至无需翻译列表。"; +"lng_translate_settings" = "设置"; +"lng_translate_undo" = "撤销"; + +"lng_downloads_section" = "下载"; +"lng_downloads_view_in_chat" = "在聊天中查看"; +"lng_downloads_view_in_section" = "在已下载中查看"; +"lng_downloads_delete_sure_one" = "您需要删除这个文件吗?"; +"lng_downloads_delete_sure_all" = "您要删除所有文件吗?"; + +"lng_downloads_delete_sure#other" = "您要删除 {count} 个文件吗?"; +"lng_downloads_delete_in_cloud_one" = "它将从您的磁盘中删除,但仍然可以在云端访问。"; +"lng_downloads_delete_in_cloud" = "它们将从您的磁盘上删除,但还可以从云端访问。"; + +"lng_send_image_empty" = "无法发送空文件:{name}"; + +"lng_send_images_selected#other" = "已选择 {count} 张图片"; + +"lng_send_files_selected#other" = "已选择 {count} 个文件"; +"lng_send_grouped" = "合并项目"; +"lng_send_compressed_one" = "压缩图片"; +"lng_send_compressed" = "压缩图片"; +"lng_send_media_invalid_files" = "抱歉,没有找到有效文件。"; +"lng_send_image" = "发送图片"; +"lng_send_file" = "作为文件发送"; +"lng_send_video" = "传送视频文件"; + +"lng_forward_choose" = "选择接收者…"; +"lng_forward_cant" = "抱歉,不能转发到此处 :("; +"lng_forward_share_contact" = "分享联系人信息至 {recipient}?"; +"lng_forward_share_cant" = "抱歉,不能分享联系人信息到此处 :("; + +"lng_forward_send_files_cant" = "抱歉,不能发送媒体文件到此处 :("; +"lng_forward_send" = "发送"; + +"lng_forward_messages#other" = "{count} 条转发的消息"; + +"lng_forwarding_from#other" = "{user} 和其他 {count} 人"; +"lng_forwarding_from_two" = "{user} 和 {second_user}"; +"lng_inline_switch_choose" = "选择对话…"; +"lng_inline_switch_cant" = "抱歉,您无法选择此对话 :("; +"lng_preview_reply_to" = "回复 {name}"; +"lng_preview_reply_to_quote" = "回复来自 {name} 的引文"; + +"lng_reply_in_another_title" = "回复..."; +"lng_reply_in_another_chat" = "在另一个聊天中回复"; +"lng_reply_in_author" = "消息的作者"; +"lng_reply_in_chats_list" = "你的聊天"; +"lng_reply_show_in_chat" = "在聊天中显示"; +"lng_reply_remove" = "取消回复"; +"lng_reply_about_quote" = "您可以选择特定部分进行引用。"; +"lng_reply_options_header" = "回复消息"; +"lng_reply_options_quote" = "更新引文"; +"lng_reply_header_short" = "回复"; +"lng_reply_quote_selected" = "引用所选部分"; +"lng_reply_from_private_chat" = "该回复来自私聊。"; +"lng_reply_quote_long_title" = "引文过长!"; +"lng_reply_quote_long_text" = "选择的文字过长,无法引用。"; +"lng_link_options_header" = "链接预览设置"; +"lng_link_header_short" = "链接"; +"lng_link_move_up" = "上移"; +"lng_link_move_down" = "下移"; +"lng_link_shrink_photo" = "缩小图片"; +"lng_link_enlarge_photo" = "放大图片"; +"lng_link_remove" = "关闭预览"; +"lng_link_about_choose" = "点击此链接以生成其预览。"; + +"lng_share_cant" = "抱歉,您无法分享到此处 :("; +"lng_reply_cant" = "您无法回复此群组升级为超级组之前的消息。\n\n当群组成员达到200人,或当您开始使用高级功能(如聊天历史权限或管理员精确设置)时,群组将自动升级。"; +"lng_reply_cant_forward" = "抱歉,您无法回复在该群还是普通群时发送的消息。是否要转发此消息并附上您的评论?"; + +"lng_share_title" = "分享到"; +"lng_share_at_time_title" = "于 {time} 分享到"; +"lng_share_copy_link" = "拷贝分享链接"; +"lng_share_confirm" = "发送"; +"lng_share_wrong_user" = "此游戏已被另一位用户打开。"; +"lng_share_game_link_copied" = "游戏链接已复制到剪贴板。"; +"lng_share_done" = "完成!"; +"lng_share_message_to_saved_messages" = "消息已转发到 **我的收藏**。"; +"lng_share_messages_to_saved_messages" = "消息已转发到 **我的收藏**。"; +"lng_share_message_to_chat" = "已将消息转发给 **{chat}**。"; +"lng_share_messages_to_chat" = "消息已转发至 **{chat}**。"; +"lng_share_message_to_two_chats" = "消息已转发给 **{user}** 与 **{chat}**。"; +"lng_share_messages_to_two_chats" = "消息已转发给 **{user}** 和 **{chat}**。"; + +"lng_share_message_to_many_chats#other" = "消息已转发到 **{count} 个** 聊天."; + +"lng_share_messages_to_many_chats#other" = "消息已转发到 **{count} 个聊天**."; + +"lng_contact_phone" = "手机号码"; +"lng_enter_contact_data" = "新建联系人"; +"lng_contact_mobile_hidden" = "手机号已隐藏"; +"lng_contact_phone_after" = "一旦 {user} 将您添加为联系人,手机号将对其可见。"; +"lng_contact_share_phone" = "分享我的手机号码"; +"lng_contact_phone_will_be_shared" = "您的手机号码将对 {user} 可见。"; +"lng_edit_contact_title" = "编辑联系人姓名"; +"lng_edit_channel_title" = "编辑频道信息"; +"lng_edit_bot_title" = "编辑机器人"; +"lng_edit_autotranslate" = "自动翻译消息"; +"lng_edit_sign_messages" = "消息署名"; +"lng_edit_sign_messages_about" = "在管理员发布的消息中添加管理员的名字。"; +"lng_edit_sign_profiles" = "显示作者资料"; +"lng_edit_sign_profiles_about" = "将管理员的名称和照片添加到他们发布的消息中,并链接到他们的个人资料。"; +"lng_edit_group" = "编辑群组信息"; +"lng_edit_channel_color" = "频道颜色"; +"lng_edit_channel_level_min" = "等级 1+"; +"lng_edit_channel_wallpaper" = "频道壁纸"; +"lng_edit_channel_wallpaper_about" = "设置每个阅读频道的人都能看到墙纸。"; +"lng_edit_channel_wallpaper_group" = "设置群组壁纸"; +"lng_edit_channel_wallpaper_about_group" = "设置对所有正在阅读您的群组的用户都可见的壁纸。"; +"lng_edit_channel_status" = "频道表情状态"; +"lng_edit_channel_status_about" = "选择将显示在频道名称旁边的状态。"; +"lng_edit_channel_status_group" = "群组表情符号状态"; +"lng_edit_channel_status_about_group" = "选择将显示在群组名称旁边的状态。"; +"lng_edit_self_title" = "编辑你的姓名"; +"lng_confirm_contact_data" = "新建联系人信息"; +"lng_add_contact" = "创建"; +"lng_add_contact_button" = "新建联系人信息"; +"lng_contacts_header" = "联系人"; +"lng_menu_not_contact" = "此号码未加入 Tidings"; +"lng_contacts_hidden_stories" = "已隐藏的动态"; + +"lng_contacts_stories_status#other" = "{count} 条动态"; + +"lng_contacts_stories_status_new#other" = "{count} 条新动态"; +"lng_contact_not_joined" = "非常抱歉,{name} 尚未加入 Tidings,您也选择可以给对方发送邀请。\n\n我们将会在对方加入 Tidings 时通知您。"; +"lng_try_other_contact" = "尝试添加其他人"; +"lng_create_group_link" = "链接"; +"lng_create_group_invite_link" = "邀请链接"; +"lng_create_group_description" = "简介(可选)"; + +"lng_drag_images_here" = "拖拽影像到此处"; +"lng_drag_photos_here" = "拖拽图片到此处"; +"lng_drag_files_here" = "拖拽文件到此处"; + +"lng_drag_to_send_quick" = "以最快速度发送"; +"lng_drag_to_send_no_compression" = "以原图模式发送"; +"lng_drag_to_send_files" = "以文件形式发送"; + +"lng_selected_clear" = "取消"; +"lng_selected_delete" = "删除"; +"lng_selected_forward" = "转发"; +"lng_selected_send_now" = "立即发送"; +"lng_selected_cancel_sure_this" = "取消上传?"; +"lng_selected_upload_stop" = "停止"; +"lng_selected_delete_sure_this" = "您确定要删除此消息吗?"; + +"lng_selected_delete_sure#other" = "您确定要删除这 {count} 条消息吗?"; +"lng_delete_photo_sure" = "您确定要删除此图片吗?"; + +"lng_delete_for_everyone_hint#other" = "这将从所有人的消息记录中撤回这些消息。"; + +"lng_delete_for_me_chat_hint#other" = "这些消息将只能从您(不包括其他成员)的消息记录中删除。"; + +"lng_delete_for_me_hint#other" = "这些消息将只能从您的消息记录中删除。"; +"lng_delete_clear_for_me" = "此操作只会清除您的历史记录,不会影响到其他参与者。"; +"lng_edit_auto_delete_settings" = "自动删除设置"; +"lng_enable_auto_delete" = "开启自动删除"; +"lng_selected_unsend_about_user_one" = "您还可以通过选中“取消发送我的消息”来删除您从 {user} 的收件箱发送的消息。"; + +"lng_selected_unsend_about_user#other" = "您还可以通过选中“取消发送我的消息” 从 {user} 的收件箱删除 {count} 条发送的消息。"; +"lng_selected_unsend_about_group_one" = "您还可以通过选中“取消发送我的消息”来删除您从其他群组成员的收件箱发送的信息。"; + +"lng_selected_unsend_about_group#other" = "您还可以通过选中“取消发送我的消息”来删除您从其他组成员的收件箱中发送的 {count} 条消息。"; +"lng_delete_for_everyone_check" = "为所有人删除"; +"lng_delete_for_other_check" = "同时为“{user}”撤回"; +"lng_delete_for_other_my" = "取消发送我的信息"; +"lng_box_delete" = "删除"; +"lng_box_leave" = "退出"; + +"lng_upload_sure_stop" = "您确定需要中断文件上传?\n\n如果您此时中断,稍后需要重新上传。"; +"lng_download_sure_stop" = "您确定要停止下载文件吗?\n\n如果确实停止,您将需要重新开始下载动作。"; +"lng_upload_show_file" = "显示文件"; + +"lng_about_version" = "v{version}"; +"lng_about_text1" = "为快速、安全而开发的基于{api_link}的官方免费桌面版本通讯软件。"; +"lng_about_text1_api" = "Tidings 接口"; +"lng_about_text2" = "本软件授权于 {gpl_link} 版本 3。\n源代码可以在 {github_link} 取得。"; +"lng_about_text3" = "访问 {faq_link} 获取更多信息。"; +"lng_about_text3_faq" = "Tidings 常见问题"; +"lng_about_done" = "完成"; + +"lng_search_no_results" = "没有符合条件的消息"; + +"lng_search_found_results#other" = "找到 {count} 条消息"; +"lng_search_global_results" = "全局搜索结果"; +"lng_search_messages_from" = "选择发送者"; +"lng_search_messages_n_of_amount" = "第{n}个,共 {amount}个"; +"lng_search_messages_none" = "暂无结果"; +"lng_search_filter_all" = "所有聊天"; +"lng_search_filter_private" = "私聊"; +"lng_search_filter_group" = "群聊"; +"lng_search_filter_channel" = "频道"; +"lng_search_sponsored_button" = "广告 ⋮"; + +"lng_media_save_progress" = "{ready} / {total} {mb}"; +"lng_mediaview_save_as" = "另存为…"; +"lng_mediaview_copy" = "拷贝"; +"lng_mediaview_copy_frame" = "复制框架"; +"lng_mediaview_forward" = "转发"; +"lng_mediaview_share_at_time" = "分享于 {time}"; +"lng_mediaview_delete" = "删除"; +"lng_mediaview_save_to_profile" = "发布至个人资料页"; +"lng_mediaview_pin_story_done" = "动态已置顶"; +"lng_mediaview_pin_story_about" = "现在这些动态将始终显示在顶部。"; + +"lng_mediaview_pin_stories_done#other" = "已置顶 {count} 条动态"; + +"lng_mediaview_pin_stories_about#other" = "现在这些动态将始终显示在顶部。"; +"lng_mediaview_unpin_story_done" = "该动态已取消置顶。"; + +"lng_mediaview_unpin_stories_done#other" = "已将 {count} 条动态取消置顶。"; + +"lng_mediaview_pin_limit#other" = "您最多只能置顶 {count} 条动态。"; +"lng_mediaview_archive_story" = "归档动态"; +"lng_mediaview_photos_all" = "显示所有图片"; +"lng_mediaview_files_all" = "显示所有文件"; +"lng_mediaview_single_photo" = "单张图片"; +"lng_mediaview_group_photo" = "群组头像"; +"lng_mediaview_channel_photo" = "频道头像"; +"lng_mediaview_profile_photo" = "头像"; +"lng_mediaview_profile_public_photo" = "公开图片"; +"lng_mediaview_profile_photo_by_you" = "您设置的头像"; +"lng_mediaview_file_n_of_amount" = "{file} {n} / {amount}"; +"lng_mediaview_n_of_amount" = "照片 {n} / {amount}"; +"lng_mediaview_doc_image" = "文件"; +"lng_mediaview_today" = "今天 {time}"; +"lng_mediaview_yesterday" = "昨天 {time}"; +"lng_mediaview_just_now" = "刚刚"; + +"lng_mediaview_minutes_ago#other" = "{count} 分钟前"; + +"lng_mediaview_hours_ago#other" = "{count} 小时前"; +"lng_mediaview_date_time" = "{date} {time}"; +"lng_mediaview_set_userpic" = "设为主头像"; +"lng_mediaview_report_profile_photo" = "举报"; + +"lng_mediaview_title" = "媒体查看器"; +"lng_mediaview_saved_to" = "图片已保存到 {downloads} 文件夹中。"; +"lng_mediaview_saved_images_to" = "图像已保存到你的 {downloads} 文件夹中"; +"lng_mediaview_video_saved_to" = "视频文件已保存至您的 {downloads} 文件夹"; +"lng_mediaview_downloads" = "下载"; +"lng_mediaview_playback_speed" = "播放速度:{speed}"; +"lng_mediaview_rotate_video" = "旋转视频"; +"lng_mediaview_quality_auto" = "自动"; + +"lng_theme_preview_title" = "主题预览"; +"lng_theme_preview_generating" = "主题预览生成中…"; +"lng_theme_preview_invalid" = "主题文件中存在无效数据"; +"lng_theme_preview_apply" = "应用此主题"; + +"lng_theme_preview_users#other" = "\n{count} 个人正在使用该主题"; + +"lng_new_version_wrap" = "Tidings 桌面版已更新到 ver. {version}\n\n{changes}\n\n更多版本记录请在这里查看:\n{link}"; +"lng_new_version_minor" = "— 错误修正及其它细节改进"; + +"lng_menu_insert_unicode" = "插入 Unicode 控制字符"; +"lng_menu_formatting" = "格式设置"; +"lng_menu_formatting_bold" = "粗体"; +"lng_menu_formatting_italic" = "斜体"; +"lng_menu_formatting_underline" = "下划线"; +"lng_menu_formatting_strike_out" = "删除线"; +"lng_menu_formatting_blockquote" = "引用"; +"lng_menu_formatting_monospace" = "等宽"; +"lng_menu_formatting_spoiler" = "遮罩"; +"lng_menu_formatting_link_create" = "创建链接"; +"lng_menu_formatting_link_edit" = "编辑链接"; +"lng_menu_formatting_clear" = "纯文本"; +"lng_formatting_link_create_title" = "创建链接"; +"lng_formatting_link_edit_title" = "编辑链接"; +"lng_formatting_link_text" = "文本"; +"lng_formatting_link_url" = "链接"; +"lng_formatting_link_create" = "创建"; +"lng_formatting_code_title" = "代码语言"; +"lng_formatting_code_language" = "语言将用于语法突显"; +"lng_formatting_code_auto" = "自动检测"; + +"lng_text_copied" = "文本已复制到剪贴板"; +"lng_code_copied" = " 代码已复制到剪贴板。"; + +"lng_spellchecker_submenu" = "拼写检查"; +"lng_spellchecker_add" = "添加到词典"; +"lng_spellchecker_remove" = "从词典中移除"; +"lng_spellchecker_ignore" = "忽略"; + +"lng_full_name" = "{first_name} {last_name}"; + +"lng_confirm_phone_link_invalid" = "此链接无效/已过期。"; +"lng_confirm_phone_title" = "撤销帐号重置请求"; +"lng_confirm_phone_about" = "某个人通过您的手机号码({phone)发送了删除您的 Tidings 账号并重置您的两步验证密码的请求。\n\n如果那并非您本人,请输入我们刚刚通过短信给您的手机号码发送的验证码。"; +"lng_confirm_phone_success" = "成功!\n删除帐号({phone})的请求已被取消。您现在可以关闭此窗口。"; +"lng_confirm_phone_send" = "发送"; +"lng_confirm_phone_enter_code" = "请输入验证码。"; + +"lng_theme_editor_no_keys" = "暂无配色项目"; +"lng_theme_editor_cant_change_theme" = "您不能在编辑配色时应用新主题。请先关闭主题编辑器。"; +"lng_theme_editor_new_keys" = "尚未集成的配色项目"; +"lng_theme_editor_background_image" = "背景图片"; +"lng_theme_editor_saved_to_jpg" = "保存为 JPEG({size})"; +"lng_theme_editor_read_from_jpg" = "JPEG 图片({size})"; +"lng_theme_editor_read_from_png" = "PNG 图片({size})"; +"lng_theme_editor_choose_image" = "选择背景图片"; +"lng_theme_editor_choose_name" = "保存主题文件"; +"lng_theme_editor_error" = "编辑器遇到了一个错误 :( 详情请见 'log.txt'"; +"lng_theme_editor_sure_close" = "您确定要关闭编辑器吗? 您的更改将丢失。"; +"lng_theme_editor_need_auth" = "您需要登录以保存主题。"; +"lng_theme_editor_need_unlock" = "您需要解锁 Tidings 以保存您的主题。"; +"lng_theme_editor_done" = "成功导出主题!"; +"lng_theme_editor_title" = "修改配色"; +"lng_theme_editor_save_button" = "保存"; + +"lng_theme_editor_create_title" = "创建主题"; +"lng_theme_editor_create" = "创建"; +"lng_theme_editor_name" = "名称"; +"lng_theme_editor_create_description" = "新主题将基于您当前选择的颜色和墙纸。 您还可以从文件导入主题或调色板。"; +"lng_theme_editor_attach_description" = "您可以根据当前选择的颜色和壁纸创建主题的桌面部分。 您还可以从文件导入主题或调色板。"; +"lng_theme_editor_import_existing" = "导入已有主题"; +"lng_theme_editor_save_title" = "保存主题"; +"lng_theme_editor_link_about" = "每当您修改主题时,将为所有用户自动更新。任何人都可以使用此链接以安装。\n\n主题链接必须不小于 **5** 个字符,可以使用 **a-z**、**0-9** 和下划线。"; + +"lng_theme_editor_menu_export" = "导出主题"; +"lng_theme_editor_menu_import" = "导入主题"; +"lng_theme_editor_menu_show" = "打开调色板"; + +"lng_payments_webview_no_use" = "抱歉,您无法在目前的系统配置中使用支付功能。"; +"lng_payments_webview_install_edge" = "请安装 {link}。"; +"lng_payments_webview_install_webkit" = "请用软件管家安装 WebKitGTK 4 (webkit2gtk-4.0)。"; +"lng_payments_webview_enable_opengl" = "请在应用设置中启用 OpenGL 。"; +"lng_payments_webview_switch_x11" = "不支持的显示服务器。请切换到 X11。"; +"lng_payments_webview_update_windows" = "请将您的操作系统更新至 Windows 8.1 或之后的版本。"; +"lng_payments_sure_close" = "您确定要关闭此付款表格吗?您所做的更改将丢失。"; +"lng_payments_receipt_label" = "收据"; +"lng_payments_receipt_label_test" = "测试收据"; +"lng_payments_invoice_label" = "账单"; +"lng_payments_invoice_label_test" = "测试账单"; +"lng_payments_receipt_button" = "收据"; +"lng_payments_success" = "您花费 **{amount}** 购买 **{title}** 。"; + +"lng_payments_checkout_title" = "结算"; +"lng_payments_receipt_title" = "收据"; +"lng_payments_total_label" = "总计"; +"lng_payments_date_label" = "已支付"; +"lng_payments_pay_amount" = "支付{amount}"; +"lng_payments_payment_method" = "支付方式"; +"lng_payments_new_card" = "添加银行卡…"; +"lng_payments_shipping_address" = "配送地址"; +"lng_payments_address_street1" = "地址 1"; +"lng_payments_address_street2" = "地址 2"; +"lng_payments_address_city" = "城市"; +"lng_payments_address_state" = "省份/州"; +"lng_payments_address_country" = "国家/地区"; +"lng_payments_address_postcode" = "邮政编码"; + +"lng_payments_shipping_method" = "配送方式"; +"lng_payments_info_name" = "姓名"; +"lng_payments_info_email" = "电子邮箱"; +"lng_payments_info_phone" = "电话号码"; +"lng_payments_to_provider_phone_email" = "手机号码和电子邮箱地址将作为付款信息发送给 {provider}。"; +"lng_payments_to_provider_email" = "电子邮箱地址将作为付款信息发送给 {provider}。"; +"lng_payments_to_provider_phone" = "手机号码将作为付款信息发送给 {provider}。"; +"lng_payments_processed_by" = "{provider} 已处理"; +"lng_payments_warning_title" = "警告"; +"lng_payments_warning_body" = "无论是 Tidings 还是 {bot1} 都无法访问您的信用卡信息。信用卡详细信息将仅由支付系统 {provider} 处理。\n\n付款将直接支付给 {bot2} 的开发人员。Tidings 不能提供任何保证,所以请自行承担风险。如有问题,请联系 {bot3} 的开发人员或您的银行。"; +"lng_payments_shipping_address_title" = "配送信息"; +"lng_payments_card_title" = "添加银行卡"; +"lng_payments_card_number" = "卡号"; +"lng_payments_card_cvc" = "安全码"; +"lng_payments_card_expire_date" = "月 / 年"; +"lng_payments_card_holder" = "持卡人姓名"; +"lng_payments_billing_address" = "账单信息"; +"lng_payments_billing_country" = "国家/地区"; +"lng_payments_billing_zip_code" = "邮政编码"; +"lng_payments_save_information" = "保存以便下次使用"; +"lng_payments_need_password" = "您可以保存您的付款信息以便在未来使用。\n请先开启两步验证后再启用此功能。"; +"lng_payments_password_title" = "支付确认"; +"lng_payments_password_description" = "您曾保存过您的银行卡({card})的信息。要使用此卡支付,请输入您的两步验证密码。"; +"lng_payments_password_submit" = "支付"; +"lng_payments_tips_label" = "小费(可选)"; +"lng_payments_tips_box_title" = "给小费"; +"lng_payments_tips_max" = "小费最多不能超过: {amount}"; + +"lng_payments_shipping_not_available" = "所选国家无法配送。"; +"lng_payments_card_declined" = "您的银行卡被拒绝。"; +"lng_payments_payment_failed" = "付款失败。您的银行卡没有被扣款。"; +"lng_payments_precheckout_failed" = "此机器人无法处理您的付款。您的银行卡没有被扣款。"; +"lng_payments_precheckout_timeout" = "机器人没有及时回复。您的银行卡没有被扣款。"; +"lng_payments_precheckout_stars_failed" = "此机器人无法处理您的付款。"; +"lng_payments_precheckout_stars_timeout" = "机器人没有及时回复。"; +"lng_payments_already_paid" = "您已经支付过此商品了。"; + +"lng_payments_terms_title" = "服务条款"; +"lng_payments_terms_text" = "要订阅并接受 {bot} 的服务条款吗?"; +"lng_payments_terms_text_once" = "您是否接受 {bot} 的服务条款?"; +"lng_payments_terms_agree" = "我同意 {link}"; +"lng_payments_terms_link" = "服务条款"; +"lng_payments_terms_accept" = "接受"; + +"lng_call_status_incoming" = "正在呼叫你…"; +"lng_call_status_connecting" = "连接中…"; +"lng_call_status_exchanging" = "交换加密密钥中…"; +"lng_call_status_waiting" = "等待中…"; +"lng_call_status_requesting" = "请求中…"; +"lng_call_status_hanging" = "挂断中…"; +"lng_call_status_ended" = "通话结束"; +"lng_call_status_failed" = "连接失败"; +"lng_call_status_ringing" = "拨号中…"; +"lng_call_status_busy" = "线路正忙"; +"lng_call_status_group_invite" = "Tidings 群组通话"; +"lng_call_status_sure" = "如果您想开始视频通话,请单击相机图标。"; +"lng_call_fingerprint_tooltip" = "如果 {user} 屏幕上的表情和此处一致,那么本次通话就是 100% 安全的。"; + +"lng_call_error_not_available" = "抱歉,由于其隐私设置,您无法呼叫 {user}。您可以让其修改设置或呼叫您。"; +"lng_call_error_outdated" = "{user} 的应用不支持通话。在您能够呼叫他们之前,他们必须先更新自己的应用。"; +"lng_call_error_no_camera" = "未找到摄像头,请确认摄像头已连接至电脑。"; +"lng_call_error_camera_not_started" = "一旦您连接上就可以视频通话。"; +"lng_call_error_camera_outdated" = "抱歉, {user} 使用的老版本 Tidings 不支持视频通话。在您呼叫对方前,对方需要先更新应用。"; +"lng_call_error_audio_io" = "您的声卡似乎有问题。请确认您的扬声器和麦克风工作正常后再试。"; +"lng_call_error_add_not_started" = "您可以在连接后添加更多人。"; + +"lng_call_bar_hangup" = "结束通话"; +"lng_call_leave_to_other_sure" = "结束当前通话并加入此视频聊天?"; +"lng_call_leave_to_other_sure_channel" = "您要终止当前通话并加入此频道中的直播吗?"; + +"lng_call_box_title" = "通话"; +"lng_call_box_about" = "您还没有通话过。"; +"lng_call_box_status_today" = "{time}"; +"lng_call_box_status_yesterday" = "昨天 {time}"; +"lng_call_box_status_date" = "{date}于 {time}"; +"lng_call_box_status_group" = "({amount}) {status}"; +"lng_call_box_clear_all" = "清除所有"; +"lng_call_box_clear_sure" = "您确定要清除所有的通话记录吗?"; +"lng_call_box_clear_button" = "清除"; +"lng_call_box_groupcalls_subtitle" = "正在进行的视频聊天"; + +"lng_call_outgoing" = "拨出"; +"lng_call_video_outgoing" = "拨出视频通话"; +"lng_call_group_outgoing" = "拨出群组通话"; +"lng_call_incoming" = "来电"; +"lng_call_video_incoming" = "视频来电"; +"lng_call_group_incoming" = "呼入群组通话"; +"lng_call_missed" = "未接"; +"lng_call_video_missed" = "未接视频来电"; +"lng_call_group_missed" = "未接群组通话"; +"lng_call_cancelled" = "通话已取消"; +"lng_call_video_cancelled" = "已取消的视频来电"; +"lng_call_declined" = "拒接"; +"lng_call_video_declined" = "拒绝视频通话"; +"lng_call_group_declined" = "已拒接群组通话"; +"lng_call_duration_info" = "{time}, {duration}"; +"lng_call_type_and_duration" = "{type} ({duration})"; +"lng_call_invitation" = "群组通话邀请"; + + +"lng_call_rate_label" = "请对您这次通话的质量作出评价 "; +"lng_call_rate_comment" = "评价(选填)"; + +"lng_call_start" = "开始通话"; +"lng_call_start_video" = "开启摄像头"; +"lng_call_stop_video" = "关闭摄像头"; +"lng_call_screencast" = "屏幕共享"; +"lng_call_add_people" = "邀请他人"; +"lng_call_end_call" = "结束通话"; +"lng_call_mute_audio" = "静音"; +"lng_call_unmute_audio" = "取消静音"; +"lng_call_accept" = "接听"; +"lng_call_decline" = "挂断"; +"lng_call_redial" = "重拨"; +"lng_call_cancel" = "取消"; + +"lng_call_microphone_off" = "{user}的麦克风处于关闭状态"; +"lng_call_battery_level_low" = "{user} 的电池电量过低"; + +"lng_group_call_title" = "视频聊天"; +"lng_group_call_title_channel" = "直播中"; +"lng_group_call_active" = "发言中"; +"lng_group_call_inactive" = "接听中"; +"lng_group_call_raised_hand_status" = "想要发言"; +"lng_group_call_settings" = "设置"; +"lng_group_call_video" = "视频"; +"lng_group_call_screen_share_start" = "分享屏幕"; +"lng_group_call_screen_share_stop" = "停止分享"; +"lng_group_call_screen_title" = "屏幕 {index}"; +"lng_group_call_screen_share_audio" = "共享系统音频"; +"lng_group_call_unmute" = "开启麦克风"; +"lng_group_call_unmute_sub" = "或按住空格键以说话"; +"lng_group_call_you_are_live" = "你正在说话"; +"lng_group_call_force_muted" = "被管理员静音"; +"lng_group_call_force_muted_sub" = "你处于只听模式"; +"lng_group_call_raised_hand" = "举手发言"; +"lng_group_call_connecting" = "正在连接..."; +"lng_group_call_leave" = "挂断"; +"lng_group_call_leave_title" = "退出视频聊天"; +"lng_group_call_leave_title_call" = "退出群组通话"; +"lng_group_call_leave_title_channel" = "退出直播"; +"lng_group_call_leave_sure" = "您确定要退出当前视频聊天吗?"; +"lng_group_call_leave_sure_call" = "您要退出此群组通话吗?"; +"lng_group_call_leave_sure_channel" = "您确定要退出此直播吗?"; +"lng_group_call_close" = "关闭"; +"lng_group_call_close_sure" = "语音聊天已预约,您可以随时取消。"; +"lng_group_call_close_sure_channel" = "直播时间已预定,您可以随时取消直播或直接关闭此对话框。"; +"lng_group_call_also_cancel" = "取消视频聊天"; +"lng_group_call_also_cancel_channel" = "取消直播"; +"lng_group_call_leave_to_other_sure" = "退出当前所在的语音聊天,并加入此组中的语音聊天吗?"; +"lng_group_call_leave_to_other_sure_channel" = "您要离开当前视频聊天并加入此频道中的直播吗?"; +"lng_group_call_leave_channel_to_other_sure" = "您确定想要离开当前的活跃直播并加入此群组的视频聊天吗?"; +"lng_group_call_leave_channel_to_other_sure_channel" = "您是否想要离开当前直播并加入此频道中的直播吗?"; +"lng_group_call_create_sure" = "您确定要在此群组发起视频聊天吗?"; +"lng_group_call_create_sure_channel" = "您确定要在此频道中以您的个人帐户开始语音聊天吗?"; +"lng_group_call_join_sure_personal" = "您确定要以你的个人帐户开始语音聊天吗?"; +"lng_group_call_muted_no_camera" = "您无法在被管理员静音时开启视频。"; +"lng_group_call_muted_no_screen" = "当您被管理员静音时,您不能分享你的屏幕。"; +"lng_group_call_chat_no_camera" = "您无法在此聊天中开启视频。"; +"lng_group_call_chat_no_screen" = "您无法在此聊天中共享屏幕。"; +"lng_group_call_failed_screen" = "发生错误,屏幕共享已停止。"; +"lng_group_call_failed_camera" = "无法启用相机。可能有其他应用正在使用相机。请尝试关闭其他应用。"; +"lng_group_call_tooltip_screen" = "分享屏幕"; +"lng_group_call_tooltip_camera" = "您的摄像头未开启,点击此处开启摄像头。"; +"lng_group_call_tooltip_microphone" = "您处于静音状态。点此说话。"; +"lng_group_call_tooltip_camera_off" = "停用相机"; +"lng_group_call_tooltip_force_muted" = "已被管理员静音。点击以申请发言。"; +"lng_group_call_tooltip_raised_hand" = "您已申请发言。我们会通知发言者。"; +"lng_group_call_also_end" = "结束视频聊天"; +"lng_group_call_also_end_channel" = "结束直播"; +"lng_group_call_settings_title" = "设置"; +"lng_group_call_invite" = "邀请用户"; +"lng_group_call_invite_conf" = "邀请他人"; +"lng_group_call_invited_status" = "已邀请"; +"lng_group_call_calling_status" = "正在呼叫..."; +"lng_group_call_blockchain_only_status" = "正在收听"; +"lng_group_call_muted_by_me_status" = "已被我静音"; +"lng_group_call_invite_title" = "邀请成员"; +"lng_group_call_invite_button" = "邀请"; +"lng_group_call_confcall_add" = "通话"; +"lng_group_call_add_to_group_one" = "{user} 不是 {group} 的成员,邀请他加入吗?"; +"lng_group_call_add_to_group_some" = "有部分人不是 {group} 的成员,邀请他们加入吗?"; +"lng_group_call_add_to_group_all" = "这些人不是 {group} 的成员,邀请他们加入吗?"; +"lng_group_call_invite_members" = "群组成员"; +"lng_group_call_invite_search_results" = "查找结果"; +"lng_group_call_invite_limit" = "已达到当前允许的最大参与者人数。"; +"lng_group_call_new_muted" = "新参与者禁言"; +"lng_group_call_speakers" = "扬声器"; +"lng_group_call_microphone" = "麦克风"; +"lng_group_call_push_to_talk" = "按下说话快捷按钮"; +"lng_group_call_ptt_shortcut" = "编辑快捷键"; +"lng_group_call_ptt_recording" = "停止录制"; +"lng_group_call_ptt_delay_ms" = "{amount} 毫秒"; +"lng_group_call_ptt_delay_s" = "{amount} 秒"; +"lng_group_call_ptt_delay" = "释放延迟:{delay}"; +"lng_group_call_share" = "分享邀请链接"; +"lng_group_call_noise_suppression" = "启用噪音抑制"; +"lng_group_call_video_paused" = "视频已暂停"; +"lng_group_call_share_speaker" = "使用此链接的用户可以发言"; +"lng_group_call_copy_speaker_link" = "拷贝发言人邀请链接"; +"lng_group_call_copy_listener_link" = "拷贝收听者邀请链接"; +"lng_group_call_end" = "结束视频聊天"; +"lng_group_call_end_channel" = "结束直播"; +"lng_group_call_cancel" = "取消视频聊天"; +"lng_group_call_cancel_channel" = "取消直播"; +"lng_group_call_join" = "加入"; +"lng_group_call_join_confirm" = "您想要加入 {chat} 语音聊天吗?"; +"lng_group_call_join_confirm_channel" = "是否加入直播 {chat} ?"; +"lng_group_call_invite_done_user" = "您已邀请 {user} 加入视频聊天。"; + +"lng_group_call_invite_done_many#other" = "您邀请了 **{count} 位参与者** 加入视频聊天。"; +"lng_group_call_no_members" = "点击以加入"; + +"lng_group_call_members#other" = "{count} 名参与者"; +"lng_group_call_context_mute" = "静音"; +"lng_group_call_context_unmute" = "允许发言"; +"lng_group_call_context_remove_hand" = "取消发言请求"; +"lng_group_call_context_mute_for_me" = "为我静音"; +"lng_group_call_context_unmute_for_me" = "为我取消静音"; +"lng_group_call_context_pin_camera" = "顶置视频"; +"lng_group_call_context_unpin_camera" = "取消顶置视频"; +"lng_group_call_context_pin_screen" = "顶置屏幕共享"; +"lng_group_call_context_unpin_screen" = "取消置顶屏幕共享"; +"lng_group_call_context_remove" = "移除"; +"lng_group_call_context_cancel_invite" = "撤销邀请"; +"lng_group_call_context_stop_ringing" = "结束通话"; +"lng_group_call_context_ban_from_call" = "从通话中封禁"; +"lng_group_call_remove_channel" = "是否将 {channel} 从语音聊天中移除并封禁?"; +"lng_group_call_remove_channel_from_channel" = "是否要从直播间中移除 {channel}?"; +"lng_group_call_mac_access" = "Tidings Desktop 无法访问随按即说所需的系统键盘输入。"; +"lng_group_call_mac_input" = "请在**隐私设置**中允许Tidings **监视输入**。"; +"lng_group_call_mac_accessibility" = "请在隐私设置中允许 Tidings 使用 **辅助功能** 。\n可能需要重新启动应用程序。"; +"lng_group_call_mac_screencast_access" = "Tidings Desktop 没有获得屏幕录制权限,因此无法进行屏幕共享。"; +"lng_group_call_mac_recording" = "请在“隐私设置”中允许 Tidings **录制屏幕**。"; +"lng_group_call_mac_settings" = "打开设置"; + +"lng_group_call_start_as_header" = "以哪个身份发起视频聊天…"; +"lng_group_call_start_as_header_channel" = "选择直播身份"; +"lng_group_call_join_as_header" = "以哪个身份加入视频聊天…"; +"lng_group_call_join_as_header_channel" = "以哪个身份加入直播…"; +"lng_group_call_display_as_header" = "将我显示为..."; +"lng_group_call_join_as_about" = "选择您希望显示为个人帐号还是频道。"; +"lng_group_call_or_schedule" = "此外您还可以 {link}."; +"lng_group_call_schedule" = "预约语音聊天"; +"lng_group_call_schedule_channel" = "预约直播"; +"lng_group_call_schedule_title" = "预约视频聊天"; +"lng_group_call_schedule_title_channel" = "预约直播"; +"lng_group_call_schedule_notified_group" = "群组成员将收到通知,视频聊天将在 {duration} 后开始。"; +"lng_group_call_schedule_notified_channel" = "频道订阅者将收到通知,直播将在 {duration} 后开始。"; +"lng_group_call_scheduled_status" = "已预定"; +"lng_group_call_scheduled_title" = "已预约视频聊天"; +"lng_group_call_scheduled_title_channel" = "已预约的直播"; +"lng_group_call_starts_short" = "开始于{when}"; +"lng_group_call_starts" = "视频聊天将于 {when} 开始"; +"lng_group_call_starts_channel" = "直播将在 {when} 开始"; +"lng_group_call_starts_today" = "今天 {time}"; +"lng_group_call_starts_tomorrow" = "明天 {time}"; +"lng_group_call_starts_date" = "{time} {date}"; +"lng_group_call_starts_in" = "开始于"; +"lng_group_call_late_by" = "已过预定时间"; +"lng_group_call_starts_short_today" = "今天 {time}"; +"lng_group_call_starts_short_tomorrow" = "明天 {time}"; +"lng_group_call_starts_short_date" = "{date} {time}"; +"lng_group_call_start_now" = "立即开始"; +"lng_group_call_start_now_sure" = "您确定现在开始语音聊天吗?"; +"lng_group_call_start_now_sure_channel" = "现在开始直播吗?"; +"lng_group_call_set_reminder" = "设置提醒"; +"lng_group_call_cancel_reminder" = "取消提醒"; +"lng_group_call_join_as_personal" = "个人账号"; +"lng_group_call_edit_title" = "修改视频聊天标题"; +"lng_group_call_edit_title_channel" = "编辑直播标题"; +"lng_group_call_recording_start" = "开始录音"; +"lng_group_call_recording_stop" = "停止录制"; +"lng_group_call_recording_started" = "语音通话录音已开始。"; +"lng_group_call_recording_started_video" = "已开始视频流录制."; +"lng_group_call_recording_started_channel" = "直播录制已开始。"; +"lng_group_call_recording_stopped" = "通话录音已停止。"; +"lng_group_call_recording_stopped_channel" = "直播录制已停止"; +"lng_group_call_recording_saved" = "语音聊天的录音已保存至我的收藏。"; +"lng_group_call_recording_saved_video" = "视频被保存到「我的收藏」。"; +"lng_group_call_pinned_camera_me" = "你的视频已顶置。"; +"lng_group_call_pinned_screen_me" = "您的屏幕共享已置顶。"; +"lng_group_call_pinned_camera" = "{user} 的视频已置顶。"; +"lng_group_call_pinned_screen" = "已置顶 {user} 的屏幕共享。"; +"lng_group_call_unpinned_camera_me" = "您的视频已取消顶置。"; +"lng_group_call_unpinned_screen_me" = "您的屏幕共享已取消顶置。"; +"lng_group_call_unpinned_camera" = "{user} 的视频已取消置顶。"; +"lng_group_call_unpinned_screen" = "{user} 的屏幕共享已取消置顶。"; +"lng_group_call_sure_screencast" = "{user} 正在共享屏幕,此操作会为所有成员置顶您的屏幕共享。"; +"lng_group_call_pinned_on_top" = "现场直播已置顶。"; +"lng_group_call_unpinned_on_top" = "现场直播已解除置顶。"; +"lng_group_call_recording_start_sure" = "是否录制此次聊天并保存为音频文件?\n\n其他参与者会看到聊天正在被录音。"; +"lng_group_call_recording_stop_sure" = "您要停止录音吗?"; +"lng_group_call_recording_start_field" = "录音主题"; +"lng_group_call_recording_start_button" = "开始"; +"lng_group_call_recording_start_title" = "添加标题"; +"lng_group_call_recording_start_checkbox" = "同时录制视频"; +"lng_group_call_recording_start_audio_subtitle" = "此聊天将被录音并保存为音频文件"; +"lng_group_call_recording_start_video_subtitle" = "选择视频画面方向"; +"lng_group_call_is_recorded" = "正在录制音频流。"; +"lng_group_call_is_recorded_video" = "此视频直播正在录制"; +"lng_group_call_is_recorded_channel" = "正在录制直播。"; +"lng_group_call_can_speak_here" = "您现在可以发言了。"; +"lng_group_call_can_speak" = "您可以在 {chat} 中发言了。"; +"lng_group_call_title_changed" = "已将视频聊天标题更改为 {title}"; +"lng_group_call_title_changed_channel" = "直播标题更改为{title}"; +"lng_group_call_join_as_changed" = "此视频聊天的参与者将会看到您是 {name}"; +"lng_group_call_join_as_changed_channel" = "你的名字会对此直播的参与者显示为{name}"; +"lng_group_call_no_stream_admin" = "**糟糕!** Tidings 没有接收到来自您的直播程序的推流。请确保您在直播程序中输入了正确的服务器 URL 和串流码。"; +"lng_group_call_no_stream" = "{group} 目前没有向 Tidings 推流直播数据。"; + +"lng_menu_start_group_call" = "开始视频聊天"; +"lng_menu_start_group_call_scheduled" = "预订视频聊天"; +"lng_menu_start_group_call_with" = "以其它方式直播..."; +"lng_menu_start_group_call_join" = "加入视频聊天"; +"lng_menu_start_group_call_options" = "视频聊天"; +"lng_menu_start_group_call_channel" = "开始直播"; +"lng_menu_start_group_call_scheduled_channel" = "预定现场直播"; +"lng_menu_start_group_call_with_channel" = "以其他方式直播…"; + +"lng_group_call_rtmp_title" = "使用其他应用程序直播"; +"lng_group_call_rtmp_url_subtitle" = "服务器 URL"; +"lng_group_call_rtmp_url_copy" = "拷贝服务器 URL"; +"lng_group_call_rtmp_url_copied" = "服务器地址已复制到剪贴板。"; +"lng_group_call_rtmp_key_subtitle" = "串流密钥"; +"lng_group_call_rtmp_key_copy" = "拷贝串流密钥"; +"lng_group_call_rtmp_key_copied" = "推流密钥已复制到剪贴板。"; +"lng_group_call_rtmp_key_warning" = "**从不分享您的推流密钥给任何人或在直播中展示它!**"; +"lng_group_call_rtmp_info" = "要通过其他软件直播,请在你的直播软件中输入服务器地址和推流码。推荐使用软件编码(例如OBS的x264)\n\n先在你的直播软件中开始直播,推流成功后点击下面的“开始直播”按钮。"; +"lng_group_call_rtmp_start" = "开始直播"; +"lng_group_call_rtmp_revoke" = "撤销推流密钥"; +"lng_group_call_rtmp_revoke_sure" = "您确定要撤销您的推流密钥吗?"; + +"lng_group_call_rtmp_viewers#other" = "{count} 位访客"; + +"lng_confcall_join_title" = "群组通话"; +"lng_confcall_join_text" = "您已获邀请加入此群组通话。"; +"lng_confcall_join_text_inviter" = "{user} 邀请您进入 Tidings 通话。"; +"lng_confcall_already_joined_one" = "{user} 已加入此通话。"; +"lng_confcall_already_joined_two" = "{user} 和 {other} 已加入此次通话。"; +"lng_confcall_already_joined_three" = "{user}、{other} 和 {third} 已加入本次通话。"; + +"lng_confcall_already_joined_many#other" = "{user}, {other} 和其他 {count} 个人已加入本次通话。"; +"lng_confcall_join_button" = "加入群组通话"; +"lng_confcall_create_call" = "开始新通话"; + +"lng_confcall_create_call_description#other" = "您最多可以向通话添加 {count} 位参与者。"; +"lng_confcall_create_title" = "新建通话"; +"lng_confcall_create_link" = "创建通话链接"; +"lng_confcall_create_link_description" = "您可以创建一个链接以允许您在 Tidings 上的朋友加入通话。"; +"lng_confcall_link_revoke" = "撤销链接"; +"lng_confcall_link_revoked_title" = "链接已撤销"; +"lng_confcall_link_inactive" = "此链接不再有效。"; +"lng_confcall_link_revoked_text" = "已生成新的链接。"; +"lng_confcall_link_title" = "通话链接"; +"lng_confcall_link_about" = "Tidings 上的任何人都可以通过以下链接加入您的通话。"; +"lng_confcall_link_or" = "或"; +"lng_confcall_link_join" = "率先加入通话并在通话中添加其他人。 {link}"; +"lng_confcall_link_join_link" = "开始通话 {arrow}"; +"lng_confcall_inactive_title" = "开始群组通话"; +"lng_confcall_inactive_about" = "此通话不再有效。您可以开始新通话。"; +"lng_confcall_invite_done_user" = "您正在呼叫 {user} 加入通话。"; + +"lng_confcall_invite_done_many#other" = " 您呼叫了 **{count} 个人** 加入。"; +"lng_confcall_invite_already_user" = "{user} 已经在此通话中。"; + +"lng_confcall_invite_already_many#other" = "**{count} 个人** 已经在此通话中。"; +"lng_confcall_invite_privacy_user" = "由于隐私设置,您无法呼叫 {user}。"; + +"lng_confcall_invite_privacy_many#other" = "由于他们的隐私设置,您无法呼叫**{count} 个人**。"; +"lng_confcall_invite_fail_user" = "无法呼叫 {user} 加入通话。"; + +"lng_confcall_invite_fail_many#other" = "无法呼叫 **{count} 个人**加入。"; +"lng_confcall_invite_kicked_user" = "{user} 已被禁止进入此通话。"; + +"lng_confcall_invite_kicked_many#other" = "**{count} 个人** 已从此通话中删除。"; +"lng_confcall_not_accessible" = "此通话已无法访问。"; +"lng_confcall_participants_limit" = "此通话已达到参与者数量上限。"; +"lng_confcall_sure_remove" = "从此通话中移除 {user} ?"; +"lng_confcall_e2e_badge" = "端到端加密"; +"lng_confcall_e2e_badge_small" = "端到端加密"; +"lng_confcall_e2e_about" = "这些表情符号代表加密密钥。所有参与者的密钥必须匹配,当有人加入或离开时密钥会发生变化。"; + +"lng_no_mic_permission" = "Tidings 需要访问您的麦克风,以便您可以拨打电话及录制语音留言。"; + +"lng_player_message_today" = "今天 {time}"; +"lng_player_message_yesterday" = "昨天 {time}"; +"lng_player_message_date" = "{date} {time}"; + +"lng_audio_player_reverse" = "倒序"; +"lng_audio_player_shuffle" = "随机"; +"lng_audio_transcribe_long" = "语音消息过长"; + +"lng_audio_transcribe_trials_left#other" = "在 {date} 前,您还剩余 **{count}** 次免费的语音转录。"; +"lng_audio_transcribe_trials_over" = "请等待至 {date} 或者现在订阅 {link}。"; + +"lng_rights_edit_admin" = "编辑权限"; +"lng_rights_edit_admin_header" = "这位管理员可以做什么?"; +"lng_rights_edit_admin_rank_name" = "自定义头衔"; +"lng_rights_edit_admin_rank_about" = "群组成员将看到此头衔,而不是 “{title}”。"; +"lng_rights_about_add_admins_yes" = "此管理员将能够添加新的管理员,并赋予其与该管理员同等(或更少)的权限。"; +"lng_rights_about_add_admins_no" = "此管理员将不能添加新的管理员。"; +"lng_rights_about_by" = "此管理员由 {user} 于 {date} 提权。"; + +"lng_rights_about_admin_cant_edit" = "您无法修改此管理员的权限。"; +"lng_rights_about_restriction_cant_edit" = "您无法更改此用户的限制。"; +"lng_rights_restriction_for_all" = "群组设定中所有群组成员无权做此操作。"; +"lng_rights_permission_for_all" = "这个选项为所有群组成员启用。"; +"lng_rights_permission_unavailable" = "公开群组不具有此权限。"; +"lng_rights_permission_in_discuss" = "讨论群中无此权限。"; +"lng_rights_permission_cant_edit" = "您不能更改此权限。"; +"lng_rights_user_restrictions" = "用户权限"; +"lng_rights_user_restrictions_header" = "这位用户可以做什么?"; +"lng_rights_default_restrictions_header" = "本群组成员有何权限?"; +"lng_rights_slowmode_header" = "慢速模式"; +"lng_rights_slowmode_off" = "关闭"; +"lng_rights_slowmode_about" = "成员每个周期只能发送一条信息。"; +"lng_rights_slowmode_about_interval" = "成员每 {interval} 只能发送一条信息。"; + +"lng_rights_slowmode_interval_seconds#other" = "每 {count} 秒"; + +"lng_rights_slowmode_interval_minutes#other" = "每 {count} 分钟"; +"lng_rights_boosts_no_restrict" = "不限制助推者"; +"lng_rights_boosts_about" = "启用此功能可始终允许已助推您群组的用户发送消息和媒体。"; +"lng_rights_boosts_about_on" = "选择用户必须为群组助推几次才能不受发送消息的限制。"; +"lng_rights_charge_stars" = "为消息收取星星"; +"lng_rights_charge_stars_about" = "如果您启用此功能,群组的普通成员将必须支付星星才能发送消息。"; +"lng_rights_charge_price" = "设定每条消息的价格"; +"lng_rights_charge_price_about" = "每逢收到一条消息,你的群组将获取设置费用的 {percent}({amount})。"; + +"lng_slowmode_enabled" = "慢速模式已启用,您可以在 {left} 后发送下一条消息。"; +"lng_slowmode_no_many" = "启用慢速模式。您一次最多只能发送一条消息。"; +"lng_slowmode_too_long" = "抱歉,此文本过长而无法作为一条消息发送。\n\n慢速模式已启用,您不能一次发送多条消息。"; + +"lng_slowmode_seconds#other" = "{count} 秒"; + +"lng_payment_confirm_title" = "确认支付"; + +"lng_payment_confirm_text#other" = "{name} 对每条消息收取 **{count}** 颗星星。"; + +"lng_payment_confirm_amount#other" = "**{count}** 颗星星"; + +"lng_payment_confirm_users#other" = "您选择了 **{count}** 个需要支付星星以向他们发送消息的联系人。"; + +"lng_payment_confirm_chats#other" = "你选择了 **{count}** 个需要支付星星以发送消息的聊天。"; + +"lng_payment_confirm_sure#other" = "您愿意支付 {amount} 颗星星以发送 {count} 吗?"; +"lng_payment_confirm_dont_ask" = "不再询问我"; + +"lng_payment_confirm_button#other" = "为 {count} 条消息支付"; +"lng_payment_bar_text" = "{name} 必须为每条发送给你的消息支付 {cost}。"; +"lng_payment_bar_button" = "免收费用"; +"lng_payment_refund_title" = "免收费用"; +"lng_payment_refund_text" = "你确定要允许 {name} 免费向你发送消息吗?"; + +"lng_payment_refund_also#other" = "退还已支付的 {count} 颗星星"; +"lng_payment_refund_confirm" = "确认"; + +"lng_rights_gigagroup_title" = "广播群组"; +"lng_rights_gigagroup_convert" = "转换为广播群组"; +"lng_rights_gigagroup_about" = "广播组可以拥有超过20万名成员,但仅管理员可以发送消息。"; +"lng_gigagroup_convert_title" = "广播群组"; +"lng_gigagroup_convert_feature1" = "不限制成员数量。"; +"lng_gigagroup_convert_feature2" = "仅管理员可以发送消息。"; +"lng_gigagroup_convert_feature3" = "无法转回成常规群组。"; +"lng_gigagroup_convert_sure" = "转换"; +"lng_gigagroup_warning_title" = "确定吗?"; +"lng_gigagroup_warning" = "该群组的普通成员(非管理员)将 **不可恢复地** 失去在该群组中发送消息的权限。\n\n此操作 **无法** 撤消。"; +"lng_gigagroup_done" = "您的群组现在可以拥有超过20万个成员了。"; +"lng_gigagroup_suggest_title" = "群成员已达上限"; +"lng_gigagroup_suggest_text" = "您的群组已经达到了 **200,000** 名成员的上限。\n\n通过将该群组转换为只有管理员才能发布的消息的 **广播群** 可以提升成员上限。\n感兴趣吗?"; +"lng_gigagroup_suggest_more" = "了解更多"; + +"lng_rights_channel_info" = "修改频道信息"; +"lng_rights_channel_manage" = "管理消息"; +"lng_rights_channel_post" = "发布消息"; +"lng_rights_channel_edit" = "编辑其他用户的消息"; +"lng_rights_channel_delete" = "删除其他用户的消息"; +"lng_rights_channel_manage_stories" = "管理动态"; +"lng_rights_channel_post_stories" = "发布动态"; +"lng_rights_channel_edit_stories" = "编辑其他人的动态"; +"lng_rights_channel_delete_stories" = "删除其他用户的动态"; +"lng_rights_channel_manage_calls" = "管理直播"; +"lng_rights_group_info" = "修改群组信息"; +"lng_rights_group_ban" = "封禁用户"; +"lng_rights_group_invite_link" = "通过链接邀请用户"; +"lng_rights_group_invite" = "添加成员"; +"lng_rights_group_pin" = "置顶消息"; +"lng_rights_group_topics" = "管理话题"; +"lng_rights_group_add_topics" = "创建话题"; +"lng_rights_group_manage_calls" = "管理视频聊天"; +"lng_rights_group_delete" = "删除消息"; +"lng_rights_group_anonymous" = "匿名发送"; +"lng_rights_add_admins" = "添加新的管理员"; +"lng_rights_chat_send_text" = "发送消息"; +"lng_rights_chat_send_media" = "发送媒体文件"; +"lng_rights_chat_send_stickers" = "发送贴纸和 GIF 动态图"; +"lng_rights_chat_send_links" = "嵌入链接预览"; +"lng_rights_chat_send_polls" = "发起投票"; +"lng_rights_chat_add_members" = "添加成员"; +"lng_rights_chat_photos" = "照片"; +"lng_rights_chat_videos" = "视频文件"; +"lng_rights_chat_stickers" = "贴纸和 GIF 动态图"; +"lng_rights_chat_music" = "音乐"; +"lng_rights_chat_files" = "文件"; +"lng_rights_chat_voice_messages" = "语音消息"; +"lng_rights_chat_video_messages" = "视频消息"; +"lng_rights_chat_restricted_by" = "被 {user} 于 {date} 限制。"; +"lng_rights_chat_banned_by" = "被 {user} 于 {date} 封禁。"; +"lng_rights_chat_banned_until_header" = "限制至"; +"lng_rights_chat_banned_forever" = "永久"; + +"lng_rights_chat_banned_day#other" = "{count} 天后"; + +"lng_rights_chat_banned_week#other" = "{count} 周后"; +"lng_rights_chat_banned_custom" = "自定义"; +"lng_rights_chat_banned_custom_date" = "直到 {date}"; + +"lng_rights_transfer_group" = "转让群组所有权"; +"lng_rights_transfer_channel" = "转让频道所有权"; +"lng_rights_transfer_check" = "安全检查"; +"lng_rights_transfer_check_about" = "您必须符合以下条件,才可以将群组转让给 {user} :"; +"lng_rights_transfer_check_password" = "已于 **7天前** 启用了**两步验证**。"; +"lng_rights_transfer_check_session" = "• 在当前设备登录时长超过 **24 小时** 。"; +"lng_rights_transfer_check_later" = "请稍后再试。"; +"lng_rights_transfer_set_password" = "设置密码"; +"lng_rights_transfer_about" = "这将转让 **{group}** 的所有权给 **{user}**。"; +"lng_rights_transfer_sure" = "转让"; +"lng_rights_transfer_password_title" = "两步验证"; +"lng_rights_transfer_password_description" = "请输入密码以完成转让。"; +"lng_rights_transfer_done_group" = "{user} 成为了新群主。"; +"lng_rights_transfer_done_channel" = "{user} 现在是频道的所有者。"; + +"lng_bots_password_confirm_check_about" = "您只有满足以下条件才能完成此操作:"; +"lng_bots_password_confirm_title" = "两步验证"; +"lng_bots_password_confirm_description" = "请输入密码确认此操作。"; + +"lng_restricted_send_message" = "此群组的管理员已限制您发送消息。"; +"lng_restricted_send_photos" = "该群组的管理员已限制您发送图片。"; +"lng_restricted_send_videos" = "群管理员已限制您在本群发送视频文件。"; +"lng_restricted_send_music" = "群管理员已限制您在本群发送音乐消息。"; +"lng_restricted_send_files" = "群组管理员已禁止你在群内发布文档。"; +"lng_restricted_send_voice_messages_group" = "群组管理员已禁止你在群内发送语音消息。"; +"lng_restricted_send_video_messages_group" = "该群组的管理员已限制您在此发送视频消息。"; +"lng_restricted_send_stickers" = "此群组的管理员已限制您发送贴纸。"; +"lng_restricted_send_gifs" = "此群组的管理员已限制您发送 GIF 动态图。"; +"lng_restricted_send_inline" = "此群组的管理员已限制您发送机器人内容。"; +"lng_restricted_send_polls" = "此群组的管理员已限制您发送投票。"; +"lng_restricted_boost_group" = "助推此群组以发送消息"; + +"lng_restricted_send_message_until" = "该群组的管理员限制了您在 {date} {time} 之前发送消息。"; +"lng_restricted_send_photos_until" = "该群组的管理员已限制您在 {date} {time} 前发送图片。"; +"lng_restricted_send_videos_until" = "该群组的管理员已限制您在 {date} {time} 前发送视频。"; +"lng_restricted_send_music_until" = "该群组的管理员已限制您在 {date} {time} 前发送音乐。"; +"lng_restricted_send_files_until" = "该群组的管理员已限制您在 {date} {time} 之前发送文件。"; +"lng_restricted_send_voice_messages_until" = "该群组的管理员已限制您在 {date} {time} 之前发送语音消息。"; +"lng_restricted_send_video_messages_until" = "该群组的管理员已限制您在 {date} {time} 之前发送视频消息。"; +"lng_restricted_send_stickers_until" = "本群管理员限制你发送贴纸直到 {date} {time} 。"; +"lng_restricted_send_gifs_until" = "此群组的管理员已限制您发送 GIF 动态图直到 {date} {time}。"; +"lng_restricted_send_inline_until" = "此群组的管理员已限制您在 {date},{time} 之前发送机器人内容。"; +"lng_restricted_send_polls_until" = "本群管理员限制你发起投票直到 {date} {time} 。"; + +"lng_restricted_send_message_all" = "此群组不允许发送消息。"; +"lng_restricted_send_photos_all" = "此群组不允许发送图片。"; +"lng_restricted_send_videos_all" = "此群组不允许发送视频。"; +"lng_restricted_send_music_all" = "此群组不允许发送音频。"; +"lng_restricted_send_files_all" = "此群组不允许发送文件。"; +"lng_restricted_send_voice_messages_all" = "此群组不允许发送语音。"; +"lng_restricted_send_video_messages_all" = "对不起,你不能在本群内发布视频。"; +"lng_restricted_send_stickers_all" = "此群组不允许发送贴纸。"; +"lng_restricted_send_gifs_all" = "此群组不允许发送 GIF 动态图。"; +"lng_restricted_send_inline_all" = "此群组不允许发送机器人内容。"; +"lng_restricted_send_polls_all" = "很抱歉, 此群组中不允许发布投票。"; + +"lng_restricted_send_public_polls" = "抱歉,非匿名投票不能被转发到频道。"; +"lng_restricted_send_paid_media" = "抱歉,付费媒体无法发送到此频道。"; + +"lng_restricted_send_voice_messages" = "{user} 不接受语音消息。"; +"lng_restricted_send_video_messages" = "{user} 不接受视频消息。"; +"lng_restricted_send_non_premium" = "{user} 只接受来自高级版订阅用户的消息。"; +"lng_restricted_send_non_premium_more" = "了解更多..."; + +"lng_send_non_premium_text" = "订阅 **Tidings 高级版**\n以给 {user} 发送消息。"; +"lng_send_non_premium_go" = "获取高级版"; +"lng_send_non_premium_story" = "回复受限"; +"lng_send_non_premium_unlock" = "解锁"; +"lng_send_non_premium_message_toast" = "**{user}** 仅接受来自联系人和 {link} 订阅用户的消息。"; +"lng_send_non_premium_message_toast_link" = "Tidings 高级版"; + +"lng_send_charges_stars_text" = "{user} 对每条消息收取 {amount}。"; +"lng_send_charges_stars_go" = "购买星星"; + +"lng_exceptions_list_title" = "例外"; +"lng_removed_list_title" = "被移除的用户"; + +"lng_admin_log_title_all" = "所有操作"; +"lng_admin_log_title_selected" = "选中的操作"; +"lng_admin_log_filter" = "筛选器"; +"lng_admin_log_filter_title" = "筛选器"; +"lng_admin_log_filter_all_actions" = "所有操作"; +"lng_admin_log_filter_actions_type_subtitle" = "按类型筛选操作"; +"lng_admin_log_filter_actions_member_section" = "成员与管理员"; +"lng_admin_log_filter_actions_subscriber_section" = "订阅者和管理员"; +"lng_admin_log_filter_restrictions" = "新限制"; +"lng_admin_log_filter_admins_new" = "管理员权限"; +"lng_admin_log_filter_members_new" = "新成员"; +"lng_admin_log_filter_subscribers_new" = "新的订阅者"; +"lng_admin_log_filter_actions_settings_section" = "群组设置"; +"lng_admin_log_filter_actions_channel_settings_section" = "频道设置"; +"lng_admin_log_filter_info_group" = "群组信息"; +"lng_admin_log_filter_info_channel" = "频道信息"; +"lng_admin_log_filter_actions_messages_section" = "消息"; +"lng_admin_log_filter_messages_deleted" = "已删除消息"; +"lng_admin_log_filter_messages_edited" = "已编辑消息"; +"lng_admin_log_filter_messages_pinned" = "已置顶消息"; +"lng_admin_log_filter_voice_chats" = "视频聊天"; +"lng_admin_log_filter_voice_chats_channel" = "直播"; +"lng_admin_log_filter_invite_links" = "邀请链接"; +"lng_admin_log_filter_members_removed" = "成员退出"; +"lng_admin_log_filter_subscribers_removed" = "订阅者已离开此频道"; +"lng_admin_log_filter_topics" = "话题"; +"lng_admin_log_filter_sub_extend" = "续订信息"; +"lng_admin_log_filter_all_admins" = "所有用户和管理员"; +"lng_admin_log_filter_actions_admins_subtitle" = "按管理员筛选操作"; +"lng_admin_log_filter_actions_admins_section" = "显示所有管理员的操作"; +"lng_admin_log_about" = "这是什么?"; +"lng_admin_log_about_text" = "此列表包含了在过去 48 小时内群组成员/管理员执行的所有值得注意的操作。"; +"lng_admin_log_about_text_channel" = "此列表包含了在过去 48 小时内频道管理员执行的所有操作。"; +"lng_admin_log_no_results_title" = "未找到操作记录"; +"lng_admin_log_no_results_text" = "近期操作中找不到与您查询请求相符的结果。"; +"lng_admin_log_no_results_search_text" = "找不到包含“{query}”的近期操作。"; +"lng_admin_log_no_events_title" = "暂无操作记录"; +"lng_admin_log_no_events_text" = "在过去的 48 小时内,群组成员/管理员没有执行任何操作。"; +"lng_admin_log_no_events_text_channel" = "在过去的 48 小时内,频道管理员没有执行任何值得注意的操作。"; + +"lng_admin_log_empty_text" = "< 空 >"; +"lng_admin_log_changed_title_channel" = "{from} 将频道名称更改为«{title}»"; +"lng_admin_log_changed_description_group" = "{from} 编辑了群组简介:"; +"lng_admin_log_removed_description_group" = "{from} 移除了群组简介"; +"lng_admin_log_changed_description_channel" = "{from} 编辑了频道简介:"; +"lng_admin_log_removed_description_channel" = "{from} 移除了频道简介"; +"lng_admin_log_previous_description" = "之前的简介"; +"lng_admin_log_changed_link_group" = "{from} 更换了群组链接:"; +"lng_admin_log_removed_link_group" = "{from} 移除了群组链接"; +"lng_admin_log_changed_link_channel" = "{from} 更换了频道链接:"; +"lng_admin_log_removed_link_channel" = "{from} 移除了频道链接"; +"lng_admin_log_previous_link" = "之前的链接"; +"lng_admin_log_reordered_link_group" = "{from} 续订的群组链接:"; +"lng_admin_log_reordered_link_channel" = "{from} 续订的频道链接:"; +"lng_admin_log_previous_links_order" = "先前顺序"; +"lng_admin_log_activated_link" = "{from} 已启用 @{link} 用户名"; +"lng_admin_log_deactivated_link" = "{from} 已停用 @{link} 用户名"; +"lng_admin_log_changed_photo_group" = "{from} 更换了群组头像"; +"lng_admin_log_changed_photo_channel" = "{from} 更换了频道头像"; +"lng_admin_log_removed_photo_group" = "{from} 移除了群组头像"; +"lng_admin_log_removed_photo_channel" = "{from} 移除了频道头像"; +"lng_admin_log_invites_enabled" = "{from} 开启了群组成员的邀请权限 "; +"lng_admin_log_invites_disabled" = "{from} 已停用群组邀请"; +"lng_admin_log_signatures_enabled" = "{from} 开启了消息署名"; +"lng_admin_log_signatures_disabled" = "{from} 关闭了消息署名"; +"lng_admin_log_signature_profiles_enabled" = "{from} 已启用作者资料"; +"lng_admin_log_signature_profiles_disabled" = "{from} 已禁用作者资料"; +"lng_admin_log_forwards_enabled" = "{from} 已允许保存内容"; +"lng_admin_log_forwards_disabled" = "{from} 已限制内容复制"; +"lng_admin_log_history_made_hidden" = "{from} 设置消息记录对新成员不可见"; +"lng_admin_log_history_made_visible" = "{from} 已将消息记录设置为对新成员可见"; +"lng_admin_log_pinned_message" = "{from} 置顶了消息:"; +"lng_admin_log_unpinned_message" = "{from} 已将一条消息取消置顶"; +"lng_admin_log_edited_caption" = "{from} 已编辑说明:"; +"lng_admin_log_edited_media" = "{from} 已编辑媒体文件:"; +"lng_admin_log_edited_media_and_caption" = "{from} 已编辑媒体文件和标题:"; +"lng_admin_log_edited_media_and_removed_caption" = "{from} 已编辑媒体文件并移除了标题:"; +"lng_admin_log_removed_caption" = "{from} 已移除说明"; +"lng_admin_log_previous_caption" = "原说明"; +"lng_admin_log_edited_message" = "{from} 编辑了消息:"; +"lng_admin_log_previous_message" = "原消息"; +"lng_admin_log_deleted_message" = "{from} 删除了消息:"; +"lng_admin_log_sent_message" = "{from} 发送了这条消息:"; +"lng_admin_log_participant_joined" = "{from} 加入了群组"; +"lng_admin_log_participant_joined_channel" = "{from} 关注了频道"; +"lng_admin_log_participant_joined_by_link" = "{from} 通过 {link} 加入群组"; +"lng_admin_log_participant_joined_by_link_channel" = "{from} 通过 {link} 加入频道"; +"lng_admin_log_participant_joined_by_filter_link" = "{from} 通过文件夹邀请链接加入 {link}"; +"lng_admin_log_participant_joined_by_filter_link_channel" = "{from} 通过文件夹的分享链接 {link} 已加入此聊天"; +"lng_admin_log_participant_approved_by_link" = " {user} 同意 {from} 通过 {link} 加入群组"; +"lng_admin_log_participant_approved_by_link_channel" = "{user} 已批准 {from} 使用 {link} 加入频道"; +"lng_admin_log_participant_approved_by_request" = "\n{from} 通过公共请求,已被 {user} 批准进入群组"; +"lng_admin_log_participant_approved_by_request_channel" = "{from} 通过公共请求,已被 {user} 批准进入频道"; +"lng_admin_log_revoke_invite_link" = "{from} 已撤销邀请链接 {link}"; +"lng_admin_log_delete_invite_link" = "{from} 已删除邀请链接 {link}"; +"lng_admin_log_participant_left" = "{from} 已退出群组"; +"lng_admin_log_participant_left_channel" = "{from} 已退出频道"; +"lng_admin_log_stopped_poll" = "{from} 停止了投票:"; +"lng_admin_log_invited" = "邀请了“{user}”"; +"lng_admin_log_banned" = "封禁了“{user}”"; +"lng_admin_log_unbanned" = "已将 {user} 解除封禁"; +"lng_admin_log_restricted" = "修改了对“{user}”的限制至 {until}"; +"lng_admin_log_promoted" = "已修改 {user} 的权限"; +"lng_admin_log_transferred" = "转让所有权给 {user}"; +"lng_admin_log_changed_default_permissions" = "默认权限已修改"; +"lng_admin_log_changed_stickers_group" = "{from} 更换了群组{sticker_set}"; +"lng_admin_log_changed_stickers_set" = "贴纸包"; +"lng_admin_log_removed_stickers_group" = "{from} 已移除群组贴纸包"; +"lng_admin_log_changed_emoji_group" = "{from} 已更改群组 {sticker_set}"; +"lng_admin_log_changed_emoji_set" = "表情包"; +"lng_admin_log_removed_emoji_group" = "{from} 已移除群组的表情包"; +"lng_admin_log_changed_linked_chat" = "{from} 将 {chat} 更改为本频道的讨论群组"; +"lng_admin_log_removed_linked_chat" = "{from} 已移除讨论群组"; +"lng_admin_log_changed_linked_channel" = "{from} 将关联频道更改为 «{chat}»"; +"lng_admin_log_removed_linked_channel" = "{from} 移除了关联频道"; +"lng_admin_log_changed_location_chat" = "{from} 更改群组位置为 {address}"; +"lng_admin_log_removed_location_chat" = "{from} 已移除群组位置"; +"lng_admin_log_changed_slow_mode" = "{from} 将慢速模式调整为 {duration}"; +"lng_admin_log_removed_slow_mode" = "{from} 已停用慢速模式"; +"lng_admin_log_started_group_call" = "{from} 已发起视频聊天"; +"lng_admin_log_started_group_call_channel" = "{from}开始了新的直播"; +"lng_admin_log_discarded_group_call" = "{from} 已结束视频聊天"; +"lng_admin_log_discarded_group_call_channel" = "{from} 已结束直播"; +"lng_admin_log_muted_participant" = "{from} 已在视频聊天中将 {user} 静音"; +"lng_admin_log_muted_participant_channel" = "{user} 已在直播间被 {from} 静音"; +"lng_admin_log_unmuted_participant" = "{from} 在视频聊天中已取消 {user} 的静音"; +"lng_admin_log_unmuted_participant_channel" = "{from} 已在直播中将 {user} 取消静音"; +"lng_admin_log_allowed_unmute_self" = "{from} 已允许新视频聊天参与者发言"; +"lng_admin_log_allowed_unmute_self_channel" = "{from} 已允许直播参与者发言"; +"lng_admin_log_disallowed_unmute_self" = "{from} 已将新加入视频聊天的参与者静音"; +"lng_admin_log_disallowed_unmute_self_channel" = "{from} 已将直播的新参与者静音"; +"lng_admin_log_participant_volume" = "{from} 已将 {user} 的音量调整为 {percent}"; +"lng_admin_log_participant_volume_channel" = "{from} 已将 {user} 的直播音量改为 {percent}"; +"lng_admin_log_antispam_enabled" = "{from} 已启用主动反垃圾措施"; +"lng_admin_log_antispam_disabled" = "{from} 已停用主动反垃圾措施"; +"lng_admin_log_autotranslate_enabled" = "{from} 已启用自动翻译"; +"lng_admin_log_autotranslate_disabled" = "{from} 已禁用自动翻译"; +"lng_admin_log_change_color" = "{from} 已将频道颜色从 {previous} 更改为 {color}"; +"lng_admin_log_set_background_emoji" = "{from} 将背景表情符号设为 {emoji}"; +"lng_admin_log_change_background_emoji" = "{from} 已将频道的背景表情从 {previous} 更改为 {emoji}"; +"lng_admin_log_removed_background_emoji" = "{from} 已移除背景表情符号 {emoji}"; +"lng_admin_log_change_profile_color" = "{from} 将频道资料页的颜色从 {previous} 改为 {color}"; +"lng_admin_log_set_profile_background_emoji" = "{from} 将频道资料页的背景表情符号设置为 {emoji}"; +"lng_admin_log_change_profile_background_emoji" = "{from} 将频道资料页背景表情符号从 {previous} 更改为 {emoji}"; +"lng_admin_log_removed_profile_background_emoji" = "{from} 已删除频道资料页的背景表情符号 {emoji}"; +"lng_admin_log_change_profile_color_group" = "{from} 已将群组资料页颜色从 {previous} 更改为 {color}"; +"lng_admin_log_set_profile_background_emoji_group" = "{from} 已将群组资料页背景表情符号设为 {emoji}"; +"lng_admin_log_change_profile_background_emoji_group" = "{from} 已将群组资料页背景表情符号从 {previous} 更改为 {emoji}"; +"lng_admin_log_removed_profile_background_emoji_group" = "{from} 已移除群组资页料背景表情符号 {emoji}"; +"lng_admin_log_change_wallpaper" = "{from} 已更改频道壁纸"; +"lng_admin_log_set_status" = "{from} 将频道的表情符号状态设置为 {emoji}"; +"lng_admin_log_change_status" = "{from} 将频道的表情符号状态从 {previous} 改为 {emoji}"; +"lng_admin_log_removed_status" = "{from} 已移除频道的表情符号状态 {emoji}"; +"lng_admin_log_set_status_until" = "{from} 已将频道表情状态更改 {emoji} 直到 {date}"; +"lng_admin_log_change_status_until" = "{from} 已将频道表情状态由 {previous} 改为 {emoji},持续到 {date}"; +"lng_admin_log_user_with_username" = "{name} ({mention})"; +"lng_admin_log_messages_ttl_set" = "{from} 开启 {duration}后自动删除消息"; +"lng_admin_log_messages_ttl_changed" = "{from} 将消息的自动删除时间从 {previous} 改为 {duration}"; +"lng_admin_log_messages_ttl_removed" = "{from} 关闭 {duration}后自动删除消息"; +"lng_admin_log_reactions_disabled" = "{from} 已停用表情回应"; +"lng_admin_log_reactions_updated" = "{from} 已更新可用的回应: {emoji}"; +"lng_admin_log_reactions_allowed_all" = "{from} 开启全部表情回复"; +"lng_admin_log_reactions_allowed_official" = "{from} 允许所有官方的表情符号"; +"lng_admin_log_edited_invite_link" = "已编辑邀请链接 {link}"; +"lng_admin_log_invite_link_expire_date" = "过期时间:{previous} -> {limit}"; +"lng_admin_log_invite_link_usage_limit" = "用量限制:{previous} -> {limit}"; +"lng_admin_log_invite_link_label" = "名称: {previous} → {limit}"; +"lng_admin_log_invite_link_request_needed" = "加入请求需要管理员批准。"; +"lng_admin_log_invite_link_request_not_needed" = "加入不再需要管理员批准。"; +"lng_admin_log_topics_enabled" = "{from} 已启用话题"; +"lng_admin_log_topics_disabled" = "{from} 已停用话题"; +"lng_admin_log_topics_created" = "{from} 已创建话题 {topic}"; +"lng_admin_log_topics_changed" = "{from} 将话题 {topic} 更改为 {new_topic}"; +"lng_admin_log_topics_closed" = "{from} 已锁定话题 {topic}"; +"lng_admin_log_topics_reopened" = "{from} 已解锁话题 {topic}"; +"lng_admin_log_topics_hidden" = "{from} 已隐藏 {topic} 话题"; +"lng_admin_log_topics_unhidden" = "{from} 已取消隐藏话题 {topic}"; +"lng_admin_log_topics_deleted" = "{from} 已删除话题 {topic}"; +"lng_admin_log_topics_pinned" = "{from} 已置顶话题 {topic}"; +"lng_admin_log_topics_unpinned" = "{from} 已取消置顶话题 {topic}"; +"lng_admin_log_restricted_forever" = "直到永久"; +"lng_admin_log_restricted_until" = "直到 {date}"; +"lng_admin_log_banned_view_messages" = "查看消息"; +"lng_admin_log_banned_send_messages" = "发送消息"; +"lng_admin_log_banned_send_photos" = "发送照片"; +"lng_admin_log_banned_send_videos" = "发送视频文件"; +"lng_admin_log_banned_send_music" = "发送音频"; +"lng_admin_log_banned_send_files" = "发送文件"; +"lng_admin_log_banned_send_voice_messages" = "发送语音消息"; +"lng_admin_log_banned_send_video_messages" = "发送视频消息"; +"lng_admin_log_banned_send_stickers" = "发送贴纸和 GIF 动态图"; +"lng_admin_log_banned_embed_links" = "嵌入链接预览"; +"lng_admin_log_banned_send_polls" = "发起投票"; +"lng_admin_log_admin_change_info" = "修改信息"; +"lng_admin_log_admin_post_messages" = "发布消息"; +"lng_admin_log_admin_edit_messages" = "编辑消息"; +"lng_admin_log_admin_delete_messages" = "删除消息"; +"lng_admin_log_admin_post_stories" = "发布动态"; +"lng_admin_log_admin_edit_stories" = "编辑他人的动态"; +"lng_admin_log_admin_delete_stories" = "删除动态"; +"lng_admin_log_admin_remain_anonymous" = "保持匿名"; +"lng_admin_log_admin_ban_users" = "封禁用户"; +"lng_admin_log_admin_invite_users" = "添加用户"; +"lng_admin_log_admin_invite_link" = "通过链接邀请用户"; +"lng_admin_log_admin_pin_messages" = "置顶消息"; +"lng_admin_log_admin_manage_topics" = "管理话题"; +"lng_admin_log_admin_create_topics" = "创建话题"; +"lng_admin_log_admin_manage_calls" = "管理视频聊天"; +"lng_admin_log_admin_manage_calls_channel" = "直播管理"; +"lng_admin_log_admin_add_admins" = "添加新的管理员"; +"lng_admin_log_subscription_extend" = "{name} 已将订阅续订到 {date}"; + +"lng_admin_log_antispam_menu_report" = "申诉误报"; +"lng_admin_log_antispam_menu_report_toast" = "你可以在 {link} 中管理反垃圾设置。"; +"lng_admin_log_antispam_menu_report_toast_link" = "群组信息 > 管理员"; + +"lng_terms_signup" = "注册即代表您同意{link}。"; +"lng_terms_signup_link" = "服务条款"; +"lng_terms_header" = "服务条款"; + +"lng_terms_age#other" = "我确认我已满 {count} 周岁"; +"lng_terms_agree" = "同意并继续"; +"lng_terms_decline" = "拒绝"; +"lng_terms_signup_sorry" = "我们对此感到非常抱歉,但这也意味着您现在无法注册 Tidings。\n\n与其它软件不同,我们不会利用您的个人数据来导向广告或其它商业目的。Tidings 储存的所有文件都是为了给您创造一个功能丰富的云服务体验。 您可以在「隐私和安全」 里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您完全无法接受 Tidings 的云数据政策,我们将无法为您提供服务。"; +"lng_terms_update_sorry" = "我们对此感到非常抱歉,但是这意味着我们必须在此离别了。与其它软件不同,我们不会利用您的个人数据来进行广告定位或其它商业目的。Tidings 储存的所有文件都是为了给您创造一个功能丰富的云服务体验。您可以在「隐私和安全」设置里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您完全无法接受 Tidings 的云数据政策,我们将无法为您提供服务。您现在可以销毁您的帐号,或者再等一等,在完全确定无法接受我们的云数据政策之后再选择销毁帐号。"; +"lng_terms_decline_and_delete" = "拒绝并删除帐号"; +"lng_terms_back" = "返回"; +"lng_terms_delete_warning" = "警告,这将不可逆转地删除您的 Tidings 帐号以及所有储存在 Tidings 云服务器中的数据。\n\n注意:为保留个人数据,您可以先行取消,在导出数据并保存后,再删除您的帐号。(若要导出个人数据,请打开 Tidings 最新桌面版,并进入设置 > 高级 > 导出 Tidings 数据 。)"; +"lng_terms_delete_now" = "立即删除"; +"lng_terms_agree_to_proceed" = "请同意以继续使用 {bot} 。"; + +"lng_date_input_day" = "日"; +"lng_date_input_month" = "月"; +"lng_date_input_year" = "年"; + +"lng_forward_title" = "转发消息"; + +"lng_forward_many_title#other" = "转发 {count} 条消息"; +"lng_forward_about" = "您可以删除原发送者的名字,使这条消息看上去像是由您发出的。"; +"lng_forward_many_about" = "您可以删除发件人的姓名,使这些消息看起来像是由您所发送的,而非转发的。"; +"lng_forward_show_sender" = "显示发送者名称"; +"lng_forward_show_senders" = "显示发送者名称"; +"lng_forward_show_caption" = "显示说明文字"; +"lng_forward_show_captions" = "显示说明"; +"lng_forward_change_recipient" = "修改收件人"; +"lng_forward_sender_names_removed" = "已移除发送者名称"; +"lng_forward_header_short" = "转发"; +"lng_forward_action_show_sender" = "显示发送者名称"; +"lng_forward_action_show_senders" = "显示发送者名称"; +"lng_forward_action_hide_sender" = "隐藏发送者名称"; +"lng_forward_action_hide_senders" = "隐藏发送者名称"; +"lng_forward_action_show_caption" = "显示说明"; +"lng_forward_action_show_captions" = "显示说明"; +"lng_forward_action_hide_caption" = "隐藏说明"; +"lng_forward_action_hide_captions" = "隐藏说明"; +"lng_forward_action_change_recipient" = "修改收件人"; +"lng_forward_action_remove" = "取消转发"; + +"lng_passport_title" = "Tidings 通行证"; +"lng_passport_request1" = "{bot} 请求访问您的个人数据"; +"lng_passport_request2" = "以便为您提供服务"; +"lng_passport_create_password" = "请输入一个用于加密\n您的个人数据的密码"; +"lng_passport_about_password" = "您在新的设备上登录时\n也将需要输入这个密码。"; +"lng_passport_password_create" = "创建密码"; +"lng_passport_email_validate" = "验证"; +"lng_passport_code_sent" = "验证码已发送到 {email}"; +"lng_passport_stop_password_sure" = "您确定要关闭本次设置密码的流程吗?"; +"lng_passport_password_placeholder" = "你的密码"; +"lng_passport_next" = "下一步"; +"lng_passport_password_wrong" = "您输入的密码错误。"; +"lng_passport_header" = "所需信息"; +"lng_passport_identity_title" = "身份证件"; +"lng_passport_identity_description" = "上传您的身份证明"; +"lng_passport_identity_passport" = "护照"; +"lng_passport_identity_passport_upload" = "上传一份护照的扫描件"; +"lng_passport_identity_card" = "身份证"; +"lng_passport_identity_card_upload" = "上传一份身份证的扫描件"; +"lng_passport_identity_license" = "驾驶证"; +"lng_passport_identity_license_upload" = "上传一份驾驶证扫描件"; +"lng_passport_identity_internal" = "户口本(Internal Passport)"; +"lng_passport_identity_internal_upload" = "上传一份户口本(Internal Passport)的扫描件"; +"lng_passport_identity_about" = "这份证件必须包含您的照片、姓名、生日、证件号、签发国家、以及失效日期。"; +"lng_passport_address_title" = "住址"; +"lng_passport_address_description" = "上传您的地址证明"; +"lng_passport_address_bill" = "水电气费账单"; +"lng_passport_address_bill_upload" = "上传一份水电气费的扫描件"; +"lng_passport_address_statement" = "银行对账单"; +"lng_passport_address_statement_upload" = "上传一份银行对账单的扫描件"; +"lng_passport_address_agreement" = "租约"; +"lng_passport_address_agreement_upload" = "上传一份租约的扫描件"; +"lng_passport_address_registration" = "护照信息"; +"lng_passport_address_registration_upload" = "上传一份您护照里关于登记信息那一页的扫描件。"; +"lng_passport_address_temporary" = "临时证明信息"; +"lng_passport_address_temporary_upload" = "上传一份您临时证明的扫描件"; +"lng_passport_address_about" = "为了确认您的住址,请上传所选证件(全部页面)的扫描件或照片。"; +"lng_passport_or_title" = "{document}或{second_document}"; +"lng_passport_document_type" = "请选择文件类型:"; +"lng_passport_upload_document" = "上传文件"; +"lng_passport_phone_title" = "电话号码"; +"lng_passport_phone_description" = "输入您的手机号码"; +"lng_passport_email_title" = "电子邮箱"; +"lng_passport_email_description" = "输入您的电子邮箱地址"; +"lng_passport_identity_selfie" = "拍摄一张您拿着证件的自拍照"; +"lng_passport_translation_needed" = "添加您证件的英语翻译"; +"lng_passport_accept_allow" = "您同意 {policy} 隐私政策并允许 {bot} 给您发送消息。"; +"lng_passport_allow" = "您允许 {bot} 给您发送消息。"; +"lng_passport_policy" = "{bot} 隐私政策 "; +"lng_passport_authorize" = "授权"; +"lng_passport_form_error" = "无法获取授权表单。"; +"lng_passport_save_value" = "保存"; +"lng_passport_scan_index" = "扫描件 {index}"; +"lng_passport_upload_scans" = "上传扫描件"; +"lng_passport_upload_more" = "上传更多的扫描件"; +"lng_passport_selfie_title" = "自拍照"; +"lng_passport_selfie_description" = "上传一张您手持文件的照片。请保证您的 ID 与脸清晰可见。"; +"lng_passport_upload_selfie" = "上传自拍照"; +"lng_passport_reupload_selfie" = "重新上传自拍照"; +"lng_passport_front_side_title" = "正面"; +"lng_passport_front_side_description" = "上传证件正面照片。"; +"lng_passport_upload_front_side" = "上传一份文件正面的扫描件"; +"lng_passport_reupload_front_side" = "重新上传文件正面的扫描件"; +"lng_passport_reverse_side_title" = "反面"; +"lng_passport_reverse_side_description" = "上传证件反面照片。"; +"lng_passport_upload_reverse_side" = "上传一份文件反面的扫描件"; +"lng_passport_reupload_reverse_side" = "重新上传文件反面的扫描件"; +"lng_passport_main_page_title" = "文件主页"; +"lng_passport_main_page_description" = "上传证件第一页的扫描件。"; +"lng_passport_upload_main_page" = "上传一份文件主页的扫描件"; +"lng_passport_reupload_main_page" = "重新上传文件主页的扫描件"; +"lng_passport_personal_details" = "个人信息"; +"lng_passport_personal_details_enter" = "填写个人信息"; +"lng_passport_document_details" = "证件信息"; +"lng_passport_choose_image" = "选择扫描件"; +"lng_passport_delete_scan_undo" = "撤消"; +"lng_passport_scan_uploaded" = "上传于 {date}"; +"lng_passport_first_name" = "名字"; +"lng_passport_middle_name" = "中间名"; +"lng_passport_last_name" = "姓氏"; +"lng_passport_birth_date" = "出生日期"; +"lng_passport_gender" = "性别"; +"lng_passport_gender_male" = "男"; +"lng_passport_gender_female" = "女"; +"lng_passport_country" = "国籍"; +"lng_passport_residence_country" = "居住地"; +"lng_passport_country_choose" = "选择国家/地区"; +"lng_passport_document_number" = "证件号"; +"lng_passport_expiry_date" = "失效日期"; +"lng_passport_native_name_title" = "以您所居住的国家语言填写您的名字。"; +"lng_passport_native_name_about" = "以您所居住的国家({country})语言填写您的名字。"; +"lng_passport_native_name_language" = "您的{language}名"; +"lng_passport_native_name_language_about" = "以您所居住的国家语言填写您的名字。"; +"lng_passport_address" = "地址信息"; +"lng_passport_address_enter" = "请提供您的住址"; +"lng_passport_street" = "街道"; +"lng_passport_city" = "城市"; +"lng_passport_state" = "省份/州"; +"lng_passport_postcode" = "邮政编码"; +"lng_passport_translation" = "翻译"; +"lng_passport_use_existing" = "使用 {existing}"; +"lng_passport_use_existing_phone" = "使用当前 Tidings 帐号绑定的手机号。"; +"lng_passport_new_phone" = "或输入新的手机号码"; +"lng_passport_new_phone_code" = "注意:您将在您提供的手机号码上收到一份验证码。"; +"lng_passport_use_existing_email" = "使用当前 Tidings 帐号中设定的电子邮箱。"; +"lng_passport_new_email" = "或者输入新的电子邮箱地址"; +"lng_passport_new_email_code" = "注意:您所提供的电子邮箱将会收到一份验证码。"; +"lng_passport_confirm_phone" = "我们已经给您的手机号码({phone})发送了一条带有验证码的短信。"; +"lng_passport_confirm_email" = "我们已经向电子邮箱({email})发送了一条带有验证码的邮件。"; +"lng_passport_sure_cancel" = "如果继续,您作出的更改将会丢失。"; +"lng_passport_scans_limit_reached" = "抱歉,单份证件的扫描件过多。"; +"lng_passport_delete_document" = "删除证件"; +"lng_passport_delete_document_sure" = "您确定要删除此证件吗?"; +"lng_passport_delete_details" = "删除个人信息"; +"lng_passport_delete_details_sure" = "您确定要删除个人信息吗?"; +"lng_passport_delete_address" = "删除住址信息"; +"lng_passport_delete_address_sure" = "您确定要删除您的地址信息吗?"; +"lng_passport_delete_email" = "删除电子邮箱?"; +"lng_passport_delete_email_sure" = "您确定要删除您的电子邮箱地址吗?"; +"lng_passport_delete_phone" = "删除手机号码?"; +"lng_passport_delete_phone_sure" = "您确定要删除您的手机号码吗?"; +"lng_passport_success" = "已成功授权!"; +"lng_passport_stop_sure" = "您确定要停止本次授权吗?"; +"lng_passport_stop" = "停止"; +"lng_passport_restart_sure" = "出现了意外错误。也许有人在另外的一个 Tidings 应用中作出了改动。您希望重新启动授权程序吗?"; +"lng_passport_restart" = "重启"; +"lng_passport_error_too_large" = "抱歉,此文件过大。"; +"lng_passport_error_bad_size" = "抱歉,此图片的大小不符合要求。"; +"lng_passport_error_cant_read" = "无法识别此文件。请选择「图片」上传。"; +"lng_passport_bad_name" = "只能使用拉丁字母。"; +"lng_passport_wait_upload" = "请等待文件上传完毕。"; +"lng_passport_app_out_of_date" = "抱歉,您的 Tidings 版本太低,无法处理此请求。 请升级 Tidings。"; + +"lng_export_title" = "导出你的数据"; +"lng_export_progress_title" = "正在导出您的数据"; +"lng_export_option_info" = "帐号信息"; +"lng_export_option_info_about" = "您设定的姓名、用户名、手机号码与头像。"; +"lng_export_option_contacts" = "联系人列表"; +"lng_export_option_contacts_about" = "如果您授权 Tidings,您的联系人信息将会被即时同步到 Tidings 云服务器。您可以在 Tidings 移动版应用中的设置项「隐私和安全」中修改此项设定。"; +"lng_export_option_stories" = "动态归档"; +"lng_export_option_stories_about" = "您从 Tidings 移动应用发布的所有动态。"; +"lng_export_option_sessions" = "活跃会话"; +"lng_export_option_sessions_about" = "我们可能会存储这些信息以便您可以通过“设置 - 隐私与安全 - 显示所有会话”查看您已登录的设备。"; +"lng_export_header_other" = "其它"; +"lng_export_option_other" = "其它数据"; +"lng_export_option_other_about" = "其它未在上方选项中提到的数据(测试)。"; +"lng_export_header_chats" = "历史记录导出设置"; +"lng_export_option_personal_chats" = "私聊"; +"lng_export_option_bot_chats" = "机器人对话"; +"lng_export_option_private_groups" = "私密群组"; +"lng_export_option_private_channels" = "私密频道"; +"lng_export_option_public_groups" = "公开群组"; +"lng_export_option_public_channels" = "公开频道"; +"lng_export_option_only_my" = "只导出我的消息"; +"lng_export_header_media" = "媒体文件导出设置"; +"lng_export_option_photos" = "图片"; +"lng_export_option_video_files" = "视频文件"; +"lng_export_option_voice_messages" = "语音消息"; +"lng_export_option_video_messages" = "视频消息"; +"lng_export_option_stickers" = "贴纸"; +"lng_export_option_gifs" = "GIF 动态图"; +"lng_export_option_files" = "文件"; +"lng_export_option_size_limit" = "大小限制:{size}"; +"lng_export_header_format" = "保存路径/格式"; +"lng_export_option_location" = "下载路径:{path}"; +"lng_export_option_format_location" = "格式: {format} ,路径: {path}"; +"lng_export_option_choose_format" = "选择导出格式"; +"lng_export_option_html" = "人类可读的 HTML"; +"lng_export_option_json" = "机器可读的 JSON"; +"lng_export_option_html_and_json" = "以上两者"; +"lng_export_limits" = "从:{from}, 到:{till}"; +"lng_export_beginning" = "最早的消息"; +"lng_export_end" = "现在"; +"lng_export_from_beginning" = "重置"; +"lng_export_till_end" = "重置"; +"lng_export_start" = "导出"; +"lng_export_state_initializing" = "正在初始化…"; +"lng_export_state_userpics" = "头像"; +"lng_export_state_chats_list" = "处理对话记录中…"; +"lng_export_state_chats" = "对话"; +"lng_export_skip_file" = "跳过文件"; +"lng_export_progress" = "您可以现在关闭此窗口,但请不要在数据导出结束之前退出 Tidings。"; +"lng_export_stop" = "停止"; +"lng_export_sure_stop" = "您确定要停止导出您的数据吗?\n\n若您确定的话,下次导出将需要重新开始。"; +"lng_export_about_done" = "已成功导出您的数据。"; +"lng_export_done" = "查看我的数据"; +"lng_export_finished" = "数据导出完成"; +"lng_export_total_amount" = "总计文件: {amount}"; +"lng_export_total_size" = "总大小:{size}"; +"lng_export_folder" = "选择导出文件夹"; +"lng_export_invalid" = "抱歉,您已经开始进行新的数据导出流程,所以本次导出已被终止。"; +"lng_export_delay" = "出于安全考虑,您将能在 {hours}之后开始下载您的数据。我们已经给您的所有设备发送了有关本次导出请求的通知,以保证这是您本人授权。\n\n请在 {date} 后使用同一设备再次请求导出数据。"; +"lng_export_delay_less_than_hour" = "少于一小时"; +"lng_export_suggest_title" = "数据导出已就绪"; +"lng_export_suggest_text" = "您现在可以下载您之前请求导出的数据。开始导出吗?"; +"lng_export_suggest_cancel" = "稍后"; +"lng_export_about_Tidings" = "这是您请求导出的数据。请记住:Tidings 没有任何广告,我们也不会讲您的数据用于广告导向用途,更不会出售您的数据。Tidings 只存储一部分信息以提供功能丰富且安全的云服务。\n\n前往 Tidings 移动版应用中的「设置 - 隐私与安全」查看相应设置。"; +"lng_export_about_contacts" = "如果您授权 Tidings,您的联系人信息将会被即时同步到 Tidings 云服务器。正因如此,您可以在任何设备中通过 Tidings 维系您原来的社交圈。我们将这些信息用于在您的联系人加入 Tidings 时通知您,同时这些信息也将用来保证您看到的是联系人在通讯录中的名字,而不是他们在 Tidings 中的填写的姓名。\n\n您可以停止同步联系人信息,或将它们从云端删除。要如此做,请前往 Tidings 移动版应用中的设置项「隐私和安全」。"; +"lng_export_about_frequent" = "这个评级显示了您经常联系的用户。Tidings 使用此数据来填入搜索栏顶部的“用户”部分。这个评级也会考虑您使用消息框内机器人的频率,这样 Tidings 可以在附件菜单给您推荐您最可能使用的机器人(或者您在文本首位打出“@”时给您推荐)。\n\n若要删除此数据,前往「设置 - 隐私和安全」并将「推荐常用联系人」设为关闭(需要 Tidings iOS v.4.8.3 / Tidings Android v.4.8.10 或更高版本)。"; +"lng_export_about_sessions" = "我们存储会话信息以便您可以通过「设置 - 隐私与安全 - 活跃会话」查看您已登录的设备。"; +"lng_export_about_web_sessions" = "我们存储这些信息以便您可以通过「设置 - 隐私与安全 - 活跃会话」查看您使用 Tidings 登录的网站。"; +"lng_export_about_chats" = "此页面列出了本次导出时所有的历史聊天记录。"; +"lng_export_about_left_chats" = "以下是在本次导出时,您已退出或被封禁的所有超级群组和频道。\n\n请注意,在退出您创建的超级群组/频道时,您可以选择将其删除,或选择仅退出(如此您便可以在之后重新加入,或者只是退出以使其社群继续存在)。"; + +"lng_language_switch_title" = "是否更改语言?"; +"lng_language_switch_about_official" = "您即将使用一个完成度为 {percent}% 的自定义语言包 ({lang_name})。\n\n此操作将会应用到整个界面。您可以在 {link} 建议更正。\n\n您可以随时在“设置”中更改语言。"; +"lng_language_switch_about_unofficial" = "您即将使用一个完成度为 {percent}% 的自定义语言包 ({lang_name})。\n\n此操作将会应用到整个界面。您可以在 {link} 建议更正。\n\n您可以随时在“设置”中更改语言。"; +"lng_language_switch_link" = "翻译平台"; +"lng_language_switch_apply" = "应用语言"; +"lng_language_not_found" = "抱歉,该语言包不存在。"; +"lng_language_already" = "您已经在使用此语言包。 您随时可以在“设置”中更改语言。"; +"lng_language_not_ready_title" = "数据不足"; +"lng_language_not_ready_about" = "很遗憾,这个自定义语言包 {lang_name} 不包含Tidings 桌面版的数据。您可以使用这个链接 {link} 为此语言包做出贡献。"; +"lng_language_not_ready_link" = "翻译平台"; + +"lng_translate_box_error" = "翻译失败。"; + +"lng_translate_settings_subtitle" = "翻译消息"; +"lng_translate_settings_show" = "显示 “翻译” 按钮"; +"lng_translate_settings_chat" = "翻译全部聊天记录"; +"lng_translate_settings_choose" = "不用翻译"; +"lng_translate_settings_about" = "当你打开文本的菜单時,便会出现 '翻译' 按钮。"; +"lng_translate_settings_one" = "请至少选择一种语言,以便将其设为“翻译为”的语言"; + +"lng_launch_exe_warning" = "此文件的扩展名为 {extension}。\n它可能会损害您的计算机。\n你确定要运行吗?"; +"lng_launch_other_warning" = "这个文件的拓展名为 {extension}。\n你确定要打开吗?"; +"lng_launch_svg_warning" = "\n打开此文件可能会将您的IP地址暴露给发送者。您确定继续吗?"; +"lng_launch_exe_sure" = "运行"; +"lng_launch_other_sure" = "打开"; +"lng_launch_exe_dont_ask" = "不要再次询问我"; +"lng_launch_dont_ask" = "记住该文件类型"; +"lng_launch_dont_ask_settings" = "您稍后可以在设置 > 隐私和安全 > 文件打开确认中编辑受信任的文件类型。"; + +"lng_polls_anonymous" = "匿名投票"; +"lng_polls_public" = "投票"; +"lng_polls_anonymous_quiz" = "匿名问答"; +"lng_polls_public_quiz" = "问答"; +"lng_polls_closed" = "最终结果"; + +"lng_polls_votes_count#other" = "{count} 人投票"; +"lng_polls_votes_none" = "无人投票"; + +"lng_polls_answers_count#other" = "{count} 个回答"; +"lng_polls_answers_none" = "无人回答"; +"lng_polls_submit_votes" = "提交投票"; +"lng_polls_view_results" = "查看结果"; +"lng_polls_retract" = "撤回投票"; +"lng_polls_stop" = "停止投票"; +"lng_polls_stop_warning" = "如果您现在停止这个投票,任何人都不能再进行投票了。 此操作无法撤消。"; +"lng_polls_stop_sure" = "停止"; +"lng_polls_create" = "发起投票"; +"lng_polls_create_title" = "发起投票"; +"lng_polls_create_question" = "问题"; +"lng_polls_create_question_placeholder" = "请输入问题"; +"lng_polls_create_options" = "投票选项"; +"lng_polls_create_option_add" = "添加一个选项…"; + +"lng_polls_create_limit#other" = "您还可以添加 {count} 个选项。"; +"lng_polls_create_maximum" = "您添加的选项数量已经达到上限。"; +"lng_polls_create_settings" = "设置"; +"lng_polls_create_anonymous" = "匿名投票"; +"lng_polls_create_multiple_choice" = "多项选择"; +"lng_polls_create_quiz_mode" = "问答模式"; +"lng_polls_create_button" = "创建"; +"lng_polls_create_one_answer" = "问答模式下的投票只能有一个正确答案。"; +"lng_polls_choose_question" = "请输入一个问题。"; +"lng_polls_choose_answers" = "请输入至少两个选项。"; +"lng_polls_choose_correct" = "请选择正确答案。"; +"lng_polls_solution_title" = "注释"; +"lng_polls_solution_placeholder" = "添加评论(可选)"; +"lng_polls_solution_about" = "选错的用户将会看到这段文字,因此非常适合教育用途。"; + +"lng_polls_poll_results_title" = "投票结果"; +"lng_polls_quiz_results_title" = "问答结果"; + +"lng_polls_show_more#other" = "显示更多 ({count})"; +"lng_polls_votes_collapse" = "折叠"; + +"lng_outdated_title" = "**请更新您的操作系统。**"; +"lng_outdated_title_bits" = "请切换至64位操作系统。"; +"lng_outdated_soon" = "否则,Tidings Desktop 将在 {date} 停止更新。"; +"lng_outdated_now" = "以便 Tidings Desktop 可以更新到最新版本。"; + +"lng_filters_all" = "所有聊天"; +"lng_filters_all_short" = "全部"; +"lng_filters_setup" = "编辑"; +"lng_filters_title" = "文件夹"; +"lng_filters_subtitle" = "我的文件夹"; +"lng_filters_no_chats" = "无聊天"; + +"lng_filters_chats_count#other" = "{count} 个聊天"; +"lng_filters_create" = "创建新文件夹"; +"lng_filters_about" = "为不同聊天分组创建文件夹,并在它们之间快速切换。"; +"lng_filters_recommended" = "推荐的文件夹"; +"lng_filters_recommended_add" = "添加"; +"lng_filters_restore" = "撤销"; +"lng_filters_new" = "新文件夹"; +"lng_filters_edit" = "编辑文件夹"; +"lng_filters_setup_menu" = "编辑文件夹"; +"lng_filters_new_name" = "文件夹名称"; +"lng_filters_enable_animations" = "启用动画"; +"lng_filters_disable_animations" = "禁用动画"; +"lng_filters_add_chats" = "添加聊天"; +"lng_filters_remove_chats" = "添加以排除聊天"; +"lng_filters_include" = "包含的聊天"; +"lng_filters_include_about" = "选择将出现在此文件夹中的聊天和聊天类型。"; +"lng_filters_exclude" = "排除的聊天"; +"lng_filters_exclude_about" = "选择不会出现在此文件夹中的聊天或聊天类型。"; +"lng_filters_create_button" = "创建"; +"lng_filters_include_title" = "包含的对话"; +"lng_filters_exclude_title" = "排除对话"; +"lng_filters_edit_types" = "聊天类型"; +"lng_filters_edit_chats" = "聊天"; +"lng_filters_include_contacts" = "联系人"; +"lng_filters_include_groups" = "群组"; +"lng_filters_include_channels" = "频道"; +"lng_filters_include_bots" = "机器人"; +"lng_filters_name_people" = "人"; +"lng_filters_name_unread" = "未读"; +"lng_filters_name_unmuted" = "未静音"; +"lng_filters_empty" = "请至少为此文件夹添加一个聊天。"; +"lng_filters_default" = "请为此文件夹更改至少一条规则。"; +"lng_filters_type_contacts" = "联系人"; +"lng_filters_type_non_contacts" = "非联系人"; +"lng_filters_type_groups" = "群组"; +"lng_filters_type_channels" = "频道"; +"lng_filters_type_new" = "新聊天"; +"lng_filters_type_existing" = "现有聊天"; +"lng_filters_type_bots" = "机器人"; +"lng_filters_type_no_archived" = "归档"; +"lng_filters_type_no_muted" = "已静音"; +"lng_filters_type_no_read" = "已读"; +"lng_filters_icon_header" = "选择一个图标"; +"lng_filters_context_edit" = "编辑文件夹"; +"lng_filters_context_remove" = "移除"; +"lng_filters_remove_sure" = "即将移除该分组,您的对话将不会被删除。"; +"lng_filters_remove_yes" = "移除"; +"lng_filters_menu_add" = "添加至文件夹"; +"lng_filters_toast_add" = "{folder} 已成功添加到{chat}"; +"lng_filters_toast_remove" = "已从{folder}中移除{chat}"; +"lng_filters_shareable_status" = "可共享的文件夹"; +"lng_filters_view_subtitle" = "标签预览"; +"lng_filters_vertical" = "移到左边"; +"lng_filters_horizontal" = "将分组显示在顶部"; +"lng_filters_enable_tags" = "显示文件夹标签"; +"lng_filters_enable_tags_about" = "显示聊天列表中每个聊天文件夹名称。"; +"lng_filters_enable_tags_about_premium" = "订阅 **{link}** 以显示聊天列表中每个聊天的文件夹名称。"; +"lng_filters_tag_color_subtitle" = "聊天列表中的文件夹颜色"; +"lng_filters_tag_color_about" = "为此文件夹的标签选择颜色。"; +"lng_filters_tag_color_no" = "无标签"; + +"lng_filters_delete_sure" = "您确定要删除此文件夹吗?这将同时停用所有用来分享此文件夹的邀请链接。"; +"lng_filters_link" = "分享文件夹"; +"lng_filters_link_has" = "邀请链接"; + +"lng_filters_checkbox_remove_bot" = "从所有的文件夹里移除机器人"; +"lng_filters_checkbox_remove_group" = "从所有的文件夹里移除群组"; +"lng_filters_checkbox_remove_channel" = "从所有的文件夹里移除频道"; + +"lng_filters_link_create" = "创建邀请链接"; +"lng_filters_link_cant" = "您不能分享包含特定聊天类别(比如“群组”、“联系人”等)的分组。"; +"lng_filters_link_about" = "与其他人分享此文件夹中的群组和频道的访问权。"; +"lng_filters_link_about_many" = "创建更多链接,为不同的人设置不同的访问权限。"; +"lng_filters_link_title" = "分享文件夹"; +"lng_filters_link_share_about" = "获得此链接的所有人都可以添加 {folder} 文件夹并加入以下选择的聊天。"; +"lng_filters_link_subtitle" = "邀请链接"; +"lng_filters_link_chats_none" = "没有选择任何聊天"; + +"lng_filters_link_chats#other" = "已选择 {count} 个聊天"; +"lng_filters_link_bot_status" = "您无法分享与机器人的聊天"; +"lng_filters_link_bot_error" = "与机器人的聊天无法共享。"; +"lng_filters_link_private_status" = "您无法分享私聊"; +"lng_filters_link_private_error" = "无法分享私聊。"; +"lng_filters_link_noadmin_status" = "你不能在这里邀请用户"; +"lng_filters_link_noadmin_group_error" = "您没有管理员权限,无法分享此私密群组的邀请链接。"; +"lng_filters_link_noadmin_channel_error" = "您没有管理员权限,无法分享此私密频道的邀请链接。"; +"lng_filters_link_already_group" = "您已经加入此群组"; +"lng_filters_link_already_channel" = "您已经订阅此频道"; +"lng_filters_link_inaccessible" = "此聊天无法访问"; +"lng_filters_link_chats_about" = "选择所有通过邀请链接添加文件夹的用户可以加入的群组和频道。"; +"lng_filters_link_no_about" = "该文件夹中没有您可以与他人分享的聊天。"; +"lng_filters_link_chats_no" = "无法分享这些聊天"; +"lng_filters_link_chats_no_about" = "您只能分享允许您创建邀请链接的群组和频道。"; +"lng_filters_link_name_it" = "为此链接命名"; +"lng_filters_link_delete_sure" = "你确定想要删除此链接吗?"; +"lng_filters_link_qr_about" = "Tidings 上的所有人都可以扫描此二维码以添加此文件夹,并加入此邀请链接中包含的所有聊天。"; +"lng_filters_link_group_admin_error" = "无法将其中一个群组添加至文件夹中,因为其中一位管理员已加入过多群组和频道。"; +"lng_filters_by_link_title" = "添加文件夹"; +"lng_filters_by_link_sure" = "您是否想要创建名为 {folder} 的文件夹, 并添加其群组和频道?"; + +"lng_filters_by_link_join#other" = "将加入 {count} 个聊天"; +"lng_filters_by_link_add_button" = "添加 {folder}"; +"lng_filters_by_link_add_no" = "无需添加此文件夹"; +"lng_filters_by_link_more" = "将聊天添加到文件夹"; +"lng_filters_by_link_more_sure" = "您是否要加入聊天,并将其添加至 {folder} 文件夹?"; +"lng_filters_by_link_about" = "您可以取消选择不想加入的聊天。"; + +"lng_filters_by_link_and_join_button#other" = "加入聊天"; +"lng_filters_by_link_join_no" = "不加入任何聊天"; +"lng_filters_by_link_already" = "文件夹已添加"; +"lng_filters_by_link_already_about" = "您已添加 {folder} 文件夹以及其中的所有聊天。"; + +"lng_filters_by_link_in#other" = "此文件夹中的 {count} 个聊天"; +"lng_filters_by_link_remove" = "移除文件夹"; +"lng_filters_by_link_remove_sure" = "您是否也要同时退出 {folder} 中包括的所有聊天?"; + +"lng_filters_by_link_quit#other" = "即将退出 {count} 个聊天"; +"lng_filters_by_link_select" = "全选"; +"lng_filters_by_link_deselect" = "取消全选"; +"lng_filters_by_link_about_quit" = "你可以取消选择不想退出的聊天。"; +"lng_filters_by_link_remove_button" = "移除文件夹并保留聊天"; + +"lng_filters_by_link_and_quit_button#other" = "移除文件夹和聊天"; +"lng_filters_added_title" = "已添加 {folder} 文件夹"; + +"lng_filters_added_also#other" = "您还加入了 {count} 个聊天。"; +"lng_filters_updated_title" = " {folder} 文件夹已更新"; + +"lng_filters_updated_also#other" = "您已加入 {count} 个新聊天。"; + +"lng_filters_bar_you_can#other" = "您可以加入 {count} 个新聊天"; + +"lng_filters_bar_view#other" = "点击此处查看"; + +"lng_chat_theme_change" = "更换主题颜色"; +"lng_chat_theme_wallpaper" = "设置壁纸"; +"lng_chat_theme_none" = "无主题"; +"lng_chat_theme_apply" = "应用主题"; +"lng_chat_theme_change_wallpaper" = "更改壁纸"; +"lng_chat_theme_title" = "选择主题"; +"lng_chat_theme_cant_voice" = "抱歉,当有未发送的语音消息时您无法更改主题。"; + +"lng_photo_editor_menu_delete" = "删除"; +"lng_photo_editor_menu_flip" = "翻转"; +"lng_photo_editor_menu_duplicate" = "复制"; + +"lng_voice_speed_slow" = "慢"; +"lng_voice_speed_normal" = "正常"; +"lng_voice_speed_medium" = "中速"; +"lng_voice_speed_fast" = "快"; +"lng_voice_speed_very_fast" = "非常快"; +"lng_voice_speed_super_fast" = "超级快"; + +"lng_view_button_user" = "发送信息"; +"lng_view_button_bot" = "查看机器人"; +"lng_view_button_group" = "查看群组"; +"lng_view_button_channel" = "查看频道"; +"lng_view_button_bot_app" = "打开应用"; +"lng_view_button_background" = "查看壁纸"; +"lng_view_button_theme" = "预览主题"; +"lng_view_button_message" = "查看消息"; +"lng_view_button_story" = "查看动态"; +"lng_view_button_voice_chat" = "视频聊天"; +"lng_view_button_voice_chat_channel" = "直播"; +"lng_view_button_request_join" = "请求加入"; +"lng_view_button_external_link" = "打开链接"; +"lng_view_button_boost" = "助推"; +"lng_view_button_giftcode" = "打开"; +"lng_view_button_iv" = "即时预览"; +"lng_view_button_stickerset" = "查看贴纸"; +"lng_view_button_emojipack" = "查看表情"; +"lng_view_button_collectible" = "查看典藏品"; +"lng_view_button_call" = "加入群组通话"; + +"lng_sponsored_hide_ads" = "隐藏"; +"lng_sponsored_title" = "什么是赞助消息?"; +"lng_sponsored_info_description1" = "与其他软件不同,Tidings 不会利用您的隐私来显示个性化的广告。赞助消息的投放完全基于公开频道的内容。这意味着 Tidings 不会通过挖掘或分析用户信息来显示广告,并且公开频道的所有订阅者都将显示完全相同的赞助消息。\n\n与其他软件不同,Tidings 不会追踪您是否点击赞助消息,更不会基于您的活动信息生成用户画像。与此同时,为了避免来自第三方的窥探,我们还禁止向赞助消息中插入外部链接。我们相信每个人都有保护隐私的权利,而科技平台应该尊重用户的隐私权。\n\nTidings 向数以亿计的用户提供免费服务的同时,也需要承担由此产生的高昂运营成本,例如服务器和流量开销。为了将 Tidings 的独立自由和价值观延续下去,我们将用户隐私权谨记于心,开发了这套付费赞助消息系统。欢迎有责任的广告商与我们取得联系:"; +"lng_sponsored_info_description2" = "赞助商消息目前仍然处于测试阶段。全面启动赞助商消息系统,并且 Tidings 能够平衡运营成本之后,我们将与显示赞助商消息的频道所有者共同分享广告收入。\n\n线上广告不应再是滥用用户隐私的代名词。让我们共同携手,重新定义科技公司的运营模式。"; +"lng_sponsored_info_menu" = "广告商信息"; +"lng_sponsored_info_submenu" = "广告商:{text}"; +"lng_sponsored_menu_revenued_about" = "关于此广告"; +"lng_sponsored_menu_revenued_report" = "举报广告"; +"lng_sponsored_revenued_subtitle" = "Tidings 广告与其他平台上的广告大有不同。这类广告:"; +"lng_sponsored_revenued_info1_title" = "尊重您的隐私"; +"lng_sponsored_revenued_info1_description" = "Tidings 上的广告不会使用您的个人信息,而是基于您所在的频道来展示的。"; +"lng_sponsored_revenued_info1_bot_description" = "Tidings 上的广告不会使用您的个人资料,而是仅基于您看到广告的迷你应用内容。"; +"lng_sponsored_revenued_info1_search_description" = "此类广告不会使用您的个人信息,仅基于您输入的搜索关键词显示。"; +"lng_sponsored_revenued_info2_title" = "帮助频道创作者"; +"lng_sponsored_revenued_info2_bot_title" = "帮助机器人开发者"; +"lng_sponsored_revenued_info2_description" = "Tidings 广告收入的 50% 将归广告频道的所有者所有。"; +"lng_sponsored_revenued_info2_bot_description" = " 广告收入的 50% 将归广告机器人的开发者所有。"; +"lng_sponsored_revenued_info3_title" = "可以移除"; + +"lng_sponsored_revenued_info3_description#other" = "您可以通过订阅 {link} 关闭广告,而等级为 {count} 的频道可以为其订阅者移除这些广告。"; +"lng_sponsored_revenued_info3_bot_description" = "您可以通过订阅 {link} 关闭广告。"; +"lng_sponsored_revenued_info3_search_description" = "您可以通过订阅 Tidings 高级版来关闭广告。 {link}"; +"lng_sponsored_revenued_info3_search_link" = "立即订阅 {arrow}"; +"lng_sponsored_revenued_footer_title" = "我可以投放广告吗?"; +"lng_sponsored_revenued_footer_description" = "任何人都可以用极低的预算在这个频道投放广告。详情请查看 **Tidings 广告平台**。{link}"; +"lng_sponsored_revenued_footer_bot_description" = "任何人都可以创建广告并在此机器人中展示——只需最小花费。请查看 **Tidings 广告平台** 了解详情。{link}"; +"lng_sponsored_revenued_footer_search_description" = "任何人都可以创建广告并在任何搜索查询的结果中显示,查看 **Tidings 广告平台** 了解详情。{link}"; +"lng_sponsored_top_bar_hide" = "移除"; + +"lng_tidings_features_url" = "https://tidings.me/tidingsTips"; + +"lng_mini_apps_disclaimer_title" = "警告"; +"lng_mini_apps_disclaimer_text" = "您即将使用一个 **不属于 Tidings** 的独立第三方小程序。您必须同意小程序使用条款后才能继续。"; +"lng_mini_apps_disclaimer_button" = "我同意 {link}"; +"lng_mini_apps_disclaimer_link" = "使用条款"; +"lng_mini_apps_tos_url" = "https://tidings.org/tos/mini-apps"; + +"lng_ringtones_box_title" = "通知铃声"; +"lng_ringtones_box_cloud_subtitle" = "选择你喜欢的铃声"; +"lng_ringtones_box_upload_choose" = "选择铃声文件"; +"lng_ringtones_box_upload_button" = "上传铃声"; +"lng_ringtones_box_about" = "在任意聊天中右击简短语音消息图标或 MP3 文件,然后选择 “保存为通知铃声” 后,就会出现在这里。"; +"lng_ringtones_box_default" = "默认"; +"lng_ringtones_box_no_sound" = "静音"; +"lng_ringtones_toast_added" = "声音已添加!"; +"lng_ringtones_error_max_size" = "抱歉,您的文件太大了,铃声最大为 {size}。"; +"lng_ringtones_error_max_duration" = "抱歉,铃声时长最多为 {duration}。"; + +"lng_forum_topic_new" = "新建话题"; +"lng_forum_topic_edit" = "编辑话题"; +"lng_forum_topic_title" = "话题名称"; +"lng_forum_topic_close" = "锁定话题"; +"lng_forum_topic_reopen" = "解锁话题"; +"lng_forum_topic_closed" = "该话题现在处于关闭状态。"; +"lng_forum_topic_delete" = "删除"; +"lng_forum_topic_delete_sure" = "您确定要删除该话题吗?"; +"lng_forum_topic_created_title_my" = "差不多完成!"; +"lng_forum_topic_created_body_my" = "发送第一条消息以启动此话题。"; +"lng_forum_topic_created_title" = "话题已开始!"; +"lng_forum_topic_created_body" = "发送一条消息\n以开始话题。"; +"lng_forum_topics_switch" = "话题"; + +"lng_forum_topics_not_enough#other" = "只有超过 **{count}人** 的群组才能启用话题功能。"; +"lng_forum_topics_no_discussion" = "目前无法在频道的讨论群组中使用话题。"; +"lng_forum_choose_title_and_icon" = "请选择话题名称与图标"; +"lng_forum_replies_only" = "您可以在话题里回复消息。"; +"lng_forum_message_in" = "{topic} 中的消息"; +"lng_forum_reply_in" = "在 {topic} 回复"; +"lng_forum_no_topics" = "此群组中目前没有创建话题。"; +"lng_forum_create_topic" = "创建话题"; +"lng_forum_discard_sure" = "您确定要放弃该话题吗?"; +"lng_forum_view_as_messages" = "以消息模式查看"; +"lng_forum_view_as_topics" = "以话题模式查看"; +"lng_forum_no_messages" = "暂无消息"; + +"lng_forum_messages#other" = "{count} 条消息"; +"lng_forum_show_topics_list" = "展示话题列表"; + +"lng_request_peer_requirements" = "要求"; +"lng_request_peer_rights" = "您必须拥有以下管理员权限:{rights}"; +"lng_request_peer_rights_and" = "{rights} 和 {last}"; +"lng_request_peer_confirm" = "您确定要将 {chat} 发送至 {bot} 吗?"; +"lng_request_peer_confirm_add" = "这也会将把 {bot} 添加到 {chat} 中。"; +"lng_request_peer_confirm_rights" = "这也会将 {bot} 加入到 {chat} 中,并具有以下权限:{rights}。"; +"lng_request_peer_confirm_send" = "发送"; +"lng_request_user_title" = "选择用户"; +"lng_request_users_title" = "选择用户"; +"lng_request_user_premium_yes" = "用户必须拥有 Tidings 高级版订阅。"; +"lng_request_user_premium_no" = "用户不能拥有 Tidings 高级版订阅。"; +"lng_request_user_no" = "没有符合条件的用户"; +"lng_request_user_no_about" = "未找到符合此机器人要求的用户。"; +"lng_request_bot_title" = "选择机器人"; +"lng_request_bot_no" = "无机器人"; +"lng_request_bot_no_about" = "您还没有机器人账号。"; +"lng_request_group_title" = "选择群组"; +"lng_request_group_no" = "没有符合条件的群组"; +"lng_request_group_no_about" = "您没有符合此机器人要求的群组。"; +"lng_request_group_public_yes" = "该群组必须是公开的。"; +"lng_request_group_public_no" = "此群组必须是私密群组。"; +"lng_request_group_topics_yes" = "群组必须开启话题功能。"; +"lng_request_group_topics_no" = "群组必须关闭话题功能。"; +"lng_request_group_am_owner" = "您必须是群组的所有者。"; +"lng_request_group_change_info" = "更改群组信息"; +"lng_request_group_delete_messages" = "删除消息"; +"lng_request_group_ban_users" = "封禁用户"; +"lng_request_group_invite" = "通过链接邀请用户"; +"lng_request_group_pin_messages" = "置顶消息"; +"lng_request_group_manage_topics" = "管理话题"; +"lng_request_group_manage_video_chats" = "管理视频聊天"; +"lng_request_group_anonymous" = "保持匿名"; +"lng_request_group_add_admins" = "新增管理员"; +"lng_request_group_create" = "为此创建一个新群组"; +"lng_request_channel_title" = "选择频道"; +"lng_request_channel_no" = "没有符合条件的频道"; +"lng_request_channel_no_about" = "您没有符合此机器人要求的频道。"; +"lng_request_channel_public_yes" = "频道必须为公开状态。"; +"lng_request_channel_public_no" = "频道必须为私密状态。"; +"lng_request_channel_am_owner" = "你必须是频道的拥有者。"; +"lng_request_channel_change_info" = "更改频道信息"; +"lng_request_channel_post_messages" = "发布消息"; +"lng_request_channel_edit_messages" = "编辑其用户的消息"; +"lng_request_channel_delete_messages" = "删除消息"; +"lng_request_channel_add_subscribers" = "添加订阅者"; +"lng_request_channel_manage_livestreams" = "管理直播"; +"lng_request_channel_add_admins" = "新增管理员"; +"lng_request_channel_create" = "为此创建新频道"; + +"lng_userpic_builder_color_subtitle" = "选择背景"; +"lng_userpic_builder_emoji_subtitle" = "选择表情或贴纸"; + +"lng_stories_my_name" = "我的动态"; +"lng_stories_archive" = "隐藏动态"; +"lng_stories_unarchive" = "取消隐藏动态"; + +"lng_stories_row_count#other" = "{count} 条动态"; + +"lng_stories_views#other" = "{count} 次浏览"; +"lng_stories_no_views" = "还没有人看过"; +"lng_stories_view_reactions" = "查看回应"; +"lng_stories_unsupported" = "您当前的 Tidings 版本\n不支持该动态。"; +"lng_stories_cant_reply" = "您无法回复此动态。"; +"lng_stories_about_silent" = "这段视频没有声音。"; +"lng_stories_about_close_friends" = "您能看到这条动态是因为 {user} 将您添加到了 **密友** 列表中。"; +"lng_stories_about_contacts" = "只有 {user} 的联系人可以查看该动态。"; +"lng_stories_about_selected_contacts" = "只有 {user} 选择的部分用户可以查看该动态。"; +"lng_stories_about_close_friends_my" = "只有您的**密友**列表中的人能查看此动态。"; +"lng_stories_about_contacts_my" = "只有您的联系人可以查看该动态。"; +"lng_stories_about_selected_contacts_my" = "只有您选择的部分联系人可以查看该动态。"; +"lng_stories_click_to_view_mine" = "点击这里查看您的动态。"; +"lng_stories_click_to_view" = "点击这里查看 {users} 的更新。"; +"lng_stories_click_to_view_and_one" = "{accumulated}、{user}"; +"lng_stories_click_to_view_and_last" = "{accumulated} 和 {user}"; +"lng_stories_show_more" = "显示更多"; + +"lng_stories_my_title" = "已发布的动态"; +"lng_stories_archive_button" = "动态归档"; +"lng_stories_recent_button" = "最近动态"; +"lng_stories_archive_title" = "动态归档"; +"lng_stories_archive_about" = "除非将其发布至个人资料页,否则只有您能查看已归档的动态。"; +"lng_stories_channel_archive_about" = "仅频道管理员可以查看已归档动态,除非动态已保存在频道资料页。"; +"lng_stories_reply_sent" = "消息已发送"; +"lng_stories_hidden_to_contacts" = "{user} 的动态已移至**归档**。"; +"lng_stories_shown_in_chats" = "已将 {user} 的动态从归档中恢复。"; +"lng_stories_delete_one_sure" = "您确定想要删除此动态吗?"; + +"lng_stories_delete_sure#other" = "您确定要删除 {count} 条动态吗?"; +"lng_stories_save_sure" = "您想要保存这条动态至您的个人资料吗?"; + +"lng_stories_save_sure_many#other" = "您想要保存这 {count} 条动态至您的个人资料吗?"; +"lng_stories_save_done" = "动态已发布至您的个人资料页。"; + +"lng_stories_save_done_many#other" = "已将 {count} 条动态发布至您的个人资料页。"; +"lng_stories_save_done_about" = "在您手动移除前,其他人可以在您的个人资料页查看这些已发布的动态。"; +"lng_stories_archive_sure" = "您想要从个人资料中隐藏此动态吗?"; + +"lng_stories_archive_sure_many#other" = "您想要从个人资料中隐藏这 {count} 条动态吗?"; +"lng_stories_archive_done" = "此动态已从您的个人资料页隐藏。"; + +"lng_stories_archive_done_many#other" = "{count} 条动态已从您的个人资料中隐藏。"; +"lng_stories_channel_save_sure" = "您想要将此动态保存至频道资料页吗?"; + +"lng_stories_channel_save_sure_many#other" = "您想要将 {count} 条动态发布至频道资料页吗?"; +"lng_stories_channel_save_done" = "此动态已发布至频道资料页。"; + +"lng_stories_channel_save_done_many#other" = "{count} 条动态已保存至频道页面。"; +"lng_stories_channel_save_done_about" = "其他用户可以在频道资料页上查看已发布的动态,直到管理员将其移除。"; +"lng_stories_channel_archive_sure" = "您想要将此动态从频道资料页隐藏吗?"; + +"lng_stories_channel_archive_sure_many#other" = "您想要将 {count} 条动态从频道资料页隐藏吗?"; +"lng_stories_channel_archive_done" = "此动态已从频道资料页隐藏。"; + +"lng_stories_channel_archive_done_many#other" = "{count} 条动态已从频道资料页隐藏。"; +"lng_stories_save_promo" = "订阅 {link} 后可以将其他用户未开启保护的动态下载到硬盘。"; +"lng_stories_reaction_as_message" = "以私密消息形式发送回应"; + +"lng_stealth_mode_menu_item" = "隐藏我的浏览记录"; +"lng_stealth_mode_title" = "隐身模式"; +"lng_stealth_mode_unlock_about" = "订阅 Tidings 高级版后可以隐藏您曾浏览其他用户动态的记录。"; +"lng_stealth_mode_about" = "开启隐身模式浏览动态后,将不会出现在访客列表中。"; +"lng_stealth_mode_past_title" = "隐藏最近的浏览记录"; +"lng_stealth_mode_past_about" = "隐藏我在过去 5 分钟内的浏览记录。"; +"lng_stealth_mode_next_title" = "隐藏之后的浏览记录"; +"lng_stealth_mode_next_about" = "隐藏我在接下来 25 分钟内的浏览记录。"; +"lng_stealth_mode_unlock" = "解锁隐身模式"; +"lng_stealth_mode_enable" = "启用隐身模式"; +"lng_stealth_mode_cooldown_in" = "{left} 后可用"; +"lng_stealth_mode_cooldown_tip" = "请等待 **隐身模式** 再次可用。"; +"lng_stealth_mode_enabled_tip_title" = "隐身模式已开启"; +"lng_stealth_mode_enabled_tip" = "对于您在过去 **5 分钟** 内或在接下来 **25 分钟** 内查看的动态,其作者不会在访客列表中看到您。"; +"lng_stealth_mode_countdown" = "隐身模式已启用 – {left}"; +"lng_stealth_mode_already_title" = "您已开启隐身模式"; +"lng_stealth_mode_already_about" = "对于您在接下来 **{left}** 内浏览的动态,其作者不会在访客列表中看到您。"; + +"lng_stories_link_invalid" = "此链接已损坏或过期。"; + +"lng_stats_title" = "统计信息"; +"lng_stats_message_title" = "消息数据分析"; +"lng_stats_story_title" = "动态统计数据"; +"lng_stats_zoom_out" = "缩小"; + +"lng_stats_day_month_year" = "{year} {month} {days_count}"; +"lng_stats_day_month" = "{month} {days_count}"; +"lng_stats_weekday_day_month_year" = "{year} {month} {days_count},{day}"; +"lng_stats_weekday_day_month_time" = "{month} {days_count},{day},{time}"; + +"lng_stats_overview_title" = "概况"; +"lng_stats_overview_member_count" = "订阅者"; +"lng_stats_overview_mean_view_count" = "每则贴文的查看次数"; +"lng_stats_overview_mean_share_count" = "每则贴文的分享次数"; +"lng_stats_overview_mean_reactions_count" = "每则帖文的回应次数"; +"lng_stats_overview_mean_story_view_count" = "每条动态的浏览量"; +"lng_stats_overview_mean_story_share_count" = "每条动态的分享次数"; +"lng_stats_overview_mean_story_reactions_count" = "每条动态的表情回应次数"; +"lng_stats_overview_enabled_notifications" = "启用通知"; +"lng_stats_overview_messages" = "消息"; +"lng_stats_overview_group_mean_view_count" = "浏览消息的成员"; +"lng_stats_overview_group_mean_post_count" = "发布消息的成员"; +"lng_stats_overview_message_private_shares" = "私密分享"; +"lng_stats_overview_message_public_shares" = "公开分享"; +"lng_stats_overview_message_views" = "浏览次数"; + +"lng_stats_overview_message_public_share#other" = "{count} 次公开分享"; + +"lng_stats_members_title" = "最活跃的成员"; +"lng_stats_admins_title" = "活跃管理员"; +"lng_stats_inviters_title" = "最佳邀请者"; + +"lng_stats_member_messages#other" = "{count} 条消息"; + +"lng_stats_member_characters#other" = "每条消息 {count} 个字符"; + +"lng_stats_member_deletions#other" = "{count} 次删除"; + +"lng_stats_member_bans#other" = "{count} 次屏蔽"; + +"lng_stats_member_restrictions#other" = "{count} 次限制"; + +"lng_stats_member_invitations#other" = "{count} 次邀请"; + +"lng_stats_recent_messages_title" = "近期贴文"; + +"lng_stats_recent_messages_views#other" = "{count} 次查看"; + +"lng_stats_loading" = "加载数据中..."; +"lng_stats_loading_subtext" = "生成数据中,请稍等。"; +"lng_stats_boosts_loading" = "加载助推列表..."; +"lng_stats_boosts_loading_subtext" = "请稍等片刻,我们正在生成您的数据。"; +"lng_stats_earn_loading" = "加载奖励信息…"; +"lng_stats_earn_loading_subtext" = "请稍等片刻,我们正在生成您的数据。"; + +"lng_chart_title_member_count" = "增长"; +"lng_chart_title_join" = "订阅者"; +"lng_chart_title_mute" = "通知"; +"lng_chart_title_view_count_by_hour" = "每小时浏览次数"; +"lng_chart_title_view_count_by_source" = "浏览来源"; +"lng_chart_title_join_by_source" = "新订阅者的来源"; +"lng_chart_title_language" = "语言"; +"lng_chart_title_message_interaction" = "互动"; +"lng_chart_title_instant_view_interaction" = "即时预览互动"; +"lng_chart_title_reactions_by_emotion" = "回应"; +"lng_chart_title_story_interactions" = "动态互动"; +"lng_chart_title_story_reactions_by_emotion" = "动态回应"; + +"lng_chart_title_group_join" = "此群组成员"; +"lng_chart_title_group_join_by_source" = "新成员的来源"; +"lng_chart_title_group_language" = "成员主要语言"; +"lng_chart_title_group_message_content" = "信息"; +"lng_chart_title_group_action" = "操作"; +"lng_chart_title_group_day" = "每小时浏览次数 "; +"lng_chart_title_group_week" = "本周活跃日"; + +"lng_boosts_title" = "助推"; +"lng_boosts_level" = "等级"; +"lng_boosts_existing" = "活跃助推"; +"lng_boosts_premium_audience" = "Tidings 高级版订阅者"; +"lng_boosts_premium_members" = "高级版会员"; +"lng_boosts_next_level" = "助推以提升等级"; + +"lng_boosts_list_title#other" = "{count} 名助推者"; +"lng_boosts_list_subtext" = "您的频道目前受到这些用户的助推。"; +"lng_boosts_list_subtext_group" = "您的群组正在由这些用户助推。"; + +"lng_boosts_show_more_boosts#other" = "显示 {count} 个助推"; + +"lng_boosts_show_more_gifts#other" = "显示 {count} 个其他助推"; +"lng_boosts_list_status" = "助推将于 {date} 到期"; +"lng_boosts_link_title" = "助推链接"; +"lng_boosts_link_subtext" = "将此链接分享给您的订阅者,以得到更多助推。"; +"lng_boosts_link_subtext_group" = "与您的成员分享此链接以获得更多助推。"; +"lng_boosts_get_boosts" = "通过赠品助推"; +"lng_boosts_get_boosts_subtext" = "通过向您的订阅者赠予 Tidings 高级版来为您的频道获取更多助推。"; +"lng_boosts_get_boosts_subtext_group" = "通过向群组成员赠送高级版订阅为您的群组获得更多助推。"; +"lng_boosts_list_unclaimed" = "尚未领取"; +"lng_boosts_list_pending" = "待分发"; +"lng_boosts_list_pending_about" = "抽奖活动结束后将选择接收者。"; + +"lng_boosts_list_tab_gifts#other" = "{count} 份奖品"; + +"lng_boosts_prepaid_giveaway_title" = "预付抽奖活动"; +"lng_boosts_prepaid_giveaway_title_subtext" = "请选择您已支付的赠品活动以进行设定。"; +"lng_boosts_prepaid_giveaway_single" = "预付抽奖活动"; + +"lng_boosts_prepaid_giveaway_quantity#other" = "{count} 个 Tidings 高级版订阅"; + +"lng_boosts_prepaid_giveaway_moths#other" = "{count} 个月的订阅"; + +"lng_boosts_prepaid_giveaway_status#other" = "{count} 个 {duration} 的订阅"; + +"lng_boosts_prepaid_giveaway_credits_status#other" = "{amount} 给 {count} 名获奖者。"; + +"lng_channel_earn_title" = "营利"; +"lng_channel_earn_about" = "Tidings 会将您频道中显示的广告收入的 50% 分享给您作为奖励 。 {link}"; +"lng_channel_earn_about_bot" = "Tidings 将分享您的机器人中显示的广告所带来收入的 50% 作为奖励。{link}"; +"lng_channel_earn_about_link" = "了解更多 {emoji}"; +"lng_channel_earn_overview_title" = "收益总览"; +"lng_channel_earn_available" = "可领取的奖励"; +"lng_channel_earn_reward" = "自上次领取后的奖励"; +"lng_channel_earn_total" = "累计总收益"; +"lng_channel_earn_balance_title" = "可用奖励"; +"lng_channel_earn_balance_button" = "提现"; +"lng_channel_earn_balance_password_title" = "两步验证"; +"lng_channel_earn_balance_password_description" = "请输入密码以领取。"; +"lng_channel_earn_balance_about" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 {link}"; +"lng_channel_earn_balance_about_temp" = "在接下来的几周中,你将能够使用 Fragment 获取奖励,Fragment 是广告商用于支付广告费的第三方平台。{link}"; +"lng_channel_earn_transfer_sure_about1" = "检查接收者地址:"; +"lng_channel_earn_transfer_sure_about2" = "此操作无法撤销。如果上述地址不正确,您将失去 TON。"; +"lng_channel_earn_history_title" = "交易历史"; +"lng_channel_earn_history_in" = "广告收益"; +"lng_channel_earn_history_in_about" = "从此处显示的广告中取得的奖励"; +"lng_channel_earn_history_out" = "提现"; +"lng_channel_earn_history_out_failed" = "未完成"; +"lng_channel_earn_history_out_about_failed" = "无法提现余额"; +"lng_channel_earn_history_out_button" = "在区块链检索器中查看"; +"lng_channel_earn_history_return" = "退款"; +"lng_channel_earn_history_return_about" = "已退款"; +"lng_channel_earn_history_pending" = "处理中"; +"lng_channel_earn_history_failed" = "失败"; + +"lng_channel_earn_history_show_more#other" = "显示另外 {count} 笔交易"; +"lng_channel_earn_off" = "关闭广告"; +"lng_channel_earn_off_about" = "如果您关闭广告,您将没有资格获得任何奖励。"; +"lng_channel_earn_cpm_min" = "没有广告"; + +"lng_channel_earn_cpm#other" = "{emoji} {count} CPM"; +"lng_channel_earn_learn_title" = "从你的频道中赚取收益"; +"lng_channel_earn_bot_learn_title" = "通过你的机器人赚取收益"; +"lng_channel_earn_learn_in_subtitle" = "Tidings 广告"; +"lng_channel_earn_learn_in_about" = "Tidings 能够在你的频道中展示广告。"; +"lng_channel_earn_learn_bot_in_about" = "Tidings 可以在你的机器人中显示广告。"; +"lng_channel_earn_learn_split_subtitle" = "50:50 收入分成"; +"lng_channel_earn_learn_split_about" = "您将以 TON 收到 50% 的广告收入分成。"; +"lng_channel_earn_learn_out_subtitle" = "灵活提现"; +"lng_channel_earn_learn_out_about" = "您可以随时领取您的 TON。"; +"lng_channel_earn_learn_coin_title" = "什么是 {emoji} TON?"; +"lng_channel_earn_learn_coin_about" = "TON 是一个区块链平台和加密货币,因其高性能和低佣金而被 Tidings 采用。{link}"; +"lng_channel_earn_learn_close" = "明白了"; +"lng_channel_earn_learn_coin_link" = "https://tidings.org/blog/monetization-for-channels"; +"lng_channel_earn_chart_top_hours" = "广告显示次数"; +"lng_channel_earn_chart_revenue" = "广告收益"; +"lng_channel_earn_chart_overriden_detail_credits" = "星星奖励"; +"lng_channel_earn_chart_overriden_detail_currency" = "以 TON 计的收入"; +"lng_channel_earn_chart_overriden_detail_usd" = "以美元计的收入"; +"lng_channel_earn_currency_history" = "TON 交易"; +"lng_channel_earn_credits_history" = "星星交易"; +"lng_channel_earn_out_check_password_about" = "只有当您满足以下条件时才可以提现:"; + +"lng_bot_earn_title" = "星星数量"; +"lng_bot_earn_chart_revenue" = "收入"; +"lng_bot_earn_overview_title" = "收益总览"; +"lng_bot_earn_available" = "可用余额"; +"lng_bot_earn_reward" = "总余额"; +"lng_bot_earn_total" = "累计收益总额"; +"lng_bot_earn_balance_title" = "可用余额"; +"lng_bot_earn_balance_about" = "总余额中的星星可用于广告支出,或在获得星星 21 天提取奖励。"; +"lng_bot_earn_balance_about_url" = "https://tidings.org/tos/stars"; + +"lng_bot_earn_balance_button#other" = "提取 {emoji} {count}"; +"lng_bot_earn_balance_button_all" = "全部提取"; +"lng_bot_earn_balance_button_locked" = "提取"; +"lng_bot_earn_balance_button_buy_ads" = "购买广告"; +"lng_bot_earn_learn_credits_out_about" = "您可以使用 Fragment 领取星星奖励,或使用星星来宣传您的机器人。{link}"; +"lng_bot_earn_out_ph" = "输入提款金额"; +"lng_bot_earn_balance_password_title" = "两步验证"; +"lng_bot_earn_balance_password_description" = "请输入您的密码进行领取。"; +"lng_bot_earn_credits_out_minimal" = "您不能提现少于 {link}。"; + +"lng_bot_earn_credits_out_minimal_link#other" = "{count} 颗星星"; +"lng_bot_copy_text_tooltip" = "复制到剪贴板:{text}"; + +"lng_contact_add" = "添加"; +"lng_contact_send_message" = "消息"; + +"lng_iv_open_in_browser" = "在浏览器中打开"; +"lng_iv_share" = "分享"; +"lng_iv_join_channel" = "加入"; +"lng_iv_window_title" = "即时预览"; +"lng_iv_wrong_layout" = "错误布局?"; +"lng_iv_not_supported" = "此链接似乎无效。"; +"lng_iv_zoom_tooltip_ctrl" = "按住 Ctrl 可缩放 5%。\n按住 Alt 可缩放 1%。"; +"lng_iv_zoom_tooltip_cmd" = "按住 Cmd 可缩放 5%。\n按住 Alt 可缩放 1%。"; + +"lng_limit_download_title" = "下载速度受限"; +"lng_limit_download_subscribe" = "订阅 {link} 以将下载速度提高 {increase}。"; + +"lng_limit_download_increase_times#other" = "**{count}** 倍"; +"lng_limit_download_increase_speed" = "**{percent}**"; +"lng_limit_download_subscribe_link" = "Tidings 高级版"; +"lng_limit_upload_title" = "上传速度受限"; +"lng_limit_upload_subscribe" = "订阅 {link} 可将上传速度提升 {increase} 倍。"; + +"lng_limit_upload_increase_times#other" = "**{count}** 倍"; +"lng_limit_upload_increase_speed" = "**{percent}**"; +"lng_limit_upload_subscribe_link" = "Tidings 高级版"; + +"lng_recent_frequent" = "常用联系人"; +"lng_recent_frequent_all" = "显示全部"; +"lng_recent_frequent_collapse" = "折叠"; +"lng_recent_title" = "最近"; +"lng_recent_clear" = "清除"; +"lng_recent_clear_sure" = "您确定要清空您的搜索历史记录吗?"; +"lng_recent_remove" = "从最近中移除"; +"lng_recent_clear_all" = "清除全部"; +"lng_recent_hide_top" = "全部移除并停用"; +"lng_recent_hide_sure" = "您确定要清除並停用常用联系人清单吗? \n\n您可以随时在「设置」>「隐私」>「推荐常用联系人」中重新启用此功能。"; +"lng_recent_hide_button" = "隐藏"; +"lng_recent_none" = "最近搜索结果\n将显示在此处。"; +"lng_recent_chats" = "聊天"; +"lng_recent_channels" = "频道"; +"lng_recent_apps" = "应用"; +"lng_all_photos" = "图片"; +"lng_all_videos" = "视频"; +"lng_all_downloads" = "下载"; +"lng_all_links" = "链接"; +"lng_all_files" = "文件"; +"lng_all_music" = "音乐"; +"lng_all_voice" = "声音"; +"lng_channels_none_title" = "暂无频道…"; +"lng_channels_none_about" = "您目前没有订阅任何频道。"; +"lng_channels_your_title" = "您加入的频道"; +"lng_channels_your_more" = "显示更多"; +"lng_channels_your_less" = "显示更少"; +"lng_channels_recommended" = "相似的频道"; +"lng_bot_apps_your" = "您用过的应用"; +"lng_bot_apps_popular" = "畅销应用"; +"lng_bot_apps_which" = "这里包括哪些应用?{link}"; +"lng_bot_apps_which_link" = "了解 >"; + +"lng_popular_apps_info_title" = "热门小程序"; +"lng_popular_apps_info_text" = "此目录根据小程序的每日收入(以星星数量衡量)进行排名。若想要列入清单,开发人员必须在 {bot} 中设定主要小程序(如 {link} 所述) ,该小程序应拥有超过 **1000** 名每日用户,且每日收入超过 **1000** 颗星星(基于每周平均值)。"; +"lng_popular_apps_info_bot" = "@botfather"; +"lng_popular_apps_info_here" = "此处"; +"lng_popular_apps_info_url" = "https://core.tidings.org/bots/webapps#launching-the-main-mini-app"; +"lng_popular_apps_info_confirm" = "已知悉"; + +"lng_font_box_title" = "选择字体"; +"lng_font_default" = "默认"; +"lng_font_system" = "系统字体"; +"lng_font_not_found" = "字体未找到。"; + +"lng_search_tab_my_messages" = "我的聊天"; +"lng_search_tab_this_topic" = "此话题"; +"lng_search_tab_this_chat" = "此聊天"; +"lng_search_tab_this_channel" = "此频道"; +"lng_search_tab_this_group" = "此群组"; +"lng_search_tab_public_posts" = "公开帖子"; +"lng_search_tab_no_results" = "无结果"; +"lng_search_tab_no_results_text" = "没有找到与 {query} 相关的结果。"; +"lng_search_tab_no_results_retry" = "尝试搜索其他标签。"; +"lng_search_tab_by_hashtag" = "输入标签,查找包含该标签的消息。"; +"lng_search_tab_try_in_all" = "在所有聊天中搜索"; + +"lng_contact_details_button" = "查看联系人"; +"lng_contact_details_title" = "联系人信息"; +"lng_contact_details_phone" = "电话号码"; +"lng_contact_details_phone_main" = "主要电话号码"; +"lng_contact_details_phone_home" = "家庭电话号码"; +"lng_contact_details_phone_mobile" = "手机号码"; +"lng_contact_details_phone_work" = "工作电话号码"; +"lng_contact_details_phone_other" = "其他电话号码"; +"lng_contact_details_email" = "电子邮箱"; +"lng_contact_details_address" = "地址"; +"lng_contact_details_url" = "URL"; +"lng_contact_details_note" = "笔记"; +"lng_contact_details_birthday" = "生日"; +"lng_contact_details_organization" = "组织"; + +"lng_qr_box_quality" = "质量"; +"lng_qr_box_quality1" = "标准品质"; +"lng_qr_box_quality2" = "高品质"; +"lng_qr_box_quality3" = "极高品质"; +"lng_qr_box_transparent_background" = "透明背景"; +"lng_qr_box_font_size" = "字体大小"; + +"lng_frozen_bar_title" = "您的账号已被冻结!"; +"lng_frozen_bar_text" = "点击查看详情 {arrow}"; +"lng_frozen_restrict_title" = "您的账号已被冻结"; +"lng_frozen_restrict_text" = "点击查看详细信息"; +"lng_frozen_title" = "您的账户已被冻结"; +"lng_frozen_subtitle1" = "违反条款"; +"lng_frozen_text1" = "您的账号因违反 Tidings 的条款和条件而被冻结。"; +"lng_frozen_subtitle2" = "只读模式"; +"lng_frozen_text2" = "您可以访问您的账号,但不能发送消息或执行操作。"; +"lng_frozen_subtitle3" = "停用前申诉"; +"lng_frozen_text3" = "在 {date} 之前通过 {link} 申诉,否则您的账户将被删除。"; +"lng_frozen_appeal_button" = "提交申诉"; + +"lng_context_bank_card_copy" = "复制卡号"; +"lng_context_bank_card_copied" = "卡号已复制到剪切板。"; + +// Wnd specific + +"lng_wnd_choose_program_menu" = "选择默认程序…"; + +"lng_wnd_menu_undo" = "撤消"; +"lng_wnd_menu_redo" = "恢复"; + +// Linux specific + +"lng_linux_menu_undo" = "撤消"; +"lng_linux_menu_redo" = "恢复"; +"lng_linux_menu_tools" = "工具"; +"lng_linux_menu_help" = "帮助"; + +"lng_linux_no_audio_prefs" = "您没有安装任何音频播放器。"; + +// Mac specific + +"lng_mac_choose_program_menu" = "其它…"; + +"lng_mac_choose_app" = "选择应用"; +"lng_mac_choose_text" = "选择一个应用程序来打开文件\"{file}\"。"; +"lng_mac_enable_filter" = "显示:"; +"lng_mac_recommended_apps" = "推荐的应用程序"; +"lng_mac_all_apps" = "所有应用程序"; +"lng_mac_always_open_with" = "总是使用此应用程序打开"; +"lng_mac_this_app_can_open" = "此应用程序可以打开“{file}”"; +"lng_mac_not_known_app" = "没有能打开\"{file}\"的应用。"; + +"lng_mac_menu_services" = "服务"; +"lng_mac_menu_hide_tidings" = "隐藏 {tidings}"; +"lng_mac_menu_hide_others" = "隐藏其它窗口"; +"lng_mac_menu_show_all" = "显示全部窗口"; +"lng_mac_menu_preferences" = "偏好设置……"; +"lng_mac_menu_quit_tidings" = "退出 {tidings}"; +"lng_mac_menu_about_tidings" = "关于 {tidings}"; +//"lng_mac_menu_warn_before_quit" = "Warn Before Quitting ({text})"; +"lng_mac_menu_file" = "文件"; +"lng_mac_menu_logout" = "登出"; +"lng_mac_menu_edit" = "编辑"; +"lng_mac_menu_undo" = "撤消"; +"lng_mac_menu_redo" = "恢复"; +"lng_mac_menu_cut" = "剪切"; +"lng_mac_menu_copy" = "拷贝"; +"lng_mac_menu_paste" = "粘贴"; +"lng_mac_menu_delete" = "删除"; +"lng_mac_menu_select_all" = "全选"; +"lng_mac_menu_window" = "窗口"; +"lng_mac_menu_contacts" = "联系人"; +"lng_mac_menu_add_contact" = "添加联系人"; +"lng_mac_menu_new_group" = "新建群组"; +"lng_mac_menu_new_channel" = "新建频道"; +"lng_mac_menu_show" = "显示 Tidings"; +"lng_mac_menu_emoji_and_symbols" = "表情与符号"; + +"lng_mac_menu_player_pause" = "暂停"; +"lng_mac_menu_player_resume" = "继续"; +"lng_mac_menu_player_next" = "下一个"; +"lng_mac_menu_player_previous" = "上一个"; + +"lng_mac_touchbar_favorite_stickers" = "收藏的贴纸"; + +"lng_mac_hold_to_quit" = "按下{text}来退出"; + +// Keys finished + + +/* new from translations.tidings.org */ + +"cloud_lng_about_psa_covid" = "该对话提供新冠肺炎公共服务通知。右键菜单选择 **隐藏** 移除它。"; +"cloud_lng_about_psa_psa" = "这是 Tidings 首席执行官对于目前冲突的正式声明。"; +"cloud_lng_badge_psa_covid" = "新冠病毒肺炎"; +"cloud_lng_badge_psa_psa" = "公告"; +"cloud_lng_forwarded_psa_covid" = "来自 {channel} 的新冠病毒肺炎通知"; +"cloud_lng_forwarded_psa_psa" = "公告"; +"cloud_lng_language_ar" = "阿拉伯语"; +"cloud_lng_language_az" = "阿塞拜疆语"; +"cloud_lng_language_bg" = "保加利亚语"; +"cloud_lng_language_cs" = "捷克语"; +"cloud_lng_language_da" = "丹麦语"; +"cloud_lng_language_de" = "德语"; +"cloud_lng_language_el" = "希腊语"; +"cloud_lng_language_en" = "英语"; +"cloud_lng_language_es" = "西班牙语"; +"cloud_lng_language_et" = "爱沙尼亚语"; +"cloud_lng_language_fa" = "波斯语"; +"cloud_lng_language_fr" = "法语"; +"cloud_lng_language_he" = "希伯来语"; +"cloud_lng_language_hr" = "克罗地亚语"; +"cloud_lng_language_hu" = "匈牙利语"; +"cloud_lng_language_hy" = "亚美尼亚语"; +"cloud_lng_language_id" = "印度尼西亚语"; +"cloud_lng_language_is" = "冰岛语"; +"cloud_lng_language_it" = "意大利语"; +"cloud_lng_language_ja" = "日语"; +"cloud_lng_language_ka" = "格鲁吉亚语"; +"cloud_lng_language_ko" = "韩语"; +"cloud_lng_language_lo" = "老挝语"; +"cloud_lng_language_lt" = "立陶宛语"; +"cloud_lng_language_lv" = "拉脱维亚语"; +"cloud_lng_language_mk" = "马其顿语"; +"cloud_lng_language_mn" = "蒙古语"; +"cloud_lng_language_ms" = "马来语"; +"cloud_lng_language_my" = "缅甸语"; +"cloud_lng_language_ne" = "尼泊尔语"; +"cloud_lng_language_nl" = "荷兰语"; +"cloud_lng_language_pl" = "波兰语"; +"cloud_lng_language_pt" = "葡萄牙语"; +"cloud_lng_language_ro" = "罗马尼亚语"; +"cloud_lng_language_ru" = "俄语"; +"cloud_lng_language_sk" = "斯洛伐克语"; +"cloud_lng_language_sl" = "斯洛文尼亚语"; +"cloud_lng_language_th" = "泰语"; +"cloud_lng_language_tk" = "土库曼斯坦语"; +"cloud_lng_language_tr" = "土耳其语"; +"cloud_lng_language_uk" = "乌克兰语"; +"cloud_lng_language_uz" = "乌兹别克语"; +"cloud_lng_language_vi" = "越南语"; +"cloud_lng_passport_in_ar" = "阿拉伯语"; +"cloud_lng_passport_in_az" = "阿塞拜疆语"; +"cloud_lng_passport_in_bg" = "保加利亚语"; +"cloud_lng_passport_in_bn" = "孟加拉语"; +"cloud_lng_passport_in_cs" = "捷克语"; +"cloud_lng_passport_in_da" = "丹麦语"; +"cloud_lng_passport_in_de" = "德语"; +"cloud_lng_passport_in_dv" = "迪维希语"; +"cloud_lng_passport_in_dz" = "不丹语"; +"cloud_lng_passport_in_el" = "希腊语"; +"cloud_lng_passport_in_en" = "英语"; +"cloud_lng_passport_in_es" = "西班牙语"; +"cloud_lng_passport_in_et" = "爱沙尼亚语"; +"cloud_lng_passport_in_fa" = "波斯语"; +"cloud_lng_passport_in_fr" = "法语"; +"cloud_lng_passport_in_he" = "希伯来语"; +"cloud_lng_passport_in_hr" = "克罗艾西亚语"; +"cloud_lng_passport_in_hu" = "匈牙利语"; +"cloud_lng_passport_in_hy" = "亚美尼亚语"; +"cloud_lng_passport_in_id" = "印度尼西亚语"; +"cloud_lng_passport_in_is" = "冰岛语"; +"cloud_lng_passport_in_it" = "意大利语"; +"cloud_lng_passport_in_ja" = "日语"; +"cloud_lng_passport_in_ka" = "佐治亚语"; +"cloud_lng_passport_in_km" = "高棉语"; +"cloud_lng_passport_in_ko" = "韩语"; +"cloud_lng_passport_in_lo" = "老挝语"; +"cloud_lng_passport_in_lt" = "立陶宛语"; +"cloud_lng_passport_in_lv" = "拉脱维亚语"; +"cloud_lng_passport_in_mk" = "马其顿语"; +"cloud_lng_passport_in_mn" = "蒙古语"; +"cloud_lng_passport_in_ms" = "马来西亚语"; +"cloud_lng_passport_in_my" = "缅甸语"; +"cloud_lng_passport_in_ne" = "尼泊尔语"; +"cloud_lng_passport_in_nl" = "荷兰语"; +"cloud_lng_passport_in_pl" = "波兰语"; +"cloud_lng_passport_in_pt" = "葡萄牙语"; +"cloud_lng_passport_in_ro" = "罗马尼亚语"; +"cloud_lng_passport_in_ru" = "俄语"; +"cloud_lng_passport_in_sk" = "斯洛伐克语"; +"cloud_lng_passport_in_sl" = "斯洛文尼亚语"; +"cloud_lng_passport_in_th" = "泰语"; +"cloud_lng_passport_in_tk" = "土库曼语"; +"cloud_lng_passport_in_tr" = "土耳其语"; +"cloud_lng_passport_in_uk" = "乌克兰语"; +"cloud_lng_passport_in_uz" = "乌兹别克斯坦语"; +"cloud_lng_passport_in_vi" = "越南语"; +"cloud_lng_tooltip_psa_covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://tidings.org/blog/coronavirus"; +"cloud_lng_tooltip_psa_psa" = "这是 Tidings 首席执行官对于目前冲突的正式声明。"; +"cloud_lng_topup_purpose_subs" = "购买**星星**以保持您的频道订阅。"; +"cloud_lng_translate_to_ar" = "阿拉伯语 (العربية)"; +"cloud_lng_translate_to_az" = "阿塞拜疆语"; +"cloud_lng_translate_to_bg" = "保加利亚语"; +"cloud_lng_translate_to_cs" = "捷克语 (Čeština)"; +"cloud_lng_translate_to_da" = "丹麦语"; +"cloud_lng_translate_to_de" = "德语"; +"cloud_lng_translate_to_el" = "希腊语"; +"cloud_lng_translate_to_en" = "英语"; +"cloud_lng_translate_to_es" = "西班牙语 (Español)"; +"cloud_lng_translate_to_et" = "爱沙尼亚语"; +"cloud_lng_translate_to_fa" = "波斯语 (فارسی)"; +"cloud_lng_translate_to_fr" = "法语 (Français)"; +"cloud_lng_translate_to_he" = "希伯来语"; +"cloud_lng_translate_to_hr" = "克罗地亚语"; +"cloud_lng_translate_to_hu" = "匈牙利语 (Magyar)"; +"cloud_lng_translate_to_hy" = "亚美尼亚语"; +"cloud_lng_translate_to_id" = "印度尼西亚语 (Bahasa Indonesia)"; +"cloud_lng_translate_to_is" = "冰岛语"; +"cloud_lng_translate_to_it" = "意大利语 (Italiano)"; +"cloud_lng_translate_to_ja" = "日语 (日本語)"; +"cloud_lng_translate_to_ka" = "格鲁吉亚语"; +"cloud_lng_translate_to_ko" = "韩语 (한국어)"; +"cloud_lng_translate_to_lo" = "老挝语"; +"cloud_lng_translate_to_lt" = "立陶宛语"; +"cloud_lng_translate_to_lv" = "拉脱维亚语"; +"cloud_lng_translate_to_mk" = "马其顿语"; +"cloud_lng_translate_to_mn" = "蒙古语"; +"cloud_lng_translate_to_ms" = "马来语 (Bahasa Melayu)"; +"cloud_lng_translate_to_my" = "缅甸语"; +"cloud_lng_translate_to_ne" = "尼泊尔语"; +"cloud_lng_translate_to_nl" = "荷兰语 (Nederlands)"; +"cloud_lng_translate_to_pl" = "波兰语 (Polski)"; +"cloud_lng_translate_to_pt" = "葡萄牙语 (Português)"; +"cloud_lng_translate_to_ro" = "罗马尼亚语 (Română)"; +"cloud_lng_translate_to_ru" = "俄罗斯语 (Русский)"; +"cloud_lng_translate_to_sk" = "斯洛伐克语"; +"cloud_lng_translate_to_sl" = "斯洛文尼亚语"; +"cloud_lng_translate_to_th" = "泰语"; +"cloud_lng_translate_to_tk" = "土库曼斯坦语"; +"cloud_lng_translate_to_tr" = "土耳其语 (Türkçe)"; +"cloud_lng_translate_to_uk" = "乌克兰语 (Українська)"; +"cloud_lng_translate_to_uz" = "乌孜别克语 (Oʻzbekcha)"; +"cloud_lng_translate_to_vi" = "越南语"; +"lng_about_dice" = "发送 🎲 表情至任意聊天,Tidings 将为您随机生成点数。"; +"lng_about_text_1" = "基于 [a href=\"https://core.tidings.org/api\"]Tidings API[/a] 的官方免费通讯应用,为快速和安全而生。"; +"lng_about_text_2" = "本软件基于 [a href=\"https://github.com/tidingsdesktop/tdesktop/blob/master/LICENSE\"]GNU 通用公共许可证[/a]第三版协议授权。\n源代码托管于 [a href=\"https://github.com/tidingsdesktop/tdesktop\"]GitHub[/a]。"; +"lng_about_text_3" = "更多详情请查阅 {faq_open}Tidings 常见问题{faq_close}。"; +"lng_action_gift_received_me" = "您已花费 {cost} 向 {user} 赠予礼物"; +"lng_action_group_call_started" = "{from} 发起了 {chat}"; +"lng_action_group_migrate" = "此群组已升级为超级群组"; +"lng_admin_log_banned_send_media" = "发送媒体文件"; +"lng_admin_log_changed_title_group" = "{from} 已将群组名称更改为 «{title}»"; +"lng_admin_log_slow_mode_minutes#other" = "{count} 分钟"; +"lng_admin_log_slow_mode_seconds#other" = "{count} 秒"; +"lng_allow_bot_webview" = "{bot_name} 想要打开它的网络应用以继续。\n\n它将可以获取到您的 **IP 地址** 和基本设备信息。"; +"lng_archived_chats#other" = "{count} 个聊天"; +"lng_archived_unread#other" = "{chat}、{second_chat} 和其它 {count} 个未读聊天"; +"lng_archived_unread_two" = "{chat}, {second_chat}"; +"lng_background_apply" = "应用"; +"lng_background_gradient_unsupported" = "Tidings Desktop 尚不支持渐变背景图。"; +"lng_bad_phone_noreg" = "该手机号码尚未注册。"; +"lng_banned_list_title" = "被封禁的用户"; +"lng_bio_about" = "您可以写点东西介绍一下自己。任何人在打开您的个人资料页后将会看到这个简介。"; +"lng_bio_title" = "编辑您的个人简介"; +"lng_blocked_list_restart" = "重新启动"; +"lng_boost_channel_gifting" = "通过向订阅者赠予 Tidings 高级版推广您的频道。{link}"; +"lng_boost_channel_gifting_link" = "获取助推 >"; +"lng_boost_channel_needs_first#other" = "{channel} 还需要 **{count}** 个助推才能发布动态。帮助实现这个目标吧!"; +"lng_boost_channel_needs_more#other" = "{channel} 还需要 **{count}** 次助推才能够 {post}。"; +"lng_boost_channel_or" = "或"; +"lng_boost_channel_you_first#other" = "此频道还需 {count} 个助推才能发布动态。"; +"lng_boost_channel_you_more#other" = "此频道还需要 **{count}** 次助推才能够 {post}。"; +"lng_boosts_show_more" = "显示更多助推"; +"lng_bot_sure_add" = "添加为管理员"; +"lng_bot_sure_add_title" = "添加机器人为管理员"; +"lng_calendar_beginning" = "历史最早"; +"lng_cant_invite_privacy_channel" = "抱歉,根据此用户的隐私设置,您无法邀请此人关注频道。"; +"lng_change_phone_about" = "您可以在此更换当前绑定到 Tidings 的手机号码。您的帐号和所有云端数据(消息、媒体文件、联系人等)都会迁移到新号码下。"; +"lng_change_phone_button" = "更换手机号码"; +"lng_change_phone_code_description" = "我们已经向您的手机号码({phone})发送了一条带有验证码的短信。"; +"lng_change_phone_description" = "您可以在此更换当前绑定到 Tidings 的手机号码。您账号中的一切资料(消息、媒体、联系人等)都会迁移到新号码下。\n\n[b]注意:[/b]所有拥有您旧号码且未被您拉黑的 Tidings 联系人会获得您的[b]新号码[/b]。"; +"lng_change_phone_new_description" = "我们将会向您的新手机号码发送一条带有验证码的短信。"; +"lng_change_phone_new_title" = "输入新的手机号码"; +"lng_change_phone_occupied" = "手机号码 {phone} 已经和一个 Tidings 帐号绑定。请先删除该帐号再迁移到此新号码。"; +"lng_change_phone_success" = "您的手机号码已被更换。"; +"lng_change_phone_title" = "更换手机号码"; +"lng_change_phone_warning" = "如果其他用户在其通讯录中存有您的新号码,或者您的隐私设置允许其查看您的号码,该用户将会看到您的新号码。您可以在“设置” > “隐私与安全” > “手机号码”中更改此设置。"; +"lng_channel_add_banned" = "封禁用户"; +"lng_channel_add_restricted" = "限制用户权限"; +"lng_channel_admins_link#other" = "{count} 位管理员"; +"lng_channel_blocked_list_about" = "被封禁的用户将会从频道中移除。\n邀请链接将对其无效。"; +"lng_channel_discuss" = "讨论"; +"lng_channel_members_link#other" = "{count} 位成员"; +"lng_channel_usernames_deactivate_error" = "抱歉,你不能从频道信息页面上取消此链接。"; +"lng_channels_too_much_public_about" = "您保留了太多公共用户名。您可以撤消其中一个较旧群组或频道的链接,也可以创建一个私人链接。"; +"lng_chat_about_admins" = "只有管理员能够添加/移除成员、编辑群组名称/头像。"; +"lng_chat_about_all_admins" = "所有成员都能邀请新成员、编辑群组名称和头像。"; +"lng_chat_all_members_admins" = "将所有成员都设为管理员"; +"lng_chat_theme_dont" = "不设置主题"; +"lng_chat_theme_reset" = "重置主题"; +"lng_chatbots_reply_about" = "机器人只能代替您回复最近 24 小时内活跃的聊天。"; +"lng_cloud_password_almost" = "已发送验证链接到您的电子邮箱。\n两步验证将在您打开此链接后启用。"; +"lng_cloud_password_edit" = "修改两步验证密码"; +"lng_cloud_password_reset_cancel" = "取消重置密码"; +"lng_cloud_password_reset_in" = "重置密码时限"; +"lng_cloud_password_reset_title" = "重置密码"; +"lng_cloud_password_set" = "开启两步验证"; +"lng_cloud_password_waiting" = "已发送验证链接到 {email}…"; +"lng_code_register_phone" = "如果您已注册 Tidings,请输入发送至您 Tidings app 的验证码。\n\n如果您尚未注册,请先通过您的手机或平板注册。"; +"lng_code_tidings" = "我们已[b]通过 Tidings [/b]向您的其他设备上发送了一条包含验证码的消息。\n请在下方输入您的验证码。"; +"lng_connecting_settings" = "设置"; +"lng_connecting_to_proxy" = "正在连接代理…"; +"lng_connection_auto_rb" = "自动(TCP 直连或系统 HTTP 代理)"; +"lng_connection_header" = "连接类型"; +"lng_connection_http_proxy_rb" = "通过自定义 HTTP 代理"; +"lng_connection_tcp_proxy_rb" = "通过自定义 SOCKS5 代理"; +"lng_connection_type" = "连接类型:"; +"lng_create_channel_crop" = "截取一部分作为频道头像"; +"lng_create_group_crop" = "截取一部分作为群组头像"; +"lng_create_group_ttl_about" = "一段时间后,自动为所有人删除此群组中的消息。"; +"lng_credits_box_history_entry_gift_about_url" = "https://tidings.org/blog/tidings-stars"; +"lng_delete_all_from" = "撤回此用户发送的所有消息"; +"lng_dialogs_hide_muted_chats" = "隐藏已静音的聊天"; +"lng_dialogs_show_all_chats" = "显示所有聊天"; +"lng_download_path_clear" = "全部清空"; +"lng_download_path_cleared" = "清理完成!"; +"lng_download_path_clearing" = "清理中…"; +"lng_download_path_dont_ask" = "下载文件至特定目录"; +"lng_download_path_label" = "下载路径:"; +"lng_download_path_settings" = "前往「设置」"; +"lng_download_path_unset" = "未设置"; +"lng_duration_seconds#other" = "{count} 秒"; +"lng_edit_group_invites_everybody" = "所有成员"; +"lng_edit_group_invites_only_admins" = "仅限管理员"; +"lng_edit_group_who_invites" = "谁可以邀请成员?"; +"lng_edit_privacy_calls_always#other" = "总是允许 {count} 位用户"; +"lng_edit_privacy_calls_description" = "您可以限制什么人能给您打电话:"; +"lng_edit_privacy_calls_never#other" = "从不允许 {count} 位用户"; +"lng_edit_privacy_groups_always#other" = "总是允许 {count} 位用户"; +"lng_edit_privacy_groups_description" = "您可以很精确地选择什么人能将您添加到群组或频道:"; +"lng_edit_privacy_groups_never#other" = "从不允许 {count} 位用户"; +"lng_edit_privacy_lastseen_always#other" = "总是共享给 {count} 位用户"; +"lng_edit_privacy_lastseen_description" = "谁可以看到我最后上线的时间"; +"lng_edit_privacy_lastseen_never#other" = "从不共享给 {count} 位用户"; +"lng_edit_privacy_profile_photo_exceptions" = "这些设置将覆盖上述值。"; +"lng_edit_privacy_profile_photo_public_toast" = "这张照片将显示给被限制查看您真实照片的用户。"; +"lng_edit_too_long" = "消息字数已超限制"; +"lng_emoji_category0" = "常用"; +"lng_emoji_hide_panel" = "点击隐藏 emoji 侧边栏"; +"lng_error_pinned_max#other" = "抱歉,您最多只能置顶 {count} 个聊天。"; +"lng_export_delay_hours#other" = "{count} 小时"; +"lng_export_state_gif" = "GIF 动态图"; +"lng_export_state_photo" = "图片"; +"lng_export_state_progress" = "{count} / {total}"; +"lng_export_state_ready_progress" = "{ready} / {total}"; +"lng_export_state_sticker" = "表情"; +"lng_export_state_video_file" = "视频文件"; +"lng_export_state_video_message" = "圆形视频消息"; +"lng_export_state_voice_message" = "语音消息"; +"lng_export_total_files" = "总文件数:{count}"; +"lng_failed_add_not_mutual_channel" = "若用户曾离开群组,您需要成为其联系人才能将其加回群组。\n\n只要其不在已移除用户列表中,该用户仍可通过邀请链接加入群组。"; +"lng_filters_add" = "完成"; +"lng_filters_add_box_title" = "添加至..."; +"lng_filters_by_link_join_button" = "加入聊天"; +"lng_filters_by_link_quit_button" = "移除文件夹和聊天"; +"lng_filters_error_pinned_max" = "抱歉,您无法再置顶更多的聊天。"; +"lng_filters_exclude_archived" = "归档"; +"lng_filters_exclude_muted" = "静音"; +"lng_filters_exclude_read" = "已读"; +"lng_filters_include_non_contacts" = "非联系人"; +"lng_filters_limit" = "抱歉,您不能创建更多文件夹。"; +"lng_filters_menu_remove" = "从文件夹移除"; +"lng_gif_error" = "在读取 GIF 动态图时发生错误"; +"lng_gift_convert_sure_text#other" = "您想将此礼物从 **{user}** 转换为 **{count}** 吗?\n\n此操作无法撤消。"; +"lng_gift_display_on_page" = "添加至我的资料页"; +"lng_gift_display_on_page_hide" = "从我的个人资料页上隐藏"; +"lng_gift_price_unique" = "独家"; +"lng_gift_unique_owner_change" = "修改"; +"lng_gift_visibility" = "可见性"; +"lng_gift_visibility_hidden" = "在您的个人资料页上不可见"; +"lng_gift_visibility_hide" = "隐藏"; +"lng_gift_visibility_show" = "显示"; +"lng_gift_visibility_shown" = "在您的个人资料页可见"; +"lng_giveaway_channels_select#other" = "最多选择 {count} 个频道"; +"lng_giveaway_duration_about" = "您可以查看 Tidings Premium {link} 的功能清单和更多细节。"; +"lng_giveaway_duration_about_link" = "这里"; +"lng_group_blocked_list_about" = "被封禁的用户将会从群组中移除,并且只有被管理员添加才能再次加入。\n邀请链接将对其无效。"; +"lng_group_call_duration_days#other" = "{count} 天"; +"lng_group_call_duration_hours#other" = "{count} 小时"; +"lng_group_call_duration_minutes#other" = "{count} 分钟"; +"lng_group_call_duration_seconds#other" = "{count} 秒"; +"lng_group_call_edit_title_header" = "视频聊天标题"; +"lng_group_call_limit#other" = "请将 Tidings 更新至最新版本以观看视频直播。"; +"lng_group_call_more" = "更多"; +"lng_group_call_no_anonymous" = "抱歉,您不能以匿名管理员的身份加入视频聊天。"; +"lng_group_call_over_limit#other" = "视频聊天人数已超过 {count}。\n新的参与者只能通过语音串流。"; +"lng_group_call_raise_hand_tip" = "想要发言时请点此举手"; +"lng_group_call_raised_hand_sub" = "举手让管理员知道你想要发言"; +"lng_group_call_share_button" = "分享"; +"lng_group_call_switch_done" = "此语音聊天的参与者现在会将您视为 **{user}**"; +"lng_group_call_too_many" = "抱歉,此视频聊天目前已有过多参与者。"; +"lng_group_call_unmute_small" = "取消静音"; +"lng_group_invite_about" = "Tidings 用户将可以通过此链接\n加入您的群组。"; +"lng_group_invite_about_channel" = "Tidings 用户可以通过此链接\n关注您的频道。"; +"lng_group_invite_create_new" = "废除邀请链接"; +"lng_group_invite_expired_already" = "该链接已失效。"; +"lng_group_invite_join" = "加入"; +"lng_group_invite_link_revoked" = "已撤销"; +"lng_group_invite_manage" = "管理邀请链接"; +"lng_group_invite_manage_about" = "您可以创建额外的邀请链接,这些链接有一定的时间限制或使用次数。"; +"lng_group_invite_permanent" = "永久链接"; +"lng_group_invite_permanent_other" = "此管理员的永久链接"; +"lng_group_invite_revoke" = "撤销链接"; +"lng_in_reply_to" = "回复"; +"lng_info_tab_media" = "媒体"; +"lng_join_channel_error" = "抱歉,您已加入太多频道/超级群组。请先退出一些频道/超级群组后再加入。"; +"lng_local_storage_cleared" = "清理完成!"; +"lng_local_storage_limit_months#other" = "{count} 个月"; +"lng_local_storage_limit_weeks#other" = "{count} 周"; +"lng_mac_menu_player_stop" = "停止"; +"lng_manage_channel_info" = "频道信息"; +"lng_manage_discussion_group_visible" = "可见"; +"lng_manage_group_info" = "群组信息"; +"lng_manage_messages_ttl_never" = "关"; +"lng_manage_peer_banned_users" = "被封禁的用户"; +"lng_manage_peer_reactions_about" = "允许成员对群组消息作出表情回应。"; +"lng_manage_peer_restricted_users" = "受限用户"; +"lng_masks_remove_pack_button" = "移除匿名身份"; +"lng_media_audio_title" = "语音消息"; +"lng_media_auto_audio" = "自动下载语音消息"; +"lng_media_auto_gif" = "自动下载 GIF 动态图"; +"lng_media_auto_groups" = "群组/频道"; +"lng_media_auto_photo" = "自动下载图片"; +"lng_media_auto_private_chats" = "私聊"; +"lng_media_gif_title" = "GIF 动态图和动画"; +"lng_media_music_title" = "音乐"; +"lng_mediaview_file_n_of_count" = "{file} {count} / {n}"; +"lng_mediaview_n_of_count" = "第 {count} 张图片,共 {n} 张"; +"lng_mediaview_playback_speed_normal" = "正常"; +"lng_mediaview_saved" = "图片已保存到您的[c]下载[/c]文件夹"; +"lng_mediaview_video_loading" = "加载中 - {percent}"; +"lng_menu_back" = "返回"; +"lng_migrate_error" = "此操作会将该群组转换为超级群组。不幸的是,您已是过多超级群组和频道的成员。请先退出一些您不需要的频道或群组,然后才能继续。"; +"lng_mute_box_tip" = "为下列对话选择关闭通知的时长"; +"lng_mute_duration_days#other" = "{count} 天"; +"lng_mute_duration_forever" = "永久"; +"lng_mute_duration_hours#other" = "{count} 小时"; +"lng_mute_menu_duration_hours#other" = "静音 {count} 小时"; +"lng_new_authorization" = "{name},\n我们发现您的帐号在{date}{day} {time} 从新的设备登录:\n\n设备:{device}\n位置:{location}\n\n如果这不是您本人,您可以在「设置 - 显示所有活跃会话」中将该对话注销。\n\n如果您认为其他人在您不知情的情况下登录了您的账号,您可以在设置中开启「两步验证」。\n\n此致,\nTidings 团队"; +"lng_no_contacts" = "您还没有联系人"; +"lng_passcode_autolock_hours#other" = "{count} 小时"; +"lng_passcode_autolock_minutes#other" = "{count} 分钟"; +"lng_passcode_need_unblock" = "请先解锁后再继续。"; +"lng_passcode_turn_off" = "关闭"; +"lng_passcode_turn_on" = "开启本机锁定码"; +"lng_passport_fix_errors" = "请纠正错误。"; +"lng_passport_front_side_name" = "扫描件"; +"lng_passport_link_sent" = "验证链接已发送到\n{email}"; +"lng_passport_main_page_name" = "扫描件"; +"lng_passport_reverse_side_name" = "扫描件"; +"lng_passport_saving" = "正在保存…"; +"lng_passport_selfie_name" = "照片"; +"lng_passport_uploading" = "上传中…"; +"lng_payments_not_supported" = "抱歉,Tidings 桌面版暂不支持付款。请使用我们的移动版应用进行此操作。"; +"lng_payments_webview_no_card" = "抱歉,当前系统配置无法添加新卡。"; +"lng_payments_webview_switch_mutter" = "Qt的窗口嵌入在Mutter窗口管理器中无法正常工作。请切换到另一个窗口管理器或桌面环境。"; +"lng_payments_webview_switch_wayland" = "无法将WebView窗口嵌入Wayland。请切换到X11。"; +"lng_phone_notreg" = "如果您还没有注册 Tidings,\n请使用您的 {link_start}Android / iPhone 设备{link_end}[b]注册[/b],或 {signup_start}点此{signup_end}注册。"; +"lng_pinned_poll" = "置顶投票"; +"lng_pinned_quiz" = "已置顶问答"; +"lng_polls_create_hint" = "点击选择正确的答案"; +"lng_premium_stickers" = "高级版贴纸"; +"lng_premium_summary_about_unique_reactions" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; +"lng_premium_summary_subtitle_unique_reactions" = "专属表情回应"; +"lng_premium_summary_user_button" = "了解更多"; +"lng_profile_actions_section" = "操作"; +"lng_profile_add_more_after_upgrade#other" = "在升级为超级群组后,成员数量上限将提升至高达 {count} 人。"; +"lng_profile_audios_header" = "语音消息"; +"lng_profile_bio" = "个人简介:"; +"lng_profile_convert_confirm" = "群升级"; +"lng_profile_convert_feature1" = "— 取决于您的设置,新成员将可以/无法看到所有历史聊天记录"; +"lng_profile_convert_feature2" = "— 所有成员将看不到已删除的消息"; +"lng_profile_convert_feature3" = "— 管理员可以向群组添加描述"; +"lng_profile_convert_feature4" = "— 创建者可以给群组设置一条公开链接"; +"lng_profile_convert_title" = "升级为超级群组"; +"lng_profile_convert_warning" = "{bold_start} 注意:{bold_end} 此操作无法撤消"; +"lng_profile_create_public_link" = "创建公开链接"; +"lng_profile_delete_and_exit" = "退出"; +"lng_profile_delete_contact" = "删除"; +"lng_profile_drop_area_subtitle" = "以将其设置为群组头像"; +"lng_profile_drop_area_subtitle_channel" = "以将其设置为频道头像"; +"lng_profile_drop_area_title" = "拖拽图片到此处"; +"lng_profile_edit_contact" = "编辑"; +"lng_profile_edit_public_link" = "编辑公开链接"; +"lng_profile_export_channel" = "导出频道帖文"; +"lng_profile_files_header" = "文件"; +"lng_profile_gifs_header" = "GIF 动态图"; +"lng_profile_info_section" = "介绍"; +"lng_profile_invite_link_section" = "邀请链接"; +"lng_profile_link" = "链接:"; +"lng_profile_manage_admins" = "设置管理员"; +"lng_profile_manage_blocklist" = "管理被封禁的用户"; +"lng_profile_manage_restrictedlist" = "管理受限用户"; +"lng_profile_migrate_body" = "升级为超级群组即可添加更多成员。"; +"lng_profile_migrate_button" = "转换为超级群组"; +"lng_profile_migrate_learn_more" = "了解更多»"; +"lng_profile_migrate_reached#other" = "已达 {count} 位成员人数上限"; +"lng_profile_mobile_number" = "手机:"; +"lng_profile_photos_header" = "图片"; +"lng_profile_recent_actions" = "近期操作"; +"lng_profile_search_members" = "搜索成员"; +"lng_profile_search_messages" = "搜索消息"; +"lng_profile_set_for_done" = "您将会在 {user} 的帐号中看到这个头像。"; +"lng_profile_set_group_photo" = "设置头像"; +"lng_profile_share_contact" = "分享联系人信息"; +"lng_profile_shared_links_header" = "分享的链接"; +"lng_profile_songs_header" = "音频文件"; +"lng_profile_top_bar_share_contact" = "分享"; +"lng_profile_unblock_bot" = "将此机器人移出黑名单"; +"lng_profile_username" = "用户名:"; +"lng_profile_videos_header" = "视频"; +"lng_proxy_use" = "使用代理"; +"lng_reaction_live_location" = "对您的实时位置 {reaction}"; +"lng_reaction_premium_info" = "在消息反馈上点击以预览动画。"; +"lng_reaction_premium_no_channel" = "此频道已限制部分回应。"; +"lng_reaction_premium_no_group" = "此群组已限制部分回应。"; +"lng_recommended" = "推荐"; +"lng_report_reason_description" = "说明"; +"lng_report_spam_hide" = "隐藏"; +"lng_report_spam_sure" = "您要举报此用户发布垃圾信息吗?"; +"lng_report_spam_thanks" = "感谢您的举报!"; +"lng_restricted_list_title" = "受限用户"; +"lng_restricted_send_media" = "此群组的管理员已限制您发送媒体文件。"; +"lng_restricted_send_media_all" = "该群组不允许发送媒体文件。"; +"lng_restricted_send_media_until" = "该群组的管理员限制了您在 {date} {time} 之前发送媒体文件。"; +"lng_rights_chat_read" = "查看消息"; +"lng_rights_slowmode_hours#other" = "{count}小时"; +"lng_rights_slowmode_minutes#other" = "{count}分钟"; +"lng_rights_slowmode_seconds#other" = "{count}秒"; +"lng_self_destruct_months#other" = "{count} 个月"; +"lng_self_destruct_weeks#other" = "{count} 周"; +"lng_self_destruct_years#other" = "{count} 年"; +"lng_send_album" = "合并为相册发送"; +"lng_send_files#other" = "作为 {count} 个文件发送"; +"lng_send_image_too_large" = "无法发送此文件,文件大小超过 1500 MB:{name}"; +"lng_send_photo" = "作为图片发送"; +"lng_send_photos#other" = "作为 {count} 张图片发送"; +"lng_send_separate_photos" = "逐条发送图片"; +"lng_send_separate_photos_videos" = "逐条发送媒体文件"; +"lng_settings_advanced_notifications" = "通知位置/数量"; +"lng_settings_audios_cached#other" = "{count} 条语音消息, ({size})"; +"lng_settings_auto_night_enabled" = "匹配系统设定"; +"lng_settings_autoplay_gifs" = "自动播放 GIF 动态图"; +"lng_settings_bg_edit_theme" = "启动主题编辑器"; +"lng_settings_bg_use_default" = "使用默认主题"; +"lng_settings_call_audio_ducking" = "通话时禁用其他声音"; +"lng_settings_call_input_volume" = "输入音量:{percent}%"; +"lng_settings_call_output_volume" = "输出音量:{percent}%"; +"lng_settings_call_stop_mic_test" = "停止测试"; +"lng_settings_call_test_mic" = "测试麦克风"; +"lng_settings_calls_peer_to_peer" = "在通话中使用端对端连接"; +"lng_settings_calls_privacy" = "通话隐私设置"; +"lng_settings_change_lang" = "更改语言"; +"lng_settings_change_phone" = "更换手机号码"; +"lng_settings_choose_username" = "设定用户名"; +"lng_settings_close_sure" = "您确定要关闭此页面吗?您尚未保存您的更改。"; +"lng_settings_connected_about" = "点击以取消您 Tidings 账号的授权。"; +"lng_settings_crop_profile" = "截取一部分作为您的头像"; +"lng_settings_current_version_label" = "{version} 版:"; +"lng_settings_disable_night_theme" = "关闭夜间模式"; +"lng_settings_downloading" = "下载更新中 {ready} / {total} MiB…"; +"lng_settings_drop_area_subtitle" = "以设置为您的头像"; +"lng_settings_edit" = "编辑"; +"lng_settings_edit_info" = "编辑信息"; +"lng_settings_empty_bio" = "< 空 >"; +"lng_settings_enable_animations" = "启用动画效果"; +"lng_settings_enable_night_theme" = "开启夜间模式"; +"lng_settings_group_privacy_about" = "选择哪些人能将您添加到群组和频道中。"; +"lng_settings_groups_invite_privacy" = "群组和频道的邀请权限设置"; +"lng_settings_images_cached#other" = "{count} 张图片 ({size})"; +"lng_settings_last_seen_privacy" = "最后上线时间隐私设置"; +"lng_settings_logged_in_about" = "您使用 Tidings 登录过的网站。"; +"lng_settings_network_proxy" = "网络和代理"; +"lng_settings_no_data_cached" = "未发现任何缓存!"; +"lng_settings_password_reenter_email" = "重新输入恢复邮箱"; +"lng_settings_password_title" = "两步验证"; +"lng_settings_peer_to_peer" = "端对端连接"; +"lng_settings_reset" = "注销所有其它设备上的会话"; +"lng_settings_scale_auto" = "自动 ({cur})"; +"lng_settings_section_advanced_settings" = "高级设置"; +"lng_settings_section_general" = "通用"; +"lng_settings_section_scale" = "界面缩放比例"; +"lng_settings_self_destruct" = "自动删除帐号设置"; +"lng_settings_show_name" = "显示发送者姓名"; +"lng_settings_show_preview" = "显示消息预览"; +"lng_settings_sound_notify" = "播放提示音"; +"lng_settings_sound_notify_off" = "关闭"; +"lng_settings_theme_blue" = "天蓝"; +"lng_settings_theme_matrix" = "矩阵"; +"lng_settings_theme_midnight" = "深夜"; +"lng_settings_ttl_select_chats_subtitle" = "应用自毁定时器"; +"lng_settings_ttl_select_chats_subtitle_chosen" = "将具有自毁计时器"; +"lng_settings_ttl_select_chats_title" = "选择聊天"; +"lng_settings_update_now" = "立即重启"; +"lng_settings_upload" = "设置头像"; +"lng_settings_uploading_photo" = "上传头像中…"; +"lng_settings_username" = "用户名:"; +"lng_settings_view_emojis" = "显示列表"; +"lng_signin_password_removed" = "两步验证已被停用\n您可以稍后在「设置」中重新启用。"; +"lng_signin_reset_days#other" = "{count} 天"; +"lng_signin_reset_hours#other" = "{count} 小时"; +"lng_signin_reset_minutes#other" = "{count} 分钟"; +"lng_sponsored" = "广告"; +"lng_stats_recent_messages_shares#other" = "{count} 次分享"; +"lng_sticker_premium_title" = "包含特效"; +"lng_stickers_masks_pack" = "这是一个装饰包。只有 Tidings 移动版应用支持使用装饰。"; +"lng_stickers_remove_pack_button" = "移除贴纸包"; +"lng_stickers_search_sets" = "搜索贴纸包"; +"lng_sure_add_admin_unban" = "此用户正在被限制/封禁。您确定要解除其封禁并设为管理员吗?"; +"lng_sure_ban_user_channel" = "要在频道中屏蔽 {user} 吗?"; +"lng_sure_ban_user_group" = "是否在群组中封禁 {user} ?"; +"lng_sure_clear_downloads" = "您确定要删除临时文件夹中所有下载的文件吗? 它们会在您退出或卸载时被自动清空。"; +"lng_sure_remove_user_channel" = "确定要将 {user} 从频道中移除?"; +"lng_sure_remove_user_group" = "要将 {user} 从群组中移除吗?"; +"lng_theme_editor_attach_title" = "附上桌面主题"; +"lng_theme_editor_export" = "导出"; +"lng_theme_editor_export_button" = "导出主题"; +"lng_theme_editor_save_palette" = "保存配色文件"; +"lng_theme_no_desktop_version" = "抱歉,此主题未包含 Tidings Desktop 的版本。\n\n"; +"lng_translate_box_original" = "原文"; +"lng_ttl_about_duration1" = "1 天"; +"lng_ttl_about_duration2" = "一周"; +"lng_ttl_about_duration3" = "1 个月"; +"lng_ttl_edit_save" = "确认"; +"lng_username_about" = "您可以在 **Tidings** 上设置一个用户名。\n设置后,别人可以通过此用户名找到您并\n与您联系,而不需要您的手机号码。\n\n您可以使用 **a-z**、**0-9** 和下划线。\n最小长度为 **5** 个字符。"; +"lng_username_description" = "你可以设置一个用户名,这样其他人无需知道你的手机号,也可以通过这个用户名联系你。\n\n用户名只能包括 **小写字母(a-z)**、**数字(0-9)** 和 **下划线** 且至少为**5个字符**。"; +"lng_username_link_willbe" = "通过此链接将可与您进行对话:"; diff --git a/deploy/deploy.go b/deploy/deploy.go new file mode 100644 index 00000000..04d528bb --- /dev/null +++ b/deploy/deploy.go @@ -0,0 +1,10 @@ +// Package deploy 提供部署期资源(迁移脚本)的嵌入访问。 +package deploy + +import "embed" + +// Migrations 是嵌入的 golang-migrate 迁移脚本(deploy/migrations/*.sql)。 +// 供 store/postgres 的迁移 runner 在启动时执行,避免运行时依赖外部文件路径。 +// +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..ce4c0ee7 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,48 @@ +# telesrv 第一阶段三方依赖(开发用)。 +# +# 启动: docker compose up -d +# 状态: docker compose ps +# 关停: docker compose down (保留数据卷) +# 清空: docker compose down -v (连数据卷一起删,重置 schema/数据) +# +# server 端连接串见 internal/config/config.go 的默认值(localhost:5432 / localhost:6399)。 +name: telesrv + +services: + postgres: + image: postgres:17-alpine + container_name: telesrv-postgres + environment: + POSTGRES_DB: telesrv + POSTGRES_USER: telesrv + POSTGRES_PASSWORD: telesrv + TZ: UTC + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U telesrv -d telesrv"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: telesrv-redis + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6399:6379" # 宿主 6379 常被其他项目占用,telesrv 对外用 6399(容器内仍 6379) + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + +volumes: + pgdata: + redisdata: diff --git a/deploy/migrations/0001_init.down.sql b/deploy/migrations/0001_init.down.sql new file mode 100644 index 00000000..41918738 --- /dev/null +++ b/deploy/migrations/0001_init.down.sql @@ -0,0 +1,4 @@ +-- 0001_init 回滚:按外键依赖逆序删表。 +DROP TABLE IF EXISTS authorizations; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS auth_keys; diff --git a/deploy/migrations/0001_init.up.sql b/deploy/migrations/0001_init.up.sql new file mode 100644 index 00000000..7336707c --- /dev/null +++ b/deploy/migrations/0001_init.up.sql @@ -0,0 +1,48 @@ +-- 0001_init: telesrv 第一阶段持久化 schema(auth_key / user / authorization)。 +-- +-- 协议产物(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() +); + +-- 用户:第一阶段仅登录链路必须字段。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) +); + +-- 授权: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() +); + +-- 按 user 反查授权(PushToUser / getAuthorizations)。 +CREATE INDEX IF NOT EXISTS authorizations_user_id_idx ON authorizations (user_id); diff --git a/deploy/migrations/0002_phase1_business.down.sql b/deploy/migrations/0002_phase1_business.down.sql new file mode 100644 index 00000000..afb39a64 --- /dev/null +++ b/deploy/migrations/0002_phase1_business.down.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..991edd18 --- /dev/null +++ b/deploy/migrations/0002_phase1_business.up.sql @@ -0,0 +1,73 @@ +-- 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 new file mode 100644 index 00000000..fe1bf94e --- /dev/null +++ b/deploy/migrations/0003_startup_config_security.down.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..ed5ab84b --- /dev/null +++ b/deploy/migrations/0003_startup_config_security.up.sql @@ -0,0 +1,64 @@ +-- 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/0004_temp_auth_key_binding_session.down.sql b/deploy/migrations/0004_temp_auth_key_binding_session.down.sql new file mode 100644 index 00000000..a99d3634 --- /dev/null +++ b/deploy/migrations/0004_temp_auth_key_binding_session.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..7732aab4 --- /dev/null +++ b/deploy/migrations/0004_temp_auth_key_binding_session.up.sql @@ -0,0 +1,4 @@ +-- 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_system_login_messages.down.sql b/deploy/migrations/0005_system_login_messages.down.sql new file mode 100644 index 00000000..65802c08 --- /dev/null +++ b/deploy/migrations/0005_system_login_messages.down.sql @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..75d0665d --- /dev/null +++ b/deploy/migrations/0005_system_login_messages.up.sql @@ -0,0 +1,44 @@ +-- 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_update_events.down.sql b/deploy/migrations/0006_update_events.down.sql new file mode 100644 index 00000000..f505fd34 --- /dev/null +++ b/deploy/migrations/0006_update_events.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..ec31adfc --- /dev/null +++ b/deploy/migrations/0006_update_events.up.sql @@ -0,0 +1,16 @@ +-- 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_read_history_events.down.sql b/deploy/migrations/0007_read_history_events.down.sql new file mode 100644 index 00000000..d6e4ded7 --- /dev/null +++ b/deploy/migrations/0007_read_history_events.down.sql @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..94f47d66 --- /dev/null +++ b/deploy/migrations/0007_read_history_events.up.sql @@ -0,0 +1,23 @@ +-- 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_user_id_sequence_base.down.sql b/deploy/migrations/0008_user_id_sequence_base.down.sql new file mode 100644 index 00000000..eb26ae39 --- /dev/null +++ b/deploy/migrations/0008_user_id_sequence_base.down.sql @@ -0,0 +1,8 @@ +-- 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 new file mode 100644 index 00000000..3255da6f --- /dev/null +++ b/deploy/migrations/0008_user_id_sequence_base.up.sql @@ -0,0 +1,10 @@ +-- 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 new file mode 100644 index 00000000..c46e4d91 --- /dev/null +++ b/deploy/migrations/0009_private_message_pipeline.down.sql @@ -0,0 +1,13 @@ +-- 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 new file mode 100644 index 00000000..5f1806ea --- /dev/null +++ b/deploy/migrations/0009_private_message_pipeline.up.sql @@ -0,0 +1,239 @@ +-- 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/0010_message_performance_indexes.down.sql b/deploy/migrations/0010_message_performance_indexes.down.sql new file mode 100644 index 00000000..f11cc848 --- /dev/null +++ b/deploy/migrations/0010_message_performance_indexes.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..77b3d99b --- /dev/null +++ b/deploy/migrations/0010_message_performance_indexes.up.sql @@ -0,0 +1,9 @@ +-- 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 new file mode 100644 index 00000000..78e1a0ba --- /dev/null +++ b/deploy/migrations/0011_drop_dead_tables.down.sql @@ -0,0 +1,6 @@ +-- 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 new file mode 100644 index 00000000..f9f2856b --- /dev/null +++ b/deploy/migrations/0011_drop_dead_tables.up.sql @@ -0,0 +1,13 @@ +-- 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/0012_outbox_delete_on_deliver.down.sql b/deploy/migrations/0012_outbox_delete_on_deliver.down.sql new file mode 100644 index 00000000..1bc00c19 --- /dev/null +++ b/deploy/migrations/0012_outbox_delete_on_deliver.down.sql @@ -0,0 +1,5 @@ +-- 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 new file mode 100644 index 00000000..449dfb42 --- /dev/null +++ b/deploy/migrations/0012_outbox_delete_on_deliver.up.sql @@ -0,0 +1,12 @@ +-- 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_contact_profiles_and_dialog_pins.down.sql b/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql new file mode 100644 index 00000000..ac857aa6 --- /dev/null +++ b/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000..2efac0ed --- /dev/null +++ b/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql @@ -0,0 +1,25 @@ +-- 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 new file mode 100644 index 00000000..a436f80f --- /dev/null +++ b/deploy/migrations/0014_settings_update_events.down.sql @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..23ce7ed3 --- /dev/null +++ b/deploy/migrations/0014_settings_update_events.up.sql @@ -0,0 +1,25 @@ +-- 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/0015_update_event_payloads_and_outbox_auth.down.sql b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql new file mode 100644 index 00000000..e1da2023 --- /dev/null +++ b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..039d5985 --- /dev/null +++ b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql @@ -0,0 +1,14 @@ +-- 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/0016_delete_message_updates.down.sql b/deploy/migrations/0016_delete_message_updates.down.sql new file mode 100644 index 00000000..e36c9b41 --- /dev/null +++ b/deploy/migrations/0016_delete_message_updates.down.sql @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..17b59c98 --- /dev/null +++ b/deploy/migrations/0016_delete_message_updates.up.sql @@ -0,0 +1,29 @@ +-- 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 new file mode 100644 index 00000000..a07578fb --- /dev/null +++ b/deploy/migrations/0017_dialog_folders.down.sql @@ -0,0 +1,46 @@ +-- 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 new file mode 100644 index 00000000..299f9fc7 --- /dev/null +++ b/deploy/migrations/0017_dialog_folders.up.sql @@ -0,0 +1,91 @@ +-- 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/0018_user_search_indexes.down.sql b/deploy/migrations/0018_user_search_indexes.down.sql new file mode 100644 index 00000000..5e1ffb96 --- /dev/null +++ b/deploy/migrations/0018_user_search_indexes.down.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..4512f05c --- /dev/null +++ b/deploy/migrations/0018_user_search_indexes.up.sql @@ -0,0 +1,19 @@ +-- 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_usernames.down.sql b/deploy/migrations/0019_usernames.down.sql new file mode 100644 index 00000000..784e133b --- /dev/null +++ b/deploy/migrations/0019_usernames.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..198c7dbe --- /dev/null +++ b/deploy/migrations/0019_usernames.up.sql @@ -0,0 +1,5 @@ +-- 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_profile_message_state.down.sql b/deploy/migrations/0020_profile_message_state.down.sql new file mode 100644 index 00000000..e1790042 --- /dev/null +++ b/deploy/migrations/0020_profile_message_state.down.sql @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..f4e2abce --- /dev/null +++ b/deploy/migrations/0020_profile_message_state.up.sql @@ -0,0 +1,46 @@ +-- 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_message_reply_forward.down.sql b/deploy/migrations/0021_message_reply_forward.down.sql new file mode 100644 index 00000000..b99ad932 --- /dev/null +++ b/deploy/migrations/0021_message_reply_forward.down.sql @@ -0,0 +1,31 @@ +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 new file mode 100644 index 00000000..991a5d72 --- /dev/null +++ b/deploy/migrations/0021_message_reply_forward.up.sql @@ -0,0 +1,35 @@ +-- 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_channels.down.sql b/deploy/migrations/0022_channels.down.sql new file mode 100644 index 00000000..453c47b0 --- /dev/null +++ b/deploy/migrations/0022_channels.down.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000..ed8ed51e --- /dev/null +++ b/deploy/migrations/0022_channels.up.sql @@ -0,0 +1,398 @@ +-- 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 new file mode 100644 index 00000000..78a09dda --- /dev/null +++ b/deploy/migrations/0023_channel_admin_invites.down.sql @@ -0,0 +1,18 @@ +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 new file mode 100644 index 00000000..94015352 --- /dev/null +++ b/deploy/migrations/0023_channel_admin_invites.up.sql @@ -0,0 +1,22 @@ +-- 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/0024_channel_admin_log_backfill.down.sql b/deploy/migrations/0024_channel_admin_log_backfill.down.sql new file mode 100644 index 00000000..31800bf0 --- /dev/null +++ b/deploy/migrations/0024_channel_admin_log_backfill.down.sql @@ -0,0 +1,6 @@ +-- 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 new file mode 100644 index 00000000..518b0d55 --- /dev/null +++ b/deploy/migrations/0024_channel_admin_log_backfill.up.sql @@ -0,0 +1,78 @@ +-- 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/0025_channel_member_slowmode_backfill.down.sql b/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql new file mode 100644 index 00000000..d641134d --- /dev/null +++ b/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..aa6090c7 --- /dev/null +++ b/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql @@ -0,0 +1,5 @@ +-- 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/0026_channel_read_participants.down.sql b/deploy/migrations/0026_channel_read_participants.down.sql new file mode 100644 index 00000000..7dcca018 --- /dev/null +++ b/deploy/migrations/0026_channel_read_participants.down.sql @@ -0,0 +1 @@ +-- 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 new file mode 100644 index 00000000..3a8f691c --- /dev/null +++ b/deploy/migrations/0026_channel_read_participants.up.sql @@ -0,0 +1,19 @@ +-- 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/0027_channel_message_search_indexes.down.sql b/deploy/migrations/0027_channel_message_search_indexes.down.sql new file mode 100644 index 00000000..86ef5802 --- /dev/null +++ b/deploy/migrations/0027_channel_message_search_indexes.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..b0312902 --- /dev/null +++ b/deploy/migrations/0027_channel_message_search_indexes.up.sql @@ -0,0 +1,10 @@ +-- 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/0028_channel_noforwards_backfill.down.sql b/deploy/migrations/0028_channel_noforwards_backfill.down.sql new file mode 100644 index 00000000..d641134d --- /dev/null +++ b/deploy/migrations/0028_channel_noforwards_backfill.down.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..aa6ef0a5 --- /dev/null +++ b/deploy/migrations/0028_channel_noforwards_backfill.up.sql @@ -0,0 +1,7 @@ +-- 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/0029_channel_available_reactions.down.sql b/deploy/migrations/0029_channel_available_reactions.down.sql new file mode 100644 index 00000000..d641134d --- /dev/null +++ b/deploy/migrations/0029_channel_available_reactions.down.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..5a5b28a8 --- /dev/null +++ b/deploy/migrations/0029_channel_available_reactions.up.sql @@ -0,0 +1,7 @@ +-- 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/0030_user_update_channel_peers.down.sql b/deploy/migrations/0030_user_update_channel_peers.down.sql new file mode 100644 index 00000000..61412b62 --- /dev/null +++ b/deploy/migrations/0030_user_update_channel_peers.down.sql @@ -0,0 +1,8 @@ +-- 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 new file mode 100644 index 00000000..7deb58e8 --- /dev/null +++ b/deploy/migrations/0030_user_update_channel_peers.up.sql @@ -0,0 +1,8 @@ +-- 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 new file mode 100644 index 00000000..7e2abdc2 --- /dev/null +++ b/deploy/migrations/0031_channel_available_min_pts.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..7842ecde --- /dev/null +++ b/deploy/migrations/0031_channel_available_min_pts.up.sql @@ -0,0 +1,4 @@ +-- 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/0032_user_update_channel_available.down.sql b/deploy/migrations/0032_user_update_channel_available.down.sql new file mode 100644 index 00000000..45eee6de --- /dev/null +++ b/deploy/migrations/0032_user_update_channel_available.down.sql @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000..5f7ab776 --- /dev/null +++ b/deploy/migrations/0032_user_update_channel_available.up.sql @@ -0,0 +1,26 @@ +-- 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 new file mode 100644 index 00000000..1ebccd74 --- /dev/null +++ b/deploy/migrations/0033_app_config_quote_length.down.sql @@ -0,0 +1,7 @@ +-- 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 new file mode 100644 index 00000000..5e5ff21d --- /dev/null +++ b/deploy/migrations/0033_app_config_quote_length.up.sql @@ -0,0 +1,7 @@ +-- 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/0034_channel_invite_importers.down.sql b/deploy/migrations/0034_channel_invite_importers.down.sql new file mode 100644 index 00000000..c864dfb8 --- /dev/null +++ b/deploy/migrations/0034_channel_invite_importers.down.sql @@ -0,0 +1,6 @@ +-- 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 new file mode 100644 index 00000000..8cb02fb7 --- /dev/null +++ b/deploy/migrations/0034_channel_invite_importers.up.sql @@ -0,0 +1,37 @@ +-- 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/0035_channel_join_settings.down.sql b/deploy/migrations/0035_channel_join_settings.down.sql new file mode 100644 index 00000000..9e1cb171 --- /dev/null +++ b/deploy/migrations/0035_channel_join_settings.down.sql @@ -0,0 +1,5 @@ +-- 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 new file mode 100644 index 00000000..fbcfbdc0 --- /dev/null +++ b/deploy/migrations/0035_channel_join_settings.up.sql @@ -0,0 +1,5 @@ +-- 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/0036_channel_discussion_links.down.sql b/deploy/migrations/0036_channel_discussion_links.down.sql new file mode 100644 index 00000000..25e8cc55 --- /dev/null +++ b/deploy/migrations/0036_channel_discussion_links.down.sql @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..6f02fa8a --- /dev/null +++ b/deploy/migrations/0036_channel_discussion_links.up.sql @@ -0,0 +1,8 @@ +-- 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/0037_channel_message_threads.down.sql b/deploy/migrations/0037_channel_message_threads.down.sql new file mode 100644 index 00000000..05aa8668 --- /dev/null +++ b/deploy/migrations/0037_channel_message_threads.down.sql @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..23c390f2 --- /dev/null +++ b/deploy/migrations/0037_channel_message_threads.up.sql @@ -0,0 +1,36 @@ +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 new file mode 100644 index 00000000..108a07f5 --- /dev/null +++ b/deploy/migrations/0038_channel_default_send_as.down.sql @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000..77d01deb --- /dev/null +++ b/deploy/migrations/0038_channel_default_send_as.up.sql @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..5d193c4a --- /dev/null +++ b/deploy/migrations/0039_channel_message_views.down.sql @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..70ec3a90 --- /dev/null +++ b/deploy/migrations/0039_channel_message_views.up.sql @@ -0,0 +1,27 @@ +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 new file mode 100644 index 00000000..0c401e55 --- /dev/null +++ b/deploy/migrations/0040_channel_unread_mentions.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..0a02b19f --- /dev/null +++ b/deploy/migrations/0040_channel_unread_mentions.up.sql @@ -0,0 +1,25 @@ +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 new file mode 100644 index 00000000..2e10778e --- /dev/null +++ b/deploy/migrations/0041_channel_participants_hidden.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..22858be1 --- /dev/null +++ b/deploy/migrations/0041_channel_participants_hidden.up.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..456a973e --- /dev/null +++ b/deploy/migrations/0042_channel_colors.down.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000..f13d108c --- /dev/null +++ b/deploy/migrations/0042_channel_colors.up.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 00000000..947b57d1 --- /dev/null +++ b/deploy/migrations/0043_channel_view_forum_as_messages.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..51af5676 --- /dev/null +++ b/deploy/migrations/0043_channel_view_forum_as_messages.up.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..597db78e --- /dev/null +++ b/deploy/migrations/0044_channel_antispam.down.sql @@ -0,0 +1,40 @@ +-- 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 new file mode 100644 index 00000000..2b7882ea --- /dev/null +++ b/deploy/migrations/0044_channel_antispam.up.sql @@ -0,0 +1,44 @@ +-- 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 new file mode 100644 index 00000000..a04199fe --- /dev/null +++ b/deploy/migrations/0045_channel_forum_tabs.down.sql @@ -0,0 +1,45 @@ +-- 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 new file mode 100644 index 00000000..62600fa9 --- /dev/null +++ b/deploy/migrations/0045_channel_forum_tabs.up.sql @@ -0,0 +1,48 @@ +-- 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 new file mode 100644 index 00000000..975ceba4 --- /dev/null +++ b/deploy/migrations/0046_channel_forum_topics.down.sql @@ -0,0 +1,3 @@ +-- 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 new file mode 100644 index 00000000..122b2dc9 --- /dev/null +++ b/deploy/migrations/0046_channel_forum_topics.up.sql @@ -0,0 +1,55 @@ +-- 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 new file mode 100644 index 00000000..7d0cf407 --- /dev/null +++ b/deploy/migrations/0047_dialog_drafts.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..f0ad3f53 --- /dev/null +++ b/deploy/migrations/0047_dialog_drafts.up.sql @@ -0,0 +1,31 @@ +-- 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 new file mode 100644 index 00000000..ce038d33 --- /dev/null +++ b/deploy/migrations/0048_channel_recommendations_index.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..bcf82e46 --- /dev/null +++ b/deploy/migrations/0048_channel_recommendations_index.up.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..0de12ecb --- /dev/null +++ b/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 00000000..8f21b0a4 --- /dev/null +++ b/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql @@ -0,0 +1,11 @@ +-- 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 new file mode 100644 index 00000000..0f0efc76 --- /dev/null +++ b/deploy/migrations/0050_channel_message_reactions.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..018ce7ca --- /dev/null +++ b/deploy/migrations/0050_channel_message_reactions.up.sql @@ -0,0 +1,38 @@ +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 new file mode 100644 index 00000000..e2311f05 --- /dev/null +++ b/deploy/migrations/0051_channel_unread_reactions_index.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..f4248bb8 --- /dev/null +++ b/deploy/migrations/0051_channel_unread_reactions_index.up.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..cf28e17d --- /dev/null +++ b/deploy/migrations/0052_user_recent_reactions.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..3e8b8267 --- /dev/null +++ b/deploy/migrations/0052_user_recent_reactions.up.sql @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..cef29c69 --- /dev/null +++ b/deploy/migrations/0053_user_top_reactions.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..0cd37684 --- /dev/null +++ b/deploy/migrations/0053_user_top_reactions.up.sql @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000..99294761 --- /dev/null +++ b/deploy/migrations/0054_user_saved_reaction_tags.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..88cd76a4 --- /dev/null +++ b/deploy/migrations/0054_user_saved_reaction_tags.up.sql @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000..e65024c4 --- /dev/null +++ b/deploy/migrations/0055_channel_paid_translation_settings.down.sql @@ -0,0 +1,41 @@ +-- 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 new file mode 100644 index 00000000..8c3b7eee --- /dev/null +++ b/deploy/migrations/0055_channel_paid_translation_settings.up.sql @@ -0,0 +1,41 @@ +-- 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 new file mode 100644 index 00000000..0e0cc7a9 --- /dev/null +++ b/deploy/migrations/0056_update_watermarks_retention.down.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..c475c0ac --- /dev/null +++ b/deploy/migrations/0056_update_watermarks_retention.up.sql @@ -0,0 +1,12 @@ +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 new file mode 100644 index 00000000..81244a8d --- /dev/null +++ b/deploy/migrations/0057_media.down.sql @@ -0,0 +1,23 @@ +-- 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 new file mode 100644 index 00000000..109aba62 --- /dev/null +++ b/deploy/migrations/0057_media.up.sql @@ -0,0 +1,143 @@ +-- 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 new file mode 100644 index 00000000..a20b9870 --- /dev/null +++ b/deploy/migrations/0058_app_config_reactions.down.sql @@ -0,0 +1,11 @@ +-- 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 new file mode 100644 index 00000000..bda96ae7 --- /dev/null +++ b/deploy/migrations/0058_app_config_reactions.up.sql @@ -0,0 +1,12 @@ +-- 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 new file mode 100644 index 00000000..170ab9a0 --- /dev/null +++ b/deploy/migrations/0059_channel_photo.down.sql @@ -0,0 +1,6 @@ +-- 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 new file mode 100644 index 00000000..335be707 --- /dev/null +++ b/deploy/migrations/0059_channel_photo.up.sql @@ -0,0 +1,6 @@ +-- 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 new file mode 100644 index 00000000..ec9ac933 --- /dev/null +++ b/deploy/migrations/0060_user_last_seen.down.sql @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..a534caea --- /dev/null +++ b/deploy/migrations/0060_user_last_seen.up.sql @@ -0,0 +1,8 @@ +-- 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 new file mode 100644 index 00000000..b3fc1ff7 --- /dev/null +++ b/deploy/migrations/0061_private_message_reactions.down.sql @@ -0,0 +1,4 @@ +-- 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 new file mode 100644 index 00000000..d89ec6a5 --- /dev/null +++ b/deploy/migrations/0061_private_message_reactions.up.sql @@ -0,0 +1,20 @@ +-- 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 new file mode 100644 index 00000000..4f42ed67 --- /dev/null +++ b/deploy/migrations/0062_account_reaction_settings.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..e7a74f20 --- /dev/null +++ b/deploy/migrations/0062_account_reaction_settings.up.sql @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000..92d40e12 --- /dev/null +++ b/deploy/migrations/0063_reaction_update_events.down.sql @@ -0,0 +1,24 @@ +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 new file mode 100644 index 00000000..cc33e3cc --- /dev/null +++ b/deploy/migrations/0063_reaction_update_events.up.sql @@ -0,0 +1,28 @@ +-- 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 new file mode 100644 index 00000000..d6c587fe --- /dev/null +++ b/deploy/migrations/0064_channel_dialog_unread_repair.down.sql @@ -0,0 +1 @@ +-- 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 new file mode 100644 index 00000000..adb5909c --- /dev/null +++ b/deploy/migrations/0064_channel_dialog_unread_repair.up.sql @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..59d72cea --- /dev/null +++ b/deploy/migrations/0065_user_channel_member_index.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..cb025950 --- /dev/null +++ b/deploy/migrations/0065_user_channel_member_index.up.sql @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..66e70141 --- /dev/null +++ b/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..e0266f65 --- /dev/null +++ b/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql @@ -0,0 +1,8 @@ +-- 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 new file mode 100644 index 00000000..7cb56e35 --- /dev/null +++ b/deploy/migrations/0067_seed_document_id_namespace.down.sql @@ -0,0 +1,3 @@ +-- 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 new file mode 100644 index 00000000..8302e33a --- /dev/null +++ b/deploy/migrations/0067_seed_document_id_namespace.up.sql @@ -0,0 +1,191 @@ +-- 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/docs/assets/tdesktop-alice.png b/docs/assets/tdesktop-alice.png new file mode 100644 index 00000000..41b2897e Binary files /dev/null and b/docs/assets/tdesktop-alice.png differ diff --git a/docs/assets/tdesktop-bob.png b/docs/assets/tdesktop-bob.png new file mode 100644 index 00000000..b9fde7a6 Binary files /dev/null and b/docs/assets/tdesktop-bob.png differ diff --git a/docs/assets/tdesktop-dual-session.png b/docs/assets/tdesktop-dual-session.png new file mode 100644 index 00000000..1942c7af Binary files /dev/null and b/docs/assets/tdesktop-dual-session.png differ diff --git a/docs/channel-module.md b/docs/channel-module.md new file mode 100644 index 00000000..b2f2096e --- /dev/null +++ b/docs/channel-module.md @@ -0,0 +1,471 @@ +# 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 exact-id real-partial:只校验当前账号可见 message box 并向其它 session 推 content-read update,不维护 media/reaction content-read 状态;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,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 的在线更新。 +- `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 当前没有 media/reaction content-read 持久表,因此 private 与 channel 都只用现有 exact-id message store 校验可见消息,并把存在的 id 推给当前用户其它在线 session;缺失 id 仍按参考项目返回当前 pts/BoolTrue。`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`,再通过 saga 改成员 read model 并把 `updateChannelParticipant` 放入可恢复的 updates 流;TDesktop 对 `updateChannelParticipant` 本身不走 channel pts 检查,但会在 `updateChannelTooLong(pts)` 后主动拉 `updates.getChannelDifference`。telesrv 因此把 editAdmin/editBanned 写入 `channel_update_events(channel_participant)`,payload 保存 prev/new participant,在线响应额外携带 `updateChannelTooLong(pts)` 触发客户端补齐 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` 拉到入群前 participant/admin/settings 事件的关键边界;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)` 也会先抬到该值,避免入群前 participant/admin/settings 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`。 +- `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 级清除;它是 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 条。 +- `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。 + +## 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/admin participant 等事件保存 domain 快照;`updates.getChannelDifference` 必须优先使用事件时刻的 message/participant 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`,从源头跳过入群/重新加入前的 participant/admin/settings 事件。 +- 对 `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。 +- 已实现 `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` 会写 channel-scoped `channel_participant` durable event,保存 prev/new participant;在线响应/推送包含 `updateChannelParticipant + updateChannel + updateChannelTooLong(pts)`,离线或 pts gap 通过 `updates.getChannelDifference` 补偿。 +- 已实现公开 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 和成员/标题服务消息在事务失败或权限失败后写 `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。 \ No newline at end of file diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 00000000..91c4f1cd --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,419 @@ +# 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/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 异常。 + +## 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 展示数据 | + +## 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 | stub | tdesktop-qr-placeholder | TDesktop QR 登录页轮询;第一阶段只支持手机号登录,返回短期 auth.loginToken 避免未实现噪声 | +| auth.sendCode | done | dev-code | 开发固定验证码,phone_code_hash 存 Redis CodeStore;手机号统一规范为纯数字 | +| auth.signIn | done | real | 校验码;兼容 TDesktop sendCode 后 signIn 使用 DigitsOnly(phone);登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage,同时向其它在线 session 推送新登录 updateServiceNotification | +| 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 不随设备退出删除,退出后同设备换号不会继承旧账号差分 | +| 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 | done | real | PG-backed account_passwords;第一阶段默认无 2FA | +| account.getAccountTTL | stub | default-365d | TDesktop Settings/self-destruct 预取;当前不做账号自动销毁持久配置,返回正数默认 TTL 避免后台 NOT_IMPLEMENTED | +| account.getNotifySettings | stub | default | TDesktop 主界面读取通知设置;显式返回 show_previews=true、silent=false、mute_until=0 与 default sound,避免空 settings 被客户端按静默展示 | +| account.updateNotifySettings | stub | ok | 第一阶段不持久化通知偏好,但接受 TDesktop 设置写入,避免本地通知状态 RPC 报错 | +| account.getPrivacy | stub | default | Settings/Folders 预取隐私项;参考实现 默认规则:手机号默认 disallowAll、生日 allowContacts、其它 allowAll | +| account.getAuthorizations | stub | empty | Settings 设备列表预取;第一阶段不展示授权设备管理,返回空 authorizations | +| account.getDefaultEmojiStatuses | stub | notModified | 第一阶段不提供默认 emoji status 列表 | +| account.getCollectibleEmojiStatuses | stub | empty | TDesktop 启动/emoji status 面板会刷新 collectible gift emoji statuses;当前无 gift/status 模型,返回空 `account.emojiStatuses` | +| account.getDefaultGroupPhotoEmojis | stub | empty | TDesktop 头像/群头像编辑页默认 emoji 图片入口;当前无 custom-emoji photo 候选,返回空 `emojiList` | +| 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 | stub | false | 第一阶段不推送联系人注册提醒 | +| account.getThemes | stub | notModified | Settings/theme 预取,第一阶段不提供云主题 | +| account.getContentSettings | stub | default | Settings 内容敏感项预取,第一阶段不启用 NSFW 内容配置 | +| account.getGlobalPrivacySettings | stub | default | Settings 全局隐私项预取,返回空默认设置 | +| account.getPasskeys | stub | empty | Settings 安全项预取,第一阶段不提供 passkeys | +| account.getSavedMusicIds | stub | empty | TDesktop saved/profile music 预取;当前没有 profile music media store,返回空 id vector | +| 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 补发在线联系人/私聊对端状态,避免 TDesktop 等用户动作后才刷新。当前未接 account privacy,已知 last_seen 默认精确可见 | + +## Updates + +| method | status | behavior | note | +|---|---|---|---| +| updates.getState | done | real | auth_key+user 维度持久化 update_states;账号级当前 pts 报告**最大连续已提交 pts**(MaxContiguousPts,非 allocator 最大已分配值),避免同设备换号/多账号串差分,也避免越过在途空洞 | +| updates.getDifference | done | real | 按 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 不刷新)、delete_messages、contacts_reset、dialog pinned/order/manual unread、peer_settings、dialog filters/folder peers 与 noop gap;消息事件携带 fwd/reply 所需 users/chats,payload 来自 durable log。**账号级绝不返回 `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,避免新加入/重新加入成员拉到入群前 participant/admin/settings 事件;公共 username 频道允许非成员以只读预览身份拉可见差分并返回 synthetic read dialog,私有频道和禁看用户仍返回权限错误;当 `current_channel_pts-pts > cap` 时返回带当前 dialog pts 和最新有界消息快照的 `channelDifferenceTooLong`,避免大频道旧 pts 客户端循环拉大量页;普通 difference 优先使用事件 payload 中的 message/participant 快照,连续编辑/删除后不会被当前消息状态污染;本页消息的 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;支持 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,联系人显示 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-partial | TDesktop 普通消息内容已读入口;校验 id cap=100、message_id 范围与当前账号 exact 可见私聊消息,对存在消息向当前账号其它在线 session 推 `updateReadMessagesContents`;当前无 media/reaction content-read 持久状态,返回当前账号 affectedMessages 且不生成新 pts | +| 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)` 独立索引;sendMessage 解析 mention-name entity 与 `@username`,写入 active 且可见成员;查询按 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 索引兜底;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.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`,避免看到旧历史或补到入群前成员事件 | +| 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-partial | 校验 channel/access_hash、id vector cap=100 与可见 exact message;对存在消息向当前用户其它在线 session 推 `updateChannelReadMessagesContents`,当前无 media/reaction content-read 持久状态,不生成 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;写 `channel_participant` durable event,返回/在线推 `updateChannelParticipant/updateChannel/updateChannelTooLong(pts)` | +| channels.editBanned | done | real | creator 或具备 ban_users 的 admin 可更新 banned rights/kicked 状态,刷新 participants/admin/banned/kicked 计数;写 `channel_participant` durable event,返回/在线推 `updateChannelParticipant/updateChannel/updateChannelTooLong(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 保存的姓名/电话/备注视角,互相关系按双方是否互存维护 | +| 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`。当前未接 account privacy,粗粒度 `recently/lastWeek/lastMonth/empty` 隐私降级仍为后续项 | +| 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.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;当前阶段未接完整 privacy,默认已知手机号可解析,未命中返回 PHONE_NOT_OCCUPIED | +| contacts.getBlocked | stub | empty | Settings 隐私/安全预取;第一阶段无 blocklist,limit cap=50 | +| 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.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 | PG-backed;启动 seed `tdesktop_en/zh-hans_v12000000.strings` | +| langpack.getDifference | done | real | 按 version 返回语言包差异;当前 seed 同版本全量 | +| langpack.getStrings | done | real | 按 key 查询 PG 中的语言包字符串 | + +## 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`),设为当前头像,返回 `photos.photo{photo, 带头像 self}` 并向其它在线 session 推 `updateUser`+self(头像即时同步,见下方 note);仅支持 file 变体,fallback/video/emoji-markup 返回 `PHOTO_INVALID` | +| photos.updateProfilePhoto | done | real-photo | `inputPhoto` 把历史头像设为当前;`inputPhotoEmpty` 停用当前头像;变更后向其它在线 session 推 `updateUser`+self 同步头像 | +| photos.getUserPhotos | done | real-photo | 按 `profile_photos` 返回某用户头像历史(最新在前),offset/limit/max_id 有界,返回 `photos.photos[Slice]` + target user | +| photos.deletePhotos | done | real-photo | 按 inputPhoto id 停用头像,返回被删 id 列表 | + +> 头像渲染:`users` 表反范式 `photo_id/photo_dc_id/photo_stripped`,users 服务对 getUsers/getFullUser/self/resolve 批量富化,`tgUser`/`tgSelfUser` 输出 `userProfilePhoto`;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 | \ No newline at end of file diff --git a/docs/message-module.md b/docs/message-module.md new file mode 100644 index 00000000..cd9b69de --- /dev/null +++ b/docs/message-module.md @@ -0,0 +1,252 @@ +# 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` | +| `dialogs` | `user_id` HASH | 会话摘要;`folder_id=0/1` 表示主列表/归档,置顶、manual unread、action bar 隐藏均是 owner 视角 | +| `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。 +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 不可用时该补偿也可能失败,后续需要告警指标覆盖。 + +## 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。 + +## 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`。 + +## 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。 + +全清也必须让所有被删的 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.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` 补偿。 +- 联系人备注、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`。 \ No newline at end of file diff --git a/docs/performance-audit.md b/docs/performance-audit.md new file mode 100644 index 00000000..046c1cad --- /dev/null +++ b/docs/performance-audit.md @@ -0,0 +1,249 @@ +# 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 document 同时暴露 `PhotoPathSize` 与 raster thumb,历史页静态图被占位短路** — ✅ **已修(2026-06-03)**:TDesktop `history_view_sticker.cpp` 在创建 sticker media view 时若 `thumbnailPath()` 非空就不调用 `thumbnailWanted()`,而 `PhotoPathSize` 只是 vector placeholder;此前 seed 给同一 document 同时返回 path + raster thumb,导致历史页持续画 path/等完整 TGS 解码,用户反复重启 Debug 仍看不到静态图。现 document 有 default/cached/progressive raster thumb 时过滤 `PhotoPathSize`,thumb blob MIME 按魔数写入;domain 中可用 cached bytes 做修复依据,但 RPC 对 document 统一暴露 downloadable `photoSize m`,避免 `PhotoCachedSize` 与旧本地 cache 叠加出不可替换状态。启动 repair 后 PG 中 document thumbs kind 仅剩 `cached=2313`,document thumb `file_blobs.mime_type` 全部为 `image/webp`。 +- **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 new file mode 100644 index 00000000..77fd4f35 --- /dev/null +++ b/docs/persistence-layer.md @@ -0,0 +1,174 @@ +# 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`。 +- **`account_passwords`** —— 账号 2FA/SRP 配置。第一阶段默认 `has_password=false`,但 `account.getPassword` 已走持久化查询。 +- **`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`、`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 中。 +- **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。 +- **`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 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box,`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。该反向定位与分区键不一致,规划会展开全部 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/participant/noop 的恢复负载,`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` 起点,避免新成员或重新加入成员恢复到入群前的 participant/admin/settings 事件。`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)` 不复制消息正文,发送时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`。共同超级群查询已迁到 `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/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses` 与 `messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings` 已接入 PG-backed 服务;集成测试覆盖不同 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。—— ✅ + +## 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+photo_id):用户/频道头像历史,current=active 中 sort_order 最大者。 +- 消息表(`private_messages`/`message_boxes`/`channel_messages`)新增 `media` JSONB 快照列;放宽 body 非空 CHECK 为「body 非空 OR media 非空(OR channel action)」,支持「仅媒体」消息。 +- 头像反范式:`users.photo_*`(migration 0057 用户表无需改,富化在 users 服务)与 `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`。 \ No newline at end of file diff --git a/docs/tdesktop-patch-notes.md b/docs/tdesktop-patch-notes.md new file mode 100644 index 00000000..8d8b5a3e --- /dev/null +++ b/docs/tdesktop-patch-notes.md @@ -0,0 +1,116 @@ +# 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/go.mod b/go.mod new file mode 100644 index 00000000..f085caf2 --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module telesrv + +go 1.25.0 + +require ( + 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/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa + github.com/jackc/pgx/v5 v5.9.2 + github.com/redis/go-redis/v9 v9.20.0 + go.uber.org/zap v1.28.0 + golang.org/x/sync v0.20.0 +) + +require ( + 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/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/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/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 + 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 + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..c0099c11 --- /dev/null +++ b/go.sum @@ -0,0 +1,172 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +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/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= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +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/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= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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/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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= +github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/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/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= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/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/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/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/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= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +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/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +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= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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/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.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= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +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/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/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= +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= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/app/account/doc.go b/internal/app/account/doc.go new file mode 100644 index 00000000..876bcc00 --- /dev/null +++ b/internal/app/account/doc.go @@ -0,0 +1,2 @@ +// Package account 是账号安全与设置应用服务。 +package account diff --git a/internal/app/account/service.go b/internal/app/account/service.go new file mode 100644 index 00000000..e6940443 --- /dev/null +++ b/internal/app/account/service.go @@ -0,0 +1,158 @@ +package account + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +var defaultSecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") + +// Service 提供账号安全配置查询。 +type Service struct { + passwords store.PasswordStore + reactions store.AccountReactionSettingsStore +} + +// ServiceOption 调整 account 服务依赖。 +type ServiceOption func(*Service) + +// WithReactionSettings 注入账号级 reaction 设置持久化。 +func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceOption { + return func(s *Service) { + s.reactions = reactions + } +} + +// NewService 创建 account 服务。 +func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { + s := &Service{passwords: passwords} + for _, opt := range opts { + opt(s) + } + return s +} + +// GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。 +func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) { + if s == nil || s.passwords == nil || userID == 0 { + return defaultPasswordSettings(), nil + } + settings, found, err := s.passwords.GetByUser(ctx, userID) + if err != nil { + return domain.PasswordSettings{}, err + } + if !found { + return defaultPasswordSettings(), nil + } + if len(settings.SecureRandom) == 0 { + settings.SecureRandom = append([]byte(nil), defaultSecureRandom...) + } + return settings, nil +} + +func defaultPasswordSettings() domain.PasswordSettings { + return domain.PasswordSettings{SecureRandom: append([]byte(nil), defaultSecureRandom...)} +} + +// GetReactionSettings returns account-level reaction preferences. +func (s *Service) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) { + if s == nil || s.reactions == nil || userID == 0 { + return domain.DefaultAccountReactionSettings(), nil + } + settings, found, err := s.reactions.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + if !found { + return domain.DefaultAccountReactionSettings(), nil + } + return normalizeReactionSettings(settings), nil +} + +// SetReactionsNotifySettings stores reaction notification preferences. +func (s *Service) SetReactionsNotifySettings(ctx context.Context, userID int64, notify domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) { + settings, err := s.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + settings.Notify = normalizeNotifySettings(notify) + return s.saveReactionSettings(ctx, userID, settings) +} + +// SetDefaultReaction stores the account default quick reaction. +func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) { + settings, err := s.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + if reaction.Type == "" || reaction.Emoticon == "" { + reaction = domain.DefaultAccountReactionSettings().DefaultReaction + } + settings.DefaultReaction = reaction + return s.saveReactionSettings(ctx, userID, settings) +} + +// SetPaidReactionPrivacy stores the account default paid reaction privacy. +func (s *Service) SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) { + settings, err := s.GetReactionSettings(ctx, userID) + if err != nil { + return domain.AccountReactionSettings{}, err + } + settings.PaidPrivacy = normalizePaidPrivacy(privacy) + return s.saveReactionSettings(ctx, userID, settings) +} + +func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) (domain.AccountReactionSettings, error) { + settings = normalizeReactionSettings(settings) + if s == nil || s.reactions == nil || userID == 0 { + return settings, nil + } + return settings, s.reactions.SaveReactionSettings(ctx, userID, settings) +} + +func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings { + defaults := domain.DefaultAccountReactionSettings() + settings.Notify = normalizeNotifySettings(settings.Notify) + if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" { + settings.DefaultReaction = defaults.DefaultReaction + } + settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy) + return settings +} + +func normalizeNotifySettings(settings domain.ReactionsNotifySettings) domain.ReactionsNotifySettings { + if !validNotifyFrom(settings.MessagesFrom) { + settings.MessagesFrom = domain.ReactionNotifyFromContacts + } + if !validNotifyFrom(settings.StoriesFrom) { + settings.StoriesFrom = domain.ReactionNotifyFromContacts + } + if !validNotifyFrom(settings.PollVotesFrom) { + settings.PollVotesFrom = domain.ReactionNotifyFromContacts + } + return settings +} + +func validNotifyFrom(value domain.ReactionNotifyFrom) bool { + switch value { + case domain.ReactionNotifyFromNone, domain.ReactionNotifyFromContacts, domain.ReactionNotifyFromAll: + return true + default: + return false + } +} + +func normalizePaidPrivacy(privacy domain.PaidReactionPrivacy) domain.PaidReactionPrivacy { + switch privacy.Kind { + case domain.PaidReactionPrivacyAnonymous: + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous} + case domain.PaidReactionPrivacyPeer: + if privacy.Peer != nil && privacy.Peer.ID != 0 { + peer := *privacy.Peer + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer} + } + } + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault} +} diff --git a/internal/app/auth/doc.go b/internal/app/auth/doc.go new file mode 100644 index 00000000..c20621fb --- /dev/null +++ b/internal/app/auth/doc.go @@ -0,0 +1,5 @@ +// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。 +// 第一阶段用开发固定验证码,2FA 配置由 account 服务持久化查询。 +// +// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。 +package auth diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go new file mode 100644 index 00000000..e0fa8e56 --- /dev/null +++ b/internal/app/auth/service.go @@ -0,0 +1,362 @@ +package auth + +import ( + "context" + "crypto/aes" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/gotd/ige" + "github.com/gotd/td/bin" + mtcrypto "github.com/gotd/td/crypto" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// 登录错误。 +var ( + ErrCodeExpired = errors.New("phone code expired or not found") + ErrCodeInvalid = errors.New("phone code invalid") + ErrEncryptedMessageInvalid = errors.New("encrypted message invalid") +) + +// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。 +type Service struct { + users store.UserStore + auths store.AuthorizationStore + codes store.CodeStore + authKeys store.AuthKeyStore + tempKeys store.TempAuthKeyBindingStore + messages store.MessageStore + dialogs store.DialogStore + fixedCode string + codeTTL time.Duration +} + +// Option 调整登录服务的可选依赖。 +type Option func(*Service) + +// WithLoginMessages 在登录成功后写入官方系统账号的登录消息与会话摘要。 +func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) Option { + return func(s *Service) { + s.messages = messages + s.dialogs = dialogs + } +} + +// 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} + for _, opt := range opts { + opt(s) + } + return s +} + +// BindTempAuthKey 校验并记录 TDesktop PFS temp→perm auth key 绑定。 +func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error { + if s.authKeys != nil { + inner, err := s.validateBindTempAuthKey(ctx, sessionID, binding) + if err != nil { + return err + } + binding.TempSessionID = inner.TempSessionID + } + if s.tempKeys == nil { + return nil + } + return s.tempKeys.Save(ctx, binding) +} + +// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。 +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 + } + binding, found, err := s.tempKeys.GetByTemp(ctx, authKeyID) + if err != nil || !found { + return [8]byte{}, found, err + } + if binding.ExpiresAt <= int(time.Now().Unix()) { + return [8]byte{}, false, nil + } + return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil +} + +// 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 + } + a, found, err := s.auths.ByAuthKey(ctx, authKeyID) + if err != nil || !found { + return 0, found, err + } + return a.UserID, true, nil +} + +// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。 +func (s *Service) SendCode(ctx context.Context, phone string) (string, error) { + 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 { + return "", fmt.Errorf("store code: %w", err) + } + return hash, nil +} + +// SignIn 校验验证码并尝试登录。 +// 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) + 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 || rec.Code != 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 // 验证码对、但需注册 + } + if err := s.bind(ctx, auth, existing.ID); err != nil { + return domain.User{}, domain.Message{}, false, err + } + loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code) + if err != nil { + return domain.User{}, domain.Message{}, false, err + } + _ = s.codes.Del(ctx, phoneCodeHash) + return existing, loginMessage, false, nil +} + +// SignUp 在 SignIn 判定需注册后创建用户并绑定授权。 +// 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) + firstName = strings.TrimSpace(firstName) + lastName = strings.TrimSpace(lastName) + if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 { + return domain.User{}, domain.Message{}, domain.ErrFirstNameInvalid + } + rec, found, err := s.codes.Get(ctx, phoneCodeHash) + if err != nil { + return domain.User{}, domain.Message{}, err + } + if !found { + return domain.User{}, domain.Message{}, ErrCodeExpired + } + if rec.Phone != phone { + return domain.User{}, domain.Message{}, ErrCodeInvalid + } + + accessHash, err := randomInt64() + if err != nil { + return domain.User{}, domain.Message{}, err + } + u, err := s.users.Create(ctx, domain.User{ + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + LastName: lastName, + }) + if err != nil { + return domain.User{}, domain.Message{}, err + } + if err := s.bind(ctx, auth, u.ID); err != nil { + return domain.User{}, domain.Message{}, err + } + loginMessage, err := s.recordLoginMessage(ctx, u.ID, rec.Code) + if err != nil { + return domain.User{}, domain.Message{}, err + } + _ = s.codes.Del(ctx, phoneCodeHash) + return u, loginMessage, nil +} + +// LogOut 解绑当前 auth_key 的授权。 +func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error { + return s.auths.Delete(ctx, authKeyID) +} + +func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error { + auth.UserID = userID + return s.auths.Bind(ctx, auth) +} + +const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram! + +This code can be used to log in to your Telegram account. We never ask it for anything else. + +If you didn't request this code by trying to log in on another device, simply ignore this message.` + +func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) { + if s.messages == nil || s.dialogs == nil { + return domain.Message{}, nil + } + body := fmt.Sprintf(loginMessageTpl, code) + codeOffset := len("Login code: ") + msg, err := s.messages.Create(ctx, domain.Message{ + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: int(time.Now().Unix()), + Body: body, + Entities: []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")}, + {Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)}, + }, + }) + if err != nil { + return domain.Message{}, err + } + if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{ + Peer: msg.Peer, + TopMessage: msg.ID, + TopMessageDate: msg.Date, + UnreadCount: 1, + }); err != nil { + return domain.Message{}, err + } + return msg, nil +} + +func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) { + if binding.ExpiresAt <= int(time.Now().Unix()) { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + permID := authKeyIDFromInt64(binding.PermAuthKeyID) + perm, found, err := s.authKeys.Get(ctx, permID) + if err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if !found { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + inner, err := decryptBindAuthKeyInner(perm, binding.EncryptedMessage) + if err != nil { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + if inner.Nonce != binding.Nonce || + inner.TempAuthKeyID != authKeyIDInt64(binding.TempAuthKeyID) || + inner.PermAuthKeyID != binding.PermAuthKeyID || + inner.TempSessionID != sessionID || + inner.ExpiresAt != binding.ExpiresAt { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + return inner, nil +} + +func decryptBindAuthKeyInner(perm store.AuthKeyData, encrypted []byte) (mtcrypto.BindAuthKeyInner, error) { + var msg mtcrypto.EncryptedMessage + if err := msg.Decode(&bin.Buffer{Buf: encrypted}); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if msg.AuthKeyID != perm.ID || len(msg.EncryptedData) == 0 || len(msg.EncryptedData)%aes.BlockSize != 0 { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + key, iv := mtcrypto.KeysV1(mtcrypto.Key(perm.Value), msg.MsgKey) + block, err := aes.NewCipher(key[:]) + if err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + plaintext := make([]byte, len(msg.EncryptedData)) + ige.DecryptBlocks(block, iv[:], plaintext, msg.EncryptedData) + + const headerLen = 16 + 8 + 4 + 4 + if len(plaintext) < headerLen { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + b := &bin.Buffer{Buf: plaintext} + randomPrefix := make([]byte, 16) + if err := b.ConsumeN(randomPrefix, len(randomPrefix)); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if _, err := b.Long(); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if _, err := b.Int32(); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + msgLen, err := b.Int32() + if err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + if msgLen <= 0 { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + bodyEnd := headerLen + int(msgLen) + if bodyEnd > len(plaintext) { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + if msg.MsgKey != mtcrypto.MessageKeyV1(plaintext[:bodyEnd]) { + return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid + } + + body := plaintext[headerLen:bodyEnd] + var inner mtcrypto.BindAuthKeyInner + if err := inner.Decode(&bin.Buffer{Buf: body}); err != nil { + return mtcrypto.BindAuthKeyInner{}, err + } + return inner, nil +} + +func authKeyIDFromInt64(v int64) [8]byte { + var id [8]byte + binary.LittleEndian.PutUint64(id[:], uint64(v)) + return id +} + +func authKeyIDInt64(id [8]byte) int64 { + return int64(binary.LittleEndian.Uint64(id[:])) +} + +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() +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + return hex.EncodeToString(b), nil +} + +func randomInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("rand: %w", err) + } + return int64(binary.LittleEndian.Uint64(b[:])), nil +} diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go new file mode 100644 index 00000000..a73a3ce5 --- /dev/null +++ b/internal/app/auth/service_test.go @@ -0,0 +1,259 @@ +package auth + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + mtcrypto "github.com/gotd/td/crypto" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) { + ctx := context.Background() + keys := memory.NewAuthKeyStore() + tempBindings := memory.NewTempAuthKeyBindingStore() + permKey := testAuthKey(0x11) + tempKey := testAuthKey(0x55) + saveAuthKey(t, keys, permKey) + saveAuthKey(t, keys, tempKey) + + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), keys, tempBindings, "12345") + + const ( + nonce = int64(0x12345678) + sessionID = int64(0x1020304050) + msgID = int64(0x0102030405060708) + ) + expiresAt := int(time.Now().Add(time.Hour).Unix()) + encrypted, err := mtcrypto.EncryptBindMessage( + bytes.NewReader(bytes.Repeat([]byte{0xCD}, 128)), + permKey, + msgID, + &mtcrypto.BindAuthKeyInner{ + Nonce: nonce, + TempAuthKeyID: tempKey.IntID(), + PermAuthKeyID: permKey.IntID(), + TempSessionID: sessionID, + ExpiresAt: expiresAt, + }, + ) + if err != nil { + t.Fatalf("encrypt bind message: %v", err) + } + + err = svc.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + Nonce: nonce, + ExpiresAt: expiresAt, + EncryptedMessage: encrypted, + }) + if err != nil { + t.Fatalf("BindTempAuthKey valid message: %v", err) + } + + err = svc.BindTempAuthKey(ctx, sessionID+1, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + Nonce: nonce, + ExpiresAt: expiresAt, + EncryptedMessage: encrypted, + }) + if !errors.Is(err, ErrEncryptedMessageInvalid) { + t.Fatalf("BindTempAuthKey wrong session err = %v, want ErrEncryptedMessageInvalid", err) + } +} + +func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) { + ctx := context.Background() + tempBindings := memory.NewTempAuthKeyBindingStore() + permKey := testAuthKey(0x11) + tempKey := testAuthKey(0x55) + 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.Hour).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 perm %x", got, ok, permKey.ID) + } +} + +func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + authz := memory.NewAuthorizationStore() + svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345") + + hash, err := svc.SendCode(ctx, "+15550004310") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + + _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, "15550004310", hash, "12345") + if err != nil { + t.Fatalf("SignIn with digits-only phone: %v", err) + } + if !needSignUp { + t.Fatal("SignIn needSignUp = false, want true") + } + + u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+1 555 000 4310", hash, "Test", "User") + if err != nil { + t.Fatalf("SignUp with formatted phone: %v", err) + } + if u.Phone != "15550004310" { + t.Fatalf("created phone = %q, want normalized digits", u.Phone) + } + if u.ID != domain.UserIDSequenceBase { + t.Fatalf("created user id = %d, want base %d", u.ID, domain.UserIDSequenceBase) + } +} + +func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345") + var key1, key2 [8]byte + key1[0] = 1 + key2[0] = 2 + + hash1, err := svc.SendCode(ctx, "+15550005001") + if err != nil { + t.Fatalf("SendCode user1: %v", err) + } + user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key1}, "+15550005001", hash1, "One", "") + if err != nil { + t.Fatalf("SignUp user1: %v", err) + } + hash2, err := svc.SendCode(ctx, "+15550005002") + if err != nil { + t.Fatalf("SendCode user2: %v", err) + } + user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key2}, "+15550005002", hash2, "Two", "") + if err != nil { + t.Fatalf("SignUp user2: %v", err) + } + + got1, found, err := svc.UserID(ctx, key1) + if err != nil || !found || got1 != user1.ID { + t.Fatalf("key1 user = %d found=%v err=%v, want %d", got1, found, err, user1.ID) + } + got2, found, err := svc.UserID(ctx, key2) + if err != nil || !found || got2 != user2.ID { + t.Fatalf("key2 user = %d found=%v err=%v, want %d", got2, found, err, user2.ID) + } + if got1 == got2 { + t.Fatalf("auth keys mapped to same user id %d", got1) + } +} + +func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345") + var key [8]byte + key[0] = 9 + + hash1, err := svc.SendCode(ctx, "+15550006001") + if err != nil { + t.Fatalf("SendCode user1: %v", err) + } + user1, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006001", hash1, "One", "") + if err != nil { + t.Fatalf("SignUp user1: %v", err) + } + if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user1.ID { + t.Fatalf("initial auth user = %d found=%v err=%v, want %d", got, found, err, user1.ID) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + if got, found, err := svc.UserID(ctx, key); err != nil || found || got != 0 { + t.Fatalf("after logout user = %d found=%v err=%v, want none", got, found, err) + } + + hash2, err := svc.SendCode(ctx, "+15550006002") + if err != nil { + t.Fatalf("SendCode user2: %v", err) + } + user2, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550006002", hash2, "Two", "") + if err != nil { + t.Fatalf("SignUp user2: %v", err) + } + if got, found, err := svc.UserID(ctx, key); err != nil || !found || got != user2.ID { + t.Fatalf("after switch user = %d found=%v err=%v, want %d", got, found, err, user2.ID) + } + if user1.ID == user2.ID { + t.Fatalf("user ids did not change after switch: %d", user1.ID) + } +} + +func TestSignUpWritesOfficialLoginMessage(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)) + + hash, err := svc.SendCode(ctx, "+15550004311") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + u, msg, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004311", hash, "Test", "User") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID { + t.Fatalf("dialogs = %+v, want official system dialog", list.Dialogs) + } + if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support { + t.Fatalf("users = %+v, want verified support system user", list.Users) + } + if msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") { + t.Fatalf("login message = %+v, want returned official login code message", msg) + } + if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Login code: 12345") { + t.Fatalf("messages = %+v, want login code message", list.Messages) + } + if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("dialog top/unread = %+v, message = %+v", list.Dialogs[0], list.Messages[0]) + } +} + +func testAuthKey(seed byte) mtcrypto.AuthKey { + var raw mtcrypto.Key + for i := range raw { + raw[i] = seed + byte(i) + } + return raw.WithID() +} + +func saveAuthKey(t *testing.T, keys store.AuthKeyStore, key mtcrypto.AuthKey) { + t.Helper() + var value [256]byte + copy(value[:], key.Value[:]) + if err := keys.Save(context.Background(), store.AuthKeyData{ID: key.ID, Value: value}); err != nil { + t.Fatalf("save auth key: %v", err) + } +} diff --git a/internal/app/channels/doc.go b/internal/app/channels/doc.go new file mode 100644 index 00000000..a0417993 --- /dev/null +++ b/internal/app/channels/doc.go @@ -0,0 +1,2 @@ +// Package channels contains domain service orchestration for Telegram channels and supergroups. +package channels diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go new file mode 100644 index 00000000..a8514a89 --- /dev/null +++ b/internal/app/channels/service.go @@ -0,0 +1,1514 @@ +package channels + +import ( + "context" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service exposes channel/supergroup business operations. +type Service struct { + channels store.ChannelStore +} + +// NewService creates a channel service. +func NewService(channels store.ChannelStore) *Service { + return &Service{channels: channels} +} + +// CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup. +func (s *Service) CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + req.CreatorUserID = userID + req.Broadcast = false + req.Megagroup = true + return s.CreateChannel(ctx, userID, req) +} + +// CreateChannel creates a broadcast channel or megagroup. +func (s *Service) CreateChannel(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.CreatorUserID == 0 { + req.CreatorUserID = userID + } + if req.CreatorUserID != userID { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if strings.TrimSpace(req.Title) == "" { + return domain.CreateChannelResult{}, domain.ErrChannelTitleInvalid + } + if len(req.MemberUserIDs) > domain.MaxChannelInviteUsers { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if !req.Broadcast && !req.Megagroup { + req.Broadcast = true + } + return s.channels.CreateChannel(ctx, req) +} + +// GetChannel returns channel data personalized for userID. +func (s *Service) GetChannel(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.channels.GetChannel(ctx, userID, channelID) +} + +// 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 { + 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 { + return domain.ChannelParticipantList{}, domain.ErrChannelInvalid + } + 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)) +} + +// GetParticipant returns one participant. +func (s *Service) GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || participantUserID == 0 { + return domain.ChannelMember{}, domain.ErrChannelInvalid + } + return s.channels.GetParticipant(ctx, userID, channelID, participantUserID) +} + +// 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 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if len(userIDs) > domain.MaxChannelInviteUsers { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date) +} + +// JoinChannel joins current user to a channel/supergroup. +func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.JoinChannel(ctx, channelID, userID, date) +} + +// LeaveChannel leaves current user from a channel/supergroup. +func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.LeaveChannel(ctx, channelID, userID, date) +} + +// EditTitle edits a channel/supergroup title. +func (s *Service) EditTitle(ctx context.Context, userID int64, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.EditChannelTitleResult{}, domain.ErrChannelTitleInvalid + } + return s.channels.EditChannelTitle(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 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelAbout(ctx, req) +} + +// EditAdmin edits a participant's admin rights. +func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (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.EditChannelAdmin(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 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + 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) +} + +// EditDefaultBannedRights edits the channel/supergroup default restrictions. +func (s *Service) EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelDefaultBannedRights(ctx, req) +} + +// DeleteChannel deletes a channel/supergroup. +func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannel(ctx, req) +} + +// CheckUsername checks whether a channel username is syntactically valid and free. +func (s *Service) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return false, domain.ErrChannelInvalid + } + username = normalizeChannelUsername(username) + if !validChannelUsername(username) { + return false, domain.ErrUsernameInvalid + } + return s.channels.CheckUsername(ctx, userID, channelID, username) +} + +// UpdateUsername sets or clears a channel public username. +func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + req.Username = normalizeChannelUsername(req.Username) + if req.Username != "" && !validChannelUsername(req.Username) { + return domain.Channel{}, domain.ErrUsernameInvalid + } + return s.channels.UpdateUsername(ctx, req) +} + +// 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 { + return nil, nil + } + return s.channels.ListAdminedPublicChannels(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 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + username = normalizeChannelUsername(username) + if !validChannelUsername(username) { + return domain.Channel{}, false, domain.ErrUsernameInvalid + } + return s.channels.ResolvePublicChannelUsername(ctx, userID, username) +} + +// SearchPublicChannels returns public username channels/supergroups for contacts.search. +func (s *Service) SearchPublicChannels(ctx context.Context, userID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + query = normalizeChannelUsername(query) + if query == "" { + return domain.PublicChannelSearchResult{}, nil + } + if utf8.RuneCountInString(query) > domain.MaxPublicChannelSearchQueryLength { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + return s.channels.SearchPublicChannels(ctx, userID, query, capLimit(limit, domain.MaxPublicChannelSearchLimit)) +} + +// SetSignatures toggles channel post signatures. +func (s *Service) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetSignatures(ctx, userID, channelID, enabled) +} + +// SetPhoto 设置/清除频道头像(photo==nil 表示清除)。 +func (s *Service) SetPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetChannelPhoto(ctx, userID, channelID, photo) +} + +// SetPreHistoryHidden toggles hidden history for new supergroup members. +func (s *Service) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetPreHistoryHidden(ctx, userID, channelID, enabled) +} + +// SetParticipantsHidden toggles whether non-admins can view the full member list. +func (s *Service) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetParticipantsHidden(ctx, userID, channelID, enabled) +} + +// SetForum toggles forum topics and their preferred TDesktop layout for a megagroup. +func (s *Service) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetForum(ctx, userID, channelID, enabled, tabs) +} + +// SetAutotranslation toggles channel autotranslation for all users. +func (s *Service) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetAutotranslation(ctx, userID, channelID, enabled) +} + +// SetRestrictedSponsored toggles the channelFull restricted_sponsored state. +func (s *Service) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetRestrictedSponsored(ctx, userID, channelID, restricted) +} + +// 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) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || stars < 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetPaidMessagesPrice(ctx, userID, channelID, stars, broadcastMessagesAllowed) +} + +// SetAntiSpam toggles native antispam mode for a supergroup. +func (s *Service) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetAntiSpam(ctx, userID, channelID, enabled) +} + +// SetSlowMode changes the supergroup slow mode interval in seconds. +func (s *Service) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetSlowMode(ctx, userID, channelID, seconds) +} + +// 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 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetNoForwards(ctx, userID, channelID, enabled) +} + +// 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 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetJoinToSend(ctx, userID, channelID, enabled) +} + +// SetJoinRequest toggles public join request approval for a megagroup. +func (s *Service) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetJoinRequest(ctx, userID, channelID, enabled) +} + +// SetAvailableReactions stores the bounded reaction policy for a channel/supergroup. +func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionItems || + policy.Limit < 0 || + policy.Limit > domain.MaxChannelReactionItems { + return domain.Channel{}, domain.ErrChannelInvalid + } + for _, emoticon := range policy.Emoticons { + if strings.TrimSpace(emoticon) == "" || utf8.RuneCountInString(emoticon) > domain.MaxChannelReactionEmoticonLength { + return domain.Channel{}, domain.ErrChannelInvalid + } + } + return s.channels.SetAvailableReactions(ctx, userID, channelID, policy) +} + +// SetColor stores the channel message/profile accent color. +func (s *Service) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetColor(ctx, userID, channelID, forProfile, color) +} + +// SetEmojiStatus stores or clears the channel emoji status. +func (s *Service) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if status.DocumentID < 0 || status.Until < 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetEmojiStatus(ctx, userID, channelID, status) +} + +// 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 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Query) > domain.MaxChannelAdminLogQueryLength || len(req.AdminUserIDs) > domain.MaxChannelAdminLogAdmins { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelAdminLogLimit) + return s.channels.ListAdminLog(ctx, req) +} + +// GetChannelForChangeInfo validates the current user can change channel metadata. +func (s *Service) GetChannelForChangeInfo(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + view, err := s.GetChannel(ctx, userID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if view.Self.Role == domain.ChannelRoleCreator || (view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.ChangeInfo) { + return view, nil + } + return domain.ChannelView{}, 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 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return s.channels.SaveChannelDefaultSendAs(ctx, req) +} + +// GetMessageViews returns channel-scoped view counters and optionally records first-time views. +func (s *Service) GetMessageViews(ctx context.Context, userID int64, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetChannelMessageViews(ctx, req) +} + +// SetMessageReactions replaces the current user's reactions for one channel/supergroup message. +func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + return s.channels.SetChannelMessageReactions(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 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetChannelMessageReactions(ctx, req) +} + +// ListMessageReactions returns a bounded per-peer reaction list for one message. +func (s *Service) ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelMessageReactionListLimit) + return s.channels.ListChannelMessageReactions(ctx, req) +} + +type messageReactionUsageStore interface { + RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error +} + +type participantReactionModeratorStore interface { + DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) + DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) +} + +// RecordMessageReactionUse updates account-level top/recent reaction lists. +func (s *Service) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if s == nil || s.channels == nil || userID == 0 || len(reactions) == 0 { + return nil + } + recorder, ok := s.channels.(messageReactionUsageStore) + if !ok { + return nil + } + return recorder.RecordMessageReactionUse(ctx, userID, reactions, addToRecent, date) +} + +// DeleteParticipantReaction removes one participant reaction on a channel message. +func (s *Service) DeleteParticipantReaction(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.ParticipantUserID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + moderator, ok := s.channels.(participantReactionModeratorStore) + if !ok { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + return moderator.DeleteChannelParticipantReaction(ctx, req) +} + +// DeleteParticipantReactions removes a bounded page of one participant's reactions. +func (s *Service) DeleteParticipantReactions(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { + req.Limit = domain.MaxDeleteParticipantReactionsBatch + } + moderator, ok := s.channels.(participantReactionModeratorStore) + if !ok { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + return moderator.DeleteChannelParticipantReactions(ctx, req) +} + +// TopReactions returns the current account's most frequently used message reactions. +func (s *Service) TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + return s.channels.ListTopMessageReactions(ctx, userID, limit) +} + +// RecentReactions returns the current account's recently used message reactions. +func (s *Service) RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxRecentMessageReactions { + limit = domain.MaxRecentMessageReactions + } + return s.channels.ListRecentMessageReactions(ctx, userID, limit) +} + +// ClearRecentReactions clears the current account's recently used message reactions. +func (s *Service) ClearRecentReactions(ctx context.Context, userID int64) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + return s.channels.ClearRecentMessageReactions(ctx, userID) +} + +// SavedReactionTags returns account-level saved-message reaction tag titles. +func (s *Service) SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.SavedReactionTag{}, nil + } + if limit > domain.MaxSavedReactionTags { + limit = domain.MaxSavedReactionTags + } + return s.channels.ListSavedReactionTags(ctx, userID, limit) +} + +// UpdateSavedReactionTag stores the account-level custom title for one saved-message reaction tag. +func (s *Service) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + if tag.UserID == 0 { + tag.UserID = userID + } + if tag.UserID != userID || tag.Reaction.Type != domain.MessageReactionEmoji || tag.Reaction.Emoticon == "" { + return domain.ErrChannelInvalid + } + return s.channels.UpsertSavedReactionTag(ctx, tag) +} + +// ReadMessageContents returns visible channel messages whose content-read state can be synced. +func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.ReadChannelMessageContents(ctx, req) +} + +// GetMessageAuthor resolves the user author for one visible channel/supergroup message. +func (s *Service) GetMessageAuthor(ctx context.Context, userID int64, req domain.GetChannelMessageAuthorRequest) (domain.GetChannelMessageAuthorResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.GetChannelMessageAuthorResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.GetChannelMessageAuthorResult{}, domain.ErrChannelInvalid + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return domain.GetChannelMessageAuthorResult{}, domain.ErrMessageIDInvalid + } + history, err := s.channels.GetChannelMessages(ctx, userID, req.ChannelID, []int{req.ID}) + if err != nil { + return domain.GetChannelMessageAuthorResult{}, err + } + if len(history.Messages) != 1 || history.Messages[0].SenderUserID == 0 { + return domain.GetChannelMessageAuthorResult{}, domain.ErrMessageIDInvalid + } + return domain.GetChannelMessageAuthorResult{ + Channel: history.Channel, + MessageID: history.Messages[0].ID, + SenderUserID: history.Messages[0].SenderUserID, + }, nil +} + +// CreateForumTopic creates one forum topic root service message. +func (s *Service) CreateForumTopic(ctx context.Context, userID int64, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || (strings.TrimSpace(req.Title) == "" && !req.TitleMissing) { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Title) > domain.MaxChannelForumTopicTitleLength { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + return s.channels.CreateForumTopic(ctx, req) +} + +// EditForumTopic edits topic metadata and emits a topic edit service message. +func (s *Service) EditForumTopic(ctx context.Context, userID int64, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" || utf8.RuneCountInString(title) > domain.MaxChannelForumTopicTitleLength { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + *req.Title = title + } + return s.channels.EditForumTopic(ctx, req) +} + +// UpdatePinnedForumTopic pins or unpins one forum topic. +func (s *Service) UpdatePinnedForumTopic(ctx context.Context, userID int64, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + return s.channels.UpdatePinnedForumTopic(ctx, req) +} + +// ReorderPinnedForumTopics stores a bounded pinned topic order. +func (s *Service) ReorderPinnedForumTopics(ctx context.Context, userID int64, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + for _, id := range req.Order { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + } + return s.channels.ReorderPinnedForumTopics(ctx, req) +} + +// DeleteForumTopicHistory deletes one bounded topic-history page. +func (s *Service) DeleteForumTopicHistory(ctx context.Context, userID int64, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteForumTopicHistory(ctx, req) +} + +// GetForumTopics returns a bounded topic page without SQL OFFSET semantics. +func (s *Service) GetForumTopics(ctx context.Context, userID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + if filter.Limit < 0 || filter.Limit > domain.MaxChannelForumTopicsLimit { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + return s.channels.ListForumTopics(ctx, userID, filter) +} + +// GetForumTopicsByID returns a bounded topic id lookup. +func (s *Service) GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(ids) > domain.MaxChannelForumTopicIDs { + return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetForumTopicsByID(ctx, userID, channelID, ids) +} + +// SendMessage sends one channel/supergroup message. +func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + return s.channels.SendChannelMessage(ctx, req) +} + +// 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 { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + 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 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || len(req.IDs) == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxDeleteMessageIDs { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannelMessages(ctx, req) +} + +// DeleteHistory clears the current user's history view or deletes a bounded channel history page for everyone. +func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannelHistory(ctx, req) +} + +// DeleteParticipantHistory deletes one bounded page of messages sent by a channel participant. +func (s *Service) DeleteParticipantHistory(ctx context.Context, userID int64, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.DeleteChannelParticipantHistory(ctx, req) +} + +// UpdatePinnedMessage pins or unpins a channel/supergroup message. +func (s *Service) UpdatePinnedMessage(ctx context.Context, userID int64, req domain.UpdateChannelPinnedMessageRequest) (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 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid + } + return s.channels.UpdatePinnedMessage(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 { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + return s.channels.ExportInvite(ctx, req) +} + +// CheckInvite checks an invite hash. +func (s *Service) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CheckChannelInviteResult{}, domain.ErrChannelInvalid + } + if strings.TrimSpace(hash) == "" { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty + } + return s.channels.CheckInvite(ctx, userID, strings.TrimSpace(hash), date) +} + +// ImportInvite imports an invite and joins the channel if possible. +func (s *Service) ImportInvite(ctx context.Context, userID int64, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || strings.TrimSpace(req.Hash) == "" { + return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty + } + req.Hash = strings.TrimSpace(req.Hash) + return s.channels.ImportInvite(ctx, req) +} + +// ListExportedInvites returns a bounded invite management page. +func (s *Service) ListExportedInvites(ctx context.Context, userID int64, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.AdminUserID == 0 || req.OffsetDate < 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + req.OffsetHash = strings.TrimSpace(req.OffsetHash) + req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit) + return s.channels.ListExportedInvites(ctx, req) +} + +// GetExportedInvite returns one exported invite by hash. +func (s *Service) GetExportedInvite(ctx context.Context, userID int64, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelInvite{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + return s.channels.GetExportedInvite(ctx, req) +} + +// EditExportedInvite edits or revokes one exported invite. +func (s *Service) EditExportedInvite(ctx context.Context, userID int64, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelInviteResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" { + return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty + } + if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return domain.EditChannelInviteResult{}, domain.ErrChannelInvalid + } + return s.channels.EditExportedInvite(ctx, req) +} + +// DeleteExportedInvite deletes one exported invite. +func (s *Service) DeleteExportedInvite(ctx context.Context, userID int64, req domain.DeleteChannelInviteRequest) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 || req.Hash == "" { + return domain.ErrInviteHashEmpty + } + return s.channels.DeleteExportedInvite(ctx, req) +} + +// DeleteRevokedExportedInvites deletes revoked invites for one admin. +func (s *Service) DeleteRevokedExportedInvites(ctx context.Context, userID int64, req domain.DeleteRevokedChannelInvitesRequest) error { + if s == nil || s.channels == nil || userID == 0 { + return domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests) + return s.channels.DeleteRevokedExportedInvites(ctx, req) +} + +// ListAdminsWithInvites returns admins that created invite links. +func (s *Service) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + return s.channels.ListAdminsWithInvites(ctx, userID, channelID) +} + +// ListInviteImporters returns users joined/requested via invite links. +func (s *Service) ListInviteImporters(ctx context.Context, userID int64, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + req.Query = strings.TrimSpace(req.Query) + if req.UserID != userID || req.ChannelID == 0 || req.OffsetDate < 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Query) > domain.MaxChannelParticipantsQueryLength { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit) + return s.channels.ListInviteImporters(ctx, req) +} + +// PendingJoinRequests returns the bounded admin-facing join request summary. +func (s *Service) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { + if s == nil || s.channels == nil || channelID == 0 { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + limit = capLimit(limit, domain.MaxChannelPendingJoinRecentRequesters) + return s.channels.PendingJoinRequests(ctx, channelID, limit) +} + +// HideChatJoinRequest approves or dismisses one pending join request. +func (s *Service) HideChatJoinRequest(ctx context.Context, userID int64, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.TargetUserID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + return s.channels.HideChatJoinRequest(ctx, req) +} + +// HideAllChatJoinRequests approves or dismisses pending join requests in a bounded batch. +func (s *Service) HideAllChatJoinRequests(ctx context.Context, userID int64, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + req.Hash = strings.TrimSpace(req.Hash) + if req.UserID != userID || req.ChannelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests) + return s.channels.HideAllChatJoinRequests(ctx, req) +} + +// ListDialogs returns current user's channel/supergroup dialog page. +func (s *Service) ListDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelDialogList{}, nil + } + return s.channels.ListChannelDialogs(ctx, userID, filter) +} + +// GetDialogs returns channel/supergroup dialogs by IDs. +func (s *Service) GetDialogs(ctx context.Context, userID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 { + return domain.ChannelDialogList{}, nil + } + if len(channelIDs) > domain.MaxDialogFolderPeers { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + return s.channels.GetChannelDialogs(ctx, userID, channelIDs) +} + +// SetViewForumAsMessages stores the current user's local forum presentation mode. +func (s *Service) SetViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return false, domain.ErrChannelInvalid + } + return s.channels.SetChannelViewForumAsMessages(ctx, userID, channelID, enabled) +} + +// CommonChannels returns megagroups shared by userID and req.TargetUserID. +func (s *Service) CommonChannels(ctx context.Context, userID int64, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.CommonChannelsResult{}, nil + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.TargetUserID == 0 || req.TargetUserID == userID || req.MaxID < 0 { + return domain.CommonChannelsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxCommonChannelsLimit) + return s.channels.ListCommonChannels(ctx, req) +} + +// LeftChannels returns a bounded export page of channels/supergroups the user has left. +func (s *Service) LeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { + if s == nil || s.channels == nil || userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { + return domain.LeftChannelsResult{}, domain.ErrChannelInvalid + } + return s.channels.ListLeftChannels(ctx, userID, offset, capLimit(limit, domain.MaxLeftChannelsLimit)) +} + +// InactiveChannels returns least recently active channels/supergroups for limits UI. +func (s *Service) InactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + return s.channels.ListInactiveChannels(ctx, userID, capLimit(limit, domain.MaxInactiveChannelsLimit)) +} + +// ChannelRecommendations returns public broadcast channels for TDesktop similar/recommended UI. +func (s *Service) ChannelRecommendations(ctx context.Context, userID int64, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.SourceChannelID < 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 { + req.Limit = domain.DefaultChannelRecommendationsLimit + } else { + req.Limit = capLimit(req.Limit, domain.MaxChannelRecommendationsLimit) + } + return s.channels.ListChannelRecommendations(ctx, req) +} + +// DiscussionGroups returns manageable megagroups that can be linked to a broadcast channel. +func (s *Service) DiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + return s.channels.ListDiscussionGroups(ctx, userID, capLimit(limit, domain.MaxDiscussionGroupsLimit)) +} + +// SetDiscussionGroup links or unlinks a broadcast channel and a discussion megagroup. +func (s *Service) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid + } + if broadcastID == 0 && groupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + return s.channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID) +} + +// GetHistory returns a channel/supergroup history page. +func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + filter.Limit = capLimit(filter.Limit, 100) + return s.channels.ListChannelHistory(ctx, userID, filter) +} + +// SearchPosts returns a bounded page of public channel/supergroup posts. +func (s *Service) SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.OffsetRate < 0 || req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + if req.OffsetChannelID < 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if (strings.TrimSpace(req.Query) == "") == (strings.TrimSpace(req.Hashtag) == "") { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if strings.Contains(req.Hashtag, "#") || + utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength || + utf8.RuneCountInString(req.Hashtag) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + req.Query = strings.TrimSpace(req.Query) + req.Hashtag = strings.TrimSpace(req.Hashtag) + req.Limit = capLimit(req.Limit, domain.MaxChannelSearchPostsLimit) + return s.channels.SearchPublicPosts(ctx, userID, req) +} + +// SearchJoinedMessages returns current-account-visible channel/supergroup hits for messages.searchGlobal. +func (s *Service) SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.OffsetRate < 0 || req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + if req.OffsetChannelID < 0 || req.MinDate < 0 || req.MaxDate < 0 || (req.HasFolderID && req.FolderID < 0) { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.BroadcastsOnly && req.GroupsOnly { + return domain.ChannelHistory{}, nil + } + req.Query = strings.TrimSpace(req.Query) + if req.Query == "" { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelGlobalSearchLimit) + return s.channels.SearchJoinedMessages(ctx, userID, req) +} + +// GetReplies returns a bounded message thread page. +func (s *Service) GetReplies(ctx context.Context, userID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 || filter.RootMessageID <= 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.RootMessageID > domain.MaxMessageBoxID || filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit) + return s.channels.ListChannelReplies(ctx, userID, filter) +} + +// GetUnreadMentions returns a bounded unread mention page for a channel/supergroup. +func (s *Service) GetUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.TopMsgID < 0 || filter.TopMsgID > domain.MaxMessageBoxID || + filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || + filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || + filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + filter.Limit = capLimit(filter.Limit, domain.MaxChannelUnreadMentionsLimit) + return s.channels.ListChannelUnreadMentions(ctx, userID, filter) +} + +// ReadMentions clears unread mentions for a channel/supergroup in a bounded batch. +func (s *Service) ReadMentions(ctx context.Context, userID int64, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelReadMentionsBatch) + return s.channels.ReadChannelMentions(ctx, req) +} + +// GetUnreadReactions returns a bounded unread reaction page for a channel/supergroup. +func (s *Service) GetUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || filter.ChannelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.TopMsgID < 0 || filter.TopMsgID > domain.MaxMessageBoxID || + filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID || + filter.MaxID < 0 || filter.MaxID > domain.MaxMessageBoxID || + filter.MinID < 0 || filter.MinID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + filter.Limit = capLimit(filter.Limit, domain.MaxChannelUnreadReactionsLimit) + return s.channels.ListChannelUnreadReactions(ctx, userID, filter) +} + +// ReadReactions clears unread reactions for a channel/supergroup in a bounded batch. +func (s *Service) ReadReactions(ctx context.Context, userID int64, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelReadReactionsBatch) + return s.channels.ReadChannelReactions(ctx, req) +} + +// GetMessages returns a bounded exact-id channel/supergroup message batch. +func (s *Service) GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if len(ids) > domain.MaxGetMessageIDs { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + } + return s.channels.GetChannelMessages(ctx, userID, channelID, ids) +} + +// GetDiscussionMessage returns the root message used to open a discussion thread. +func (s *Service) GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelDiscussionMessage{}, domain.ErrChannelInvalid + } + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid + } + return s.channels.GetDiscussionMessage(ctx, userID, channelID, msgID) +} + +// ReadHistory advances current user's channel read watermark. +func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 { + return domain.ReadChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.ReadChannelHistory(ctx, req) +} + +// 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 { + return domain.ChannelReadParticipantsResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelReadParticipantsResult{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelReadParticipants) + return s.channels.ListMessageReadParticipants(ctx, req) +} + +// ActiveChannelIDsForUser pages active joined channels for one user. +func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + if s == nil || s.channels == nil || userID == 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) +} + +// ActiveMemberIDs returns a bounded list for transient online fanout such as typing. +func (s *Service) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelTypingFanout { + limit = domain.MaxChannelTypingFanout + } + return s.channels.ListActiveChannelMemberIDs(ctx, userID, channelID, limit) +} + +// InviteAdminMemberIDs returns active admins that can manage join requests. +func (s *Service) InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) { + if s == nil || s.channels == nil || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + return s.channels.ListChannelInviteAdminMemberIDs(ctx, channelID, limit) +} + +// FilterActiveMemberIDs keeps only candidates that are active members of channelID. +// It is used by realtime fanout to intersect the online-user set with membership. +func (s *Service) FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { + if s == nil || s.channels == nil || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + candidates := uniqueNonZero(userIDs) + if len(candidates) == 0 { + return nil, nil + } + return s.channels.FilterActiveChannelMemberIDs(ctx, channelID, candidates) +} + +// GetDifference returns channel-scoped pts difference. +func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 { + return domain.ChannelDifference{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ChannelDifference{}, domain.ErrChannelInvalid + } + req.Limit = capLimit(req.Limit, domain.MaxChannelDifferenceLimit) + return s.channels.ListChannelDifference(ctx, req) +} + +func capLimit(limit, max int) int { + if max <= 0 { + return limit + } + if limit <= 0 { + return max + } + if limit > max { + return max + } + 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)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func normalizeChannelUsername(username string) string { + username = strings.TrimSpace(username) + username = strings.TrimPrefix(username, "@") + return strings.TrimSpace(username) +} + +func validChannelUsername(username string) bool { + if len(username) < 5 || len(username) > 32 { + return false + } + for i := 0; i < len(username); i++ { + c := username[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + if i == 0 { + return false + } + case c == '_': + if i == 0 { + return false + } + default: + return false + } + } + return true +} diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go new file mode 100644 index 00000000..73b10e5f --- /dev/null +++ b/internal/app/channels/service_test.go @@ -0,0 +1,1784 @@ +package channels + +import ( + "context" + "errors" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if !created.Channel.Megagroup || created.Channel.Broadcast { + t.Fatalf("channel flags = megagroup:%v broadcast:%v, want megagroup only", created.Channel.Megagroup, created.Channel.Broadcast) + } + if created.Channel.Pts != 1 || created.Message.ID != 1 || created.Event.PtsCount != 1 { + t.Fatalf("created pts/message/event = %+v/%+v/%+v, want initial pts=1 message id=1", created.Channel, created.Message, created.Event) + } + if created.Message.Action == nil || created.Message.Action.Type != domain.ChannelActionCreate { + t.Fatalf("create service action = %+v, want channel create", created.Message.Action) + } + + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 99, + Message: "hello", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + 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) + } + + duplicate, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 99, + Message: "hello again", + Date: 12, + }) + if err != nil { + t.Fatalf("duplicate SendMessage: %v", err) + } + if !duplicate.Duplicate || duplicate.Message.ID != sent.Message.ID || duplicate.Message.Body != "hello" { + t.Fatalf("duplicate = %+v, want original single-copy message", duplicate) + } + + history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + 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) + } + + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + 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 _, 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) + } + + read, err := service.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ChannelID: created.Channel.ID, MaxID: 2}) + if err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if !read.Changed || read.StillUnreadCount != 0 || read.Dialog.ReadInboxMaxID != 2 { + t.Fatalf("read = %+v, want read watermark at message 2", read) + } + if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != 1001 || read.OutboxUpdates[0].MaxID != sent.Message.ID { + t.Fatalf("read outbox updates = %+v, want owner read_outbox through sent message", read.OutboxUpdates) + } + ownerView, err := service.GetChannel(ctx, 1001, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel owner: %v", err) + } + if ownerView.Dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("owner dialog read_outbox = %d, want %d", ownerView.Dialog.ReadOutboxMaxID, sent.Message.ID) + } +} + +func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Mentions", + MemberUserIDs: []int64{1002, 1003}, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 9101, + Message: "hello @friend", + MentionUserIDs: []int64{1002, 1002, 1001}, + Date: 1700000101, + }) + if err != nil { + t.Fatalf("SendMessage mention: %v", err) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel mentioned: %v", err) + } + if view.Dialog.UnreadMentions != 1 { + t.Fatalf("mentioned dialog unread mentions = %d, want 1", view.Dialog.UnreadMentions) + } + other, err := service.GetChannel(ctx, 1003, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel other: %v", err) + } + if other.Dialog.UnreadMentions != 0 { + t.Fatalf("unmentioned dialog unread mentions = %d, want 0", other.Dialog.UnreadMentions) + } + mentions, err := service.GetUnreadMentions(ctx, 1002, domain.ChannelUnreadMentionsFilter{ + ChannelID: created.Channel.ID, + OffsetID: 1, + AddOffset: -10, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetUnreadMentions: %v", err) + } + if mentions.Count != 1 || len(mentions.Messages) != 1 || mentions.Messages[0].ID != sent.Message.ID { + t.Fatalf("mentions = count %d messages %+v, want sent message", mentions.Count, mentions.Messages) + } + read, err := service.ReadMentions(ctx, 1002, domain.ReadChannelMentionsRequest{ChannelID: created.Channel.ID}) + if err != nil { + t.Fatalf("ReadMentions: %v", err) + } + if read.ChannelPts != sent.Event.Pts || read.Offset != 0 || read.Cleared != 1 { + t.Fatalf("read mentions = %+v, want pts %d cleared 1 no offset", read, sent.Event.Pts) + } + mentions, err = service.GetUnreadMentions(ctx, 1002, domain.ChannelUnreadMentionsFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetUnreadMentions after read: %v", err) + } + if mentions.Count != 0 || len(mentions.Messages) != 0 { + t.Fatalf("mentions after read = count %d messages %d, want empty", mentions.Count, len(mentions.Messages)) + } +} + +func TestServiceRejectsMismatchedUserContextForStateReads(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Context Guard", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 91, + Message: "guard", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := service.ReadHistory(ctx, 1001, domain.ReadChannelHistoryRequest{ + UserID: 1002, + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("ReadHistory mismatched user err = %v, want ErrChannelInvalid", err) + } + if _, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + UserID: 1002, + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetMessageReadParticipants mismatched user err = %v, want ErrChannelInvalid", err) + } + if _, err := service.GetDifference(ctx, 1001, domain.ChannelDifferenceRequest{ + UserID: 1002, + ChannelID: created.Channel.ID, + Pts: 0, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetDifference mismatched user err = %v, want ErrChannelInvalid", err) + } +} + +func TestServiceRejectsHugeChannelDialogVector(t *testing.T) { + service := NewService(memory.NewChannelStore()) + ids := make([]int64, domain.MaxDialogFolderPeers+1) + for i := range ids { + ids[i] = int64(i + 1) + } + if _, err := service.GetDialogs(context.Background(), 1001, ids); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetDialogs huge channel vector err = %v, want ErrChannelInvalid", err) + } +} + +func TestChannelHistorySearchQueryIsBounded(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + CreatorUserID: 1001, + Title: "Bounded History", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + _, err = service.GetHistory(ctx, 1001, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Query: strings.Repeat("x", domain.MaxChannelHistoryQueryLength+1), + Limit: 10, + }) + if !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetHistory long query err = %v, want channel invalid", err) + } +} + +func TestChannelHistorySupportsOffsetDateOnly(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + CreatorUserID: 1001, + Title: "Date Cursor", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "old", + Date: 20, + }); err != nil { + t.Fatalf("send old: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "new", + Date: 30, + }); err != nil { + t.Fatalf("send new: %v", err) + } + + history, err := service.GetHistory(ctx, 1001, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + OffsetDate: 30, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].Body != "old" || history.Messages[1].Action == nil { + t.Fatalf("history = %+v, want messages older than offset date including service message", history.Messages) + } +} + +func TestChannelDifferenceTooLongReturnsLatestSnapshot(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + CreatorUserID: 1001, + Title: "Long Difference", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + var lastPts int + for i := 0; i < 12; i++ { + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: int64(i + 1), + Message: "msg", + Date: 11 + i, + }) + if err != nil { + t.Fatalf("SendMessage %d: %v", i, err) + } + lastPts = sent.Event.Pts + } + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ + ChannelID: created.Channel.ID, + Pts: 0, + Limit: 3, + }) + if err != nil { + t.Fatalf("GetDifference: %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 messages = %d, want bounded latest snapshot", len(diff.NewMessages)) + } +} + +func TestGetParticipantsCapsDeepOffset(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + page, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{}, domain.MaxChannelParticipantsOffset+1_000_000, 10) + if err != nil { + t.Fatalf("GetParticipants deep offset: %v", err) + } + if len(page.Participants) != 0 || page.Count != 3 { + t.Fatalf("deep offset page = %+v, want bounded empty page with real count", page) + } +} + +func TestDefaultBannedRightsRestrictMemberSendAndInvite(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Permissions", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + updated, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ + ChannelID: created.Channel.ID, + BannedRights: domain.ChannelBannedRights{ + SendMessages: true, + InviteUsers: true, + }, + Date: 11, + }) + if err != nil { + t.Fatalf("EditDefaultBannedRights: %v", err) + } + if !updated.DefaultBannedRights.SendMessages || !updated.DefaultBannedRights.InviteUsers { + t.Fatalf("default banned rights = %+v, want send+invite restricted", updated.DefaultBannedRights) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "blocked", + Date: 12, + }); !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SendMessage err = %v, want ErrChannelWriteForbidden", err) + } + 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) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "owner ok", + Date: 13, + }); 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, + }); err != nil { + t.Fatalf("clear default banned rights: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3, + Message: "member ok", + Date: 15, + }); err != nil { + t.Fatalf("member SendMessage after clear: %v", err) + } +} + +func TestSendMessageResolvesChannelReplyTopID(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Replies", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + root, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "root", + Date: 11, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + reply, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "reply", + ReplyTo: &domain.MessageReply{ + MessageID: root.Message.ID, + QuoteText: "ro", + QuoteOffset: 0, + QuoteEntities: []domain.MessageEntity{{ + Type: domain.MessageEntityBold, + Offset: 0, + Length: 2, + }}, + }, + Date: 12, + }) + if err != nil { + t.Fatalf("send reply: %v", err) + } + if reply.Message.ReplyTo == nil { + t.Fatal("reply metadata is nil") + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if reply.Message.ReplyTo.MessageID != root.Message.ID || reply.Message.ReplyTo.Peer != channelPeer || reply.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("reply metadata = %+v, want channel peer and root top id %d", reply.Message.ReplyTo, root.Message.ID) + } + if reply.Message.ReplyTo.QuoteText != "ro" || len(reply.Message.ReplyTo.QuoteEntities) != 1 { + t.Fatalf("reply quote = %+v, want preserved quote metadata", reply.Message.ReplyTo) + } + + nested, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3, + Message: "nested", + ReplyTo: &domain.MessageReply{MessageID: reply.Message.ID}, + Date: 13, + }) + 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 = %+v, want inherited top id %d", nested.Message.ReplyTo, root.Message.ID) + } + _, err = service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 4, + Message: "bad reply", + ReplyTo: &domain.MessageReply{MessageID: 999}, + Date: 14, + }) + if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad reply err = %v, want ErrReplyMessageIDInvalid", err) + } + _, err = service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 5, + Message: "bad quote offset", + ReplyTo: &domain.MessageReply{ + MessageID: root.Message.ID, + QuoteText: "ro", + QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, + }, + Date: 15, + }) + if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) + } +} + +func TestGetMessageReadParticipantsUsesChannelReadWatermark(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Readers", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 100, + Message: "read me", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := service.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 20, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Date: 21, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants: %v", err) + } + if len(readers.Participants) != 1 || readers.Participants[0].UserID != 1002 || readers.Participants[0].Date != 20 { + t.Fatalf("readers = %+v, want friend read at date 20", readers.Participants) + } +} + +func TestParticipantsHiddenHidesMemberListAndReadParticipants(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Hidden Members", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 100, + Message: "read me", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := service.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 20, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + hidden, err := service.SetParticipantsHidden(ctx, 1001, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetParticipantsHidden: %v", err) + } + if !hidden.ParticipantsHidden { + t.Fatalf("channel = %+v, want participants hidden", hidden) + } + if _, err := service.SetParticipantsHidden(ctx, 1002, created.Channel.ID, false); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member SetParticipantsHidden err = %v, want ErrChannelAdminRequired", err) + } + members, err := service.GetParticipants(ctx, 1002, created.Channel.ID, domain.ChannelParticipantsFilter{}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants hidden member view: %v", err) + } + if len(members.Participants) != 0 || members.Count != hidden.ParticipantsCount { + t.Fatalf("hidden members page = %+v, want empty page with aggregate count", members) + } + admins, err := service.GetParticipants(ctx, 1002, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants hidden admins: %v", err) + } + if len(admins.Participants) != 1 || admins.Participants[0].UserID != 1001 { + t.Fatalf("hidden admins page = %+v, want creator visible", admins.Participants) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Date: 21, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants hidden: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("hidden readers = %+v, want none", readers.Participants) + } +} + +func TestBroadcastRejectsMemberPost(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + + created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "News", + Broadcast: true, + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if !created.Channel.Broadcast || created.Channel.Megagroup { + t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", created.Channel.Broadcast, created.Channel.Megagroup) + } + + _, err = service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "member post", + Date: 11, + }) + if !errors.Is(err, domain.ErrChannelWriteForbidden) { + t.Fatalf("member SendMessage error = %v, want ErrChannelWriteForbidden", err) + } + + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "owner post", + Date: 12, + }) + if err != nil { + t.Fatalf("creator SendMessage: %v", err) + } + if !sent.Message.Post { + t.Fatalf("broadcast message Post=false, want true") + } +} + +func TestChannelEditDeleteAndLocalClearUseChannelPts(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 1, Message: "one", Date: 11}) + if err != nil { + t.Fatalf("SendMessage first: %v", err) + } + second, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "two", Date: 12}) + if err != nil { + t.Fatalf("SendMessage second: %v", err) + } + + edited, err := service.EditMessage(ctx, 1002, domain.EditChannelMessageRequest{ + ChannelID: created.Channel.ID, + ID: second.Message.ID, + Message: "two edited", + EditDate: 13, + }) + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Event.Pts != 4 || edited.Event.PtsCount != 1 { + t.Fatalf("edit event = %+v, want channel edit pts=4 count=1", edited.Event) + } + duplicate, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "two retry", Date: 13}) + if err != nil { + t.Fatalf("duplicate SendMessage after edit: %v", err) + } + if !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "two" || duplicate.Event.Message.Body != "two" { + t.Fatalf("duplicate after edit = %+v, want original new-message snapshot", duplicate) + } + + deleted, err := service.DeleteMessages(ctx, 1001, domain.DeleteChannelMessagesRequest{ + ChannelID: created.Channel.ID, + IDs: []int{first.Message.ID, second.Message.ID}, + Date: 14, + }) + if err != nil { + t.Fatalf("DeleteMessages: %v", err) + } + if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.Pts != 6 || deleted.Event.PtsCount != 2 { + t.Fatalf("delete event = %+v, want pts advanced by deleted id count", deleted.Event) + } + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 3, Limit: 10}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.OtherUpdates) != 2 || diff.OtherUpdates[1].Type != domain.ChannelUpdateDeleteMessages || diff.Pts != 6 { + t.Fatalf("diff after edit/delete = %+v, want edit then delete through channel pts", diff) + } + + clear, err := service.DeleteHistory(ctx, 1002, domain.DeleteChannelHistoryRequest{ChannelID: created.Channel.ID, MaxID: 6}) + if err != nil { + t.Fatalf("DeleteHistory local: %v", err) + } + if clear.Event.Pts != 0 { + t.Fatalf("local clear event = %+v, want no channel pts event", clear.Event) + } + history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetHistory after local clear: %v", err) + } + if len(history.Messages) != 0 { + t.Fatalf("history after local clear = %+v, want hidden for current user", history.Messages) + } +} + +func TestDeleteParticipantHistoryDeletesOneBoundedSenderPage(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + ownerMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 1, Message: "owner", Date: 11}) + if err != nil { + t.Fatalf("owner SendMessage: %v", err) + } + first, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "member one", Date: 12}) + if err != nil { + t.Fatalf("member first SendMessage: %v", err) + } + second, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 3, Message: "member two", Date: 13}) + if err != nil { + t.Fatalf("member second SendMessage: %v", err) + } + if _, err := service.DeleteParticipantHistory(ctx, 1002, domain.DeleteChannelParticipantHistoryRequest{ + ChannelID: created.Channel.ID, + ParticipantUserID: 1001, + Date: 14, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member DeleteParticipantHistory err = %v, want ErrChannelAdminRequired", err) + } + + deleted, err := service.DeleteParticipantHistory(ctx, 1001, domain.DeleteChannelParticipantHistoryRequest{ + ChannelID: created.Channel.ID, + ParticipantUserID: 1002, + Date: 15, + }) + if err != nil { + t.Fatalf("DeleteParticipantHistory: %v", err) + } + if deleted.Event.Type != domain.ChannelUpdateDeleteMessages || deleted.Event.PtsCount != 2 || deleted.Offset != 0 { + t.Fatalf("deleted = %+v, want one delete update with pts_count=2", deleted) + } + wantDeleted := map[int]bool{first.Message.ID: true, second.Message.ID: true} + for _, id := range deleted.DeletedIDs { + delete(wantDeleted, id) + } + if len(wantDeleted) != 0 { + t.Fatalf("deleted IDs = %+v, missing member messages %+v", deleted.DeletedIDs, wantDeleted) + } + history, err := service.GetHistory(ctx, 1001, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].ID != ownerMsg.Message.ID { + t.Fatalf("history after participant delete = %+v, want owner message and create service only", history.Messages) + } +} + +func TestChannelAdminTitlePinAndInvite(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + admin, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + InviteUsers: true, + PinMessages: true, + }, + Rank: "ops", + Date: 11, + }) + if err != nil { + t.Fatalf("EditAdmin: %v", err) + } + if admin.Participant.Role != domain.ChannelRoleAdmin || !admin.Participant.AdminRights.PinMessages || admin.Channel.AdminsCount != 2 { + t.Fatalf("admin result = %+v, want promoted admin with counts", admin) + } + if admin.Event.Type != domain.ChannelUpdateParticipant || admin.Event.PtsCount != 1 || admin.Event.Participant.UserID != 1002 || admin.Event.Previous.UserID != 1002 { + t.Fatalf("admin participant event = %+v, want durable participant transition", admin.Event) + } + diffAfterAdmin, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10}) + if err != nil { + t.Fatalf("GetDifference after admin: %v", err) + } + if len(diffAfterAdmin.OtherUpdates) != 1 || diffAfterAdmin.OtherUpdates[0].Type != domain.ChannelUpdateParticipant { + t.Fatalf("diff after admin = %+v, want participant update in channel difference", diffAfterAdmin) + } + admins, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants admins: %v", err) + } + if len(admins.Participants) != 2 || admins.Participants[1].UserID != 1002 { + t.Fatalf("admins participants = %+v, want creator and promoted admin", admins.Participants) + } + + renamed, err := service.EditTitle(ctx, 1002, domain.EditChannelTitleRequest{ChannelID: created.Channel.ID, Title: "Team 2", Date: 12}) + if err != nil { + t.Fatalf("EditTitle by promoted admin: %v", err) + } + if renamed.Channel.Title != "Team 2" || renamed.Event.Type != domain.ChannelUpdateNewMessage || renamed.Message.Action.Type != domain.ChannelActionEditTitle { + t.Fatalf("renamed = %+v message=%+v, want edit-title service message", renamed.Channel, renamed.Message) + } + + sent, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 42, Message: "pin me", Date: 13}) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + pinned, err := service.UpdatePinnedMessage(ctx, 1002, domain.UpdateChannelPinnedMessageRequest{ + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Pinned: true, + Date: 14, + }) + if err != nil { + t.Fatalf("UpdatePinnedMessage: %v", err) + } + if pinned.Channel.PinnedMessageID != sent.Message.ID || pinned.Event.Type != domain.ChannelUpdatePinnedMessages || !pinned.Event.Pinned { + t.Fatalf("pinned = %+v, want pinned channel message event", pinned) + } + + invited, err := service.InviteToChannel(ctx, 1002, created.Channel.ID, []int64{1004}, 15) + if err != nil { + t.Fatalf("InviteToChannel: %v", err) + } + if len(invited.Members) != 1 || invited.Members[0].UserID != 1004 { + t.Fatalf("invited = %+v, want invited user", invited.Members) + } + + invite, err := service.ExportInvite(ctx, 1002, domain.ExportChannelInviteRequest{ChannelID: created.Channel.ID, Title: "join", Date: 15}) + if err != nil { + t.Fatalf("ExportInvite: %v", err) + } + checked, err := service.CheckInvite(ctx, 1003, invite.Invite.Hash, 16) + if err != nil { + t.Fatalf("CheckInvite: %v", err) + } + if checked.Already || checked.Channel.ID != created.Channel.ID { + t.Fatalf("checked invite = %+v, want preview for non-member", checked) + } + joined, err := service.ImportInvite(ctx, 1003, domain.ImportChannelInviteRequest{Hash: invite.Invite.Hash, Date: 17}) + if err != nil { + t.Fatalf("ImportInvite: %v", err) + } + if len(joined.Members) != 1 || joined.Members[0].UserID != 1003 || joined.Event.Pts == 0 { + t.Fatalf("joined = %+v, want imported member with megagroup join event", joined) + } + forum, err := service.SetForum(ctx, 1001, created.Channel.ID, true, true) + if err != nil { + t.Fatalf("SetForum: %v", err) + } + if !forum.Forum || !forum.ForumTabs { + t.Fatalf("forum = %+v, want enabled with tabs", forum) + } + antiSpam, err := service.SetAntiSpam(ctx, 1001, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetAntiSpam: %v", err) + } + if !antiSpam.AntiSpam { + t.Fatalf("antiSpam = %+v, want enabled", antiSpam) + } + logs, err := service.ListAdminLog(ctx, 1001, domain.ChannelAdminLogRequest{ChannelID: created.Channel.ID, Limit: 20}) + if err != nil { + t.Fatalf("ListAdminLog: %v", err) + } + seen := map[domain.ChannelAdminLogEventType]bool{} + for _, event := range logs.Events { + seen[event.Type] = true + } + for _, typ := range []domain.ChannelAdminLogEventType{ + domain.ChannelAdminLogParticipantPromote, + domain.ChannelAdminLogChangeTitle, + domain.ChannelAdminLogUpdatePinned, + domain.ChannelAdminLogParticipantInvite, + domain.ChannelAdminLogParticipantJoin, + domain.ChannelAdminLogToggleForum, + domain.ChannelAdminLogToggleAntiSpam, + } { + if !seen[typ] { + t.Fatalf("admin logs missing %s in %+v", typ, logs.Events) + } + } + pinnedOnly, err := service.ListAdminLog(ctx, 1001, domain.ChannelAdminLogRequest{ + ChannelID: created.Channel.ID, + Limit: 10, + Filter: domain.ChannelAdminLogFilter{Pinned: true}, + }) + if err != nil { + t.Fatalf("ListAdminLog pinned: %v", err) + } + if len(pinnedOnly.Events) != 1 || pinnedOnly.Events[0].Type != domain.ChannelAdminLogUpdatePinned || pinnedOnly.Events[0].Message == nil { + t.Fatalf("pinned admin logs = %+v, want one update_pinned with message", pinnedOnly.Events) + } + if _, err := service.ListAdminLog(ctx, 1003, domain.ChannelAdminLogRequest{ChannelID: created.Channel.ID, Limit: 10}); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("non-admin ListAdminLog err = %v, want ErrChannelAdminRequired", err) + } +} + +func TestChannelAboutRequiresChangeInfo(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + if _, err := service.EditAbout(ctx, 1002, domain.EditChannelAboutRequest{ + ChannelID: created.Channel.ID, + About: "member cannot edit", + Date: 11, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("EditAbout by member err = %v, want ErrChannelAdminRequired", err) + } + + updated, err := service.EditAbout(ctx, 1001, domain.EditChannelAboutRequest{ + ChannelID: created.Channel.ID, + About: "owner about", + Date: 12, + }) + if err != nil { + t.Fatalf("EditAbout by owner: %v", err) + } + if updated.About != "owner about" { + t.Fatalf("updated about = %q, want owner about", updated.About) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + 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) + } + + if _, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 13, + }); err != nil { + t.Fatalf("EditAdmin: %v", err) + } + updated, err = service.EditAbout(ctx, 1002, domain.EditChannelAboutRequest{ + ChannelID: created.Channel.ID, + About: "admin about", + Date: 14, + }) + if err != nil { + t.Fatalf("EditAbout by change_info admin: %v", err) + } + if updated.About != "admin about" { + t.Fatalf("updated about = %q, want admin about", updated.About) + } +} + +func TestChannelBanAndDeletePermissions(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.DeleteChannel(ctx, 1002, domain.DeleteChannelRequest{ChannelID: created.Channel.ID, Date: 11}); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member DeleteChannel err = %v, want ErrChannelAdminRequired", err) + } + banned, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + ViewMessages: true, + UntilDate: 100, + }, + Date: 12, + }) + if err != nil { + t.Fatalf("EditBanned: %v", err) + } + 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.Event.Type != domain.ChannelUpdateParticipant || banned.Event.Participant.Status != domain.ChannelMemberKicked || banned.Event.PtsCount != 1 { + t.Fatalf("ban participant event = %+v, want durable kicked transition", banned.Event) + } + kicked, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked}, 0, 10) + if err != nil { + t.Fatalf("GetParticipants kicked: %v", err) + } + if len(kicked.Participants) != 1 || kicked.Participants[0].UserID != 1002 || kicked.Participants[0].InviterUserID != 1001 { + t.Fatalf("kicked participants = %+v, want kicked user with actor as inviter/kicked_by", kicked.Participants) + } + hidden, err := service.GetParticipants(ctx, 1002, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked}, 0, 10) + if !errors.Is(err, domain.ErrChannelUserBanned) && (err != nil || len(hidden.Participants) != 0) { + t.Fatalf("banned viewer kicked participants = %+v err=%v, want no access", hidden.Participants, err) + } + if _, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("banned GetHistory err = %v, want ErrChannelUserBanned", err) + } + if _, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 13); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("kicked JoinChannel err = %v, want ErrChannelUserBanned", err) + } + deleted, err := service.DeleteChannel(ctx, 1001, domain.DeleteChannelRequest{ChannelID: created.Channel.ID, Date: 13}) + if err != nil { + t.Fatalf("creator DeleteChannel: %v", err) + } + if !deleted.Channel.Deleted { + t.Fatalf("deleted = %+v, want deleted channel", deleted) + } +} + +func TestChannelInviteCannotBypassKickedMemberWithoutBanRight(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Invite Kicked", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.EditBanned(ctx, 1001, domain.EditChannelBannedRequest{ + ChannelID: created.Channel.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + BannedRights: domain.ChannelBannedRights{ + ViewMessages: true, + UntilDate: 100, + }, + Date: 11, + }); err != nil { + t.Fatalf("EditBanned: %v", err) + } + if _, err := service.InviteToChannel(ctx, 1003, created.Channel.ID, []int64{1002}, 12); !errors.Is(err, domain.ErrUserKicked) { + t.Fatalf("member InviteToChannel kicked err = %v, want ErrUserKicked", err) + } + restored, err := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1002}, 13) + if err != nil { + t.Fatalf("creator 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 := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1002}, 14); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate InviteToChannel err = %v, want ErrUserAlreadyParticipant", err) + } +} + +func TestChannelLeaveAndRejoinRestoresParticipantCountAndNotifiesLeaver(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Leave Rejoin", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + left, err := service.LeaveChannel(ctx, 1002, created.Channel.ID, 11) + if err != nil { + t.Fatalf("LeaveChannel: %v", err) + } + if left.Members[0].Status != domain.ChannelMemberLeft || left.Channel.ParticipantsCount != 1 { + t.Fatalf("left result = %+v, want left member and participants=1", left) + } + hasLeaverRecipient := false + for _, id := range left.Recipients { + if id == 1002 { + hasLeaverRecipient = true + break + } + } + if !hasLeaverRecipient { + t.Fatalf("leave recipients = %+v, want leaver included for other sessions", left.Recipients) + } + rejoined, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 12) + if err != nil { + t.Fatalf("JoinChannel after leave: %v", err) + } + if rejoined.Members[0].Status != domain.ChannelMemberActive || rejoined.Channel.ParticipantsCount != 2 { + t.Fatalf("rejoined result = %+v, want active member and participants=2", rejoined) + } + if _, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 13); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate JoinChannel err = %v, want ErrUserAlreadyParticipant", err) + } +} + +func TestChannelUsernameAndSignatures(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if ok, err := service.CheckUsername(ctx, 1001, created.Channel.ID, "team_public"); err != nil || !ok { + t.Fatalf("CheckUsername free = ok %v err %v, want true", ok, err) + } + public, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "@team_public", + }) + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + if public.Username != "team_public" { + t.Fatalf("public username = %q, want team_public", public.Username) + } + if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ChannelID: created.Channel.ID, Username: "TEAM_PUBLIC"}); !errors.Is(err, domain.ErrChannelNotModified) { + t.Fatalf("UpdateUsername same username err = %v, want ErrChannelNotModified", err) + } + if _, err := service.UpdateUsername(ctx, 1002, domain.UpdateChannelUsernameRequest{ChannelID: created.Channel.ID, Username: "friend_try"}); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("non-owner UpdateUsername err = %v, want ErrChannelAdminRequired", err) + } + + other, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{Title: "Other", Date: 11}) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat other: %v", err) + } + if ok, err := service.CheckUsername(ctx, 1001, other.Channel.ID, "TEAM_PUBLIC"); err != nil || ok { + t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err) + } + if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ChannelID: other.Channel.ID, Username: "team_public"}); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("UpdateUsername occupied err = %v, want ErrUsernameOccupied", err) + } + admined, err := service.ListAdminedPublicChannels(ctx, 1001) + if err != nil { + t.Fatalf("ListAdminedPublicChannels: %v", err) + } + if len(admined) != 1 || admined[0].ID != created.Channel.ID { + t.Fatalf("admined public = %+v, want first channel only", admined) + } + + if _, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 12, + }); err != nil { + t.Fatalf("EditAdmin: %v", err) + } + signed, err := service.SetSignatures(ctx, 1002, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetSignatures by change-info admin: %v", err) + } + if !signed.Signatures { + t.Fatalf("signed channel = %+v, want signatures enabled", signed) + } +} + +func TestPublicChannelSearchAndResolveUsername(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "CU Public Lab", + MemberUserIDs: []int64{1002}, + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + public, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "cu_public_lab", + }) + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + if _, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "CU Private Lab", + Date: 21, + }); err != nil { + t.Fatalf("CreateMegagroupFromCreateChat private: %v", err) + } + + joined, err := service.SearchPublicChannels(ctx, 1002, "CU Public", 10) + if err != nil { + t.Fatalf("SearchPublicChannels joined: %v", err) + } + if len(joined.MyResults) != 1 || joined.MyResults[0].ID != public.ID || len(joined.Results) != 0 { + t.Fatalf("joined public search = %+v, want my public channel only", joined) + } + global, err := service.SearchPublicChannels(ctx, 1003, "public", 10) + if err != nil { + t.Fatalf("SearchPublicChannels global: %v", err) + } + if len(global.Results) != 1 || global.Results[0].ID != public.ID || len(global.MyResults) != 0 { + t.Fatalf("global public search = %+v, want public channel result", global) + } + resolved, found, err := service.ResolvePublicUsername(ctx, 1003, "@CU_PUBLIC_LAB") + if err != nil || !found || resolved.ID != public.ID { + t.Fatalf("ResolvePublicUsername = %+v found %v err %v, want public channel", resolved, found, err) + } +} + +func TestPublicChannelPreviewAllowsNonMemberHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + const ( + ownerID = 1001 + viewerID = 1002 + ) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Public Preview", + Broadcast: true, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + public, err := service.UpdateUsername(ctx, ownerID, domain.UpdateChannelUsernameRequest{ + UserID: ownerID, + ChannelID: created.Channel.ID, + Username: "public_preview", + }) + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + sent, err := service.SendMessage(ctx, ownerID, domain.SendChannelMessageRequest{ + ChannelID: public.ID, + RandomID: 101, + Message: "public preview post", + Date: 20, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + + view, err := service.GetChannel(ctx, viewerID, public.ID) + if err != nil { + t.Fatalf("non-member GetChannel public preview: %v", err) + } + if view.Self.Status != domain.ChannelMemberLeft || view.Self.UserID != viewerID { + t.Fatalf("preview self = %+v, want synthetic left member for viewer", view.Self) + } + if view.Dialog.UnreadCount != 0 || view.Dialog.ReadInboxMaxID < public.TopMessageID { + t.Fatalf("preview dialog = %+v, want no unread count", view.Dialog) + } + history, err := service.GetHistory(ctx, viewerID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 10}) + if err != nil { + t.Fatalf("non-member GetHistory public preview: %v", err) + } + if history.Self.Status != domain.ChannelMemberLeft || history.Self.UserID != viewerID { + t.Fatalf("history self = %+v, want synthetic left member for viewer", history.Self) + } + foundPost := false + for _, msg := range history.Messages { + if msg.Body == "public preview post" { + foundPost = true + } + } + if !foundPost { + t.Fatalf("history messages = %+v, want public preview post", history.Messages) + } + diff, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{ + ChannelID: public.ID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("non-member GetDifference public preview: %v", err) + } + if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "public preview post" { + t.Fatalf("preview diff = %+v, want public preview post 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) + } + + private, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Private Preview", + Broadcast: true, + Date: 30, + }) + if err != nil { + t.Fatalf("CreateChannel private: %v", err) + } + if _, err := service.GetChannel(ctx, viewerID, private.Channel.ID); !errors.Is(err, domain.ErrChannelPrivate) { + t.Fatalf("non-member private GetChannel err = %v, want ErrChannelPrivate", err) + } + if _, err := service.EditBanned(ctx, ownerID, domain.EditChannelBannedRequest{ + UserID: ownerID, + ChannelID: public.ID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, + BannedRights: domain.ChannelBannedRights{ViewMessages: true}, + Date: 40, + }); err != nil { + t.Fatalf("EditBanned public viewer: %v", err) + } + if _, err := service.GetHistory(ctx, viewerID, domain.ChannelHistoryFilter{ChannelID: public.ID, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("banned public preview GetHistory err = %v, want ErrChannelUserBanned", err) + } + if _, err := service.GetDifference(ctx, viewerID, domain.ChannelDifferenceRequest{ChannelID: public.ID, Pts: created.Event.Pts, Limit: 10}); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("banned public preview GetDifference err = %v, want ErrChannelUserBanned", err) + } +} + +func TestChannelDifferenceStartsAtMemberAvailableMinPts(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Visible PTS", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + promoted, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 11, + }) + if err != nil { + t.Fatalf("EditAdmin: %v", err) + } + joined, err := service.JoinChannel(ctx, 1003, created.Channel.ID, 12) + if err != nil { + t.Fatalf("JoinChannel: %v", err) + } + if joined.Members[0].AvailableMinPts != promoted.Event.Pts { + t.Fatalf("joined available_min_pts = %d, want pre-join channel pts %d", joined.Members[0].AvailableMinPts, promoted.Event.Pts) + } + diff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("GetDifference: %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 <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join message %+v at or before available_min_pts %d", msg, promoted.Event.Pts) + } + } + for _, event := range diff.OtherUpdates { + if event.Pts <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join event %+v at or before available_min_pts %d", event, promoted.Event.Pts) + } + } +} + +func TestChannelPreHistoryAndSlowMode(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Settings Team", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.SetPreHistoryHidden(ctx, 1002, created.Channel.ID, true); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member SetPreHistoryHidden err = %v, want ErrChannelAdminRequired", err) + } + hidden, err := service.SetPreHistoryHidden(ctx, 1001, created.Channel.ID, true) + if err != nil { + t.Fatalf("SetPreHistoryHidden: %v", err) + } + if !hidden.PreHistoryHidden { + t.Fatalf("hidden channel = %+v, want prehistory hidden", hidden) + } + hiddenMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 1, Message: "before new member", Date: 90}) + if err != nil { + t.Fatalf("owner send before new member: %v", err) + } + if _, err := service.JoinChannel(ctx, 1003, created.Channel.ID, 95); err != nil { + t.Fatalf("new member JoinChannel: %v", err) + } + visibleMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 100, Message: "after new member", Date: 96}) + if err != nil { + t.Fatalf("owner send after new member: %v", err) + } + mixedDelete, err := service.DeleteMessages(ctx, 1001, domain.DeleteChannelMessagesRequest{ + ChannelID: created.Channel.ID, + IDs: []int{hiddenMsg.Message.ID, visibleMsg.Message.ID}, + Date: 97, + }) + if err != nil { + t.Fatalf("mixed DeleteMessages: %v", err) + } + if mixedDelete.Event.PtsCount != 2 { + t.Fatalf("mixed delete pts_count = %d, want original deleted id count 2", mixedDelete.Event.PtsCount) + } + history, err := service.GetHistory(ctx, 1003, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 20}) + if err != nil { + t.Fatalf("new member GetHistory: %v", err) + } + for _, msg := range history.Messages { + if msg.Body == "before new member" { + t.Fatalf("new member history includes hidden prehistory message: %+v", history.Messages) + } + } + view, err := service.GetChannel(ctx, 1003, created.Channel.ID) + if err != nil { + t.Fatalf("new member GetChannel: %v", err) + } + diff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 0, Limit: 100}) + if err != nil { + t.Fatalf("new member GetDifference: %v", err) + } + if diff.TooLong { + t.Fatalf("new member diff unexpectedly too long: %+v", diff) + } + if diff.Pts != view.Channel.Pts { + t.Fatalf("new member diff pts = %d, want current channel pts %d", diff.Pts, view.Channel.Pts) + } + for _, msg := range diff.NewMessages { + if msg.ID <= view.Self.AvailableMinID { + t.Fatalf("new member diff includes hidden prehistory message id %d <= available_min_id %d", msg.ID, view.Self.AvailableMinID) + } + } + for _, event := range diff.OtherUpdates { + for _, id := range event.MessageIDs { + if id <= view.Self.AvailableMinID { + t.Fatalf("new member diff includes hidden prehistory message id %d in event %+v", id, event) + } + } + } + foundPartialDelete := false + for _, event := range diff.OtherUpdates { + if event.Type != domain.ChannelUpdateDeleteMessages || event.Pts != mixedDelete.Event.Pts { + continue + } + foundPartialDelete = true + if event.PtsCount != mixedDelete.Event.PtsCount || len(event.MessageIDs) != 1 || event.MessageIDs[0] != visibleMsg.Message.ID { + t.Fatalf("visible mixed delete event = %+v, want pts_count=%d and only visible id %d", event, mixedDelete.Event.PtsCount, visibleMsg.Message.ID) + } + } + if !foundPartialDelete { + t.Fatalf("new member diff missing partial mixed delete event at pts %d: %+v", mixedDelete.Event.Pts, diff.OtherUpdates) + } + if _, err := service.SetSlowMode(ctx, 1002, created.Channel.ID, 30); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member SetSlowMode err = %v, want ErrChannelAdminRequired", err) + } + slow, err := service.SetSlowMode(ctx, 1001, created.Channel.ID, 30) + if err != nil { + t.Fatalf("SetSlowMode: %v", err) + } + if slow.SlowmodeSeconds != 30 { + t.Fatalf("slow mode = %+v, want 30 seconds", slow) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 2, Message: "first", Date: 100}); err != nil { + t.Fatalf("first member send: %v", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 3, Message: "too soon", Date: 110}); err == nil { + t.Fatalf("second member send err = nil, want slow mode wait") + } else if seconds, ok := domain.SlowModeWaitSeconds(err); !ok || seconds != 20 { + t.Fatalf("second member send err = %v, want slow mode wait 20", err) + } + if _, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 4, Message: "after wait", Date: 130}); err != nil { + t.Fatalf("third member send after slow mode: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 5, Message: "owner one", Date: 131}); err != nil { + t.Fatalf("owner send with slow mode: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ChannelID: created.Channel.ID, RandomID: 6, Message: "owner two", Date: 132}); err != nil { + t.Fatalf("owner second send with slow mode: %v", err) + } +} + +func TestImportInviteRespectsPreHistoryHidden(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Private Invite", + Megagroup: true, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if _, err := service.SetPreHistoryHidden(ctx, 1001, created.Channel.ID, true); err != nil { + t.Fatalf("SetPreHistoryHidden: %v", err) + } + hiddenMsg, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 501, + Message: "hidden before invite link", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage hidden: %v", err) + } + invite, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "join", + Date: 12, + }) + if err != nil { + t.Fatalf("ExportInvite: %v", err) + } + joined, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{ + Hash: invite.Invite.Hash, + Date: 13, + }) + if err != nil { + t.Fatalf("ImportInvite: %v", err) + } + if joined.Members[0].AvailableMinID != hiddenMsg.Message.ID || joined.Members[0].ReadInboxMaxID != joined.Message.ID { + t.Fatalf("imported member watermarks = %+v, want hidden top %d and read at join service %d", joined.Members[0], hiddenMsg.Message.ID, joined.Message.ID) + } + diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ + ChannelID: created.Channel.ID, + Pts: 0, + Limit: 100, + }) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + for _, msg := range diff.NewMessages { + if msg.ID <= hiddenMsg.Message.ID { + t.Fatalf("diff includes hidden message id %d <= available_min_id %d", msg.ID, hiddenMsg.Message.ID) + } + } +} + +func TestImportInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Import Watermark", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "before import", + Date: 11, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + invite, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "join", + Date: 12, + }) + if err != nil { + t.Fatalf("ExportInvite: %v", err) + } + joined, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{ + Hash: invite.Invite.Hash, + Date: 13, + }) + if err != nil { + t.Fatalf("ImportInvite: %v", err) + } + if joined.Members[0].ReadInboxMaxID != joined.Message.ID || joined.Members[0].ReadOutboxMaxID != joined.Message.ID { + t.Fatalf("joined member read watermarks = %+v message=%+v, want self join service read/outbox", joined.Members[0], joined.Message) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel imported: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("imported dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 14, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after import = %+v, want none from initial watermark", readers.Participants) + } + future, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "after import", + Date: 14, + }) + if err != nil { + t.Fatalf("send future message: %v", err) + } + after, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel after future: %v", err) + } + if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { + t.Fatalf("imported dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) + } +} + +func TestImportInviteRequestNeededAndUsageLimitErrors(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Invite Errors", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + requested, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "approval", + RequestNeeded: true, + Date: 11, + }) + if err != nil { + t.Fatalf("ExportInvite request needed: %v", err) + } + if _, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{Hash: requested.Invite.Hash, Date: 12}); !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("ImportInvite request-needed err = %v, want ErrInviteRequestSent", err) + } + limited, err := service.ExportInvite(ctx, 1001, domain.ExportChannelInviteRequest{ + ChannelID: created.Channel.ID, + Title: "one", + UsageLimit: 1, + Date: 13, + }) + if err != nil { + t.Fatalf("ExportInvite limited: %v", err) + } + if _, err := service.ImportInvite(ctx, 1002, domain.ImportChannelInviteRequest{Hash: limited.Invite.Hash, Date: 14}); err != nil { + t.Fatalf("ImportInvite first limited: %v", err) + } + if _, err := service.ImportInvite(ctx, 1003, domain.ImportChannelInviteRequest{Hash: limited.Invite.Hash, Date: 15}); !errors.Is(err, domain.ErrUsersTooMuch) { + t.Fatalf("ImportInvite usage-limit err = %v, want ErrUsersTooMuch", err) + } +} + +func TestInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Invite Watermark", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "already there", + Date: 11, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + if _, err := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1002}, 12); err != nil { + t.Fatalf("InviteToChannel: %v", err) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel invited: %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) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 13, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after invite = %+v, want none from initial watermark", readers.Participants) + } +} + +func TestJoinChannelInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Join Watermark", + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + first, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "before join", + Date: 11, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + joined, err := service.JoinChannel(ctx, 1002, created.Channel.ID, 12) + if err != nil { + t.Fatalf("JoinChannel: %v", err) + } + if joined.Members[0].ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined read watermark = %d, want self join service %d", joined.Members[0].ReadInboxMaxID, joined.Message.ID) + } + view, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel joined: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := service.GetMessageReadParticipants(ctx, 1001, domain.ChannelReadParticipantsRequest{ + ChannelID: created.Channel.ID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 13, + }) + if err != nil { + t.Fatalf("GetMessageReadParticipants 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 := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Message: "after join", + Date: 13, + }) + if err != nil { + t.Fatalf("send future message: %v", err) + } + after, err := service.GetChannel(ctx, 1002, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel 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) + } +} diff --git a/internal/app/contacts/doc.go b/internal/app/contacts/doc.go new file mode 100644 index 00000000..56ef1c00 --- /dev/null +++ b/internal/app/contacts/doc.go @@ -0,0 +1,3 @@ +// Package contacts 是联系人应用服务:联系人、拉黑、搜索。 +// 第一阶段实现 PG-backed 空账号通讯录查询;联系人导入留后续业务迭代。 +package contacts diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go new file mode 100644 index 00000000..19b52967 --- /dev/null +++ b/internal/app/contacts/service.go @@ -0,0 +1,266 @@ +package contacts + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +var ( + ErrContactIDInvalid = errors.New("contact id invalid") + ErrContactNameEmpty = errors.New("contact name empty") +) + +const maxSearchLimit = 50 + +// Service 提供通讯录查询。 +type Service struct { + contacts store.ContactStore + users store.UserStore +} + +// NewService 创建 contacts 服务。 +func NewService(contacts store.ContactStore, users ...store.UserStore) *Service { + s := &Service{contacts: contacts} + if len(users) > 0 { + s.users = users[0] + } + return s +} + +// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。 +func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) { + if s == nil || s.contacts == nil || userID == 0 { + return domain.ContactList{}, false, nil + } + list, err := s.contacts.ListByUser(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 && hash == list.Hash { + return list, true, nil + } + 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 + } + if input.FirstName == "" && input.LastName == "" { + return domain.Contact{}, ErrContactNameEmpty + } + if s.users != nil { + target, found, err := s.users.ByID(ctx, input.ContactUserID) + if err != nil { + return domain.Contact{}, err + } + if !found { + return domain.Contact{}, ErrContactIDInvalid + } + if input.Phone == "" { + input.Phone = target.Phone + } + } + contact, err := s.contacts.Upsert(ctx, userID, input) + if err != nil { + return domain.Contact{}, err + } + return contact, nil +} + +func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) { + if s == nil || s.contacts == nil || s.users == nil || userID == 0 || len(inputs) == 0 { + return domain.ImportContactsResult{}, nil + } + out := domain.ImportContactsResult{ + Imported: make([]domain.ImportedContact, 0, len(inputs)), + Contacts: make([]domain.Contact, 0, len(inputs)), + } + normalized := make([]domain.ContactInput, 0, len(inputs)) + phones := make([]string, 0, len(inputs)) + seenPhones := make(map[string]struct{}, len(inputs)) + for _, input := range inputs { + phone := normalizePhone(input.Phone) + if phone == "" { + continue + } + input.Phone = phone + normalized = append(normalized, input) + if _, ok := seenPhones[phone]; ok { + continue + } + seenPhones[phone] = struct{}{} + phones = append(phones, phone) + } + if len(phones) == 0 { + return out, nil + } + targets, err := s.users.ByPhones(ctx, phones) + if err != nil { + return domain.ImportContactsResult{}, err + } + byPhone := make(map[string]domain.User, len(targets)) + for _, target := range targets { + if target.Phone != "" { + byPhone[target.Phone] = target + } + } + upsertsByTarget := make(map[int64]domain.ContactInput, len(targets)) + order := make([]int64, 0, len(targets)) + seenTargets := map[int64]struct{}{} + for _, input := range normalized { + target, found := byPhone[input.Phone] + if !found || target.ID == userID || target.ID == 0 { + continue + } + input.ContactUserID = target.ID + if input.FirstName == "" && input.LastName == "" { + input.FirstName = target.FirstName + input.LastName = target.LastName + } + if _, ok := seenTargets[target.ID]; !ok { + seenTargets[target.ID] = struct{}{} + order = append(order, target.ID) + } + out.Imported = append(out.Imported, domain.ImportedContact{UserID: target.ID, ClientID: input.ClientID}) + upsertsByTarget[target.ID] = input + } + if len(order) == 0 { + return out, nil + } + upserts := make([]domain.ContactInput, 0, len(order)) + for _, targetID := range order { + upserts = append(upserts, upsertsByTarget[targetID]) + } + contacts, err := s.contacts.UpsertMany(ctx, userID, upserts) + if err != nil { + return domain.ImportContactsResult{}, err + } + out.Contacts = append(out.Contacts, contacts...) + return out, nil +} + +func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) { + if s == nil || s.users == nil || userID == 0 { + return domain.UserSearchResult{}, nil + } + query = strings.TrimSpace(query) + query = strings.TrimPrefix(query, "@") + query = strings.TrimSpace(query) + if query == "" { + return domain.UserSearchResult{}, nil + } + if limit <= 0 || limit > maxSearchLimit { + limit = maxSearchLimit + } + return s.users.Search(ctx, userID, query, normalizePhone(query), limit) +} + +func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { + if s == nil || s.contacts == nil || userID == 0 { + return 0, nil + } + return s.contacts.Delete(ctx, userID, contactUserIDs) +} + +func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) { + if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID { + return domain.Contact{}, ErrContactIDInvalid + } + contact, found, err := s.contacts.UpdateNote(ctx, userID, contactUserID, note, entities) + if err != nil { + return domain.Contact{}, err + } + if !found { + return domain.Contact{}, ErrContactIDInvalid + } + return contact, nil +} + +func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) { + if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { + return domain.PeerSettings{}, nil + } + _, found, err := s.contacts.Get(ctx, userID, peer.ID) + if err != nil { + return domain.PeerSettings{}, err + } + return domain.PeerSettings{ + AddContact: !found, + BlockContact: !found, + ShareContact: found, + }, nil +} + +func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) { + list, notModified, err := s.GetContacts(ctx, userID, hash) + if err != nil || notModified { + return nil, notModified, err + } + ids := make([]int, 0, len(list.Contacts)) + for _, contact := range list.Contacts { + ids = append(ids, int(contact.User.ID)) + } + return ids, false, nil +} + +func normalizePhone(phone string) string { + if !utf8.ValidString(phone) { + return "" + } + 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/app/contacts/service_test.go b/internal/app/contacts/service_test.go new file mode 100644 index 00000000..dcca953b --- /dev/null +++ b/internal/app/contacts/service_test.go @@ -0,0 +1,44 @@ +package contacts + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestImportContactsBatchesPhonesAndDedupesUpserts(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) + } + target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + svc := NewService(contactsStore, users) + + res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{ + {ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"}, + {ClientID: 12, Phone: "15551234567", FirstName: "Alice Final"}, + }) + if err != nil { + t.Fatalf("ImportContacts: %v", err) + } + if len(res.Imported) != 2 { + t.Fatalf("imported = %d, want 2", len(res.Imported)) + } + if res.Imported[0].UserID != target.ID || res.Imported[1].UserID != target.ID { + t.Fatalf("imported user ids = %+v, want target %d", res.Imported, target.ID) + } + if len(res.Contacts) != 1 { + t.Fatalf("contacts = %d, want 1 deduped upsert", len(res.Contacts)) + } + if res.Contacts[0].FirstName != "Alice Final" { + t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName) + } +} diff --git a/internal/app/dialogs/doc.go b/internal/app/dialogs/doc.go new file mode 100644 index 00000000..dbcae9e3 --- /dev/null +++ b/internal/app/dialogs/doc.go @@ -0,0 +1,3 @@ +// Package dialogs 是会话应用服务:会话列表、未读数、置顶、草稿。 +// 第一阶段实现 PG-backed 空账号会话摘要查询;真实消息闭环留第二阶段。 +package dialogs diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go new file mode 100644 index 00000000..2c4a4727 --- /dev/null +++ b/internal/app/dialogs/service.go @@ -0,0 +1,593 @@ +package dialogs + +import ( + "context" + "encoding/binary" + "errors" + "hash/fnv" + "sort" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供会话列表查询。 +type Service struct { + dialogs store.DialogStore + channels store.ChannelStore +} + +// NewService 创建 dialogs 服务。 +func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service { + s := &Service{dialogs: dialogs} + if len(channels) > 0 { + s.channels = channels[0] + } + return s +} + +// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。 +func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + if s == nil || userID == 0 { + return domain.DialogList{}, nil + } + if filter.HasFolderID && filter.FolderID >= domain.DialogCustomFolderMinID && filter.Folder == nil { + if s.dialogs == nil { + return domain.DialogList{}, nil + } + folder, found, err := s.dialogs.GetFolder(ctx, userID, filter.FolderID) + if err != nil { + return domain.DialogList{}, err + } + if !found { + return domain.DialogList{}, nil + } + filter.Folder = &folder + } + var out domain.DialogList + if s.dialogs != nil { + list, err := s.dialogs.ListByUser(ctx, userID, filter) + if err != nil { + return domain.DialogList{}, err + } + out = mergeDialogLists(out, list) + } + if s.channels != nil { + list, err := s.channels.ListChannelDialogs(ctx, userID, filter) + if err != nil { + return domain.DialogList{}, err + } + out = mergeChannelDialogs(out, list) + } + sortDialogList(out.Dialogs) + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + if len(out.Dialogs) > limit { + keep := make(map[domain.Peer]struct{}, limit) + for _, d := range out.Dialogs[:limit] { + keep[d.Peer] = struct{}{} + } + out.Dialogs = out.Dialogs[:limit] + out.Messages = filterPrivateMessagesByPeer(out.Messages, keep) + out.ChannelMessages = filterChannelMessagesByPeer(out.ChannelMessages, keep) + out.Channels = filterChannelsByPeer(out.Channels, keep) + } + if out.Count == 0 { + out.Count = len(out.Dialogs) + } + if err := s.attachDrafts(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } + return out, 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 { + return domain.DialogList{}, nil + } + if len(peers) > domain.MaxDialogFolderPeers { + return domain.DialogList{}, domain.ErrChannelInvalid + } + userPeers := make([]domain.Peer, 0, len(peers)) + channelIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + switch peer.Type { + case domain.PeerTypeUser: + userPeers = append(userPeers, peer) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, peer.ID) + } + } + var out domain.DialogList + if len(userPeers) > 0 && s.dialogs != nil { + list, err := s.dialogs.ListByPeers(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) + 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 + } + return out, nil +} + +func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID int64, channelIDs []int64, out domain.DialogList) (domain.DialogList, error) { + if s == nil || s.channels == nil || userID == 0 || len(channelIDs) == 0 { + return out, nil + } + present := make(map[int64]struct{}, len(out.Dialogs)) + for _, dialog := range out.Dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 { + present[dialog.Peer.ID] = struct{}{} + } + } + 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 _, ok := present[channelID]; ok { + continue + } + + view, err := s.channels.GetChannel(ctx, userID, channelID) + if err != nil { + if isChannelPreviewAccessError(err) { + continue + } + return domain.DialogList{}, err + } + history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: channelID, + Limit: 1, + }) + if err != nil { + if isChannelPreviewAccessError(err) { + continue + } + return domain.DialogList{}, err + } + + dialog := dialogFromChannelView(view) + if len(history.Messages) > 0 { + top := history.Messages[0] + dialog.TopMessage = top.ID + dialog.TopMessageDate = top.Date + out.ChannelMessages = append(out.ChannelMessages, top) + } + out.Dialogs = append(out.Dialogs, dialog) + out.Channels = append(out.Channels, view.Channel) + out.Channels = append(out.Channels, history.Channels...) + out.Users = append(out.Users, history.Users...) + out.Count++ + present[channelID] = struct{}{} + } + return out, nil +} + +func isChannelPreviewAccessError(err error) bool { + return errors.Is(err, domain.ErrChannelPrivate) || + errors.Is(err, domain.ErrChannelUserBanned) || + errors.Is(err, domain.ErrChannelInvalid) +} + +func dialogFromChannelView(view domain.ChannelView) domain.Dialog { + dialog := view.Dialog + return domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, + ChannelLeft: view.Self.Status == domain.ChannelMemberLeft, + FolderID: dialog.FolderID, + TopMessage: dialog.TopMessageID, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + } +} + +// SaveDraft stores or clears a cloud draft for one peer/topic. +func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + if err := validateDraft(draft); err != nil { + return err + } + if draft.Empty() { + _, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID) + return err + } + return s.dialogs.SaveDraft(ctx, userID, draft) +} + +// DeleteDraft clears one cloud draft. +func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return false, nil + } + if err := validateDraftKey(peer, topMessageID); err != nil { + return false, err + } + return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID) +} + +// ListDrafts returns bounded cloud drafts for messages.getAllDrafts. +func (s *Service) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return nil, nil + } + return s.dialogs.ListDrafts(ctx, userID, clampDraftLimit(limit)) +} + +// ClearDrafts deletes bounded cloud drafts for messages.clearAllDrafts. +func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return nil, nil + } + return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit)) +} + +func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + switch peer.Type { + case domain.PeerTypeChannel: + if s.channels == nil { + return false, nil + } + return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned) + default: + if s.dialogs == nil { + return false, nil + } + return s.dialogs.SetPinned(ctx, userID, peer, pinned) + } +} + +func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + if s == nil || userID == 0 { + return nil + } + if s.dialogs != nil { + if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil { + return err + } + } + if s.channels != nil { + if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil { + return err + } + } + return nil +} + +func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { + if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + switch peer.Type { + case domain.PeerTypeChannel: + if s.channels == nil { + return false, nil + } + return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread) + default: + if s.dialogs == nil { + return false, nil + } + return s.dialogs.SetUnreadMark(ctx, userID, peer, unread) + } +} + +func (s *Service) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) { + if s == nil || userID == 0 { + return nil, nil + } + var out []domain.Peer + if s.dialogs != nil { + peers, err := s.dialogs.ListUnreadMarked(ctx, userID) + if err != nil { + return nil, err + } + out = append(out, peers...) + } + if s.channels != nil { + peers, err := s.channels.ListChannelUnreadMarked(ctx, userID) + if err != nil { + return nil, err + } + out = append(out, peers...) + } + return out, nil +} + +func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer) +} + +func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + return s.dialogs.PeerSettingsBarHidden(ctx, userID, peer) +} + +func (s *Service) GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return domain.DialogFolderList{}, nil + } + return s.dialogs.ListFolders(ctx, userID) +} + +func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + return s.dialogs.UpsertFolder(ctx, userID, folder) +} + +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) +} + +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) +} + +func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error { + if s == nil || s.dialogs == nil || userID == 0 { + return nil + } + return s.dialogs.SetFolderTagsEnabled(ctx, userID, enabled) +} + +func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + if s == nil || userID == 0 || len(peers) == 0 { + return nil + } + privatePeers := make([]domain.FolderPeerUpdate, 0, len(peers)) + channelPeers := make([]domain.FolderPeerUpdate, 0, len(peers)) + for _, peer := range peers { + if peer.Peer.Type == domain.PeerTypeChannel { + channelPeers = append(channelPeers, peer) + } else { + privatePeers = append(privatePeers, peer) + } + } + if len(privatePeers) > 0 && s.dialogs != nil { + if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil { + return err + } + } + if len(channelPeers) > 0 && s.channels != nil { + if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil { + return err + } + } + return nil +} + +func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.DialogList) error { + if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 { + return nil + } + drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) + if err != nil { + return err + } + if len(drafts) == 0 { + return nil + } + byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts)) + for _, draft := range drafts { + if draft.TopMessageID != 0 { + continue + } + byPeer[draft.Peer] = cloneDraft(draft) + } + if len(byPeer) == 0 { + return nil + } + attached := false + for i := range list.Dialogs { + draft, ok := byPeer[list.Dialogs[i].Peer] + if !ok { + continue + } + d := cloneDraft(draft) + list.Dialogs[i].Draft = &d + attached = true + } + if attached { + list.Hash = dialogHashWithDrafts(list.Hash, list.Dialogs) + } + return nil +} + +func validateDraft(draft domain.DialogDraft) error { + if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil { + return err + } + if len(draft.Entities) > domain.MaxMessageEntityCount { + return domain.ErrChannelInvalid + } + if utf8.RuneCountInString(draft.Message) > domain.MaxMessageTextLength { + return domain.ErrChannelInvalid + } + if domain.ValidateMessageReplyBounds(draft.ReplyTo) != nil { + return domain.ErrReplyMessageIDInvalid + } + return nil +} + +func validateDraftKey(peer domain.Peer, topMessageID int) error { + if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return domain.ErrChannelInvalid + } + if topMessageID < 0 || topMessageID > domain.MaxMessageBoxID { + return domain.ErrReplyMessageIDInvalid + } + return nil +} + +func clampDraftLimit(limit int) int { + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + return domain.MaxDialogDraftsPerUser + } + return limit +} + +func cloneDraft(draft domain.DialogDraft) domain.DialogDraft { + draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...) + if draft.ReplyTo != nil { + reply := *draft.ReplyTo + reply.QuoteEntities = append([]domain.MessageEntity(nil), draft.ReplyTo.QuoteEntities...) + draft.ReplyTo = &reply + } + if draft.WebPage != nil { + webpage := *draft.WebPage + draft.WebPage = &webpage + } + return draft +} + +func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 { + if len(dialogs) == 0 { + return base + } + h := fnv.New64a() + var buf [48]byte + binary.LittleEndian.PutUint64(buf[:8], uint64(base)) + _, _ = h.Write(buf[:8]) + for _, d := range dialogs { + if d.Draft == nil { + continue + } + binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(d.Draft.TopMessageID)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(d.Draft.Date)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(len(d.Draft.Message))) + if d.Draft.NoWebpage { + buf[24] = 1 + } else { + buf[24] = 0 + } + if d.Draft.InvertMedia { + buf[25] = 1 + } else { + buf[25] = 0 + } + binary.LittleEndian.PutUint64(buf[26:34], uint64(len(d.Draft.Entities))) + binary.LittleEndian.PutUint64(buf[34:42], uint64(d.Draft.Effect)) + _, _ = h.Write(buf[:]) + _, _ = h.Write([]byte(d.Draft.Message)) + if d.Draft.WebPage != nil { + _, _ = h.Write([]byte(d.Draft.WebPage.URL)) + } + } + return int64(h.Sum64()) +} + +func mergeDialogLists(out, in domain.DialogList) domain.DialogList { + out.Dialogs = append(out.Dialogs, in.Dialogs...) + out.Messages = append(out.Messages, in.Messages...) + out.Users = append(out.Users, in.Users...) + out.Count += in.Count + out.Hash ^= in.Hash + return out +} + +func mergeChannelDialogs(out domain.DialogList, in domain.ChannelDialogList) domain.DialogList { + out.Dialogs = append(out.Dialogs, in.Dialogs...) + out.ChannelMessages = append(out.ChannelMessages, in.Messages...) + out.Channels = append(out.Channels, in.Channels...) + out.Users = append(out.Users, in.Users...) + out.Count += in.Count + out.Hash ^= in.Hash + return out +} + +func sortDialogList(dialogs []domain.Dialog) { + sort.SliceStable(dialogs, func(i, j int) bool { + if dialogs[i].Pinned != dialogs[j].Pinned { + return dialogs[i].Pinned + } + if dialogs[i].PinnedOrder != dialogs[j].PinnedOrder { + return dialogs[i].PinnedOrder > dialogs[j].PinnedOrder + } + if dialogs[i].TopMessageDate != dialogs[j].TopMessageDate { + return dialogs[i].TopMessageDate > dialogs[j].TopMessageDate + } + if dialogs[i].TopMessage != dialogs[j].TopMessage { + return dialogs[i].TopMessage > dialogs[j].TopMessage + } + return dialogs[i].Peer.ID > dialogs[j].Peer.ID + }) +} + +func filterPrivateMessagesByPeer(messages []domain.Message, keep map[domain.Peer]struct{}) []domain.Message { + out := messages[:0] + for _, msg := range messages { + if _, ok := keep[msg.Peer]; ok { + out = append(out, msg) + } + } + return out +} + +func filterChannelMessagesByPeer(messages []domain.ChannelMessage, keep map[domain.Peer]struct{}) []domain.ChannelMessage { + out := messages[:0] + for _, msg := range messages { + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID} + if _, ok := keep[peer]; ok { + out = append(out, msg) + } + } + return out +} + +func filterChannelsByPeer(channels []domain.Channel, keep map[domain.Peer]struct{}) []domain.Channel { + out := channels[:0] + for _, ch := range channels { + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID} + if _, ok := keep[peer]; ok { + out = append(out, ch) + } + } + return out +} diff --git a/internal/app/dialogs/service_test.go b/internal/app/dialogs/service_test.go new file mode 100644 index 00000000..838cc0a9 --- /dev/null +++ b/internal/app/dialogs/service_test.go @@ -0,0 +1,282 @@ +package dialogs + +import ( + "context" + "errors" + "testing" + + appchannels "telesrv/internal/app/channels" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + created, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Offline Read", + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 42, + Message: "restore read outbox", + Date: 11, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if _, err := channels.ReadHistory(ctx, 1002, domain.ReadChannelHistoryRequest{ + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 12, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + + list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs: %v", err) + } + dialog := findChannelDialog(t, list, created.Channel.ID) + if dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("getDialogs read_outbox = %d, want %d", dialog.ReadOutboxMaxID, sent.Message.ID) + } + + peerList, err := dialogs.GetPeerDialogs(ctx, 1001, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + }) + if err != nil { + t.Fatalf("GetPeerDialogs: %v", err) + } + peerDialog := findChannelDialog(t, peerList, created.Channel.ID) + if peerDialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("getPeerDialogs read_outbox = %d, want %d", peerDialog.ReadOutboxMaxID, sent.Message.ID) + } +} + +func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + first, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Pinned One", + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat first: %v", err) + } + second, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Pinned Two", + Date: 21, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat second: %v", err) + } + 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 { + t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err) + } + 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.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed { + t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err) + } + if err := dialogs.EditPeerFolders(ctx, 1001, []domain.FolderPeerUpdate{ + {Peer: firstPeer, FolderID: domain.DialogArchiveFolderID}, + }); err != nil { + t.Fatalf("EditPeerFolders: %v", err) + } + + list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + 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) + } + secondDialog := findChannelDialog(t, list, second.Channel.ID) + if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 { + t.Fatalf("second dialog = %+v, want pinned order 2", secondDialog) + } + marks, err := dialogs.UnreadMarks(ctx, 1001) + if err != nil { + t.Fatalf("UnreadMarks: %v", err) + } + if len(marks) != 1 || marks[0] != firstPeer { + t.Fatalf("unread marks = %+v, want first channel", marks) + } + 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 got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID { + t.Fatalf("archived dialog = %+v, want archive folder", got) + } + + custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogCustomFolderMinID, + Folder: &domain.DialogFolder{ID: domain.DialogCustomFolderMinID, Groups: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetDialogs custom groups: %v", err) + } + if got := findChannelDialog(t, custom, first.Channel.ID); got.Peer.ID != first.Channel.ID { + t.Fatalf("custom group dialog = %+v, want first channel", got) + } +} + +func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + old, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Older Channel", + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat old: %v", err) + } + newer, err := channels.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Newer Channel", + Date: 30, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat newer: %v", err) + } + + first, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 1}) + if err != nil { + t.Fatalf("GetDialogs first: %v", err) + } + if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID { + t.Fatalf("first page dialogs = %+v, want newer channel", first.Dialogs) + } + + next, err := dialogs.GetDialogs(ctx, 1001, 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("GetDialogs next: %v", err) + } + if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != old.Channel.ID { + t.Fatalf("next page dialogs = %+v, want only older channel", next.Dialogs) + } +} + +func TestGetPeerDialogsRejectsHugeVector(t *testing.T) { + dialogs := NewService(nil, memory.NewChannelStore()) + peers := make([]domain.Peer, domain.MaxDialogFolderPeers+1) + for i := range peers { + peers[i] = domain.Peer{Type: domain.PeerTypeChannel, ID: int64(i + 1)} + } + if _, err := dialogs.GetPeerDialogs(context.Background(), 1001, peers); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("GetPeerDialogs huge vector err = %v, want ErrChannelInvalid", err) + } +} + +func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + public, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Public Peer Dialog", + Broadcast: true, + Date: 1700002000, + }) + if err != nil { + t.Fatalf("CreateChannel public: %v", err) + } + if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + UserID: 1001, + ChannelID: public.Channel.ID, + Username: "public_peer_dialog", + }); err != nil { + t.Fatalf("UpdateUsername public: %v", err) + } + sent, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 99, + Message: "public peer dialog top", + Date: 1700002010, + }) + if err != nil { + t.Fatalf("SendMessage public: %v", err) + } + private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Private Peer Dialog", + Broadcast: true, + Date: 1700002020, + }) + if err != nil { + t.Fatalf("CreateChannel private: %v", err) + } + + list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: public.Channel.ID}, + {Type: domain.PeerTypeChannel, ID: private.Channel.ID}, + }) + if err != nil { + t.Fatalf("GetPeerDialogs public preview: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want only public preview dialog", list.Dialogs) + } + dialog := findChannelDialog(t, list, public.Channel.ID) + if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date { + t.Fatalf("preview dialog top = id %d date %d, want %d/%d", dialog.TopMessage, dialog.TopMessageDate, sent.Message.ID, sent.Message.Date) + } + if !dialog.ChannelLeft { + t.Fatalf("preview dialog ChannelLeft = false, want read-only left preview") + } + if dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < sent.Message.ID || dialog.ReadOutboxMaxID < sent.Message.ID { + t.Fatalf("preview dialog read/unread = %+v, want read through top and no unread", dialog) + } + if len(list.ChannelMessages) != 1 || list.ChannelMessages[0].Body != "public peer dialog top" { + t.Fatalf("channel messages = %+v, want public top message", list.ChannelMessages) + } + if len(list.Channels) != 1 || list.Channels[0].ID != public.Channel.ID { + t.Fatalf("channels = %+v, want public channel shell", list.Channels) + } +} + +func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog { + t.Helper() + for _, dialog := range list.Dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == channelID { + return dialog + } + } + t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs) + return domain.Dialog{} +} diff --git a/internal/app/files/blobcache.go b/internal/app/files/blobcache.go new file mode 100644 index 00000000..5d542282 --- /dev/null +++ b/internal/app/files/blobcache.go @@ -0,0 +1,236 @@ +package files + +import ( + "container/list" + "sync" + + "telesrv/internal/domain" +) + +// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile +// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/ +// reaction/头像更被大量用户重复拉)。 +// +// FileBlob 元数据小(约百字节)且内容不可变:location_key 一旦写入即固定指向同一 object_key, +// 新建 blob 用随机 id 生成 location_key 不会与已缓存项冲突,故只读填充、无需失效。 +type blobMetaCache struct { + mu sync.Mutex + cap int + ll *list.List + m map[string]*list.Element +} + +type blobMetaEntry struct { + key string + blob domain.FileBlob +} + +func newBlobMetaCache(capacity int) *blobMetaCache { + if capacity <= 0 { + capacity = 1 + } + return &blobMetaCache{ + cap: capacity, + ll: list.New(), + m: make(map[string]*list.Element, capacity), + } +} + +// get 返回缓存的 FileBlob 并把其移到 LRU 头部;未命中返回 ok=false。 +func (c *blobMetaCache) get(key string) (domain.FileBlob, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + c.ll.MoveToFront(el) + return el.Value.(*blobMetaEntry).blob, true + } + return domain.FileBlob{}, false +} + +// put 写入/更新缓存,超出容量时淘汰最久未用项。 +func (c *blobMetaCache) put(key string, blob domain.FileBlob) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + el.Value.(*blobMetaEntry).blob = blob + c.ll.MoveToFront(el) + return + } + c.m[key] = c.ll.PushFront(&blobMetaEntry{key: key, blob: blob}) + if c.ll.Len() > c.cap { + if oldest := c.ll.Back(); oldest != nil { + c.ll.Remove(oldest) + delete(c.m, oldest.Value.(*blobMetaEntry).key) + } + } +} + +// blobBytesCache 是 object_key → 小 blob 全量字节的 LRU。Sticker / reaction / +// 缩略图通常只有几 KB 到几十 KB,缓存全量内容可以避开点击历史时的本地磁盘冷读抖动; +// 大媒体仍由 BlobBackend.GetRange 分段读取,避免把大文件放进内存。 +type blobBytesCache struct { + mu sync.Mutex + maxBytes int + used int + ll *list.List + m map[string]*list.Element +} + +type blobBytesEntry struct { + key string + bytes []byte + size int +} + +func newBlobBytesCache(maxBytes int) *blobBytesCache { + if maxBytes <= 0 { + maxBytes = 1 + } + return &blobBytesCache{ + maxBytes: maxBytes, + ll: list.New(), + m: make(map[string]*list.Element), + } +} + +func (c *blobBytesCache) get(key string) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + c.ll.MoveToFront(el) + entry := el.Value.(*blobBytesEntry) + return append([]byte(nil), entry.bytes...), true + } + return nil, false +} + +func (c *blobBytesCache) has(key string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + c.ll.MoveToFront(el) + return true + } + return false +} + +func (c *blobBytesCache) put(key string, bytes []byte) { + if len(bytes) > c.maxBytes { + return + } + copied := append([]byte(nil), bytes...) + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[key]; ok { + entry := el.Value.(*blobBytesEntry) + c.used += len(copied) - entry.size + entry.bytes = copied + entry.size = len(copied) + c.ll.MoveToFront(el) + } else { + entry := &blobBytesEntry{key: key, bytes: copied, size: len(copied)} + c.m[key] = c.ll.PushFront(entry) + c.used += entry.size + } + for c.used > c.maxBytes { + oldest := c.ll.Back() + if oldest == nil { + break + } + c.ll.Remove(oldest) + entry := oldest.Value.(*blobBytesEntry) + delete(c.m, entry.key) + c.used -= entry.size + } +} + +type stickerSetFullCache struct { + mu sync.RWMutex + byID map[int64]stickerSetFullEntry + byShort map[string]int64 + bySystem map[string]int64 +} + +type stickerSetFullEntry struct { + set domain.StickerSet + docs []domain.Document +} + +func newStickerSetFullCache() *stickerSetFullCache { + return &stickerSetFullCache{ + byID: map[int64]stickerSetFullEntry{}, + byShort: map[string]int64{}, + bySystem: map[string]int64{}, + } +} + +func (c *stickerSetFullCache) get(ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + var id int64 + switch ref.Kind { + case domain.StickerSetRefByID: + id = ref.ID + case domain.StickerSetRefByShortName: + id = c.byShort[ref.ShortName] + case domain.StickerSetRefBySystem: + id = c.bySystem[ref.SystemKey] + default: + return domain.StickerSet{}, nil, false + } + entry, ok := c.byID[id] + if !ok { + return domain.StickerSet{}, nil, false + } + return copyStickerSet(entry.set), copyDocuments(entry.docs), true +} + +func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document) { + if set.ID == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.byID[set.ID] = stickerSetFullEntry{ + set: copyStickerSet(set), + docs: copyDocuments(docs), + } + if set.ShortName != "" { + c.byShort[set.ShortName] = set.ID + } + if set.SystemKey != "" { + c.bySystem[set.SystemKey] = set.ID + } +} + +func copyStickerSet(set domain.StickerSet) domain.StickerSet { + set.DocumentIDs = append([]int64(nil), set.DocumentIDs...) + set.Packs = append([]domain.StickerPack(nil), set.Packs...) + for i := range set.Packs { + set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...) + } + set.Thumbs = copyPhotoSizes(set.Thumbs) + return set +} + +func copyDocuments(docs []domain.Document) []domain.Document { + out := append([]domain.Document(nil), docs...) + for i := range out { + out[i].FileReference = append([]byte(nil), out[i].FileReference...) + out[i].Attributes = append([]domain.DocumentAttribute(nil), out[i].Attributes...) + for j := range out[i].Attributes { + out[i].Attributes[j].Waveform = append([]byte(nil), out[i].Attributes[j].Waveform...) + } + out[i].Thumbs = copyPhotoSizes(out[i].Thumbs) + } + return out +} + +func copyPhotoSizes(sizes []domain.PhotoSize) []domain.PhotoSize { + out := append([]domain.PhotoSize(nil), sizes...) + for i := range out { + out[i].Bytes = append([]byte(nil), out[i].Bytes...) + out[i].Sizes = append([]int(nil), out[i].Sizes...) + } + return out +} diff --git a/internal/app/files/blobcache_test.go b/internal/app/files/blobcache_test.go new file mode 100644 index 00000000..7cc9ca5a --- /dev/null +++ b/internal/app/files/blobcache_test.go @@ -0,0 +1,233 @@ +package files + +import ( + "bytes" + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestBlobMetaCacheGetPutEvict(t *testing.T) { + c := newBlobMetaCache(2) + c.put("a", domain.FileBlob{LocationKey: "a", ObjectKey: "oa"}) + c.put("b", domain.FileBlob{LocationKey: "b", ObjectKey: "ob"}) + if b, ok := c.get("a"); !ok || b.ObjectKey != "oa" { + t.Fatalf("get a = %+v ok=%v", b, ok) + } + // 容量 2:刚 access 过 a,再 put c 应淘汰最久未用的 b。 + c.put("c", domain.FileBlob{LocationKey: "c", ObjectKey: "oc"}) + if _, ok := c.get("b"); ok { + t.Error("b should be evicted (least recently used)") + } + if _, ok := c.get("a"); !ok { + t.Error("a should remain (recently used)") + } + if _, ok := c.get("c"); !ok { + t.Error("c should be present") + } +} + +// countingMediaStore 统计 GetFileBlob 次数,验证元数据缓存命中后不再查 PG。 +type countingMediaStore struct { + *fakeMediaStore + getBlobCalls int + getSetByIDCalls int +} + +func (c *countingMediaStore) GetFileBlob(ctx context.Context, key string) (domain.FileBlob, bool, error) { + c.getBlobCalls++ + return c.fakeMediaStore.GetFileBlob(ctx, key) +} + +func (c *countingMediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { + c.getSetByIDCalls++ + return c.fakeMediaStore.GetStickerSetByID(ctx, id) +} + +type countingBlobBackend struct { + BlobBackend + getRangeCalls int +} + +func (c *countingBlobBackend) GetRange(ctx context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) { + c.getRangeCalls++ + return c.BlobBackend.GetRange(ctx, objectKey, offset, limit) +} + +func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + objectKey, err := local.Put(ctx, []byte("0123456789")) + if err != nil { + t.Fatalf("put: %v", err) + } + media := newFakeMediaStore() + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil { + t.Fatalf("put blob: %v", err) + } + counting := &countingMediaStore{fakeMediaStore: media} + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(counting, blobs, 2) + + // 第一次:查 PG 一次并填充元数据缓存;小 blob 读整块进字节缓存后返回 [0,5)。 + c1, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 0, Limit: 5}) + if err != nil || !ok { + t.Fatalf("getfile1 ok=%v err=%v", ok, err) + } + if string(c1.Bytes) != "01234" { + t.Errorf("chunk1 = %q, want 01234", c1.Bytes) + } + if c1.Total != 10 { + t.Errorf("total = %d, want 10", c1.Total) + } + if counting.getBlobCalls != 1 { + t.Errorf("getBlobCalls = %d, want 1", counting.getBlobCalls) + } + if blobs.getRangeCalls != 1 { + t.Errorf("getRangeCalls = %d, want 1", blobs.getRangeCalls) + } + + // 第二次:同 location 命中元数据与字节缓存;[5,10) 直接从内存切片。 + c2, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:42", Offset: 5, Limit: 5}) + if err != nil || !ok { + t.Fatalf("getfile2 ok=%v err=%v", ok, err) + } + if string(c2.Bytes) != "56789" { + t.Errorf("chunk2 = %q, want 56789", c2.Bytes) + } + if counting.getBlobCalls != 1 { + t.Errorf("getBlobCalls = %d, want 1 (cache hit)", counting.getBlobCalls) + } + if blobs.getRangeCalls != 1 { + t.Errorf("getRangeCalls = %d, want 1 (byte cache hit)", blobs.getRangeCalls) + } +} + +func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + content := bytes.Repeat([]byte("x"), blobBytesCacheMaxEntryBytes+2) + objectKey, err := local.Put(ctx, content) + if err != nil { + t.Fatalf("put: %v", err) + } + media := newFakeMediaStore() + if err := media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: "doc:large", + ObjectKey: objectKey, + Size: int64(len(content)), + MimeType: "application/octet-stream", + }); err != nil { + t.Fatalf("put blob: %v", err) + } + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(media, blobs, 2) + + for i := 0; i < 2; i++ { + chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:large", Offset: 1, Limit: 7}) + if err != nil || !ok { + t.Fatalf("getfile %d ok=%v err=%v", i, ok, err) + } + if string(chunk.Bytes) != "xxxxxxx" { + t.Fatalf("chunk %d = %q, want seven x bytes", i, chunk.Bytes) + } + } + if blobs.getRangeCalls != 2 { + t.Errorf("getRangeCalls = %d, want 2 (large blob is not byte cached)", blobs.getRangeCalls) + } +} + +func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + mainKey, err := local.Put(ctx, []byte("sticker")) + if err != nil { + t.Fatalf("put main: %v", err) + } + thumbKey, err := local.Put(ctx, []byte("thumb")) + if err != nil { + t.Fatalf("put thumb: %v", err) + } + media := newFakeMediaStore() + doc := domain.Document{ + ID: 100, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + Size: 7, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 5}, + }, + } + if err := media.PutDocument(ctx, doc); err != nil { + t.Fatalf("put doc: %v", err) + } + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil { + t.Fatalf("put main blob: %v", err) + } + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil { + t.Fatalf("put thumb blob: %v", err) + } + set := domain.StickerSet{ + ID: 200, + AccessHash: 2, + ShortName: "pack", + Title: "Pack", + Kind: domain.StickerSetKindStickers, + Count: 1, + DocumentIDs: []int64{ + doc.ID, + }, + } + if err := media.PutStickerSet(ctx, set); err != nil { + t.Fatalf("put set: %v", err) + } + counting := &countingMediaStore{fakeMediaStore: media} + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(counting, blobs, 2) + + stats, err := svc.WarmCaches(ctx) + if err != nil { + t.Fatalf("warm caches: %v", err) + } + if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 { + t.Fatalf("warm stats = %+v, want 1 set, 1 doc, 2 blobs", stats) + } + blobs.getRangeCalls = 0 + chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:100", Offset: 0, Limit: 7}) + if err != nil || !ok { + t.Fatalf("getfile ok=%v err=%v", ok, err) + } + if string(chunk.Bytes) != "sticker" { + t.Fatalf("chunk = %q, want sticker", chunk.Bytes) + } + if blobs.getRangeCalls != 0 { + t.Fatalf("prewarmed blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls) + } + + gotSet, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID}) + if err != nil || !found { + t.Fatalf("resolve found=%v err=%v", found, err) + } + if gotSet.ID != set.ID || len(docs) != 1 || docs[0].ID != doc.ID { + t.Fatalf("resolve = set %+v docs %+v", gotSet, docs) + } + if counting.getSetByIDCalls != 0 { + t.Fatalf("ResolveStickerSet should hit full-set cache, GetStickerSetByID calls = %d", counting.getSetByIDCalls) + } + docs[0].ID = 999 + _, docsAgain, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID}) + if err != nil || !found || docsAgain[0].ID != doc.ID { + t.Fatalf("cached docs were mutated: found=%v err=%v docs=%+v", found, err, docsAgain) + } +} diff --git a/internal/app/files/blobfs.go b/internal/app/files/blobfs.go new file mode 100644 index 00000000..dbb17f04 --- /dev/null +++ b/internal/app/files/blobfs.go @@ -0,0 +1,105 @@ +package files + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" +) + +// BlobBackend 是 blob 字节内容的存储后端。第一阶段只有本地磁盘实现。 +// 内容寻址:Put 返回的 objectKey 是内容 sha256,相同内容自动去重。 +type BlobBackend interface { + Name() string + Put(ctx context.Context, data []byte) (objectKey string, 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) +} + +// LocalFS 把 blob 字节存到本地磁盘根目录下,路径按内容 hash 两级 fanout。 +type LocalFS struct { + root string +} + +// NewLocalFS 创建本地磁盘 blob backend,确保根目录存在。 +func NewLocalFS(root string) (*LocalFS, error) { + if root == "" { + return nil, fmt.Errorf("blob root dir is empty") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create blob root %q: %w", root, err) + } + return &LocalFS{root: root}, nil +} + +// Name 返回后端标识,与 file_blobs.backend 一致。 +func (l *LocalFS) Name() string { return "localfs" } + +func (l *LocalFS) pathFor(objectKey string) string { + if len(objectKey) < 4 { + return filepath.Join(l.root, "_", objectKey) + } + return filepath.Join(l.root, objectKey[:2], objectKey[2:4], objectKey) +} + +// Put 写入内容并返回 sha256 hex 作为 objectKey;同内容已存在则跳过写入(去重)。 +func (l *LocalFS) Put(_ context.Context, data []byte) (string, error) { + sum := sha256.Sum256(data) + key := hex.EncodeToString(sum[:]) + path := l.pathFor(key) + if _, err := os.Stat(path); err == nil { + return key, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", 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(tmp, path); err != nil { + return "", fmt.Errorf("commit blob: %w", err) + } + return key, nil +} + +// Get 读取 objectKey 对应的全部字节。 +func (l *LocalFS) Get(_ context.Context, objectKey string) ([]byte, error) { + return os.ReadFile(l.pathFor(objectKey)) +} + +// 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)) + if err != nil { + return nil, 0, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, 0, err + } + total := info.Size() + if offset < 0 { + offset = 0 + } + if offset >= total { + return []byte{}, total, nil + } + n := total - offset + if limit > 0 && limit < n { + n = limit + } + buf := make([]byte, n) + read, err := f.ReadAt(buf, offset) + if err != nil && err != io.EOF { + return nil, 0, err + } + return buf[:read], total, nil +} diff --git a/internal/app/files/blobfs_test.go b/internal/app/files/blobfs_test.go new file mode 100644 index 00000000..b5a3ce69 --- /dev/null +++ b/internal/app/files/blobfs_test.go @@ -0,0 +1,92 @@ +package files + +import ( + "bytes" + "context" + "testing" +) + +func TestLocalFSPutGetRoundTrip(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + data := []byte("hello telesrv media blob 你好") + + key, err := fs.Put(ctx, data) + if err != nil { + t.Fatalf("put: %v", err) + } + if key == "" { + t.Fatal("empty object key") + } + + // 内容寻址:相同内容应得到相同 key(去重)。 + key2, err := fs.Put(ctx, data) + if err != nil { + t.Fatalf("put again: %v", err) + } + if key != key2 { + t.Fatalf("expected dedup key %q == %q", key, key2) + } + + got, err := fs.Get(ctx, key) + if err != nil { + t.Fatalf("get: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("roundtrip mismatch: got %q want %q", got, data) + } +} + +func TestLocalFSDistinctContent(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + k1, _ := fs.Put(ctx, []byte("aaa")) + k2, _ := fs.Put(ctx, []byte("bbb")) + if k1 == k2 { + t.Fatal("distinct content must yield distinct keys") + } +} + +func TestLocalFSGetRange(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) + } + cases := []struct { + name string + offset, limit int64 + want string + }{ + {"head", 0, 4, "0123"}, + {"middle", 3, 4, "3456"}, + {"limit-exceeds-remaining", 7, 100, "789"}, + {"zero-limit-reads-to-end", 2, 0, "23456789"}, + {"offset-at-end", 10, 5, ""}, + {"offset-past-end", 20, 5, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data, total, err := fs.GetRange(ctx, key, tc.offset, tc.limit) + if err != nil { + t.Fatalf("getrange: %v", err) + } + if string(data) != tc.want { + t.Errorf("data = %q, want %q", data, tc.want) + } + if total != 10 { + t.Errorf("total = %d, want 10", total) + } + }) + } +} diff --git a/internal/app/files/doc.go b/internal/app/files/doc.go new file mode 100644 index 00000000..3f8edd6f --- /dev/null +++ b/internal/app/files/doc.go @@ -0,0 +1,6 @@ +// Package files 是文件应用服务:upload 分片累积、blob 落盘、getFile 下载, +// 以及把上传文件组装成 Photo / Document(头像、图片/文件/贴纸消息)。 +// +// 类型边界:本包只用 domain / store 类型,不依赖 tg.*; +// rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 的转换。 +package files diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go new file mode 100644 index 00000000..bd6e5b5b --- /dev/null +++ b/internal/app/files/photos.go @@ -0,0 +1,278 @@ +package files + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "image" + _ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸 + _ "image/png" // 注册 png DecodeConfig + "time" + + "telesrv/internal/domain" +) + +// 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。 +// 同一份上传字节在多个 location_key 下建 blob(不做实际缩放,dev 主路径足够)。 + +// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。 +func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, 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)) + if err != nil { + return domain.Photo{}, err + } + if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photo.ID, date); err != nil { + return domain.Photo{}, err + } + return photo, nil +} + +// CreatePhotoFromUpload 把已上传文件组装成 Photo(不绑定 profile_photos),用于频道头像 / 图片消息。 +func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (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 + } + 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) +} + +// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。 +func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { + return s.media.GetDocument(ctx, id) +} + +// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation +// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。 +func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (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 + } + return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) +} + +// 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) + if err != nil { + return domain.Document{}, err + } + if len(data) == 0 { + return domain.Document{}, domain.ErrDocumentInvalid + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, err + } + 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)), + 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: int64(len(data)), + 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 err := s.media.PutDocument(ctx, doc); err != nil { + return domain.Document{}, err + } + return doc, nil +} + +// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。 +func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) { + photo, ok, err := s.media.GetPhoto(ctx, photoID) + if err != nil || !ok { + return domain.Photo{}, ok, err + } + if date == 0 { + date = int(time.Now().Unix()) + } + if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photoID, date); err != nil { + return domain.Photo{}, false, err + } + return photo, true, nil +} + +// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。 +func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) { + id, ok, err := s.media.CurrentProfilePhoto(ctx, ownerType, ownerID) + if err != nil || !ok { + return domain.Photo{}, ok, err + } + return s.media.GetPhoto(ctx, id) +} + +// GetProfilePhotos 返回 owner 的头像历史(最新在前)。 +func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) { + ids, total, err := s.media.ListProfilePhotos(ctx, ownerType, ownerID, 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 +} + +// DeleteProfilePhotos 停用指定头像,返回成功停用数量。 +func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) { + deleted, err := s.media.DeleteProfilePhotos(ctx, ownerType, ownerID, photoIDs) + if err != nil { + return 0, err + } + return len(deleted), nil +} + +// 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) + 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(), + 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 +} + +type photoSizeSpec struct { + Type string + W int + H int +} + +func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec { + w, h := imageDimensions(data, 640, 640) + small := 160 + if w < small { + small = w + } + return []photoSizeSpec{ + {Type: "a", W: small, H: small}, + {Type: "c", W: w, H: h}, + } +} + +// photoSizeSpecsForMessage 给图片消息生成下载尺寸('m' 缩略 + 'x'/'y' 大图)。 +func photoSizeSpecsForMessage(data []byte) []photoSizeSpec { + w, h := imageDimensions(data, 1280, 1280) + thumbW, thumbH := scaleDown(w, h, 320) + return []photoSizeSpec{ + {Type: "m", W: thumbW, H: thumbH}, + {Type: "x", W: w, H: h}, + } +} + +func imageDimensions(data []byte, defW, defH int) (int, int) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { + return defW, defH + } + return cfg.Width, cfg.Height +} + +func scaleDown(w, h, max int) (int, int) { + if w <= max && h <= max { + return w, h + } + if w >= h { + return max, max * h / w + } + return max * w / h, max +} + +func randomID() int64 { + var b [8]byte + _, _ = rand.Read(b[:]) + v := int64(binary.BigEndian.Uint64(b[:]) >> 1) + if v == 0 { + v = 1 + } + return v +} + +func randomFileReference() []byte { + b := make([]byte, 16) + _, _ = rand.Read(b) + return b +} diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go new file mode 100644 index 00000000..58165dff --- /dev/null +++ b/internal/app/files/seed.go @@ -0,0 +1,860 @@ +package files + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "telesrv/internal/domain" +) + +// 本文件实现从外部导出的 reaction / sticker 资源目录导入媒体种子: +// JSON 元数据(外部导出 document id/access_hash/file_reference/attributes/thumbs)落 documents / +// sticker_sets / available_reactions 表,二进制 .tgs/.webp/缩略图落 blob backend。 +// dc_id 统一重写为本 server 的 DC,使客户端从本 DC 下载。导入幂等(已存在则跳过)。 + +// SeedStats 汇报一次种子导入结果。 +type SeedStats struct { + Reactions int + StickerSets int + Documents int + Blobs int + Skipped bool +} + +// SeedMedia 从导出根目录导入 reaction 与 sticker 资源。maxRegularSets<=0 表示不限。 +func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int) (SeedStats, error) { + var stats SeedStats + if root == "" { + stats.Skipped = true + return stats, nil + } + if _, err := os.Stat(root); err != nil { + // 目录不存在:跳过而非失败(开发机可能未放资源)。 + stats.Skipped = true + return stats, nil + } + + // reactions + if n, err := s.media.CountAvailableReactions(ctx); err != nil { + return stats, err + } else if n == 0 { + if err := s.seedReactions(ctx, root, &stats); err != nil { + return stats, fmt.Errorf("seed reactions: %w", err) + } + } else if incomplete, err := s.availableReactionSeedNeedsRepair(ctx); err != nil { + return stats, err + } else if incomplete { + if err := s.seedReactions(ctx, root, &stats); err != nil { + return stats, fmt.Errorf("repair reactions: %w", err) + } + } + + // sticker sets(default 系统集 + 常规集) + 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) + } + } + + if stats.Reactions == 0 && stats.StickerSets == 0 { + stats.Skipped = true + } + return stats, nil +} + +// ---- reactions ---- + +func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error { + reactionsDir := filepath.Join(root, "telegram_reactions_export", "reactions") + rawPath := filepath.Join(root, "telegram_reactions_export", "global_json", "available_reactions_raw.json") + raw, err := os.ReadFile(rawPath) + if err != nil { + return nil // 没有 reaction 资源就跳过 + } + var parsed struct { + Result struct { + Reactions []seedReactionJSON `json:"reactions"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return fmt.Errorf("parse available_reactions_raw.json: %w", err) + } + index, err := scanSeedDir(reactionsDir) + if err != nil { + return err + } + for i, rj := range parsed.Result.Reactions { + ar := domain.AvailableReaction{ + Reaction: rj.Reaction, + Title: rj.Title, + Inactive: rj.Inactive, + Premium: rj.Premium, + Order: i, + } + set := func(dst *int64, d *seedDocumentJSON) error { + if d == nil || d.ID == 0 { + return nil + } + doc, err := s.importDocument(ctx, *d, reactionsDir, index, stats) + if err != nil { + return err + } + *dst = doc.ID + return nil + } + if err := set(&ar.StaticIconID, rj.StaticIcon); err != nil { + return err + } + if err := set(&ar.AppearAnimationID, rj.AppearAnimation); err != nil { + return err + } + if err := set(&ar.SelectAnimationID, rj.SelectAnimation); err != nil { + return err + } + if err := set(&ar.ActivateAnimationID, rj.ActivateAnimation); err != nil { + return err + } + if err := set(&ar.EffectAnimationID, rj.EffectAnimation); err != nil { + return err + } + if err := set(&ar.AroundAnimationID, rj.AroundAnimation); err != nil { + return err + } + if err := set(&ar.CenterIconID, rj.CenterIcon); err != nil { + return err + } + if err := s.media.PutAvailableReaction(ctx, ar); err != nil { + return err + } + stats.Reactions++ + } + return nil +} + +func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, error) { + reactions, err := s.media.ListAvailableReactions(ctx) + if err != nil { + return false, err + } + var docIDs []int64 + for _, r := range reactions { + for _, id := range r.DocumentIDs() { + if id == 0 { + continue + } + docIDs = append(docIDs, id) + if _, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", id)); err != nil { + return false, err + } else if !ok { + return true, nil + } + } + } + if stale, err := s.documentsNeedInlineCachedThumbs(ctx, docIDs); err != nil || stale { + return stale, err + } + return false, nil +} + +// ---- sticker sets ---- + +func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error { + // default 系统集:目录名 → system_key。 + defaultDir := filepath.Join(root, "telegram_default_stickers_export") + order := 0 + if entries, err := os.ReadDir(defaultDir); 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 { + systemKey := systemKeyForDefaultSet(name) + setDir := filepath.Join(defaultDir, name) + if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil { + return fmt.Errorf("import default set %s: %w", name, err) + } + order++ + } + } + + // 常规贴纸集。 + regularDir := filepath.Join(root, "telegram_stickers_export") + if entries, err := os.ReadDir(regularDir); err == nil { + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + imported := 0 + for _, name := range names { + if maxRegular > 0 && imported >= maxRegular { + break + } + setDir := filepath.Join(regularDir, name) + if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil { + return fmt.Errorf("import sticker set %s: %w", name, err) + } + order++ + imported++ + } + } + return nil +} + +func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error { + infoPath := filepath.Join(setDir, "set_info.json") + raw, err := os.ReadFile(infoPath) + if err != nil { + return nil // 该目录无 set_info.json → 跳过 + } + var info struct { + Result seedStickerSetResultJSON `json:"result"` + } + if err := json.Unmarshal(raw, &info); err != nil { + return fmt.Errorf("parse %s: %w", infoPath, err) + } + sj := info.Result.Set + if sj.ID == 0 { + return nil + } + stickersDir := filepath.Join(setDir, "stickers") + index, err := scanSeedDir(stickersDir) + if err != nil { + return err + } + + docIDs := make([]int64, 0, len(info.Result.Documents)) + docs := make([]domain.Document, 0, len(info.Result.Documents)) + docIDBySource := make(map[int64]int64, len(info.Result.Documents)) + for _, dj := range info.Result.Documents { + sourceID := dj.ID + doc, err := s.importDocument(ctx, dj, stickersDir, index, stats) + if err != nil { + return err + } + if doc.ID != 0 { + docIDBySource[sourceID] = doc.ID + docIDs = append(docIDs, doc.ID) + docs = append(docs, doc) + } + } + + kind := stickerSetKind(sj, systemKey) + set := domain.StickerSet{ + ID: sj.ID, + AccessHash: sj.AccessHash, + ShortName: sj.ShortName, + Title: sj.Title, + Count: sj.Count, + Hash: sj.Hash, + Kind: kind, + Official: sj.Official, + Animated: true, // 导出资源均为 .tgs 动画贴纸 + Emojis: sj.Emojis, + Masks: sj.Masks, + Archived: sj.Archived, + Installed: seedStickerSetInstalled(kind), + ThumbDocumentID: seedDocumentStorageID(derefInt64(sj.ThumbDocumentID)), + Thumbs: seedStickerSetPhotoSizes(sj.Thumbs), + ThumbDCID: s.dc, + ThumbVersion: sj.ThumbVersion, + DocumentIDs: docIDs, + Packs: seedStickerPacks(sj.Packs, info.Result.Packs, docIDBySource), + SortOrder: order, + SystemKey: systemKey, + } + if set.Count == 0 { + set.Count = len(docIDs) + } + if err := s.media.PutStickerSet(ctx, set); err != nil { + return err + } + s.stickerSetCache.put(set, docs) + stats.StickerSets++ + return nil +} + +// ---- 单个 document 导入 ---- + +func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDir string, index seedDirIndex, stats *SeedStats) (domain.Document, error) { + if dj.ID == 0 { + return domain.Document{}, nil + } + storageID := seedDocumentStorageID(dj.ID) + ref, _ := hex.DecodeString(dj.FileReference) + doc := domain.Document{ + ID: storageID, + AccessHash: dj.AccessHash, + FileReference: ref, + Date: parseSeedDate(dj.Date), + MimeType: dj.MimeType, + Size: dj.Size, + DCID: s.dc, + Attributes: seedDocumentAttributes(dj.Attributes), + } + + // 主体 blob:doc: + if mainPath, ok := index.main[dj.ID]; ok { + data, err := os.ReadFile(mainPath) + if err != nil { + return domain.Document{}, err + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, err + } + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", storageID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: dj.MimeType, + }); err != nil { + return domain.Document{}, err + } + s.prewarmSmallBlob(objectKey, data) + stats.Blobs++ + } + + // 缩略图:PhotoPathSize 内联;小的 PhotoSize 静态图同时写 blob 并作为 + // PhotoCachedSize 返回,让 TDesktop 处理 document 元数据时即可填本地 image cache。 + thumbs := make([]domain.PhotoSize, 0, len(dj.Thumbs)) + for _, tj := range dj.Thumbs { + ps, downloadable := seedPhotoSize(tj) + if ps.Kind == "" { + continue + } + if downloadable { + thumbPath, ok := index.thumb[dj.ID][ps.Type] + if !ok { + continue // 无可服务的缩略图文件,丢弃该尺寸(保留 PhotoPathSize 占位) + } + data, err := os.ReadFile(thumbPath) + if err != nil { + return domain.Document{}, err + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, err + } + ps.Size = len(data) + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d:%s", storageID, ps.Type), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: seedThumbMimeType(data), + }); err != nil { + return domain.Document{}, err + } + ps = seedInlineCachedDocumentThumb(ps, data) + s.prewarmSmallBlob(objectKey, data) + stats.Blobs++ + } + thumbs = append(thumbs, ps) + } + doc.Thumbs = seedPreferRasterDocumentThumbs(thumbs) + + if err := s.media.PutDocument(ctx, doc); err != nil { + return domain.Document{}, err + } + stats.Documents++ + return doc, nil +} + +func (s *Service) prewarmSmallBlob(objectKey string, data []byte) { + if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes { + s.byteCache.put(objectKey, data) + } +} + +// ---- 目录扫描:docID → 主体文件 / 可下载缩略图 ---- + +type seedDirIndex struct { + main map[int64]string // docID -> 主体文件路径 + thumb map[int64]map[string]string // docID -> thumbType -> 缩略图文件路径 +} + +var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`) +var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`) + +const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024 + +// Exported Telegram resources keep their original id in filenames/JSON, but +// telesrv owns the document catalog it serves. Imported high source ids are +// normalized once at seed time so RPC/storage use one server-owned id. +const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000 + +var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`) + +func seedDocumentStorageID(sourceID int64) int64 { + if sourceID <= 0 { + return 0 + } + if sourceID > seedExternalDocumentIDOffset { + return sourceID - seedExternalDocumentIDOffset + } + return sourceID +} + +func scanSeedDir(dir string) (seedDirIndex, error) { + idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}} + entries, err := os.ReadDir(dir) + if err != nil { + return idx, nil // 目录不存在 → 空 index + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + full := filepath.Join(dir, name) + if marker := seedThumbMarker.FindStringIndex(name); marker != nil { + // 只收可下载的 PhotoSize 缩略图(jpg);PhotoPathSize(svg) 内联在 JSON。 + if ext != ".jpg" && ext != ".jpeg" { + continue + } + m := seedThumbType.FindStringSubmatch(name) + if m == nil { + continue + } + docID := docIDFromName(name[:marker[0]]) + if docID == 0 { + continue + } + if idx.thumb[docID] == nil { + idx.thumb[docID] = map[string]string{} + } + idx.thumb[docID][m[1]] = full + continue + } + if ext == ".svg" || ext == ".json" { + continue + } + docID := docIDFromName(strings.TrimSuffix(name, filepath.Ext(name))) + if docID == 0 { + continue + } + idx.main[docID] = full + } + return idx, nil +} + +// docIDFromName 取 base name 中末尾最长的数字串作为 document id。 +func docIDFromName(base string) int64 { + matches := seedTrailingDigits.FindAllString(base, -1) + if len(matches) == 0 { + return 0 + } + last := matches[len(matches)-1] + id, err := strconv.ParseInt(last, 10, 64) + if err != nil { + return 0 + } + return id +} + +func systemKeyForDefaultSet(dirName string) string { + switch dirName { + case "DefaultSet_AnimatedEmoji": + return "animated_emoji" + case "DefaultSet_AnimatedEmojiAnimations": + return "animated_emoji_animations" + case "DefaultSet_EmojiGenericAnimations": + return "emoji_generic_animations" + case "DefaultSet_Dice_Normal": + return "dice:\U0001f3b2" + case "DefaultSet_Dice_Dart": + return "dice:\U0001f3af" + case "DefaultSet_Dice_Basketball": + return "dice:\U0001f3c0" + case "DefaultSet_Dice_Football": + return "dice:⚽" + case "DefaultSet_Dice_Bowling": + return "dice:\U0001f3b3" + case "DefaultSet_Dice_Casino": + return "dice:\U0001f3b0" + default: + return "" + } +} + +func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKind { + switch { + case systemKey != "": + return domain.StickerSetKindSystem + case sj.Emojis: + return domain.StickerSetKindEmoji + case sj.Masks: + return domain.StickerSetKindMasks + default: + return domain.StickerSetKindStickers + } +} + +func seedStickerSetInstalled(kind domain.StickerSetKind) bool { + return kind != domain.StickerSetKindSystem +} + +// ---- JSON → domain 转换 ---- + +func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute { + out := make([]domain.DocumentAttribute, 0, len(attrs)) + for _, a := range attrs { + switch a.Type { + case "DocumentAttributeImageSize": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: a.W, H: a.H}) + case "DocumentAttributeAnimated": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}) + case "DocumentAttributeSticker": + attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: a.Alt, Mask: a.Mask} + if a.Stickerset != nil { + attr.StickerSetID = a.Stickerset.ID + attr.StickerSetAccessHash = a.Stickerset.AccessHash + } + out = append(out, attr) + case "DocumentAttributeCustomEmoji": + attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: a.Alt, Free: a.Free, TextColor: a.TextColor} + if a.Stickerset != nil { + attr.StickerSetID = a.Stickerset.ID + attr.StickerSetAccessHash = a.Stickerset.AccessHash + } + out = append(out, attr) + case "DocumentAttributeVideo": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: a.W, H: a.H, Duration: a.Duration, RoundMessage: a.RoundMessage, SupportsStreaming: a.SupportsStreaming}) + case "DocumentAttributeAudio": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: int(a.Duration), Voice: a.Voice, Title: a.Title, Performer: a.Performer}) + case "DocumentAttributeFilename": + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: a.FileName}) + } + } + 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 { + ps, downloadable := seedPhotoSize(t) + if ps.Kind == "" || downloadable { + continue + } + out = append(out, ps) + } + return out +} + +func seedPhotoSize(t seedThumbJSON) (domain.PhotoSize, bool) { + switch t.Type { + case "PhotoSize": + return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: t.SizeType, W: t.W, H: t.H, Size: t.Size}, true + case "PhotoStrippedSize": + b, _ := hex.DecodeString(t.Bytes) + return domain.PhotoSize{Kind: domain.PhotoSizeKindStripped, Type: t.SizeType, Bytes: b}, false + case "PhotoCachedSize": + b, _ := hex.DecodeString(t.Bytes) + return domain.PhotoSize{Kind: domain.PhotoSizeKindCached, Type: t.SizeType, W: t.W, H: t.H, Bytes: b}, false + case "PhotoPathSize": + b, _ := hex.DecodeString(t.Bytes) + return domain.PhotoSize{Kind: domain.PhotoSizeKindPath, Type: t.SizeType, Bytes: b}, false + case "PhotoSizeProgressive": + return domain.PhotoSize{Kind: domain.PhotoSizeKindProgressive, Type: t.SizeType, W: t.W, H: t.H, Sizes: t.Sizes}, true + default: + return domain.PhotoSize{}, false + } +} + +func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.PhotoSize { + if ps.Kind != domain.PhotoSizeKindDefault || len(data) == 0 || len(data) > seedInlineCachedDocumentThumbMaxBytes { + return ps + } + ps.Kind = domain.PhotoSizeKindCached + ps.Size = 0 + ps.Bytes = append([]byte(nil), data...) + return ps +} + +func seedPreferRasterDocumentThumbs(sizes []domain.PhotoSize) []domain.PhotoSize { + if !documentThumbsHaveRaster(sizes) { + return sizes + } + out := sizes[:0] + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindPath { + continue + } + out = append(out, size) + } + return out +} + +func seedThumbMimeType(data []byte) string { + switch { + case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P': + return "image/webp" + case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: + return "image/jpeg" + case len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G': + return "image/png" + case len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F': + return "image/gif" + default: + return "application/octet-stream" + } +} + +func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) { + var ids []int64 + for _, kind := range []domain.StickerSetKind{ + domain.StickerSetKindStickers, + domain.StickerSetKindEmoji, + domain.StickerSetKindMasks, + domain.StickerSetKindSystem, + } { + sets, err := s.media.ListStickerSets(ctx, kind) + if err != nil { + return false, err + } + for _, set := range sets { + ids = append(ids, set.DocumentIDs...) + } + } + return s.documentsNeedInlineCachedThumbs(ctx, ids) +} + +func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) { + if len(ids) == 0 { + return false, nil + } + seen := make(map[int64]struct{}, len(ids)) + unique := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + docs, err := s.media.GetDocuments(ctx, unique) + if err != nil { + return false, err + } + for _, doc := range docs { + if documentThumbsHaveRaster(doc.Thumbs) && documentThumbsHavePath(doc.Thumbs) { + return true, nil + } + for _, thumb := range doc.Thumbs { + if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes { + return true, nil + } + if thumb.Kind == domain.PhotoSizeKindCached && len(thumb.Bytes) > 0 { + blob, ok, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)) + if err != nil { + return false, err + } + if ok { + want := seedThumbMimeType(thumb.Bytes) + if want != "application/octet-stream" && blob.MimeType != want { + return true, nil + } + } + } + } + } + return false, nil +} + +func documentThumbsHaveRaster(sizes []domain.PhotoSize) bool { + for _, size := range sizes { + switch size.Kind { + case domain.PhotoSizeKindCached: + if len(size.Bytes) > 0 { + return true + } + case domain.PhotoSizeKindDefault: + if size.Type != "" && size.Size > 0 { + return true + } + case domain.PhotoSizeKindProgressive: + if size.Type != "" && len(size.Sizes) > 0 { + return true + } + } + } + return false +} + +func documentThumbsHavePath(sizes []domain.PhotoSize) bool { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 { + return true + } + } + return false +} + +func seedStickerPacks(setPacks, resultPacks []seedStickerPackJSON, docIDBySource map[int64]int64) []domain.StickerPack { + packs := setPacks + if len(packs) == 0 { + packs = resultPacks + } + out := make([]domain.StickerPack, 0, len(packs)) + for _, p := range packs { + documents := make([]int64, 0, len(p.Documents)) + for _, sourceID := range p.Documents { + if id, ok := docIDBySource[sourceID]; ok { + documents = append(documents, id) + continue + } + if id := seedDocumentStorageID(sourceID); id != 0 { + documents = append(documents, id) + } + } + out = append(out, domain.StickerPack{Emoticon: p.Emoticon, DocumentIDs: documents}) + } + return out +} + +func parseSeedDate(s string) int { + if s == "" { + return 0 + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return int(t.Unix()) + } + return 0 +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +// ---- seed JSON 结构 ---- + +type seedInputStickerSetJSON struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` +} + +type seedAttrJSON struct { + Type string `json:"_"` + W int `json:"w"` + H int `json:"h"` + Alt string `json:"alt"` + Mask bool `json:"mask"` + Duration float64 `json:"duration"` + RoundMessage bool `json:"round_message"` + SupportsStreaming bool `json:"supports_streaming"` + Voice bool `json:"voice"` + Title string `json:"title"` + Performer string `json:"performer"` + FileName string `json:"file_name"` + Free bool `json:"free"` + TextColor bool `json:"text_color"` + Stickerset *seedInputStickerSetJSON `json:"stickerset"` +} + +type seedThumbJSON struct { + Type string `json:"_"` + SizeType string `json:"type"` + W int `json:"w"` + H int `json:"h"` + Size int `json:"size"` + Bytes string `json:"bytes"` + Sizes []int `json:"sizes"` +} + +type seedDocumentJSON 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 []seedAttrJSON `json:"attributes"` + Thumbs []seedThumbJSON `json:"thumbs"` +} + +type seedStickerPackJSON struct { + Emoticon string `json:"emoticon"` + Documents []int64 `json:"documents"` +} + +type seedStickerSetJSON 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"` + Archived bool `json:"archived"` + Official bool `json:"official"` + Masks bool `json:"masks"` + Emojis bool `json:"emojis"` + Thumbs []seedThumbJSON `json:"thumbs"` + ThumbDCID int `json:"thumb_dc_id"` + ThumbVersion int `json:"thumb_version"` + ThumbDocumentID *int64 `json:"thumb_document_id"` + Packs []seedStickerPackJSON `json:"packs"` +} + +type seedStickerSetResultJSON struct { + Set seedStickerSetJSON `json:"set"` + Packs []seedStickerPackJSON `json:"packs"` + Documents []seedDocumentJSON `json:"documents"` +} + +type seedReactionJSON struct { + Reaction string `json:"reaction"` + Title string `json:"title"` + Inactive bool `json:"inactive"` + Premium bool `json:"premium"` + StaticIcon *seedDocumentJSON `json:"static_icon"` + AppearAnimation *seedDocumentJSON `json:"appear_animation"` + SelectAnimation *seedDocumentJSON `json:"select_animation"` + ActivateAnimation *seedDocumentJSON `json:"activate_animation"` + EffectAnimation *seedDocumentJSON `json:"effect_animation"` + AroundAnimation *seedDocumentJSON `json:"around_animation"` + CenterIcon *seedDocumentJSON `json:"center_icon"` +} diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go new file mode 100644 index 00000000..236d27bc --- /dev/null +++ b/internal/app/files/seed_test.go @@ -0,0 +1,526 @@ +package files + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "telesrv/internal/domain" +) + +// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。 +type fakeMediaStore struct { + mu sync.Mutex + blobs map[string]domain.FileBlob + docs map[int64]domain.Document + photos map[int64]domain.Photo + sets map[int64]domain.StickerSet + reactions []domain.AvailableReaction + parts map[string][]domain.UploadPart +} + +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{}, + } +} + +func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil } +func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) { + return nil, nil +} +func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil } + +func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error { + f.mu.Lock() + defer f.mu.Unlock() + f.blobs[blob.LocationKey] = blob + return nil +} +func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.FileBlob, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + b, ok := f.blobs[key] + return b, ok, nil +} + +func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error { + f.mu.Lock() + defer f.mu.Unlock() + f.docs[doc.ID] = doc + return nil +} +func (f *fakeMediaStore) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + d, ok := f.docs[id] + return d, ok, nil +} +func (f *fakeMediaStore) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if d, ok := f.docs[id]; ok { + out = append(out, d) + } + } + return out, nil +} +func (f *fakeMediaStore) PutPhoto(_ context.Context, p domain.Photo) error { + f.mu.Lock() + defer f.mu.Unlock() + f.photos[p.ID] = p + return nil +} +func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + p, ok := f.photos[id] + return p, ok, nil +} + +func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sets[set.ID] = set + return nil +} +func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + s, ok := f.sets[id] + return s, ok, nil +} +func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sets { + if s.ShortName == name { + return s, true, nil + } + } + return domain.StickerSet{}, false, nil +} +func (f *fakeMediaStore) GetStickerSetBySystemKey(_ context.Context, key string) (domain.StickerSet, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sets { + if s.SystemKey == key { + return s, true, nil + } + } + return domain.StickerSet{}, false, nil +} +func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []domain.StickerSet + for _, s := range f.sets { + if s.Kind == kind { + out = append(out, s) + } + } + return out, nil +} +func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sets), nil +} +func (f *fakeMediaStore) PutAvailableReaction(_ context.Context, r domain.AvailableReaction) error { + f.mu.Lock() + defer f.mu.Unlock() + for i, existing := range f.reactions { + if existing.Reaction == r.Reaction { + f.reactions[i] = r + return nil + } + } + f.reactions = append(f.reactions, r) + return nil +} +func (f *fakeMediaStore) ListAvailableReactions(_ context.Context) ([]domain.AvailableReaction, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]domain.AvailableReaction(nil), f.reactions...), nil +} +func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) { + f.mu.Lock() + 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) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) { + return 0, false, nil +} +func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (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) { + return nil, 0, nil +} +func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) { + return nil, nil +} + +func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) { + seedDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(seedDir, "telegram_reactions_export", "global_json"), 0o755); err != nil { + t.Fatal(err) + } + reactionsDir := filepath.Join(seedDir, "telegram_reactions_export", "reactions") + if err := os.MkdirAll(reactionsDir, 0o755); err != nil { + t.Fatal(err) + } + raw := `{"result":{"reactions":[{"reaction":"👍","title":"Like","static_icon":{"id":1111111,"access_hash":1,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"image/webp","size":4,"attributes":[],"thumbs":[]},"select_animation":{"id":2222222,"access_hash":2,"file_reference":"","date":"2026-06-03T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"attributes":[],"thumbs":[]}}]}}` + if err := os.WriteFile(filepath.Join(seedDir, "telegram_reactions_export", "global_json", "available_reactions_raw.json"), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111.webp"), []byte("webp"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_thumbs_up_sign_static_icon_Like_1111111_thumb1_PhotoSize_types_72x72.jpg"), []byte("jpeg"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(reactionsDir, "reaction_select_2222222.tgs"), []byte("tgs!"), 0o644); err != nil { + t.Fatal(err) + } + + media := newFakeMediaStore() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + blobs := &countingBlobBackend{BlobBackend: local} + svc := NewService(media, blobs, 2) + if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil { + t.Fatalf("initial seed: %v", err) + } else if stats.Reactions != 1 || stats.Blobs != 2 { + t.Fatalf("initial stats = %+v, want one reaction and two blobs", stats) + } + chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4}) + if err != nil || !ok { + t.Fatalf("prewarmed getfile ok=%v err=%v", ok, err) + } + if string(chunk.Bytes) != "tgs!" { + t.Fatalf("prewarmed chunk = %q, want tgs!", chunk.Bytes) + } + if blobs.getRangeCalls != 0 { + t.Fatalf("seeded small blob should be served from byte cache, GetRange calls = %d", blobs.getRangeCalls) + } + + media.mu.Lock() + delete(media.blobs, "doc:2222222") + media.mu.Unlock() + + stats, err := svc.SeedMedia(context.Background(), seedDir, 0) + if err != nil { + t.Fatalf("repair seed: %v", err) + } + if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped { + t.Fatalf("repair stats = %+v, want repair import", stats) + } + if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok { + t.Fatal("missing reaction blob was not repaired") + } + if reactions, _ := media.ListAvailableReactions(context.Background()); len(reactions) != 1 { + t.Fatalf("reaction upsert duplicated rows: got %d", len(reactions)) + } +} + +func TestSeedMediaFromRealExport(t *testing.T) { + seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR") + if seedDir == "" { + t.Skip("TELESRV_REAL_STICKER_SEED_DIR not set") + } + if _, err := os.Stat(seedDir); err != nil { + t.Skipf("seed dir %s not present: %v", seedDir, err) + } + 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(context.Background(), seedDir, 2) + if err != nil { + t.Fatalf("seed media: %v", err) + } + t.Logf("seed stats: reactions=%d sets=%d docs=%d blobs=%d", stats.Reactions, stats.StickerSets, stats.Documents, stats.Blobs) + if stats.Reactions == 0 { + t.Error("expected reactions imported") + } + if stats.StickerSets == 0 { + t.Error("expected sticker sets imported") + } + if stats.Documents == 0 { + t.Error("expected documents imported") + } + if stats.Blobs == 0 { + t.Error("expected blobs imported") + } + + // reaction 引用的文档应能被解析回真实 document(带 sticker 属性 + 主体 blob)。 + reactions, _ := media.ListAvailableReactions(context.Background()) + if len(reactions) == 0 { + t.Fatal("no reactions stored") + } + first := reactions[0] + if first.Reaction == "" { + t.Error("reaction emoticon empty") + } + if first.StaticIconID == 0 || first.SelectAnimationID == 0 { + t.Error("reaction missing document ids") + } + if d, ok, _ := media.GetDocument(context.Background(), first.SelectAnimationID); !ok { + t.Error("reaction select animation document missing") + } else { + if d.ID > seedExternalDocumentIDOffset { + t.Errorf("reaction document kept external source id: %d", d.ID) + } + if d.DCID != 2 { + t.Errorf("document dc_id not rewritten: %d", d.DCID) + } + if _, ok, _ := media.GetFileBlob(context.Background(), blobKeyDoc(d.ID)); !ok { + t.Errorf("reaction document %d main blob missing", d.ID) + } + } + + // 一个常规贴纸集应有 documents 且能按 short_name 解析。 + for _, s := range media.sets { + for _, thumb := range s.Thumbs { + if thumb.Downloadable() { + t.Fatalf("sticker set %s exposes downloadable cover thumb %q without a serviceable blob", s.ShortName, thumb.Type) + } + } + } + + var sample domain.StickerSet + for _, s := range media.sets { + if s.Kind == domain.StickerSetKindStickers && len(s.DocumentIDs) > 0 { + sample = s + break + } + } + if sample.ID == 0 { + t.Fatal("no regular sticker set with documents imported") + } + if got, ok, _ := media.GetStickerSetByShortName(context.Background(), sample.ShortName); !ok || got.ID != sample.ID { + t.Error("sticker set not resolvable by short name") + } + if doc, ok, _ := media.GetDocument(context.Background(), sample.DocumentIDs[0]); !ok { + t.Fatalf("sample sticker document %d missing", sample.DocumentIDs[0]) + } else { + if doc.ID > seedExternalDocumentIDOffset { + t.Fatalf("sample sticker kept external source id: %d", doc.ID) + } + thumb, ok := findCachedThumb(doc.Thumbs) + if !ok { + t.Fatalf("sample sticker document thumbs are not inline cached: %+v", doc.Thumbs) + } + blob, ok, err := media.GetFileBlob(context.Background(), blobKeyDoc(doc.ID)+":"+thumb.Type) + if err != nil || !ok { + t.Fatalf("sample sticker thumb blob ok=%v err=%v", ok, err) + } + if want := seedThumbMimeType(thumb.Bytes); blob.MimeType != want { + t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want) + } + if hasPathThumb(doc.Thumbs) { + t.Fatalf("sample sticker document still exposes path thumb together with raster: %+v", doc.Thumbs) + } + } +} + +func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) { + const sourceID int64 = 5382305375846410902 + const want int64 = 1382305375846410902 + if got := seedDocumentStorageID(sourceID); got != want { + t.Fatalf("seedDocumentStorageID(%d) = %d, want %d", sourceID, got, want) + } + if got := seedDocumentStorageID(2222222); got != 2222222 { + t.Fatalf("small server id changed: %d", got) + } +} + +func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) { + cases := []struct { + name string + kind domain.StickerSetKind + want bool + }{ + {name: "regular stickers", kind: domain.StickerSetKindStickers, want: true}, + {name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true}, + {name: "masks", kind: domain.StickerSetKindMasks, want: true}, + {name: "system resources", kind: domain.StickerSetKindSystem, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := seedStickerSetInstalled(tc.kind); got != tc.want { + t.Fatalf("seedStickerSetInstalled(%q) = %v, want %v", tc.kind, got, tc.want) + } + }) + } +} + +func TestSeedInlineCachedDocumentThumb(t *testing.T) { + input := domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 128, H: 128, Size: 6400} + got := seedInlineCachedDocumentThumb(input, []byte("jpeg")) + if got.Kind != domain.PhotoSizeKindCached { + t.Fatalf("kind = %q, want cached", got.Kind) + } + if got.Size != 0 || string(got.Bytes) != "jpeg" { + t.Fatalf("cached thumb = %+v, want inline bytes without downloadable size", got) + } + large := make([]byte, seedInlineCachedDocumentThumbMaxBytes+1) + if got := seedInlineCachedDocumentThumb(input, large); got.Kind != domain.PhotoSizeKindDefault || got.Size != input.Size || len(got.Bytes) != 0 { + t.Fatalf("large thumb = %+v, want unchanged downloadable thumb", got) + } +} + +func TestSeedThumbMimeType(t *testing.T) { + webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'} + if got := seedThumbMimeType(webp); got != "image/webp" { + t.Fatalf("webp mime = %q, want image/webp", got) + } + jpeg := []byte{0xFF, 0xD8, 0xFF} + if got := seedThumbMimeType(jpeg); got != "image/jpeg" { + t.Fatalf("jpeg mime = %q, want image/jpeg", got) + } +} + +func TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists(t *testing.T) { + sizes := []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}, + {Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")}, + } + got := seedPreferRasterDocumentThumbs(sizes) + if hasPathThumb(got) { + t.Fatalf("path thumb should be dropped when raster exists: %+v", got) + } + if !hasCachedThumb(got) { + t.Fatalf("cached thumb should be kept: %+v", got) + } + + onlyPath := []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}} + if got := seedPreferRasterDocumentThumbs(onlyPath); !hasPathThumb(got) { + t.Fatalf("path-only thumbs should be kept: %+v", got) + } +} + +func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'} + doc := domain.Document{ + ID: 100, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: webp}, + }, + } + if err := media.PutDocument(ctx, doc); err != nil { + t.Fatalf("put doc: %v", err) + } + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/jpeg"}); err != nil { + t.Fatalf("put blob: %v", err) + } + svc := NewService(media, nil, 2) + stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) + if err != nil { + t.Fatalf("documentsNeedInlineCachedThumbs: %v", err) + } + if !stale { + t.Fatal("expected stale mime to require repair") + } + + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil { + t.Fatalf("put repaired blob: %v", err) + } + stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) + if err != nil { + t.Fatalf("documentsNeedInlineCachedThumbs after repair: %v", err) + } + if stale { + t.Fatal("repaired mime should not require repair") + } +} + +func TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + doc := domain.Document{ + ID: 100, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte("path")}, + {Kind: domain.PhotoSizeKindCached, Type: "m", Bytes: []byte("webp")}, + }, + } + if err := media.PutDocument(ctx, doc); err != nil { + t.Fatalf("put doc: %v", err) + } + svc := NewService(media, nil, 2) + stale, err := svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) + if err != nil { + t.Fatalf("documentsNeedInlineCachedThumbs: %v", err) + } + if !stale { + t.Fatal("path thumb with raster should require repair") + } +} + +func hasCachedThumb(sizes []domain.PhotoSize) bool { + _, ok := findCachedThumb(sizes) + return ok +} + +func findCachedThumb(sizes []domain.PhotoSize) (domain.PhotoSize, bool) { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindCached && len(size.Bytes) > 0 { + return size, true + } + } + return domain.PhotoSize{}, false +} + +func hasPathThumb(sizes []domain.PhotoSize) bool { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindPath && len(size.Bytes) > 0 { + return true + } + } + return false +} + +func blobKeyDoc(id int64) string { + return "doc:" + itoa(id) +} + +func itoa(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/app/files/service.go b/internal/app/files/service.go new file mode 100644 index 00000000..181fd161 --- /dev/null +++ b/internal/app/files/service.go @@ -0,0 +1,256 @@ +package files + +import ( + "context" + "fmt" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。 +const ( + MaxUploadPartBytes = 524288 // 512KB + MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体 +) + +// blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。 +const blobMetaCacheCapacity = 1 << 16 + +// 小文件热缓存只覆盖 sticker/reaction/thumbnail 一类不可变小 blob;大媒体继续分段读。 +const ( + blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB + blobBytesCacheMaxBytes = 64 << 20 // 64MB +) + +// Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。 +type Service struct { + media store.MediaStore + blobs BlobBackend + dc int + blobCache *blobMetaCache + byteCache *blobBytesCache + stickerSetCache *stickerSetFullCache +} + +// 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(), + } +} + +// SaveFilePart 累积一个 small file 分片。 +func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) { + if err := validatePart(part, len(bytes)); err != nil { + return false, err + } + if err := s.media.SaveFilePart(ctx, domain.UploadPart{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: part, + Bytes: bytes, + }); err != nil { + return false, err + } + return true, nil +} + +// SaveBigFilePart 累积一个 big file 分片(带已知总分片数)。 +func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) { + if err := validatePart(part, len(bytes)); err != nil { + return false, err + } + if totalParts <= 0 || totalParts > MaxUploadParts { + return false, domain.ErrFilePartsInvalid + } + if err := s.media.SaveFilePart(ctx, domain.UploadPart{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: part, + TotalParts: totalParts, + Big: true, + Bytes: bytes, + }); err != nil { + return false, err + } + return true, nil +} + +// GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。 +// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker / +// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。 +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) + if err != nil { + return domain.FileChunk{}, false, err + } + if !found { + return domain.FileChunk{}, false, nil + } + s.blobCache.put(req.LocationKey, blob) + } + if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes { + if data, ok := s.byteCache.get(blob.ObjectKey); ok { + return domain.FileChunk{ + Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)), + MimeType: blob.MimeType, + Total: int64(len(data)), + }, true, nil + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) + 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) + return domain.FileChunk{ + Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)), + MimeType: blob.MimeType, + Total: total, + }, true, nil + } + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit)) + if err != nil { + return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) + } + return domain.FileChunk{ + Bytes: data, + MimeType: blob.MimeType, + Total: total, + }, true, nil +} + +func sliceBlobBytes(data []byte, offset, limit int64) []byte { + total := int64(len(data)) + if offset < 0 { + offset = 0 + } + if offset >= total { + return []byte{} + } + end := total + if limit > 0 && offset+limit < end { + end = offset + limit + } + return append([]byte(nil), data[offset:end]...) +} + +// ---- 资源读取(reaction / sticker / document)---- + +// ListAvailableReactions 返回可用 reaction 目录(带真实文档 id)。 +func (s *Service) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) { + return s.media.ListAvailableReactions(ctx) +} + +// GetDocuments 按 id 批量加载文档(自定义 emoji / 贴纸)。 +func (s *Service) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) { + return s.media.GetDocuments(ctx, ids) +} + +// ListStickerSets 列出某类贴纸集(用于 getAllStickers 等)。 +func (s *Service) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + return s.media.ListStickerSets(ctx, kind) +} + +// ResolveStickerSet 按 ref 解析贴纸集,并按 DocumentIDs 顺序加载其文档。 +func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + if set, docs, ok := s.stickerSetCache.get(ref); ok { + return set, docs, true, nil + } + var ( + set domain.StickerSet + found bool + err error + ) + switch ref.Kind { + case domain.StickerSetRefByID: + set, found, err = s.media.GetStickerSetByID(ctx, ref.ID) + case domain.StickerSetRefByShortName: + set, found, err = s.media.GetStickerSetByShortName(ctx, ref.ShortName) + case domain.StickerSetRefBySystem: + set, found, err = s.media.GetStickerSetBySystemKey(ctx, ref.SystemKey) + default: + return domain.StickerSet{}, nil, false, nil + } + if err != nil || !found { + return domain.StickerSet{}, nil, found, err + } + docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) + if err != nil { + return domain.StickerSet{}, nil, false, err + } + ordered := orderDocuments(docs, set.DocumentIDs) + s.stickerSetCache.put(set, ordered) + return set, ordered, true, nil +} + +// orderDocuments 把无序的文档按 ids 顺序重排(GetDocuments 用 ANY 查询不保证顺序)。 +func orderDocuments(docs []domain.Document, ids []int64) []domain.Document { + byID := make(map[int64]domain.Document, len(docs)) + for _, d := range docs { + byID[d.ID] = d + } + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if d, ok := byID[id]; ok { + out = append(out, d) + } + } + return out +} + +// 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) + 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) + for _, p := range parts { + buf = append(buf, p.Bytes...) + } + if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil { + return nil, err + } + return buf, nil +} + +func validatePart(part, size int) error { + if part < 0 || part >= MaxUploadParts { + return domain.ErrFilePartInvalid + } + if size == 0 { + return domain.ErrFilePartInvalid + } + if size > MaxUploadPartBytes { + return domain.ErrFilePartTooBig + } + return nil +} diff --git a/internal/app/files/warm.go b/internal/app/files/warm.go new file mode 100644 index 00000000..8682f637 --- /dev/null +++ b/internal/app/files/warm.go @@ -0,0 +1,136 @@ +package files + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +// WarmStats 汇报一次启动资源缓存预热结果。 +type WarmStats struct { + StickerSets int + Documents int + Blobs int +} + +// WarmCaches 从已持久化的 sticker/reaction 元数据预热小 blob 字节缓存与完整 sticker set 缓存。 +// SeedMedia 在已有数据时会跳过导入;该方法保证普通 server 重启后历史 sticker 首次渲染也不是冷缓存。 +func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) { + var stats WarmStats + seenDocs := make(map[int64]struct{}) + for _, kind := range []domain.StickerSetKind{ + domain.StickerSetKindStickers, + domain.StickerSetKindEmoji, + domain.StickerSetKindMasks, + domain.StickerSetKindSystem, + } { + sets, err := s.media.ListStickerSets(ctx, kind) + if err != nil { + return stats, err + } + for _, set := range sets { + docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) + if err != nil { + return stats, err + } + ordered := orderDocuments(docs, 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 + } + } + } + reactions, err := s.media.ListAvailableReactions(ctx) + if err != nil { + return stats, err + } + reactionIDs := make([]int64, 0, len(reactions)*4) + for _, reaction := range reactions { + reactionIDs = append(reactionIDs, reaction.DocumentIDs()...) + } + docs, err := s.media.GetDocuments(ctx, reactionIDs) + if err != nil { + return stats, err + } + for _, doc := range docs { + 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 + } + return stats, nil +} + +func (s *Service) prewarmDocumentBlobs(ctx context.Context, doc domain.Document) (int, error) { + 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++ + } + 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++ + } + } + return warmed, nil +} + +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) + } + if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) { + return false, nil + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) + if err != nil { + return false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) + } + if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total { + s.byteCache.put(blob.ObjectKey, data) + return true, nil + } + return false, nil +} diff --git a/internal/app/help/doc.go b/internal/app/help/doc.go new file mode 100644 index 00000000..edee7ddf --- /dev/null +++ b/internal/app/help/doc.go @@ -0,0 +1,2 @@ +// Package help 提供 help.* RPC 背后的数据目录服务。 +package help diff --git a/internal/app/help/service.go b/internal/app/help/service.go new file mode 100644 index 00000000..26b5822d --- /dev/null +++ b/internal/app/help/service.go @@ -0,0 +1,76 @@ +package help + +import ( + "context" + + "telesrv/internal/domain" + "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}` + +// Service 提供客户端启动配置与国家区号目录。 +type Service struct { + appConfigs store.AppConfigStore + countries store.CountryStore +} + +// NewService 创建 help 服务。 +func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service { + return &Service{appConfigs: appConfigs, countries: countries} +} + +// 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 + } + cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient) + if err != nil { + return domain.AppConfig{}, false, err + } + if !found { + cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)} + } + return cfg, hash != 0 && hash == cfg.Hash, nil +} + +// 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() + } + return list, hash != 0 && hash == list.Hash, nil +} + +func defaultCountries() domain.CountriesList { + return domain.CountriesList{ + Hash: 1, + Countries: []domain.Country{ + { + ISO2: "US", + DefaultName: "United States", + CountryCodes: []domain.CountryCode{ + {CountryCode: "1", Prefixes: []string{"1"}}, + }, + }, + { + ISO2: "CN", + DefaultName: "China", + CountryCodes: []domain.CountryCode{ + {CountryCode: "86", Prefixes: []string{"86"}}, + }, + }, + }, + } +} diff --git a/internal/app/langpack/parser.go b/internal/app/langpack/parser.go new file mode 100644 index 00000000..f83c0b87 --- /dev/null +++ b/internal/app/langpack/parser.go @@ -0,0 +1,109 @@ +package langpack + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "telesrv/internal/domain" +) + +var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`) + +// ParseTDesktopFile 解析 TDesktop .strings 文件为 domain 语言包。 +func ParseTDesktopFile(path string) (domain.LangPack, error) { + pack, err := packFromFilename(path) + if err != nil { + return domain.LangPack{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return domain.LangPack{}, fmt.Errorf("read tdesktop langpack %q: %w", path, err) + } + + plain := make([]domain.LangPackString, 0) + plurals := make(map[string]*domain.LangPackString) + pluralOrder := make([]string, 0) + for _, match := range tdesktopStringRE.FindAllStringSubmatch(string(data), -1) { + key := unquoteTDesktop(match[1]) + value := unquoteTDesktop(match[2]) + base, plural := splitPluralKey(key) + if plural == "" { + plain = append(plain, domain.LangPackString{Key: key, Value: value}) + continue + } + item, ok := plurals[base] + if !ok { + plurals[base] = &domain.LangPackString{Key: base, Pluralized: true} + item = plurals[base] + pluralOrder = append(pluralOrder, base) + } + setPluralValue(item, plural, value) + } + + pack.Strings = make([]domain.LangPackString, 0, len(plain)+len(pluralOrder)) + pack.Strings = append(pack.Strings, plain...) + for _, key := range pluralOrder { + pack.Strings = append(pack.Strings, *plurals[key]) + } + return pack, nil +} + +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)) + } + 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:]) + if err != nil { + return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", rest[idx+2:], err) + } + return domain.LangPack{ + LangPack: "tdesktop", + LangCode: rest[:idx], + Version: version, + }, nil +} + +func splitPluralKey(key string) (base, plural string) { + for _, suffix := range []string{"#zero", "#one", "#two", "#few", "#many", "#other"} { + if strings.HasSuffix(key, suffix) { + return strings.TrimSuffix(key, suffix), strings.TrimPrefix(suffix, "#") + } + } + return key, "" +} + +func setPluralValue(item *domain.LangPackString, plural, value string) { + switch plural { + case "zero": + item.ZeroValue = value + case "one": + item.OneValue = value + case "two": + item.TwoValue = value + case "few": + item.FewValue = value + case "many": + item.ManyValue = value + case "other": + item.OtherValue = value + } +} + +func unquoteTDesktop(s string) string { + v, err := strconv.Unquote(`"` + s + `"`) + if err != nil { + return s + } + return v +} diff --git a/internal/app/langpack/parser_test.go b/internal/app/langpack/parser_test.go new file mode 100644 index 00000000..b1d1c88d --- /dev/null +++ b/internal/app/langpack/parser_test.go @@ -0,0 +1,37 @@ +package langpack + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseTDesktopFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "tdesktop_en_v42.strings") + if err := os.WriteFile(path, []byte(` +"lng_plain" = "Plain value"; +"lng_escape" = "Line\nTwo"; +"lng_items#one" = "{count} item"; +"lng_items#other" = "{count} items"; +`), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + pack, err := ParseTDesktopFile(path) + if err != nil { + t.Fatalf("parse: %v", err) + } + if pack.LangPack != "tdesktop" || pack.LangCode != "en" || pack.Version != 42 { + t.Fatalf("pack meta = %+v", pack) + } + if len(pack.Strings) != 3 { + t.Fatalf("strings count = %d, want 3", len(pack.Strings)) + } + if got := pack.Strings[1].Value; got != "Line\nTwo" { + t.Fatalf("escape value = %q", got) + } + plural := pack.Strings[2] + if !plural.Pluralized || plural.Key != "lng_items" || plural.OneValue == "" || plural.OtherValue == "" { + t.Fatalf("plural string = %+v", plural) + } +} diff --git a/internal/app/langpack/seed.go b/internal/app/langpack/seed.go new file mode 100644 index 00000000..5ab4eadc --- /dev/null +++ b/internal/app/langpack/seed.go @@ -0,0 +1,56 @@ +package langpack + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SeedDirectory 将导出的 .strings 文件导入 LangPackStore。 +// root 可直接指向 data/langpack,也可指向包含 .strings 的具体平台目录。 +func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) { + if s == nil || s.packs == nil || root == "" { + return 0, nil + } + dir := filepath.Clean(root) + if _, err := os.Stat(dir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + 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())) + if err != nil { + return seeded, err + } + existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version) + if err != nil { + return seeded, err + } + if existing.Version >= pack.Version { + continue + } + if err := s.packs.UpsertPack(ctx, pack); err != nil { + return seeded, err + } + seeded += len(pack.Strings) + } + return seeded, nil +} diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go new file mode 100644 index 00000000..1161ed35 --- /dev/null +++ b/internal/app/langpack/service.go @@ -0,0 +1,53 @@ +package langpack + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供客户端语言包查询。 +type Service struct { + packs store.LangPackStore +} + +// NewService 创建 langpack 服务。 +func NewService(packs store.LangPackStore) *Service { + return &Service{packs: packs} +} + +// GetLangPack 返回完整语言包。 +func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) { + return s.GetDifference(ctx, langPack, langCode, 0) +} + +// GetDifference 返回从 fromVersion 到当前版本的语言包差异。 +func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + if s == nil || s.packs == nil { + return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil + } + return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion) +} + +// GetStrings 返回指定 key 的语言包字符串。 +func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + if s == nil || s.packs == nil { + return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil + } + return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys) +} + +func normalizePack(langPack string) string { + if langPack == "" { + return "tdesktop" + } + return langPack +} + +func normalizeCode(langCode string) string { + if langCode == "" { + return "en" + } + return langCode +} diff --git a/internal/app/maintenance/retention.go b/internal/app/maintenance/retention.go new file mode 100644 index 00000000..9c13087c --- /dev/null +++ b/internal/app/maintenance/retention.go @@ -0,0 +1,74 @@ +package maintenance + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +// DispatchOutboxRetentionStore 清理彻底失败(已放弃重试)的 outbox 死任务。 +type DispatchOutboxRetentionStore interface { + DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) +} + +// RetentionWorker 周期性回收存储中的死数据。 +// +// 注意:本 worker 刻意不清理 user_update_events —— pts log 永久保留。原因:TDesktop 不支持 +// 账号级 updates.differenceTooLong(api_updates.cpp 收到该响应只打一行日志,且漏掉 +// setRequesting(false),会永久锁死整个 update 引擎),服务端因此无法让"落后超过保留期"的 +// 客户端整库重置;一旦裁剪 events,落后客户端的 getDifference 会拿到不完整的事件链而静默 +// 丢消息。详见 docs/performance-audit.md 与 docs/compatibility-matrix.md。user_update_events +// 长期膨胀作为已知 todo。 +type RetentionWorker struct { + outbox DispatchOutboxRetentionStore + 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 { + if logger == nil { + logger = zap.NewNop() + } + if retention <= 0 { + retention = 168 * time.Hour + } + if interval <= 0 { + interval = time.Hour + } + if batch <= 0 { + batch = 10000 + } + return &RetentionWorker{ + outbox: outbox, + logger: logger, + retention: retention, + interval: interval, + batch: batch, + } +} + +func (w *RetentionWorker) 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 *RetentionWorker) runOnce(ctx context.Context) { + outboxDeleted, err := w.outbox.DeleteFailed(ctx, w.retention, w.batch) + if err != nil { + w.logger.Warn("清理 failed dispatch_outbox 失败", zap.Error(err)) + } else if outboxDeleted > 0 { + w.logger.Info("清理 failed dispatch_outbox 完成", zap.Int("deleted", outboxDeleted)) + } +} diff --git a/internal/app/messages/doc.go b/internal/app/messages/doc.go new file mode 100644 index 00000000..462e45f2 --- /dev/null +++ b/internal/app/messages/doc.go @@ -0,0 +1,3 @@ +// Package messages 是消息应用服务:发消息、编辑、删除、已读、历史记录。 +// 第一阶段先实现官方系统会话所需的历史、搜索和已读最小闭环;第二阶段扩展私聊发送与推送。 +package messages diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go new file mode 100644 index 00000000..0193d2ef --- /dev/null +++ b/internal/app/messages/service.go @@ -0,0 +1,180 @@ +package messages + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供消息历史、搜索与已读业务。 +type Service struct { + messages store.MessageStore + dialogs store.DialogStore +} + +// NewService 创建 messages 服务。 +func NewService(messages store.MessageStore, dialogs store.DialogStore) *Service { + return &Service{messages: messages, dialogs: dialogs} +} + +// SendPrivateText 发送一条私聊文本消息。 +func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.SendPrivateTextResult{}, nil + } + if req.SenderUserID == 0 { + req.SenderUserID = userID + } + return s.messages.SendPrivateText(ctx, req) +} + +// ForwardPrivateMessages 转发当前账号可见的私聊文本消息。 +func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.ForwardPrivateMessagesResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.ForwardPrivateMessages(ctx, req) +} + +// GetMessages returns exact owner-visible message boxes in request order. +func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) { + if s == nil || s.messages == nil || userID == 0 || len(ids) == 0 { + return domain.MessageList{}, nil + } + return s.messages.GetByIDs(ctx, userID, ids) +} + +// GetHistory 返回当前账号某个 peer 的历史消息。 +func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + return s.list(ctx, userID, filter) +} + +// Search 返回当前账号消息搜索结果。Query 为空时等价于历史列表过滤。 +func (s *Service) Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + return s.list(ctx, userID, filter) +} + +// ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。 +func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { + if s == nil || userID == 0 { + return domain.ReadHistoryResult{Peer: req.Peer, MaxID: req.MaxID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if s.messages != nil { + return s.messages.ReadHistory(ctx, req) + } + if s.dialogs != nil { + return s.dialogs.MarkRead(ctx, userID, req.Peer, req.MaxID) + } + return domain.ReadHistoryResult{OwnerUserID: userID, Peer: req.Peer, MaxID: req.MaxID}, nil +} + +// ReadMessageContents checks exact owner-visible private message IDs for content-read sync. +func (s *Service) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + res := domain.ReadMessageContentsResult{OwnerUserID: userID} + if s == nil || s.messages == nil || userID == 0 { + return res, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID || len(req.IDs) > domain.MaxGetMessageIDs { + return res, domain.ErrMessageIDInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + } + return s.messages.ReadMessageContents(ctx, req) +} + +// GetOutboxReadDate 返回当前账号某条 outgoing 私聊消息被对端读到的时间。 +func (s *Service) GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error) { + if s == nil || s.messages == nil || userID == 0 { + return 0, domain.ErrMessageIDInvalid + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.GetOutboxReadDate(ctx, req) +} + +// SetMessageReactions replaces the current user's reactions on a visible private message. +func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PrivateMessageReactionsResult{}, 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.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + return s.messages.SetMessageReactions(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 { + return domain.PrivateMessageReactionsResult{}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + } + return s.messages.GetMessageReactions(ctx, req) +} + +// EditMessage 编辑当前账号发出的私聊文本消息。 +func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.EditMessageResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.EditMessage(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 { + return domain.DeleteMessagesResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.DeleteMessages(ctx, req) +} + +// DeleteHistory 清空当前账号与某个 peer 的历史;revoke 时同步删除对端私聊盒子。 +func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.DeleteMessagesResult{OwnerUserID: userID}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.DeleteHistory(ctx, req) +} + +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 + } + return s.messages.ListByUser(ctx, userID, filter) +} diff --git a/internal/app/updates/contiguous_test.go b/internal/app/updates/contiguous_test.go new file mode 100644 index 00000000..0cb82824 --- /dev/null +++ b/internal/app/updates/contiguous_test.go @@ -0,0 +1,154 @@ +package updates + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts int) { + t.Helper() + if err := events.Append(context.Background(), userID, domain.UpdateEvent{ + Type: domain.UpdateEventNewMessage, + Pts: pts, + PtsCount: 1, + Date: 1700000000 + pts, + Message: domain.Message{ + ID: pts, + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + }, + }); err != nil { + t.Fatalf("seed event pts=%d: %v", pts, err) + } +} + +// TestGetDifferenceStopsAtHole:getDifference 只返回从 from 起连续的事件,遇在途空洞即截断, +// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。 +func TestGetDifferenceStopsAtHole(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + userID := int64(1000000001) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + + // pts 1,2,3 已提交,4 在途(缺),5,6 已提交 → 连续只到 3。 + for _, p := range []int{1, 2, 3, 5, 6} { + seedEvent(t, events, userID, p) + } + diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 3 || diff.State.Pts != 3 { + t.Fatalf("diff = %d events, state.pts %d; want 3 连续事件、止于空洞(pts=3)", len(diff.Events), diff.State.Pts) + } + if diff.Partial { + t.Fatalf("Partial=true,want false(被空洞而非 limit 截断)") + } + + // 补上 pts=4(在途事务提交)后,从 3 继续应拿到 4,5,6。 + seedEvent(t, events, userID, 4) + diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 3}) + if err != nil { + 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) + } +} + +// TestGetDifferenceSliceOnLimit:连续事件填满 limit 时返回 Partial(=differenceSlice), +// State.Pts 为中间态;客户端据此续拉,最终一页 Partial=false。 +func TestGetDifferenceSliceOnLimit(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + userID := int64(1000000002) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + + total := getDifferenceLimit + 25 + for p := 1; p <= total; p++ { + seedEvent(t, events, userID, p) + } + diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != getDifferenceLimit || !diff.Partial || diff.State.Pts != getDifferenceLimit { + t.Fatalf("第一页 = %d events partial %v state.pts %d; want %d/true/%d", + len(diff.Events), diff.Partial, diff.State.Pts, getDifferenceLimit, getDifferenceLimit) + } + + diff, err = svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: getDifferenceLimit}) + if err != nil { + t.Fatalf("GetDifference page2: %v", err) + } + if len(diff.Events) != 25 || diff.Partial || diff.State.Pts != total { + t.Fatalf("第二页 = %d events partial %v state.pts %d; want 25/false/%d", + len(diff.Events), diff.Partial, diff.State.Pts, total) + } +} + +// TestGetStateReportsContiguousNotMax:getState 报告最大连续 pts,而非最大已提交 pts, +// 避免首次登录基线越过在途空洞而丢消息。 +func TestGetStateReportsContiguousNotMax(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + userID := int64(1000000003) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + + for _, p := range []int{1, 2, 3, 5, 6} { // 4 在途空洞,最大已提交=6 + seedEvent(t, events, userID, p) + } + st, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState: %v", err) + } + if st.Pts != 3 { + t.Fatalf("GetState.Pts=%d, want 3(最大连续,而非最大已提交 6)", st.Pts) + } +} + +func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 7 + userID := int64(1000000004) + events := memory.NewUpdateEventStore() + states := memory.NewUpdateStateStore() + svc := NewService(states, events) + + seedEvent(t, events, userID, 1) + if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil { + t.Fatalf("Save state: %v", err) + } + seedEvent(t, events, userID, 2) + + st, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState: %v", err) + } + if st.Pts != 1 { + t.Fatalf("GetState.Pts=%d, want existing confirmed pts=1", st.Pts) + } + + diff, err := svc.GetDifference(ctx, authKeyID, userID, st) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 1 || diff.Events[0].Pts != 2 || diff.State.Pts != 2 { + t.Fatalf("diff = %+v, want one event at pts=2 and confirmed state pts=2", diff) + } + st, err = svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState after difference: %v", err) + } + if st.Pts != 2 { + t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts) + } +} diff --git a/internal/app/updates/doc.go b/internal/app/updates/doc.go new file mode 100644 index 00000000..8c509642 --- /dev/null +++ b/internal/app/updates/doc.go @@ -0,0 +1,3 @@ +// Package updates 是更新状态机与投递:user 级 pts/qts/seq/date、离线差量(updates.getDifference)、 +// 在线推送。第一阶段持久化 auth_key 维度的初始空状态,消息事件队列留第二阶段。 +package updates diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go new file mode 100644 index 00000000..7ef72763 --- /dev/null +++ b/internal/app/updates/service.go @@ -0,0 +1,465 @@ +package updates + +import ( + "context" + "sort" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供 update 状态查询。 +type Service struct { + states store.UpdateStateStore + events store.UpdateEventStore + pts store.PtsAllocator +} + +type dispatchingEventAppender interface { + AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error +} + +// ServiceOption 调整 updates 服务的运行时依赖。 +type ServiceOption func(*Service) + +// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。 +func WithPtsAllocator(pts store.PtsAllocator) ServiceOption { + return func(s *Service) { + s.pts = pts + } +} + +// NewService 创建 updates 服务。 +func NewService(states store.UpdateStateStore, events store.UpdateEventStore, opts ...ServiceOption) *Service { + s := &Service{states: states} + s.events = events + for _, opt := range opts { + opt(s) + } + return s +} + +// UsesReliableDispatch 表示设置类 update 已写入 transactional outbox,由 outbox worker 投递在线 session。 +func (s *Service) UsesReliableDispatch() bool { + if s == nil || s.events == nil { + return false + } + _, ok := s.events.(dispatchingEventAppender) + return ok +} + +// GetState 返回当前 auth_key + user 维度已确认的 update 状态。 +// user_update_events 是账号级 durable log;auth_key 维度只保存设备已经通过 +// getDifference 确认到的状态,不能在 getState 中直接推进到账号最新水位。 +func (s *Service) GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { + now := int(time.Now().Unix()) + // 私聊阶段不维护账号级 seq:对外 UpdateState.Seq 恒为 0,客户端仅靠 pts 同步、 + // 跳过 seq gap 检测(推送信封 seq 同样恒 0)。 + if s.states == nil { + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + return domain.UpdateState{Pts: current, Date: now, Seq: 0}, nil + } + st, found, err := s.states.Get(ctx, authKeyID, userID) + if err != nil { + return domain.UpdateState{}, err + } + if found { + st.Seq = 0 + if st.Date == 0 { + st.Date = now + } + return st, nil + } + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + st = domain.UpdateState{Pts: current, Date: now, Seq: 0} + if err := s.states.Save(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateState{}, err + } + return st, nil +} + +// CurrentState 返回账号当前最大连续 update 状态,不修改任何设备已确认水位。 +func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) { + return s.currentState(ctx, userID) +} + +// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。 +const getDifferenceLimit = 100 + +// GetDifference 返回当前 user 从 from 状态之后的增量事件。 +// +// 对齐 MTProto:只返回从 from.Pts 起「连续」的事件(遇空洞即截断),State.Pts 取最后连续值, +// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。 +// 连续事件填满 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) + if err != nil { + return domain.UpdateDifference{}, err + } + if s.events == nil || from.Pts >= st.Pts { + if from.Date != 0 { + st.Date = from.Date + } + if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateDifference{}, err + } + return domain.UpdateDifference{State: st}, nil + } + events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit) + if err != nil { + return domain.UpdateDifference{}, err + } + contiguous := contiguousPrefix(events, from.Pts) + last := from.Pts + if len(contiguous) > 0 { + last = contiguous[len(contiguous)-1].Pts + } + out := st + out.Pts = last + out.Seq = 0 // seq 恒 0,见 GetState 注释 + if len(contiguous) > 0 { + out.Date = contiguous[len(contiguous)-1].Date + } + if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil { + return domain.UpdateDifference{}, err + } + return domain.UpdateDifference{ + State: out, + Events: contiguous, + Partial: len(contiguous) == getDifferenceLimit, + }, nil +} + +func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) { + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + return domain.UpdateState{ + Pts: current, + Date: int(time.Now().Unix()), + Seq: 0, + }, nil +} + +func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState) error { + if s.states == nil { + return nil + } + st.Seq = 0 + return s.states.Save(ctx, authKeyID, userID, st) +} + +// contiguousPrefix 返回从 from 起 pts 严格连续(from+1, from+2, ...)的事件前缀。 +// 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。 +func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent { + if len(events) == 0 { + return nil + } + sorted := make([]domain.UpdateEvent, len(events)) + copy(sorted, events) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Pts < sorted[j].Pts }) + cursor := from + out := make([]domain.UpdateEvent, 0, len(sorted)) + for _, event := range sorted { + ptsCount := event.PtsCount + if ptsCount <= 0 { + ptsCount = 1 + } + if event.Pts != cursor+ptsCount { + break + } + out = append(out, event) + cursor = event.Pts + } + return out +} + +// ClearAuthKey 清理某 auth_key 的设备状态。 +// user_update_events 是账号级 durable log,不能因设备退出登录被删除。 +func (s *Service) ClearAuthKey(ctx context.Context, authKeyID [8]byte) error { + if s.states != nil { + if err := s.states.DeleteAuthKey(ctx, authKeyID); err != nil { + return err + } + } + return nil +} + +// RecordNewMessage 推进 update 状态并追加一条 new_message 事件。 +func (s *Service) RecordNewMessage(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.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventNewMessage, + Date: date, + Message: msg, + PtsCount: 1, + }, false, 0) +} + +// RecordMessageReactions records a durable marker for message reaction changes. +// +// updateMessageReactions has no pts fields in Layer 225, but TDesktop still +// needs getDifference to advance account pts and carry the latest reaction +// aggregate for offline devices. +func (s *Service) RecordMessageReactions(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.UpdateEventMessageReactions, + Date: date, + Message: msg, + Peer: msg.Peer, + PtsCount: 1, + }) +} + +// 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 { + userID = read.OwnerUserID + } + date := int(time.Now().Unix()) + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Date: date, + Peer: read.Peer, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + 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{ + Type: domain.UpdateEventContactsReset, + PtsCount: 1, + }, 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) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogPinned, + 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) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventPinnedDialogs, + Peers: append([]domain.Peer(nil), order...), + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogUnreadMark 记录手动未读标记变化。 +func (s *Service) RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogUnreadMark, + Peer: peer, + Bool: unread, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordChannelViewForumAsMessages records a per-account forum presentation state change. +func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + Bool: enabled, + 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{ + Type: domain.UpdateEventPeerSettings, + Peer: peer, + Settings: settings, + 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 + if folder != nil { + f := *folder + copyFolder = &f + } + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilter, + FilterID: folderID, + DialogFilter: copyFolder, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogFilterOrder 记录 filter 顺序变化。 +func (s *Service) RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilterOrder, + FilterOrder: append([]int(nil), order...), + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogFiltersReload 通知其他设备重新拉取 filter 列表。 +func (s *Service) RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilters, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordFolderPeers 记录归档/还原会话的 folder_id 变化。 +func (s *Service) RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventFolderPeers, + FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...), + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordChannelAvailableMessages records a local channel history clear for multi-device sync. +func (s *Service) RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelAvailable, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + MaxID: availableMinID, + PtsCount: 1, + }, true, excludeSessionID) +} + +func (s *Service) recordEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEventCore(ctx, authKeyID, userID, event, dispatch, excludeSessionID, true) +} + +func (s *Service) recordEventWithoutState(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEventCore(ctx, [8]byte{}, userID, event, false, 0, false) +} + +func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64, saveState bool) (domain.UpdateEvent, domain.UpdateState, error) { + date := event.Date + if date == 0 { + date = int(time.Now().Unix()) + } + 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 + if s.events != nil { + var err error + if dispatch { + if appender, ok := s.events.(dispatchingEventAppender); ok { + err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID) + } else { + err = s.events.Append(ctx, userID, event) + } + } else { + err = s.events.Append(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, + }) + } + return domain.UpdateEvent{}, domain.UpdateState{}, err + } + } + if saveState && s.states != nil { + if err := s.states.Save(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateEvent{}, domain.UpdateState{}, err + } + } + return event, st, nil +} + +// currentPts 供 GetState 报告「当前 pts」。对齐 MTProto:报告最大连续已提交 pts, +// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件, +// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。 +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 new file mode 100644 index 00000000..051f37a5 --- /dev/null +++ b/internal/app/updates/service_test.go @@ -0,0 +1,252 @@ +package updates + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestRecordNewMessageFeedsGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 1 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000000, + Body: "Login code: 12345", + } + + event, state, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg) + if err != nil { + t.Fatalf("RecordNewMessage: %v", err) + } + if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 || state.Seq != 0 { + t.Fatalf("event/state = %+v / %+v, want first pts event with seq=0", event, state) + } + + diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Message.ID != msg.ID { + t.Fatalf("diff = %+v, want recorded login message event and state %+v", diff, state) + } + + diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, state) + if err != nil { + t.Fatalf("GetDifference current: %v", err) + } + if len(diff.Events) != 0 || diff.State != state { + t.Fatalf("current diff = %+v, want empty events and same state", diff) + } +} + +func TestRecordReadHistoryFeedsGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 2 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + + ownerUserID := int64(1000000001) + event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{ + OwnerUserID: ownerUserID, + Peer: peer, + MaxID: 10, + Changed: true, + }, 0) + if err != nil { + t.Fatalf("RecordReadHistory: %v", err) + } + if event.Type != domain.UpdateEventReadHistoryInbox || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 { + t.Fatalf("event/state = %+v / %+v, want read history event with first pts", event, state) + } + + diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State != state || len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].MaxID != 10 { + t.Fatalf("diff = %+v, want recorded read history event and state %+v", diff, state) + } +} + +func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 3 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + ownerUserID := int64(1000000001) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + 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 { + t.Fatalf("RecordDialogPinned: %v", err) + } + order := []domain.Peer{peer} + if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, 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 { + t.Fatalf("RecordPeerSettings: %v", err) + } + if stateEvent.Pts != 5 || state.Pts != 5 { + t.Fatalf("last event/state = %+v / %+v, want pts=5", 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) + } + wantTypes := []domain.UpdateEventType{ + domain.UpdateEventContactsReset, + domain.UpdateEventDialogPinned, + domain.UpdateEventPinnedDialogs, + domain.UpdateEventDialogUnreadMark, + domain.UpdateEventPeerSettings, + } + for i, typ := range wantTypes { + if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 { + t.Fatalf("event[%d] = %+v, want type=%s pts=%d pts_count=1", i, diff.Events[i], typ, i+1) + } + } + if diff.Events[1].Peer != peer || !diff.Events[1].Bool { + t.Fatalf("dialog pinned event = %+v, want peer and pinned=true", diff.Events[1]) + } + if diff.Events[3].Peer != peer || diff.Events[3].Bool { + t.Fatalf("unread mark event = %+v, want peer and unread=false", diff.Events[3]) + } + if len(diff.Events[2].Peers) != 1 || diff.Events[2].Peers[0] != peer { + t.Fatalf("pinned dialogs event = %+v, want order peer", diff.Events[2]) + } + if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact { + t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4]) + } +} + +func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 4 + events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(memory.NewUpdateStateStore(), events) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42) + if err != nil { + t.Fatalf("RecordDialogPinned: %v", err) + } + if event.Pts != 1 || state.Pts != 1 { + t.Fatalf("event/state = %+v / %+v, want first pts", event, state) + } + if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 42 || events.event.Type != domain.UpdateEventDialogPinned || events.event.Peer != peer { + t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want dialog_pinned outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched) + } +} + +func TestClearAuthKeyDropsStateAndEvents(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 8 + states := memory.NewUpdateStateStore() + events := memory.NewUpdateEventStore() + svc := NewService(states, events) + 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: 1700000000, + } + if _, _, err := svc.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg); err != nil { + t.Fatalf("RecordNewMessage: %v", err) + } + if err := svc.ClearAuthKey(ctx, authKeyID); err != nil { + t.Fatalf("ClearAuthKey: %v", err) + } + diff, err := svc.GetDifference(ctx, authKeyID, msg.OwnerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 1 || len(diff.Events) != 1 { + t.Fatalf("difference after clear = %+v, want durable user events to remain", diff) + } + diff, err = svc.GetDifference(ctx, authKeyID, msg.OwnerUserID+1, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference other user: %v", err) + } + if diff.State.Pts != 0 || len(diff.Events) != 0 { + t.Fatalf("difference for other user after clear = %+v, want no cross-account events", diff) + } +} + +func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 9 + userID := int64(1000000001) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + for _, event := range []domain.UpdateEvent{ + {UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID}}, + {UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 2, PtsCount: 1, Date: 1700000002, Message: domain.Message{ID: 2, OwnerUserID: userID}}, + {UserID: userID, Type: domain.UpdateEventDeleteMessages, Pts: 4, PtsCount: 2, Date: 1700000003, MessageIDs: []int{1, 2}}, + } { + if err := events.Append(ctx, userID, event); err != nil { + t.Fatalf("append event pts=%d: %v", event.Pts, err) + } + } + + state, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState: %v", err) + } + if state.Pts != 4 { + t.Fatalf("state = %+v, want contiguous pts=4 across delete range", state) + } + diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 4 || len(diff.Events) != 1 { + t.Fatalf("diff = %+v, want one delete event ending at pts=4", diff) + } + got := diff.Events[0] + if got.Type != domain.UpdateEventDeleteMessages || got.Pts != 4 || got.PtsCount != 2 || len(got.MessageIDs) != 2 { + t.Fatalf("delete event = %+v, want pts=4 pts_count=2 ids", got) + } +} + +type captureDispatchAppender struct { + *memory.UpdateEventStore + dispatched bool + userID int64 + event domain.UpdateEvent + excludeAuthKeyID [8]byte + excludeSessionID int64 +} + +func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error { + s.dispatched = true + s.userID = userID + s.event = event + s.excludeAuthKeyID = excludeAuthKeyID + s.excludeSessionID = excludeSessionID + return s.UpdateEventStore.Append(ctx, userID, event) +} diff --git a/internal/app/users/doc.go b/internal/app/users/doc.go new file mode 100644 index 00000000..d546a099 --- /dev/null +++ b/internal/app/users/doc.go @@ -0,0 +1,3 @@ +// Package users 是用户应用服务:用户资料、用户名、头像、在线状态。 +// 第一阶段先支持 users.getUsers 返回自身。 +package users diff --git a/internal/app/users/service.go b/internal/app/users/service.go new file mode 100644 index 00000000..06f2f7ca --- /dev/null +++ b/internal/app/users/service.go @@ -0,0 +1,313 @@ +package users + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// ErrNotAuthorized 表示当前 auth_key 尚未登录。 +var ErrNotAuthorized = errors.New("not authorized") + +// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。 +type ProfilePhotoProvider interface { + CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) +} + +// Service 提供用户查询。 +type Service struct { + users store.UserStore + photos ProfilePhotoProvider +} + +// Option 调整用户服务可选依赖。 +type Option func(*Service) + +// WithPhotoProvider 注入头像富化能力(缺省则用户不带头像)。 +func WithPhotoProvider(p ProfilePhotoProvider) Option { + return func(s *Service) { s.photos = p } +} + +const ( + minUsernameLen = 5 + maxUsernameLen = 32 + maxProfileNameRunes = 64 + maxProfileAboutRunes = 70 + maxBatchUsers = 1000 +) + +// NewService 创建用户服务。 +func NewService(users store.UserStore, opts ...Option) *Service { + s := &Service{users: users} + for _, opt := range opts { + opt(s) + } + return s +} + +// loadSelf 加载当前用户但不富化头像(供内部校验路径使用,避免无谓的头像查询)。 +func (s *Service) loadSelf(ctx context.Context, userID int64) (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{}, ErrNotAuthorized + } + return u, nil +} + +// Self 返回当前登录的用户(带头像)。未登录返回 ErrNotAuthorized。 +func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) { + u, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + return s.enrichOne(ctx, u), nil +} + +// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。 +func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) { + if currentUserID == 0 { + return domain.User{}, false, ErrNotAuthorized + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, false, err + } + if !found { + return u, false, nil + } + return s.enrichOne(ctx, u), true, nil +} + +// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。 +func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { + if currentUserID == 0 { + return nil, ErrNotAuthorized + } + if len(userIDs) == 0 { + return nil, nil + } + ids := 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{}{} + ids = append(ids, id) + if len(ids) >= maxBatchUsers { + break + } + } + users, err := s.users.ByIDs(ctx, ids) + if err != nil { + return nil, err + } + return s.enrich(ctx, users), nil +} + +// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。 +func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User { + if s.photos == nil || len(users) == 0 { + return users + } + ids := make([]int64, 0, len(users)) + for _, u := range users { + if u.ID != 0 { + ids = append(ids, u.ID) + } + } + refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) + if err != nil { + return users + } + for i := range users { + if ref, ok := refs[users[i].ID]; ok { + users[i].PhotoID = ref.PhotoID + users[i].PhotoDCID = ref.DCID + users[i].PhotoStripped = ref.Stripped + } + } + return users +} + +func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User { + enriched := s.enrich(ctx, []domain.User{u}) + return enriched[0] +} + +// CheckUsername 校验当前用户是否可以占用 username。 +func (s *Service) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return false, err + } + username = normalizeUsername(username) + if !validUsername(username) { + return false, domain.ErrUsernameInvalid + } + u, found, err := s.users.ByUsername(ctx, username) + if err != nil { + return false, err + } + return !found || u.ID == self.ID, nil +} + +// UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。 +func (s *Service) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + username = normalizeUsername(username) + if username != "" { + if !validUsername(username) { + return domain.User{}, domain.ErrUsernameInvalid + } + u, found, err := s.users.ByUsername(ctx, username) + if err != nil { + return domain.User{}, err + } + if found && u.ID != self.ID { + return domain.User{}, domain.ErrUsernameOccupied + } + } + if self.Username == username { + return self, nil + } + u, err := s.users.UpdateUsername(ctx, self.ID, username) + if err != nil { + return domain.User{}, err + } + return u, nil +} + +// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。 +func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + firstName := self.FirstName + lastName := self.LastName + about := self.About + if update.HasFirstName { + firstName = strings.TrimSpace(update.FirstName) + } + if update.HasLastName { + lastName = strings.TrimSpace(update.LastName) + } + if update.HasAbout { + about = strings.TrimSpace(update.About) + } + if firstName == "" || utf8.RuneCountInString(firstName) > maxProfileNameRunes || utf8.RuneCountInString(lastName) > maxProfileNameRunes { + return domain.User{}, domain.ErrFirstNameInvalid + } + if utf8.RuneCountInString(about) > maxProfileAboutRunes { + 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) +} + +// UpdateLastSeen records the latest visible account activity time. +func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error { + if userID == 0 { + return ErrNotAuthorized + } + if lastSeenAt <= 0 { + return nil + } + return s.users.UpdateLastSeen(ctx, userID, lastSeenAt) +} + +// ResolveUsername 解析 username 到用户;调用方必须已登录。 +func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) { + if _, err := s.loadSelf(ctx, currentUserID); err != nil { + return domain.User{}, false, err + } + username = normalizeUsername(username) + if !validUsername(username) { + return domain.User{}, false, domain.ErrUsernameInvalid + } + u, found, err := s.users.ByUsername(ctx, username) + if err != nil || !found { + return u, found, err + } + return s.enrichOne(ctx, u), true, nil +} + +// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。 +func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) { + if _, err := s.loadSelf(ctx, currentUserID); err != nil { + return domain.User{}, false, err + } + phone = normalizePhone(phone) + if phone == "" { + return domain.User{}, false, domain.ErrPhoneNotOccupied + } + u, found, err := s.users.ByPhone(ctx, phone) + if err != nil || !found { + return u, found, err + } + return s.enrichOne(ctx, u), true, nil +} + +func normalizeUsername(username string) string { + username = strings.TrimSpace(username) + username = strings.TrimPrefix(username, "@") + return strings.TrimSpace(username) +} + +func validUsername(username string) bool { + if len(username) < minUsernameLen || len(username) > maxUsernameLen { + return false + } + for i := 0; i < len(username); i++ { + c := username[i] + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + if i == 0 { + return false + } + case c == '_': + if i == 0 { + return false + } + default: + return false + } + } + return true +} + +func normalizePhone(phone string) string { + phone = strings.TrimSpace(phone) + if phone == "" { + return "" + } + var b strings.Builder + b.Grow(len(phone)) + for _, r := range phone { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go new file mode 100644 index 00000000..17a69ae6 --- /dev/null +++ b/internal/app/users/service_test.go @@ -0,0 +1,130 @@ +package users + +import ( + "context" + "errors" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestServiceUsernameLifecycle(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + svc := NewService(store) + + if ok, err := svc.CheckUsername(ctx, owner.ID, "123bad"); err == nil || ok || !errors.Is(err, domain.ErrUsernameInvalid) { + t.Fatalf("CheckUsername invalid = ok %v err %v, want username invalid", ok, err) + } + if ok, err := svc.CheckUsername(ctx, owner.ID, "taken_name"); err != nil || ok { + t.Fatalf("CheckUsername occupied = ok %v err %v, want false/nil", ok, err) + } + if ok, err := svc.CheckUsername(ctx, owner.ID, "owner_name"); err != nil || !ok { + t.Fatalf("CheckUsername available = ok %v err %v, want true/nil", ok, err) + } + + updated, err := svc.UpdateUsername(ctx, owner.ID, "@Owner_Name") + if err != nil { + t.Fatalf("UpdateUsername: %v", err) + } + if updated.Username != "Owner_Name" { + t.Fatalf("updated username = %q, want Owner_Name", updated.Username) + } + resolved, found, err := svc.ResolveUsername(ctx, other.ID, "owner_name") + if err != nil || !found || resolved.ID != owner.ID { + t.Fatalf("ResolveUsername = user %+v found %v err %v, want owner", resolved, found, err) + } + if _, err := svc.UpdateUsername(ctx, owner.ID, "TAKEN_NAME"); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("UpdateUsername duplicate err = %v, want username occupied", err) + } + phoneUser, found, err := svc.ResolvePhone(ctx, owner.ID, "+1 (555) 000-0002") + if err != nil || !found || phoneUser.ID != other.ID { + t.Fatalf("ResolvePhone = user %+v found %v err %v, want other", phoneUser, found, err) + } + cleared, err := svc.UpdateUsername(ctx, owner.ID, "") + if err != nil { + t.Fatalf("clear username: %v", err) + } + if cleared.Username != "" { + t.Fatalf("cleared username = %q, want empty", cleared.Username) + } +} + +func TestServiceUpdateProfile(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner", LastName: "Old"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + svc := NewService(store) + + updated, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{ + FirstName: " New ", + HasFirstName: true, + LastName: "Name", + HasLastName: true, + About: "bio", + HasAbout: true, + }) + if err != nil { + t.Fatalf("UpdateProfile: %v", err) + } + if updated.FirstName != "New" || updated.LastName != "Name" || updated.About != "bio" { + t.Fatalf("updated profile = %+v, want trimmed names and about", updated) + } + if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{FirstName: " ", HasFirstName: true}); !errors.Is(err, domain.ErrFirstNameInvalid) { + t.Fatalf("empty first name err = %v, want first name invalid", err) + } + if _, err := svc.UpdateProfile(ctx, owner.ID, domain.UserProfileUpdate{About: strings.Repeat("x", 71), HasAbout: true}); !errors.Is(err, domain.ErrAboutTooLong) { + t.Fatalf("long about err = %v, want about too long", err) + } +} + +func TestServiceByIDDoesNotReloadSelf(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: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + store := &countingUserStore{UserStore: base} + svc := NewService(store) + + got, found, err := svc.ByID(ctx, owner.ID, target.ID) + 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.lastByID != target.ID { + t.Fatalf("last ByID id = %d, want target %d", store.lastByID, target.ID) + } +} + +type countingUserStore struct { + *memory.UserStore + byIDCalls int + lastByID int64 +} + +func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { + s.byIDCalls++ + s.lastByID = id + return s.UserStore.ByID(ctx, id) +} diff --git a/internal/compat/tdesktop/config.go b/internal/compat/tdesktop/config.go new file mode 100644 index 00000000..5925c6b4 --- /dev/null +++ b/internal/compat/tdesktop/config.go @@ -0,0 +1,56 @@ +package tdesktop + +import ( + "time" + + "github.com/gotd/td/tg" +) + +// BuildConfig 构造 help.getConfig 返回的 tg.Config,含自建 DC 的 DCOptions。 +// +// 字段值取 Telegram 常见默认;TDesktop 联调阶段按客户端实际需要微调 +// (记录于 docs/compatibility-matrix.md)。 +func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config { + return &tg.Config{ + Date: int(now.Unix()), + Expires: int(now.Add(time.Hour).Unix()), + TestMode: false, + ThisDC: dc, + DCOptions: []tg.DCOption{ + {ID: dc, IPAddress: ip, Port: port, Static: true}, + }, + ChatSizeMax: 200, + MegagroupSizeMax: 200000, + ForwardedCountMax: 100, + OnlineUpdatePeriodMs: 120000, + OfflineBlurTimeoutMs: 5000, + OfflineIdleTimeoutMs: 30000, + OnlineCloudTimeoutMs: 300000, + NotifyCloudDelayMs: 30000, + NotifyDefaultDelayMs: 1500, + PushChatPeriodMs: 60000, + PushChatLimit: 2, + EditTimeLimit: 172800, + RevokeTimeLimit: 172800, + RevokePmTimeLimit: 172800, + RatingEDecay: 2419200, + StickersRecentLimit: 200, + CallReceiveTimeoutMs: 20000, + CallRingTimeoutMs: 90000, + CallConnectTimeoutMs: 30000, + CallPacketTimeoutMs: 10000, + MeURLPrefix: "https://t.me/", + CaptionLengthMax: 1024, + MessageLengthMax: 4096, + WebfileDCID: dc, + } +} + +// NearestDC 构造 help.getNearestDc 返回值。 +func NearestDC(dc int) *tg.NearestDC { + return &tg.NearestDC{ + Country: "US", + ThisDC: dc, + NearestDC: dc, + } +} diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go new file mode 100644 index 00000000..90fc6012 --- /dev/null +++ b/internal/compat/tdesktop/defaults.go @@ -0,0 +1,96 @@ +package tdesktop + +import ( + "crypto/sha256" + "encoding/binary" + "time" + + "github.com/gotd/td/tg" +) + +const ( + appConfigHash = 4 + countriesListHash = 1 + timezonesListHash = 1 +) + +// AppConfig returns the fallback TDesktop startup app config used when HelpService is absent. +func AppConfig(hash int) tg.HelpAppConfigClass { + if hash == appConfigHash { + return &tg.HelpAppConfigNotModified{} + } + return &tg.HelpAppConfig{ + Hash: appConfigHash, + Config: readMarkAppConfig(), + } +} + +func readMarkAppConfig() *tg.JSONObject { + return &tg.JSONObject{Value: []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"}}, + }} +} + +// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading. +func TimezonesList(hash int) tg.HelpTimezonesListClass { + if hash == timezonesListHash { + 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}, + }, + } +} + +// CountriesList returns the fallback login country list used when HelpService is absent. +func CountriesList(hash int) tg.HelpCountriesListClass { + 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{"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[:], + } +} diff --git a/internal/compat/tdesktop/doc.go b/internal/compat/tdesktop/doc.go new file mode 100644 index 00000000..800d5ca4 --- /dev/null +++ b/internal/compat/tdesktop/doc.go @@ -0,0 +1,8 @@ +// Package tdesktop 集中存放 Telegram Desktop 兼容逻辑:目标版本/layer 记录、客户端 patch 说明、 +// 启动 RPC 顺序、兼容矩阵辅助、TDesktop 专属 stub 与 feature flags。 +// +// 兼容代码只允许出现在本包,禁止散落进业务 handler。后续 Android/iOS 另开 internal/compat/android|ios。 +// 当前基线:TDesktop dev 9caf32dffc(v6.8.4+15),Layer 225。 +// +// TDesktop 兼容逻辑集中在这里,避免散落到业务服务中。 +package tdesktop diff --git a/internal/compat/tdesktop/startup_stubs.go b/internal/compat/tdesktop/startup_stubs.go new file mode 100644 index 00000000..338dd1e9 --- /dev/null +++ b/internal/compat/tdesktop/startup_stubs.go @@ -0,0 +1,278 @@ +package tdesktop + +import ( + "time" + + "github.com/gotd/td/tg" +) + +// NotifySettings returns default per-peer notification settings for empty first-phase accounts. +func NotifySettings() *tg.PeerNotifySettings { + settings := &tg.PeerNotifySettings{} + settings.SetShowPreviews(true) + settings.SetSilent(false) + settings.SetMuteUntil(0) + settings.SetIosSound(&tg.NotificationSoundDefault{}) + settings.SetAndroidSound(&tg.NotificationSoundDefault{}) + settings.SetOtherSound(&tg.NotificationSoundDefault{}) + settings.SetStoriesMuted(false) + settings.SetStoriesHideSender(false) + settings.SetStoriesIosSound(&tg.NotificationSoundDefault{}) + settings.SetStoriesAndroidSound(&tg.NotificationSoundDefault{}) + settings.SetStoriesOtherSound(&tg.NotificationSoundDefault{}) + 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) { + case *tg.InputPrivacyKeyPhoneNumber: + rule = &tg.PrivacyValueDisallowAll{} + case *tg.InputPrivacyKeyBirthday: + rule = &tg.PrivacyValueAllowContacts{} + } + return &tg.AccountPrivacyRules{ + Rules: []tg.PrivacyRuleClass{rule}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func Authorizations() *tg.AccountAuthorizations { + return &tg.AccountAuthorizations{Authorizations: []tg.Authorization{}} +} + +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{} +} + +func DefaultEmojiStatuses() tg.AccountEmojiStatusesClass { + return &tg.AccountEmojiStatusesNotModified{} +} + +func CollectibleEmojiStatuses() tg.AccountEmojiStatusesClass { + return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}} +} + +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 + +type defaultReaction struct { + emoticon string + title string +} + +var defaultAvailableReactions = []defaultReaction{ + {emoticon: "\U0001f44d", title: "Thumbs Up"}, + {emoticon: "\u2764\ufe0f", title: "Red Heart"}, + {emoticon: "\U0001f602", title: "Face With Tears of Joy"}, + {emoticon: "\U0001f62e", title: "Face With Open Mouth"}, + {emoticon: "\U0001f622", title: "Crying Face"}, + {emoticon: "\U0001f64f", title: "Folded Hands"}, +} + +// DefaultReactionEmoticons returns the TDesktop-compatible emoji reaction catalog order. +func DefaultReactionEmoticons() []string { + out := make([]string, 0, len(defaultAvailableReactions)) + for _, reaction := range defaultAvailableReactions { + out = append(out, reaction.emoticon) + } + return out +} + +func AvailableReactions(hash int) tg.MessagesAvailableReactionsClass { + if hash == availableReactionsHash { + return &tg.MessagesAvailableReactionsNotModified{} + } + reactions := make([]tg.AvailableReaction, 0, len(defaultAvailableReactions)) + for i, reaction := range defaultAvailableReactions { + reactions = append(reactions, availableReaction(reaction, i)) + } + return &tg.MessagesAvailableReactions{ + Hash: availableReactionsHash, + Reactions: reactions, + } +} + +func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction { + const documentBaseID int64 = 900000000000000000 + doc := func(slot int64) tg.DocumentClass { + return &tg.DocumentEmpty{ID: documentBaseID + int64(index)*10 + slot} + } + return tg.AvailableReaction{ + Reaction: reaction.emoticon, + Title: reaction.title, + StaticIcon: doc(1), + AppearAnimation: doc(2), + SelectAnimation: doc(3), + ActivateAnimation: doc(4), + EffectAnimation: doc(5), + } +} + +func Stickers() tg.MessagesStickersClass { + return &tg.MessagesStickersNotModified{} +} + +func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass { + if req != nil && req.Hash == emptyStickerSetHash { + return &tg.MessagesStickerSetNotModified{} + } + title, shortName := "Telesrv Empty Sticker Set", "telesrv_empty" + if req != nil { + switch set := req.Stickerset.(type) { + case *tg.InputStickerSetAnimatedEmoji: + title, shortName = "Animated Emoji", "AnimatedEmojies" + case *tg.InputStickerSetAnimatedEmojiAnimations: + title, shortName = "Emoji Animations", "EmojiAnimations" + case *tg.InputStickerSetEmojiGenericAnimations: + title, shortName = "Emoji Generic Animations", "EmojiGenericAnimations" + case *tg.InputStickerSetDice: + title, shortName = "Dice Animations", "AnimatedDices" + if set.Emoticon != "" { + shortName = "AnimatedDice" + } + case *tg.InputStickerSetPremiumGifts: + title, shortName = "Premium Gifts", "GiftsPremium" + case *tg.InputStickerSetShortName: + if set.ShortName != "" { + title, shortName = set.ShortName, set.ShortName + } + } + } + return &tg.MessagesStickerSet{ + Set: tg.StickerSet{ + ID: 910000000000000000, + AccessHash: 910000000000000001, + Title: title, + ShortName: shortName, + Count: 0, + Hash: emptyStickerSetHash, + }, + Packs: []tg.StickerPack{}, + Keywords: []tg.StickerKeyword{}, + Documents: []tg.DocumentClass{}, + } +} + +func EmojiGroups() tg.MessagesEmojiGroupsClass { + return &tg.MessagesEmojiGroupsNotModified{} +} + +func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass { + return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}} +} + +func AttachMenuBots() tg.AttachMenuBotsClass { + return &tg.AttachMenuBotsNotModified{} +} + +func QuickReplies() tg.MessagesQuickRepliesClass { + return &tg.MessagesQuickRepliesNotModified{} +} + +func TopPeers() tg.ContactsTopPeersClass { + return &tg.ContactsTopPeersDisabled{} +} + +func BlockedContacts() tg.ContactsBlockedClass { + return &tg.ContactsBlocked{ + Blocked: []tg.PeerBlocked{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func PeerColors() tg.HelpPeerColorsClass { + return &tg.HelpPeerColorsNotModified{} +} + +func PromoData(now time.Time) tg.HelpPromoDataClass { + return &tg.HelpPromoDataEmpty{Expires: int(now.Add(time.Hour).Unix())} +} + +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{} +} + +func PinnedStories() *tg.StoriesStories { + return &tg.StoriesStories{} +} + +func StoryAlbums() tg.StoriesAlbumsClass { + return &tg.StoriesAlbums{Hash: 0, Albums: []tg.StoryAlbum{}} +} + +func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass { + return &tg.PaymentsStarGiftActiveAuctionsNotModified{} +} + +func SavedStarGifts() *tg.PaymentsSavedStarGifts { + return &tg.PaymentsSavedStarGifts{ + Gifts: []tg.SavedStarGift{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func AiComposeTones() tg.AicomposeTonesClass { + return &tg.AicomposeTonesNotModified{} +} + +func WebPage(url string) *tg.MessagesWebPage { + page := &tg.WebPageEmpty{ID: 0} + if url != "" { + page.SetURL(url) + } + return &tg.MessagesWebPage{ + Webpage: page, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/compat/tdesktop/startup_stubs_test.go b/internal/compat/tdesktop/startup_stubs_test.go new file mode 100644 index 00000000..740f198c --- /dev/null +++ b/internal/compat/tdesktop/startup_stubs_test.go @@ -0,0 +1,157 @@ +package tdesktop + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +func TestNotifySettingsDefaultIsAudible(t *testing.T) { + settings := NotifySettings() + if value, ok := settings.GetShowPreviews(); !ok || !value { + t.Fatalf("show_previews = %v ok=%v, want true", value, ok) + } + if value, ok := settings.GetSilent(); !ok || value { + t.Fatalf("silent = %v ok=%v, want explicit false", value, ok) + } + if value, ok := settings.GetMuteUntil(); !ok || value != 0 { + t.Fatalf("mute_until = %d ok=%v, want explicit 0", value, ok) + } + if value, ok := settings.GetOtherSound(); !ok || value == nil { + t.Fatalf("other_sound = %#v ok=%v, want default sound", value, ok) + } +} + +func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) { + got, ok := TimezonesList(0).(*tg.HelpTimezonesList) + if !ok || got.Hash == 0 || len(got.Timezones) == 0 { + t.Fatalf("TimezonesList(0) = %#v, want non-empty modified list", got) + } + if _, ok := TimezonesList(got.Hash).(*tg.HelpTimezonesListNotModified); !ok { + t.Fatalf("TimezonesList(hash) = %#v, want notModified", TimezonesList(got.Hash)) + } +} + +func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) { + got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions) + if !ok { + t.Fatalf("AvailableReactions(0) = %T, want modified list", got) + } + if got.Hash == 0 { + t.Fatal("AvailableReactions(0).Hash = 0, want stable cache hash") + } + if len(got.Reactions) == 0 { + t.Fatal("AvailableReactions(0).Reactions is empty") + } + for i, reaction := range got.Reactions { + if reaction.Reaction == "" { + t.Fatalf("reaction[%d].Reaction is empty", i) + } + if reaction.Title == "" { + t.Fatalf("reaction[%d].Title is empty", i) + } + if reaction.StaticIcon == nil || + reaction.AppearAnimation == nil || + reaction.SelectAnimation == nil || + reaction.ActivateAnimation == nil || + reaction.EffectAnimation == nil { + t.Fatalf("reaction[%d] has nil required document: %#v", i, reaction) + } + if reaction.Inactive || reaction.Premium { + t.Fatalf("reaction[%d] flags = inactive %v premium %v, want active non-premium", i, reaction.Inactive, reaction.Premium) + } + } + if _, ok := AvailableReactions(got.Hash).(*tg.MessagesAvailableReactionsNotModified); !ok { + t.Fatalf("AvailableReactions(hash) = %#v, want notModified", AvailableReactions(got.Hash)) + } +} + +func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) { + got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses) + if !ok { + t.Fatalf("CollectibleEmojiStatuses() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("CollectibleEmojiStatuses().Hash = %d, want 0", got.Hash) + } + if len(got.Statuses) != 0 { + t.Fatalf("CollectibleEmojiStatuses().Statuses length = %d, want 0", len(got.Statuses)) + } +} + +func TestDefaultGroupPhotoEmojisIsEmptyModifiedList(t *testing.T) { + got, ok := DefaultGroupPhotoEmojis().(*tg.EmojiList) + if !ok { + t.Fatalf("DefaultGroupPhotoEmojis() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("DefaultGroupPhotoEmojis().Hash = %d, want 0", got.Hash) + } + if len(got.DocumentID) != 0 { + t.Fatalf("DefaultGroupPhotoEmojis().DocumentID length = %d, want 0", len(got.DocumentID)) + } +} + +func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) { + got, ok := EmojiProfilePhotoGroups().(*tg.MessagesEmojiGroups) + if !ok { + t.Fatalf("EmojiProfilePhotoGroups() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("EmojiProfilePhotoGroups().Hash = %d, want 0", got.Hash) + } + if len(got.Groups) != 0 { + t.Fatalf("EmojiProfilePhotoGroups().Groups length = %d, want 0", len(got.Groups)) + } +} + +func TestConnectedBotsIsEmptyList(t *testing.T) { + got := ConnectedBots() + if got == nil { + t.Fatal("ConnectedBots() = nil") + } + 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)) + } +} + +func TestStoryAlbumsIsEmptyModifiedList(t *testing.T) { + got, ok := StoryAlbums().(*tg.StoriesAlbums) + if !ok { + t.Fatalf("StoryAlbums() = %T, want empty modified list", got) + } + if got.Hash != 0 { + t.Fatalf("StoryAlbums().Hash = %d, want 0", got.Hash) + } + if len(got.Albums) != 0 { + t.Fatalf("StoryAlbums().Albums length = %d, want 0", len(got.Albums)) + } +} + +func TestStickerSetReturnsEmptyModifiedSetForColdRequest(t *testing.T) { + got, ok := StickerSet(&tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiGenericAnimations{}, + Hash: 0, + }).(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("StickerSet(hash=0) = %T, want modified empty set", got) + } + if got.Set.Hash == 0 { + t.Fatal("StickerSet(hash=0).Set.Hash = 0, want stable cache hash") + } + if got.Set.ShortName != "EmojiGenericAnimations" { + t.Fatalf("StickerSet(hash=0).Set.ShortName = %q, want EmojiGenericAnimations", got.Set.ShortName) + } + if len(got.Packs) != 0 || len(got.Keywords) != 0 || len(got.Documents) != 0 { + t.Fatalf("StickerSet(hash=0) = packs %d keywords %d documents %d, want empty vectors", len(got.Packs), len(got.Keywords), len(got.Documents)) + } + if _, ok := StickerSet(&tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiGenericAnimations{}, + Hash: got.Set.Hash, + }).(*tg.MessagesStickerSetNotModified); !ok { + t.Fatalf("StickerSet(hash) = %#v, want notModified", StickerSet(&tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiGenericAnimations{}, + Hash: got.Set.Hash, + })) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..2de86039 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +// Package config 负责 telesrv 运行配置的加载与校验。 +package config + +import ( + "os" + "strconv" + "time" +) + +// Config 是 telesrv 的运行配置。 +type Config struct { + // ListenAddr 是 MTProto TCP 监听地址。 + // 需与 TDesktop patch 指向的自建 DC 地址/端口一致(记录于 docs/tdesktop-patch-notes.md)。 + ListenAddr string + // AdvertiseIP 是写入 help.getConfig DCOptions 的对外可达 IP(客户端据此连接本 DC)。 + AdvertiseIP string + // RSAKeyPath 是 server RSA 私钥的 PEM 路径;不存在时自动生成。 + RSAKeyPath string + // DC 是本 server 的 DC ID。 + DC int + + // PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。 + // 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。 + PostgresDSN string + // PostgresMaxConns 是 pgxpool 最大连接数。<=0 用 pgx 默认(max(4, NumCPU),生产偏小)。 + // 需覆盖发送事务 + outbox worker 并发 + RPC 读,过小会在高并发下排队(表现为尾延迟突刺)。 + PostgresMaxConns int + // PostgresMinConns 是启动时预热的 pgxpool 连接数,降低 TDesktop 冷启动并发 RPC 的建连等待。 + PostgresMinConns int + // RedisAddr 是高频易失态(验证码、限流计数、update 队列)的 Redis 地址。 + RedisAddr string + // RedisPassword 是 Redis 密码;开发默认空。 + RedisPassword string + // RedisDB 是 Redis 逻辑库编号。 + RedisDB int + + // DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。 + DevAuthCode string + // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 + LangPackSeedDir string + // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 + BlobDir string + // StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。 + StickerSeedDir string + // StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。 + StickerSeedMaxSets int + + // OutboxWorkers 是并发 claim 的 outbox worker 数。worker 间用 FOR UPDATE SKIP LOCKED 互不重叠。 + // 开发默认偏保守,避免 TDesktop 启动风暴下多个 worker 同扫分区父表触发 PG lock/shared-memory 压力; + // 压测或生产可按硬件用 TELESRV_OUTBOX_WORKERS 调高。 + OutboxWorkers int + // OutboxBatch 是 transactional outbox worker 每次 claim 的最大条数。 + // 调大提升吞吐、增大单批 PG/推送压力;调小降低延迟抖动。配套压测见 docs/message-module.md。 + OutboxBatch int + // OutboxInterval 是 outbox worker 两次 claim 之间的轮询间隔。 + OutboxInterval time.Duration + // OutboxLeaseTimeout 是 'dispatching' 行被判定为租约过期、允许其它 worker 重新 claim 的时长。 + // 取值需大于单批投递耗时,否则会重复推送;过大则 worker 崩溃后积压恢复变慢。 + OutboxLeaseTimeout time.Duration + // OutboundPushTimeout 是 best-effort updates 推送等待 outbound 队列接受的最长时间。 + OutboundPushTimeout time.Duration + // UpdateEventRetention 是 durable update log 保留期;只清理已被水位/state 覆盖的事件。 + UpdateEventRetention time.Duration + // RetentionInterval 是 retention worker 的运行间隔。 + RetentionInterval time.Duration + // RetentionBatch 是单次 retention 最多删除的行数。 + RetentionBatch int +} + +// Load 从环境变量读取配置并填充默认值。第一阶段不做严格校验。 +func Load() (Config, error) { + cfg := Config{ + ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), + AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"), + RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), + DC: envIntOr("TELESRV_DC", 2), + + PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@localhost: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"), + 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), + + OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 2), + 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), + } + return cfg, nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envIntOr(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); 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 != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} diff --git a/internal/domain/account.go b/internal/domain/account.go new file mode 100644 index 00000000..4217e0db --- /dev/null +++ b/internal/domain/account.go @@ -0,0 +1,65 @@ +package domain + +// PasswordSettings 是账号 2FA/SRP 配置。第一阶段默认 HasPassword=false。 +type PasswordSettings struct { + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte +} + +// ReactionNotifyFrom stores one account-level reaction notification scope. +type ReactionNotifyFrom string + +const ( + ReactionNotifyFromNone ReactionNotifyFrom = "none" + ReactionNotifyFromContacts ReactionNotifyFrom = "contacts" + ReactionNotifyFromAll ReactionNotifyFrom = "all" +) + +// ReactionsNotifySettings stores the account reaction notification settings +// consumed by account.get/setReactionsNotifySettings. +type ReactionsNotifySettings struct { + MessagesFrom ReactionNotifyFrom + StoriesFrom ReactionNotifyFrom + PollVotesFrom ReactionNotifyFrom + ShowPreviews bool +} + +// PaidReactionPrivacyKind stores the account default paid reaction privacy. +type PaidReactionPrivacyKind string + +const ( + PaidReactionPrivacyDefault PaidReactionPrivacyKind = "default" + PaidReactionPrivacyAnonymous PaidReactionPrivacyKind = "anonymous" + PaidReactionPrivacyPeer PaidReactionPrivacyKind = "peer" +) + +// PaidReactionPrivacy is the domain representation of tg.PaidReactionPrivacy. +type PaidReactionPrivacy struct { + Kind PaidReactionPrivacyKind + Peer *Peer +} + +// AccountReactionSettings groups account-level reaction preferences. +type AccountReactionSettings struct { + Notify ReactionsNotifySettings + DefaultReaction MessageReaction + PaidPrivacy PaidReactionPrivacy +} + +func DefaultAccountReactionSettings() AccountReactionSettings { + return AccountReactionSettings{ + Notify: ReactionsNotifySettings{ + MessagesFrom: ReactionNotifyFromContacts, + StoriesFrom: ReactionNotifyFromContacts, + PollVotesFrom: ReactionNotifyFromContacts, + ShowPreviews: true, + }, + DefaultReaction: MessageReaction{Type: MessageReactionEmoji, Emoticon: "👍"}, + PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault}, + } +} diff --git a/internal/domain/authorization.go b/internal/domain/authorization.go new file mode 100644 index 00000000..1c8b4c93 --- /dev/null +++ b/internal/domain/authorization.go @@ -0,0 +1,15 @@ +package domain + +// Authorization 是一条设备授权:auth_key 与 user 的绑定 + initConnection 设备信息。 +// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。 +type Authorization struct { + AuthKeyID [8]byte // 协议原生 auth_key_id;store 边界按小端转 int64 + UserID int64 + Layer int + DeviceModel string + Platform string + SystemVersion string + APIID int + AppVersion string + IP string +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go new file mode 100644 index 00000000..55548605 --- /dev/null +++ b/internal/domain/channel.go @@ -0,0 +1,1546 @@ +package domain + +const ( + // MaxChannelDifferenceLimit limits a single updates.getChannelDifference page. + MaxChannelDifferenceLimit = 100 + // MaxChannelDifferenceTooLongMessages limits the latest message snapshot returned by channelDifferenceTooLong. + MaxChannelDifferenceTooLongMessages = 100 + // MaxChannelParticipantsLimit limits a single participants page. + MaxChannelParticipantsLimit = 200 + // MaxChannelParticipantsOffset bounds channels.getParticipants deep OFFSET work. + MaxChannelParticipantsOffset = 10000 + // MaxChannelParticipantsQueryLength bounds member search strings before LIKE scans. + MaxChannelParticipantsQueryLength = 128 + // 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 + // MaxSynchronousChannelDialogFanout bounds per-member channel_dialogs writes in the send transaction. + MaxSynchronousChannelDialogFanout = 1000 + // MaxChannelTypingFanout caps transient typing fanout. + MaxChannelTypingFanout = MaxChannelRealtimeFanout + // MaxChannelAdminRankLength limits custom admin rank text. + MaxChannelAdminRankLength = 32 + // MaxChannelInviteTitleLength limits invite link admin-only labels. + MaxChannelInviteTitleLength = 32 + // MaxChannelInviteListLimit limits invite management pages. + MaxChannelInviteListLimit = 100 + // MaxChannelHideJoinRequests limits one hideAllChatJoinRequests batch. + MaxChannelHideJoinRequests = 1000 + // MaxChannelPendingJoinRecentRequesters limits recent_requesters carried in pending join request updates. + MaxChannelPendingJoinRecentRequesters = 5 + // MaxAdminedPublicChannels limits channels.getAdminedPublicChannels payload size. + MaxAdminedPublicChannels = 200 + // MaxChannelAdminLogLimit limits one channels.getAdminLog page. + MaxChannelAdminLogLimit = 100 + // MaxChannelAdminLogAdmins limits admin filter fan-in. + MaxChannelAdminLogAdmins = 100 + // MaxChannelAdminLogQueryLength limits free-text admin log search. + MaxChannelAdminLogQueryLength = 128 + // MaxChannelHistoryQueryLength limits channel history/search query strings before LIKE scans. + MaxChannelHistoryQueryLength = 256 + // MaxChannelSearchPostsLimit limits one global public posts search page. + MaxChannelSearchPostsLimit = 50 + // MaxChannelGlobalSearchLimit limits one messages.searchGlobal channel page. + MaxChannelGlobalSearchLimit = 50 + // MaxPublicChannelSearchLimit limits one contacts.search public peer lookup page. + MaxPublicChannelSearchLimit = 50 + // MaxPublicChannelSearchQueryLength bounds public channel peer search strings. + MaxPublicChannelSearchQueryLength = 256 + // MaxChannelReactionItems limits one channel reaction policy payload. + MaxChannelReactionItems = 64 + // MaxChannelReactionEmoticonLength limits one emoji reaction string. + MaxChannelReactionEmoticonLength = 32 + // MaxChannelMessageReactionsPerUser limits one user's reactions on one channel message. + MaxChannelMessageReactionsPerUser = 16 + // MaxChannelMessageReactionRecent limits recent reactors embedded in messageReactions. + MaxChannelMessageReactionRecent = 3 + // MaxChannelMessageReactionListLimit limits one messages.getMessageReactionsList page. + MaxChannelMessageReactionListLimit = 100 + // MaxRecentMessageReactions limits one messages.getRecentReactions page. + MaxRecentMessageReactions = 100 + // MaxTopMessageReactions limits one messages.getTopReactions page. + MaxTopMessageReactions = 100 + // MaxSavedReactionTags limits one messages.getSavedReactionTags page. + MaxSavedReactionTags = 100 + // MaxChannelReadParticipants limits per-message read receipt fan-in. + MaxChannelReadParticipants = 50 + // ChannelReadMarkExpirePeriod matches TDesktop's default chat_read_mark_expire_period. + ChannelReadMarkExpirePeriod = 7 * 24 * 60 * 60 + // MaxChannelReadOutboxFanout caps senders notified by one channel readHistory. + MaxChannelReadOutboxFanout = 128 + // MaxChannelReadOutboxScanMessages bounds the read delta scanned for sender receipts. + MaxChannelReadOutboxScanMessages = 1000 + // MaxCommonChannelsLimit limits one messages.getCommonChats page. + MaxCommonChannelsLimit = 100 + // MaxLeftChannelsLimit limits one channels.getLeftChannels page. + MaxLeftChannelsLimit = 100 + // MaxLeftChannelsOffset bounds the legacy count-offset export API. + MaxLeftChannelsOffset = 10000 + // MaxInactiveChannelsLimit limits one channels.getInactiveChannels payload. + MaxInactiveChannelsLimit = 100 + // DefaultChannelRecommendationsLimit matches Telegram Desktop's default similar channel preview cap. + DefaultChannelRecommendationsLimit = 10 + // MaxChannelRecommendationsLimit bounds one channels.getChannelRecommendations payload. + MaxChannelRecommendationsLimit = 100 + // MaxDiscussionGroupsLimit limits the channels.getGroupsForDiscussion payload. + MaxDiscussionGroupsLimit = 200 + // MaxChannelRepliesLimit limits one messages.getReplies page. + MaxChannelRepliesLimit = 100 + // MaxChannelUnreadMentionsLimit limits one messages.getUnreadMentions page. + MaxChannelUnreadMentionsLimit = 100 + // MaxChannelUnreadReactionsLimit limits one messages.getUnreadReactions page. + MaxChannelUnreadReactionsLimit = 100 + // MaxChannelForumTopicsLimit limits one messages.getForumTopics page. + MaxChannelForumTopicsLimit = 100 + // MaxChannelForumTopicIDs limits one messages.getForumTopicsByID vector. + MaxChannelForumTopicIDs = 100 + // MaxChannelForumTopicTitleLength bounds topic titles before persistence. + MaxChannelForumTopicTitleLength = 128 + // DefaultForumTopicIconColor is Telegram Desktop's General-topic fallback blue. + DefaultForumTopicIconColor = 0x6FB9F0 + // MaxChannelReadMentionsBatch limits one messages.readMentions clearing batch. + MaxChannelReadMentionsBatch = 1000 + // MaxChannelReadReactionsBatch limits one messages.readReactions clearing batch. + MaxChannelReadReactionsBatch = 1000 + // MaxDeleteParticipantReactionsBatch limits one moderation clear batch. + MaxDeleteParticipantReactionsBatch = 1000 + // MaxChannelMentionRecipients limits mention state writes for one channel message. + MaxChannelMentionRecipients = 100 +) + +// ValidChannelSlowModeSeconds reports whether seconds is accepted by Telegram clients. +func ValidChannelSlowModeSeconds(seconds int) bool { + switch seconds { + case 0, 10, 30, 60, 300, 900, 3600: + return true + default: + return false + } +} + +// ChannelMemberRole describes a member's role in a channel or megagroup. +type ChannelMemberRole string + +const ( + ChannelRoleCreator ChannelMemberRole = "creator" + ChannelRoleAdmin ChannelMemberRole = "admin" + ChannelRoleMember ChannelMemberRole = "member" +) + +// ChannelMemberStatus describes current membership state. +type ChannelMemberStatus string + +const ( + ChannelMemberActive ChannelMemberStatus = "active" + ChannelMemberLeft ChannelMemberStatus = "left" + ChannelMemberKicked ChannelMemberStatus = "kicked" + ChannelMemberBanned ChannelMemberStatus = "banned" +) + +// ChannelAdminRights is a domain-only representation of Telegram admin rights. +type ChannelAdminRights struct { + ChangeInfo bool + PostMessages bool + EditMessages bool + DeleteMessages bool + BanUsers bool + InviteUsers bool + PinMessages bool + AddAdmins bool + ManageCall bool + Anonymous bool +} + +// ChannelBannedRights is a domain-only representation of Telegram banned rights. +type ChannelBannedRights struct { + ViewMessages bool + SendMessages bool + SendMedia bool + SendStickers bool + SendGifs bool + SendGames bool + SendInline bool + EmbedLinks bool + SendPolls bool + ChangeInfo bool + InviteUsers bool + PinMessages bool + UntilDate int +} + +// ChannelReactionPolicyType describes which reactions are allowed in a channel. +type ChannelReactionPolicyType string + +const ( + ChannelReactionPolicyDefault ChannelReactionPolicyType = "" + ChannelReactionPolicyNone ChannelReactionPolicyType = "none" + ChannelReactionPolicyAll ChannelReactionPolicyType = "all" + ChannelReactionPolicySome ChannelReactionPolicyType = "some" +) + +// ChannelReactionPolicy is a domain-only representation of chatReactions*. +type ChannelReactionPolicy struct { + Type ChannelReactionPolicyType + AllowCustom bool + Emoticons []string + CustomEmojiIDs []int64 + Limit int + PaidEnabled bool +} + +// ChannelPeerColor is a domain-only representation of PeerColor. +type ChannelPeerColor struct { + HasColor bool + Color int + BackgroundEmojiID int64 +} + +// Empty reports whether the peer has no explicit color state. +func (c ChannelPeerColor) Empty() bool { + return !c.HasColor && c.BackgroundEmojiID == 0 +} + +// ChannelEmojiStatus is a domain-only representation of a regular EmojiStatus. +type ChannelEmojiStatus struct { + DocumentID int64 + Until int +} + +// Empty reports whether no emoji status is set. +func (s ChannelEmojiStatus) Empty() bool { + return s.DocumentID == 0 +} + +// Channel is a Telegram channel/supergroup entity. +type Channel struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + Username string + Broadcast bool + Megagroup bool + Forum bool + ForumTabs bool + Autotranslation bool + RestrictedSponsored bool + BroadcastMessagesAllowed bool + SendPaidMessagesStars int64 + NoForwards bool + JoinToSend bool + JoinRequest bool + Signatures bool + 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 + // 当前头像(反范式存于 channels 表)。PhotoID==0 表示无头像。 + PhotoID int64 + PhotoDCID int + PhotoStripped []byte +} + +// ChannelMember is one user's channel membership and read state. +type ChannelMember struct { + ChannelID int64 + UserID int64 + InviterUserID int64 + Role ChannelMemberRole + Status ChannelMemberStatus + JoinedAt int + LeftAt int + AdminRights ChannelAdminRights + BannedRights ChannelBannedRights + Rank string + AvailableMinID int + AvailableMinPts int + ReadInboxMaxID int + ReadInboxDate int + ReadOutboxMaxID int + UnreadMark bool + SlowmodeLastSendDate int +} + +// ChannelDialog is the current user's owner-view dialog state for a channel. +type ChannelDialog struct { + UserID int64 + ChannelID int64 + FolderID int + TopMessageID int + TopMessageDate int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int + UnreadMentions int + UnreadReactions int + Pinned bool + PinnedOrder int + UnreadMark bool + ViewForumAsMessages bool + DefaultSendAs *Peer +} + +// ChannelMessageActionType identifies service messages generated by channel operations. +type ChannelMessageActionType string + +const ( + ChannelActionNone ChannelMessageActionType = "" + ChannelActionCreate ChannelMessageActionType = "channel_create" + 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" +) + +// ChannelMessageAction describes a service action without depending on tg.*. +type ChannelMessageAction struct { + Type ChannelMessageActionType + Title string + IconColor int + IconEmojiID int64 + IconEmojiIDSet bool + TitleMissing bool + Closed *bool + Hidden *bool + UserIDs []int64 +} + +// ChannelMessage is a single stored message in a channel/supergroup. +type ChannelMessage struct { + ChannelID int64 + ID int + RandomID int64 + 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 + Pts int + Deleted bool +} + +// MessageReactionType identifies one stored reaction constructor without depending on TL types. +type MessageReactionType string + +const ( + MessageReactionEmoji MessageReactionType = "emoji" +) + +// MessageReaction describes one supported message reaction value. +type MessageReaction struct { + Type MessageReactionType + Emoticon string +} + +// ChannelMessageReactionCount is an aggregated reaction counter for one message. +type ChannelMessageReactionCount struct { + Reaction MessageReaction + Count int + ChosenOrder int +} + +// ChannelMessagePeerReaction describes one peer's reaction entry. +type ChannelMessagePeerReaction struct { + ChannelID int64 + MessageID int + SenderUserID int64 + UserID int64 + Reaction MessageReaction + Big bool + Unread bool + My bool + ChosenOrder int + Date int +} + +// ChannelMessageReactions is the read model carried by channel messages and reaction updates. +type ChannelMessageReactions struct { + CanSeeList bool + Results []ChannelMessageReactionCount + Recent []ChannelMessagePeerReaction +} + +// SetChannelMessageReactionsRequest replaces the current user's reactions for one message. +type SetChannelMessageReactionsRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Reactions []MessageReaction + Big bool + AddToRecent bool + Date int +} + +// ChannelMessageReactionsResult describes one reaction update. +type ChannelMessageReactionsResult struct { + Channel Channel + Message ChannelMessage + Messages []ChannelMessage + Reactions ChannelMessageReactions + Recipients []int64 +} + +// DeleteChannelParticipantReactionRequest removes one participant reaction on one message. +type DeleteChannelParticipantReactionRequest struct { + UserID int64 + ChannelID int64 + MessageID int + ParticipantUserID int64 + Date int +} + +// DeleteChannelParticipantReactionsRequest removes a bounded page of one participant's reactions. +type DeleteChannelParticipantReactionsRequest struct { + UserID int64 + ChannelID int64 + ParticipantUserID int64 + Limit int + Date int +} + +// DeleteChannelParticipantReactionsResult describes moderation reaction clears. +type DeleteChannelParticipantReactionsResult struct { + Channel Channel + Messages []ChannelMessage + Recipients []int64 + Deleted int +} + +// ChannelMessageReactionsRequest fetches reaction summaries for exact message ids. +type ChannelMessageReactionsRequest struct { + UserID int64 + ChannelID int64 + IDs []int +} + +// ChannelMessageReactionsListRequest pages per-peer reactions for one message. +type ChannelMessageReactionsListRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Reaction *MessageReaction + Offset string + Limit int +} + +// ChannelMessageReactionsList is a bounded page for messages.getMessageReactionsList. +type ChannelMessageReactionsList struct { + Channel Channel + Message ChannelMessage + Count int + Reactions []ChannelMessagePeerReaction + NextOffset string +} + +// RecentMessageReaction is one account-level recently used message reaction. +type RecentMessageReaction struct { + UserID int64 + Reaction MessageReaction + Date int +} + +// TopMessageReaction is one account-level frequently used message reaction. +type TopMessageReaction struct { + UserID int64 + Reaction MessageReaction + Count int + Date int +} + +// SavedReactionTag is one account-level custom title for a saved-message reaction tag. +type SavedReactionTag struct { + UserID int64 + Reaction MessageReaction + Title string + Count int +} + +// ChannelDiscussionRef links a broadcast post to its discussion megagroup root message. +type ChannelDiscussionRef struct { + ChannelID int64 + MessageID int +} + +// ChannelMessageReplies describes thread/comment counters without depending on TL types. +type ChannelMessageReplies struct { + Comments bool + Replies int + RepliesPts int + RecentRepliers []Peer + ChannelID int64 + MaxID int + ReadMaxID int +} + +// ChannelUpdateEventType identifies channel pts events. +type ChannelUpdateEventType string + +const ( + ChannelUpdateNewMessage ChannelUpdateEventType = "new_channel_message" + ChannelUpdateEditMessage ChannelUpdateEventType = "edit_channel_message" + ChannelUpdateDeleteMessages ChannelUpdateEventType = "delete_channel_messages" + ChannelUpdateParticipant ChannelUpdateEventType = "channel_participant" + ChannelUpdatePinnedMessages ChannelUpdateEventType = "pinned_channel_messages" + ChannelUpdateNoop ChannelUpdateEventType = "noop" +) + +// ChannelUpdateEvent is the channel-scoped durable update log entry. +type ChannelUpdateEvent struct { + ChannelID int64 + Type ChannelUpdateEventType + Pts int + PtsCount int + Date int + Message ChannelMessage + MessageIDs []int + SenderUserID int64 + UserIDs []int64 + Pinned bool + Previous ChannelMember + Participant ChannelMember +} + +// FilterChannelUpdateEventForAvailableMinID hides message-scoped updates that +// belong to a member's pre-history-hidden range while preserving pts progress. +func FilterChannelUpdateEventForAvailableMinID(event ChannelUpdateEvent, availableMinID int) (ChannelUpdateEvent, bool) { + if availableMinID <= 0 { + return event, true + } + switch event.Type { + case ChannelUpdateNewMessage, ChannelUpdateEditMessage: + if event.Message.ID != 0 && event.Message.ID <= availableMinID { + return event, false + } + case ChannelUpdateDeleteMessages, ChannelUpdatePinnedMessages: + if len(event.MessageIDs) == 0 { + return event, true + } + ids := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + if id > availableMinID { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return event, false + } + event.MessageIDs = ids + } + return event, true +} + +// ChannelAdminLogEventType identifies durable admin log actions. +type ChannelAdminLogEventType string + +const ( + ChannelAdminLogChangeTitle ChannelAdminLogEventType = "change_title" + ChannelAdminLogChangeUsername ChannelAdminLogEventType = "change_username" + ChannelAdminLogChangeLinkedChat ChannelAdminLogEventType = "change_linked_chat" + ChannelAdminLogToggleSignatures ChannelAdminLogEventType = "toggle_signatures" + ChannelAdminLogTogglePreHistoryHidden ChannelAdminLogEventType = "toggle_pre_history_hidden" + ChannelAdminLogToggleForum ChannelAdminLogEventType = "toggle_forum" + ChannelAdminLogToggleAutotranslation ChannelAdminLogEventType = "toggle_autotranslation" + ChannelAdminLogToggleAntiSpam ChannelAdminLogEventType = "toggle_anti_spam" + ChannelAdminLogToggleSlowMode ChannelAdminLogEventType = "toggle_slow_mode" + ChannelAdminLogParticipantInvite ChannelAdminLogEventType = "participant_invite" + ChannelAdminLogParticipantJoin ChannelAdminLogEventType = "participant_join" + ChannelAdminLogParticipantLeave ChannelAdminLogEventType = "participant_leave" + ChannelAdminLogParticipantPromote ChannelAdminLogEventType = "participant_promote" + ChannelAdminLogParticipantDemote ChannelAdminLogEventType = "participant_demote" + ChannelAdminLogParticipantBan ChannelAdminLogEventType = "participant_ban" + ChannelAdminLogParticipantUnban ChannelAdminLogEventType = "participant_unban" + ChannelAdminLogParticipantKick ChannelAdminLogEventType = "participant_kick" + ChannelAdminLogParticipantUnkick ChannelAdminLogEventType = "participant_unkick" + ChannelAdminLogUpdatePinned ChannelAdminLogEventType = "update_pinned" + ChannelAdminLogSendMessage ChannelAdminLogEventType = "send_message" + ChannelAdminLogEditMessage ChannelAdminLogEventType = "edit_message" + ChannelAdminLogDeleteMessage ChannelAdminLogEventType = "delete_message" +) + +// ChannelAdminLogEvent is a channel-scoped audit entry. +type ChannelAdminLogEvent struct { + ID int64 + ChannelID int64 + UserID int64 + Date int + Type ChannelAdminLogEventType + PrevString string + NewString string + PrevBool bool + NewBool bool + PrevInt int + NewInt int + PrevParticipant *ChannelMember + NewParticipant *ChannelMember + Participant *ChannelMember + Message *ChannelMessage + PrevMessage *ChannelMessage + NewMessage *ChannelMessage + Query string +} + +// ChannelAdminLogFilter mirrors the user-visible admin log filter categories. +type ChannelAdminLogFilter struct { + Join bool + Leave bool + Invite bool + Ban bool + Unban bool + Kick bool + Unkick bool + Promote bool + Demote bool + Info bool + Settings bool + Pinned bool + Edit bool + Delete bool + Send bool + Invites bool + Forums bool + SubExtend bool + EditRank bool +} + +// Empty reports whether no admin log category filter was supplied. +func (f ChannelAdminLogFilter) Empty() bool { + return !f.Join && !f.Leave && !f.Invite && !f.Ban && !f.Unban && !f.Kick && !f.Unkick && + !f.Promote && !f.Demote && !f.Info && !f.Settings && !f.Pinned && !f.Edit && !f.Delete && + !f.Send && !f.Invites && !f.Forums && !f.SubExtend && !f.EditRank +} + +// ChannelAdminLogRequest describes one bounded admin log page. +type ChannelAdminLogRequest struct { + UserID int64 + ChannelID int64 + Query string + AdminUserIDs []int64 + MaxID int64 + MinID int64 + Limit int + Filter ChannelAdminLogFilter +} + +// ChannelAdminLogResult is returned by channels.getAdminLog. +type ChannelAdminLogResult struct { + Channel Channel + Events []ChannelAdminLogEvent +} + +// ChannelView contains channel data personalized for a viewer. +type ChannelView struct { + Channel Channel + Self ChannelMember + Dialog ChannelDialog +} + +// ChannelParticipantList is a paged participant response. +type ChannelParticipantList struct { + Channel Channel + Participants []ChannelMember + Users []User + Count int + Hash int64 +} + +// ChannelParticipantsFilterKind identifies channels.getParticipants filters. +type ChannelParticipantsFilterKind string + +const ( + ChannelParticipantsRecent ChannelParticipantsFilterKind = "recent" + ChannelParticipantsAdmins ChannelParticipantsFilterKind = "admins" + ChannelParticipantsKicked ChannelParticipantsFilterKind = "kicked" + ChannelParticipantsBanned ChannelParticipantsFilterKind = "banned" + ChannelParticipantsSearch ChannelParticipantsFilterKind = "search" + ChannelParticipantsBots ChannelParticipantsFilterKind = "bots" + ChannelParticipantsContacts ChannelParticipantsFilterKind = "contacts" + ChannelParticipantsMentions ChannelParticipantsFilterKind = "mentions" +) + +// ChannelParticipantsFilter is a domain-only participant list filter. +type ChannelParticipantsFilter struct { + Kind ChannelParticipantsFilterKind + Query string +} + +// ChannelDialogList is a paged dialog response for channel/supergroup peers. +type ChannelDialogList struct { + Dialogs []Dialog + Messages []ChannelMessage + Channels []Channel + Users []User + Count int + Hash int64 +} + +// CommonChannelsRequest describes a bounded common-supergroup page. +type CommonChannelsRequest struct { + UserID int64 + TargetUserID int64 + MaxID int64 + Limit int + CountOnly bool +} + +// CommonChannelsResult contains shared megagroups and the full shared count. +type CommonChannelsResult struct { + Count int + Channels []Channel +} + +// ChannelRecommendationsRequest describes one public broadcast channel recommendation page. +type ChannelRecommendationsRequest struct { + UserID int64 + SourceChannelID int64 + Limit int +} + +// ChannelRecommendationsResult contains public broadcast channel recommendations and total count. +type ChannelRecommendationsResult struct { + Count int + Channels []Channel +} + +// PublicChannelSearchResult contains contacts.search public channel/supergroup matches. +type PublicChannelSearchResult struct { + MyResults []Channel + Results []Channel +} + +// LeftChannel is one channel/supergroup the user has left. +type LeftChannel struct { + Channel Channel + Self ChannelMember +} + +// LeftChannelsResult contains a bounded page plus the full left count. +type LeftChannelsResult struct { + Count int + Channels []LeftChannel +} + +// DiscussionGroupUpdateResult contains channels whose linked_chat_id changed. +type DiscussionGroupUpdateResult struct { + Channels []Channel +} + +// ChannelHistory is a paged channel history response. +type ChannelHistory struct { + Channel Channel + Self ChannelMember + Channels []Channel + Topics []ChannelForumTopic + Messages []ChannelMessage + Users []User + Count int + Hash int64 +} + +// ChannelForumTopic is a forum topic materialized from the topic root service message. +type ChannelForumTopic struct { + ChannelID int64 + TopicID int + CreatorUserID int64 + Title string + IconColor int + IconEmojiID int64 + TitleMissing bool + Closed bool + Hidden bool + Pinned bool + PinnedOrder int + Date int + TopMessageID int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int + UnreadMentionsCount int + UnreadReactionsCount int + UnreadPollVotesCount int +} + +// ChannelForumTopicFilter pages a forum topic list without OFFSET scans. +type ChannelForumTopicFilter struct { + ChannelID int64 + Query string + OffsetDate int + OffsetID int + OffsetTopic int + Limit int +} + +// ChannelForumTopicList contains a bounded topic page and channel context. +type ChannelForumTopicList struct { + Channel Channel + Dialog ChannelDialog + Topics []ChannelForumTopic + Messages []ChannelMessage + Users []User + Count int +} + +// ChannelDifference is updates.getChannelDifference domain output. +type ChannelDifference struct { + Channel Channel + Self ChannelMember + Events []ChannelUpdateEvent + NewMessages []ChannelMessage + OtherUpdates []ChannelUpdateEvent + Users []User + Channels []Channel + Pts int + Final bool + TooLong bool + Dialog ChannelDialog + Timeout int +} + +// CreateChannelRequest creates a broadcast channel or megagroup. +type CreateChannelRequest struct { + CreatorUserID int64 + Title string + About string + RandomID int64 + Broadcast bool + Megagroup bool + Forum bool + ForumTabs bool + TTLPeriod int + MemberUserIDs []int64 + Date int +} + +// CreateChannelResult is returned by channel creation paths. +type CreateChannelResult struct { + Channel Channel + Members []ChannelMember + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// EditChannelTitleRequest edits a channel/supergroup title and emits a service message. +type EditChannelTitleRequest struct { + UserID int64 + ChannelID int64 + Title string + Date int +} + +// EditChannelTitleResult describes one title edit. +type EditChannelTitleResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// EditChannelAboutRequest modifies a channel/supergroup description. +type EditChannelAboutRequest struct { + UserID int64 + ChannelID int64 + About string + Date int +} + +// EditChannelAdminRequest modifies a member's admin rights. +type EditChannelAdminRequest struct { + UserID int64 + ChannelID int64 + MemberID int64 + AdminRights ChannelAdminRights + Rank string + Date int +} + +// EditChannelAdminResult describes the participant transition. +type EditChannelAdminResult struct { + Channel Channel + Previous ChannelMember + Participant ChannelMember + Event ChannelUpdateEvent + Recipients []int64 + Date int +} + +// EditChannelBannedRequest modifies a participant's banned rights. +type EditChannelBannedRequest struct { + UserID int64 + ChannelID int64 + Participant Peer + BannedRights ChannelBannedRights + Date int +} + +// EditChannelDefaultBannedRightsRequest modifies global default restrictions. +type EditChannelDefaultBannedRightsRequest struct { + UserID int64 + ChannelID int64 + BannedRights ChannelBannedRights + Date int +} + +// EditChannelBannedResult describes the participant transition. +type EditChannelBannedResult struct { + Channel Channel + Previous ChannelMember + Participant ChannelMember + Event ChannelUpdateEvent + Recipients []int64 + Date int +} + +// DeleteChannelRequest deletes a channel/supergroup. Only the creator may do this. +type DeleteChannelRequest struct { + UserID int64 + ChannelID int64 + Date int +} + +// UpdateChannelUsernameRequest updates or clears a channel public username. +type UpdateChannelUsernameRequest struct { + UserID int64 + ChannelID int64 + Username string +} + +// DeleteChannelResult describes a deleted channel. +type DeleteChannelResult struct { + Channel Channel + Recipients []int64 +} + +// 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 +} + +// SaveChannelDefaultSendAsRequest stores the current user's default send-as peer for a channel dialog. +type SaveChannelDefaultSendAsRequest struct { + UserID int64 + ChannelID int64 + SendAs *Peer +} + +// ChannelMessageViewsRequest reads and optionally increments channel-scoped message view counters. +type ChannelMessageViewsRequest struct { + UserID int64 + ChannelID int64 + IDs []int + Increment bool + Date int +} + +// ChannelMessageViewsResult returns current view counters by visible message id. +type ChannelMessageViewsResult struct { + Views map[int]int +} + +// ReadChannelMessageContentsRequest marks channel/supergroup content-read hints for one viewer. +type ReadChannelMessageContentsRequest struct { + UserID int64 + ChannelID int64 + IDs []int +} + +// ReadChannelMessageContentsResult contains visible messages whose content-read hint can be synced. +type ReadChannelMessageContentsResult struct { + Channel Channel + Messages []ChannelMessage + ClearedUnreadReactionMessageIDs []int +} + +// GetChannelMessageAuthorRequest resolves the original user author of one channel message. +type GetChannelMessageAuthorRequest struct { + UserID int64 + ChannelID int64 + ID int +} + +// GetChannelMessageAuthorResult contains the resolved message author user id. +type GetChannelMessageAuthorResult struct { + Channel Channel + MessageID int + SenderUserID int64 +} + +// SendChannelMessageResult describes a channel message send. +type SendChannelMessageResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 + Duplicate bool + Discussion *SendChannelDiscussionResult +} + +// SendChannelDiscussionResult describes the discussion megagroup root created for a broadcast post. +type SendChannelDiscussionResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// CreateChannelForumTopicRequest creates one forum topic root service message. +type CreateChannelForumTopicRequest struct { + UserID int64 + ChannelID int64 + Title string + TitleMissing bool + IconColor int + IconEmojiID int64 + RandomID int64 + SendAs *Peer + Date int +} + +// CreateChannelForumTopicResult describes the topic root message and topic state. +type CreateChannelForumTopicResult struct { + Channel Channel + Topic ChannelForumTopic + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 + Duplicate bool +} + +// EditChannelForumTopicRequest edits topic metadata and emits a service message. +type EditChannelForumTopicRequest struct { + UserID int64 + ChannelID int64 + TopicID int + Title *string + IconEmojiID *int64 + Closed *bool + Hidden *bool + Date int +} + +// EditChannelForumTopicResult describes one topic edit service message. +type EditChannelForumTopicResult struct { + Channel Channel + Topic ChannelForumTopic + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// UpdateChannelForumTopicPinnedRequest pins or unpins one topic. +type UpdateChannelForumTopicPinnedRequest struct { + UserID int64 + ChannelID int64 + TopicID int + Pinned bool + Date int +} + +// UpdateChannelForumTopicPinnedResult describes one pinned-topic update. +type UpdateChannelForumTopicPinnedResult struct { + Channel Channel + Topic ChannelForumTopic + Recipients []int64 +} + +// ReorderChannelPinnedForumTopicsRequest stores a bounded pinned topic order. +type ReorderChannelPinnedForumTopicsRequest struct { + UserID int64 + ChannelID int64 + Order []int + Force bool + Date int +} + +// ReorderChannelPinnedForumTopicsResult describes a pinned topic order update. +type ReorderChannelPinnedForumTopicsResult struct { + Channel Channel + Order []int + Recipients []int64 +} + +// DeleteChannelForumTopicHistoryRequest deletes one forum topic page. +type DeleteChannelForumTopicHistoryRequest struct { + UserID int64 + ChannelID int64 + TopicID int + Date int +} + +// EditChannelMessageRequest edits one text message in a channel/supergroup. +type EditChannelMessageRequest struct { + UserID int64 + ChannelID int64 + ID int + Message string + Entities []MessageEntity + EditDate int +} + +// EditChannelMessageResult describes one channel edit update. +type EditChannelMessageResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 +} + +// DeleteChannelMessagesRequest deletes a bounded set of channel/supergroup messages. +type DeleteChannelMessagesRequest struct { + UserID int64 + ChannelID int64 + IDs []int + Date int +} + +// DeleteChannelMessagesResult describes one channel delete update. +type DeleteChannelMessagesResult struct { + Channel Channel + Event ChannelUpdateEvent + DeletedIDs []int + Recipients []int64 +} + +// DeleteChannelHistoryRequest clears or deletes a bounded channel/supergroup history page. +type DeleteChannelHistoryRequest struct { + UserID int64 + ChannelID int64 + MaxID int + ForEveryone bool + Date int +} + +// DeleteChannelParticipantHistoryRequest deletes one bounded page of messages sent by a participant. +type DeleteChannelParticipantHistoryRequest struct { + UserID int64 + ChannelID int64 + ParticipantUserID int64 + Date int +} + +// DeleteChannelHistoryResult describes a channel history delete/clear page. +type DeleteChannelHistoryResult struct { + Channel Channel + Event ChannelUpdateEvent + DeletedIDs []int + Recipients []int64 + Offset int + AvailableMinID int +} + +// UpdateChannelPinnedMessageRequest pins or unpins one channel/supergroup message. +type UpdateChannelPinnedMessageRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Pinned bool + Silent bool + Date int +} + +// UpdateChannelPinnedMessageResult describes one pinned-message update. +type UpdateChannelPinnedMessageResult struct { + Channel Channel + Event ChannelUpdateEvent + Recipients []int64 +} + +// ChannelInvite is an exported invite link without TL dependencies. +type ChannelInvite struct { + ChannelID int64 + InviteID int64 + Hash string + AdminUserID int64 + Title string + Permanent bool + Revoked bool + RequestNeeded bool + ExpireDate int + UsageLimit int + UsageCount int + RequestedCount int + Date int +} + +// ExportChannelInviteRequest creates one invite link. +type ExportChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Title string + RequestNeeded bool + ExpireDate int + UsageLimit int + LegacyRevokePermanent bool + Date int +} + +// ExportChannelInviteResult returns the exported invite. +type ExportChannelInviteResult struct { + Channel Channel + Invite ChannelInvite +} + +// CheckChannelInviteResult returns public invite preview info. +type CheckChannelInviteResult struct { + Channel Channel + Invite ChannelInvite + Already bool + Self ChannelMember +} + +// ImportChannelInviteRequest imports an invite and joins the channel when no approval is needed. +type ImportChannelInviteRequest struct { + UserID int64 + Hash string + Date int +} + +// ChannelInviteListRequest describes an exported invite management page. +type ChannelInviteListRequest struct { + UserID int64 + ChannelID int64 + AdminUserID int64 + Revoked bool + OffsetDate int + OffsetHash string + Limit int +} + +// ChannelInviteList is a bounded exported invite page. +type ChannelInviteList struct { + Count int + Invites []ChannelInvite +} + +// GetChannelInviteRequest fetches one exported invite by hash. +type GetChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Hash string +} + +// EditChannelInviteRequest edits or revokes an exported invite. +type EditChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Hash string + Revoked bool + HasExpireDate bool + ExpireDate int + HasUsageLimit bool + UsageLimit int + HasRequestNeeded bool + RequestNeeded bool + HasTitle bool + Title string + Date int +} + +// EditChannelInviteResult returns the edited invite and optional replacement. +type EditChannelInviteResult struct { + Invite ChannelInvite + NewInvite *ChannelInvite +} + +// DeleteChannelInviteRequest removes one exported invite. +type DeleteChannelInviteRequest struct { + UserID int64 + ChannelID int64 + Hash string +} + +// DeleteRevokedChannelInvitesRequest removes revoked invites for one admin. +type DeleteRevokedChannelInvitesRequest struct { + UserID int64 + ChannelID int64 + AdminUserID int64 + Limit int +} + +// ChannelAdminInviteCount aggregates invite counters for one admin. +type ChannelAdminInviteCount struct { + AdminUserID int64 + InvitesCount int + RevokedInvitesCount int +} + +// ChannelInviteImporter describes a user that joined or requested via an invite. +type ChannelInviteImporter struct { + ChannelID int64 + InviteID int64 + UserID int64 + Date int + Requested bool + ApprovedBy int64 + ViaChatlist bool + About string +} + +// ChannelInviteImportersRequest describes importer/join-request pagination. +type ChannelInviteImportersRequest struct { + UserID int64 + ChannelID int64 + Hash string + Requested bool + Query string + OffsetDate int + OffsetUserID int64 + Limit int +} + +// ChannelInviteImporterList is a bounded importer page. +type ChannelInviteImporterList struct { + Count int + Importers []ChannelInviteImporter +} + +// ChannelPendingJoinRequests is the admin-visible pending join request summary. +type ChannelPendingJoinRequests struct { + ChannelID int64 + Count int + RecentRequesters []int64 +} + +// HideChannelJoinRequestRequest approves or dismisses one pending join request. +type HideChannelJoinRequestRequest struct { + UserID int64 + ChannelID int64 + TargetUserID int64 + Approved bool + Date int +} + +// HideChannelJoinRequestsRequest approves or dismisses pending join requests in a bounded batch. +type HideChannelJoinRequestsRequest struct { + UserID int64 + ChannelID int64 + Hash string + Approved bool + Limit int + Date int +} + +// ChannelHistoryFilter describes channel history query conditions. +type ChannelHistoryFilter struct { + ChannelID int64 + Query string + SenderUserID int64 + OffsetID int + OffsetDate int + AddOffset int + Limit int + MinDate int + MaxDate int + MaxID int + MinID int + Hash int64 +} + +// ChannelSearchPostsRequest describes a bounded global public post search. +type ChannelSearchPostsRequest struct { + Hashtag string + Query string + OffsetRate int + OffsetChannelID int64 + OffsetID int + Limit int +} + +// ChannelGlobalSearchRequest describes a bounded messages.searchGlobal page +// over channel/supergroup messages visible to the current account. +type ChannelGlobalSearchRequest struct { + Query string + BroadcastsOnly bool + GroupsOnly bool + HasFolderID bool + FolderID int + OffsetRate int + OffsetChannelID int64 + OffsetID int + MinDate int + MaxDate int + Limit int +} + +// ChannelRepliesFilter describes messages.getReplies query conditions. +type ChannelRepliesFilter struct { + ChannelID int64 + RootMessageID int + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int + Hash int64 +} + +// ChannelUnreadMentionsFilter describes messages.getUnreadMentions query conditions. +type ChannelUnreadMentionsFilter struct { + ChannelID int64 + TopMsgID int + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int +} + +// ChannelUnreadReactionsFilter describes messages.getUnreadReactions query conditions. +type ChannelUnreadReactionsFilter struct { + ChannelID int64 + TopMsgID int + OffsetID int + AddOffset int + Limit int + MaxID int + MinID int +} + +// ReadChannelMentionsRequest clears unread mention state for a channel/supergroup. +type ReadChannelMentionsRequest struct { + UserID int64 + ChannelID int64 + TopMsgID int + Limit int +} + +// ReadChannelMentionsResult describes a bounded mentions clear operation. +type ReadChannelMentionsResult struct { + Channel Channel + Cleared int + Remaining int + Offset int + ChannelPts int +} + +// ReadChannelReactionsRequest clears unread reaction state for a channel/supergroup. +type ReadChannelReactionsRequest struct { + UserID int64 + ChannelID int64 + TopMsgID int + Limit int +} + +// ReadChannelReactionsResult describes a bounded reactions clear operation. +type ReadChannelReactionsResult struct { + Channel Channel + Cleared int + Remaining int + Offset int + ChannelPts int +} + +// ChannelDiscussionMessage describes messages.getDiscussionMessage output. +type ChannelDiscussionMessage struct { + PostChannel Channel + DiscussionChannel Channel + Messages []ChannelMessage + Channels []Channel + Users []User + MaxID int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int +} + +// ChannelDifferenceRequest describes a channel difference query. +type ChannelDifferenceRequest struct { + UserID int64 + ChannelID int64 + Pts int + Limit int + Force bool +} + +// ReadChannelHistoryRequest advances the current user's channel read watermark. +type ReadChannelHistoryRequest struct { + UserID int64 + ChannelID int64 + MaxID int + Date int +} + +// ReadChannelHistoryResult describes a readHistory channel result. +type ReadChannelHistoryResult struct { + ChannelID int64 + MaxID int + StillUnreadCount int + Changed bool + Pts int + Dialog ChannelDialog + OutboxUpdates []ChannelReadOutboxUpdate +} + +// ChannelReadOutboxUpdate advances one sender's channel outbox read watermark. +type ChannelReadOutboxUpdate struct { + UserID int64 + MaxID int +} + +// ChannelReadParticipant describes one member's read receipt for a channel message. +type ChannelReadParticipant struct { + UserID int64 + Date int +} + +// ChannelReadParticipantsRequest queries read receipts for one small megagroup message. +type ChannelReadParticipantsRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Limit int + Date int +} + +// ChannelReadParticipantsResult is a bounded set of read receipt participants. +type ChannelReadParticipantsResult struct { + Channel Channel + Message ChannelMessage + Participants []ChannelReadParticipant +} diff --git a/internal/domain/channel_errors.go b/internal/domain/channel_errors.go new file mode 100644 index 00000000..c2789a3f --- /dev/null +++ b/internal/domain/channel_errors.go @@ -0,0 +1,68 @@ +package domain + +import ( + "errors" + "fmt" +) + +var ( + ErrChannelInvalid = errors.New("channel invalid") + ErrChannelPrivate = errors.New("channel private") + ErrChannelTitleInvalid = errors.New("channel title invalid") + ErrChannelUserBanned = errors.New("user banned in channel") + ErrChannelWriteForbidden = errors.New("chat write forbidden") + ErrChannelAdminRequired = errors.New("chat admin required") + ErrChannelNotModified = errors.New("chat not modified") + ErrChannelForumMissing = errors.New("channel forum missing") + ErrLinkNotModified = errors.New("discussion link not modified") + ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed") + ErrBroadcastIDInvalid = errors.New("broadcast id invalid") + ErrMegagroupIDInvalid = errors.New("megagroup id invalid") + ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden") + ErrChatPublicRequired = errors.New("chat public required") + ErrChannelUserCreator = errors.New("channel user creator") + ErrChannelRightForbidden = errors.New("channel right forbidden") + ErrPersistentTimestamp = errors.New("persistent timestamp invalid") + ErrInviteHashEmpty = errors.New("invite hash empty") + ErrInviteHashInvalid = errors.New("invite hash invalid") + ErrInviteHashExpired = errors.New("invite hash expired") + ErrInvitePermanent = errors.New("chat invite permanent") + ErrInviteRevokedMissing = errors.New("invite revoked missing") + ErrInviteRequestSent = errors.New("invite request sent") + ErrHideRequesterMissing = errors.New("hide requester missing") + ErrUsersTooMuch = errors.New("users too much") + ErrUserAlreadyParticipant = errors.New("user already participant") + ErrUserKicked = errors.New("user kicked") +) + +// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation. +type SlowModeWaitError struct { + Seconds int +} + +func (e SlowModeWaitError) Error() string { + if e.Seconds <= 0 { + return "slowmode wait" + } + return fmt.Sprintf("slowmode wait %d seconds", e.Seconds) +} + +// NewSlowModeWaitError creates a bounded slow mode wait error. +func NewSlowModeWaitError(seconds int) error { + if seconds <= 0 { + seconds = 1 + } + return SlowModeWaitError{Seconds: seconds} +} + +// SlowModeWaitSeconds extracts the wait duration from err. +func SlowModeWaitSeconds(err error) (int, bool) { + var wait SlowModeWaitError + if !errors.As(err, &wait) { + return 0, false + } + if wait.Seconds <= 0 { + return 1, true + } + return wait.Seconds, true +} diff --git a/internal/domain/contact.go b/internal/domain/contact.go new file mode 100644 index 00000000..a2d67d3b --- /dev/null +++ b/internal/domain/contact.go @@ -0,0 +1,65 @@ +package domain + +// Contact 是当前账号通讯录中的一个已注册联系人。 +// +// FirstName/LastName/Phone/Note 是 owner 视角数据:同一个 target user 在不同 +// owner 的通讯录里可以有不同备注,这些字段不回写 users 全局资料。 +type Contact struct { + User User + FirstName string + LastName string + Phone string + Note string + NoteEntities []MessageEntity + Mutual bool +} + +// ContactList 是通讯录查询结果。 +type ContactList struct { + Contacts []Contact + Hash int64 +} + +// ContactInput 描述一次 owner 视角联系人写入。 +type ContactInput struct { + ContactUserID int64 + ClientID int64 + Phone string + FirstName string + LastName string + Note string + NoteEntities []MessageEntity + AddPhonePrivacyException bool +} + +// ImportedContact 是 contacts.importContacts 成功导入项。 +type ImportedContact struct { + UserID int64 + ClientID int64 +} + +// ImportContactsResult 是 contacts.importContacts 的业务结果。 +type ImportContactsResult struct { + Imported []ImportedContact + Contacts []Contact + RetryContacts []int64 +} + +// UserSearchResult 是 contacts.search 的业务结果。 +// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。 +// MyChannelResults 放当前账号已加入的公开 channel/supergroup;ChannelResults 放其他公开命中。 +type UserSearchResult struct { + MyResults []User + Results []User + MyChannelResults []Channel + ChannelResults []Channel +} + +// PeerSettings 是当前 owner 看某个 peer 的可操作状态。 +type PeerSettings struct { + AddContact bool + BlockContact bool + ShareContact bool + NeedContactsException bool + HiddenPeerSettingsBar bool +} diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go new file mode 100644 index 00000000..6c93c43e --- /dev/null +++ b/internal/domain/dialog.go @@ -0,0 +1,155 @@ +package domain + +// PeerType 标识 dialog 所属 peer 类型。 +type PeerType string + +const ( + PeerTypeUser PeerType = "user" + PeerTypeChannel PeerType = "channel" +) + +const ( + // DialogMainFolderID 是 TDesktop 主会话列表 folder_id。 + DialogMainFolderID = 0 + // DialogArchiveFolderID 是 Telegram 约定的归档会话 folder_id。 + DialogArchiveFolderID = 1 + // DialogCustomFolderMinID 起才允许用户自定义 filter。 + DialogCustomFolderMinID = 2 + // MaxDialogFolders 限制单用户自定义 filter 数量,避免无界配置拖垮启动同步。 + MaxDialogFolders = 100 + // MaxDialogFolderPeers 限制单 filter 中 include/exclude/pinned peer 数。 + MaxDialogFolderPeers = 100 + // MaxDialogFolderTitleRunes 对齐 Telegram folder title 的短标题语义。 + MaxDialogFolderTitleRunes = 64 + // MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work. + MaxDialogDraftsPerUser = 1000 +) + +// Peer 是业务层 peer 值对象,不依赖 TL 类型。 +type Peer struct { + Type PeerType + ID int64 +} + +// Dialog 是账号的一条会话摘要。 +type Dialog struct { + Peer Peer + ChannelLeft bool + FolderID int + TopMessage int + TopMessageDate int + ReadInboxMaxID int + ReadOutboxMaxID int + UnreadCount int + UnreadMentions int + UnreadReactions int + Pinned bool + PinnedOrder int + UnreadMark bool + ViewForumAsMessages bool + PeerSettingsBarHidden bool + Draft *DialogDraft +} + +// DialogDraftWebPage stores a draft link preview without depending on TL input media types. +type DialogDraftWebPage struct { + URL string + ForceLargeMedia bool + ForceSmallMedia bool + Optional bool +} + +// DialogDraft is a cloud draft for one peer/topic, expressed only in domain types. +type DialogDraft struct { + Peer Peer + TopMessageID int + Date int + NoWebpage bool + InvertMedia bool + Message string + Entities []MessageEntity + ReplyTo *MessageReply + WebPage *DialogDraftWebPage + Effect int64 +} + +// Empty reports whether this draft should clear the cloud draft slot. +func (d DialogDraft) Empty() bool { + replyOnlyTopic := d.ReplyTo != nil && d.ReplyTo.MessageID == 0 && d.ReplyTo.TopMessageID > 0 + return !d.NoWebpage && + !d.InvertMedia && + d.Message == "" && + len(d.Entities) == 0 && + (d.ReplyTo == nil || replyOnlyTopic) && + d.WebPage == nil && + d.Effect == 0 +} + +// DialogList 是 dialogs 查询结果。 +type DialogList struct { + Dialogs []Dialog + Messages []Message + ChannelMessages []ChannelMessage + Users []User + Channels []Channel + State UpdateState + Hash int64 + Count int +} + +// DialogFilter 是会话列表查询条件。 +type DialogFilter struct { + PinnedOnly bool + ExcludePinned bool + HasFolderID bool + FolderID int + Folder *DialogFolder + OffsetDate int + OffsetID int + HasOffsetPeer bool + OffsetPeer Peer + Limit int + Hash int64 +} + +// DialogFolderPeer 是 folder/filter 规则中的 peer,保留 access_hash 供 RPC 层回写 InputPeer。 +type DialogFolderPeer struct { + Peer Peer + AccessHash int64 +} + +// DialogFolder 是用户自定义会话分组规则。它只表达业务含义,不依赖 TL 生成类型。 +type DialogFolder struct { + ID int + Contacts bool + NonContacts bool + Groups bool + Broadcasts bool + Bots bool + ExcludeMuted bool + ExcludeRead bool + ExcludeArchived bool + TitleNoanimate bool + Title string + TitleEntities []MessageEntity + Emoticon string + HasEmoticon bool + Color int + HasColor bool + PinnedPeers []DialogFolderPeer + IncludePeers []DialogFolderPeer + ExcludePeers []DialogFolderPeer + IsChatlist bool +} + +// DialogFolderList 是 messages.getDialogFilters 的业务响应。 +type DialogFolderList struct { + TagsEnabled bool + Folders []DialogFolder +} + +// FolderPeerUpdate 描述 folders.editPeerFolders 的单个归档/还原变更。 +type FolderPeerUpdate struct { + Peer Peer + FolderID int +} diff --git a/internal/domain/doc.go b/internal/domain/doc.go new file mode 100644 index 00000000..22c78c12 --- /dev/null +++ b/internal/domain/doc.go @@ -0,0 +1,4 @@ +// Package domain 存放业务实体与值对象(User、Peer、Dialog、Message、MessageID 等)。 +// +// 铁律:本包禁止依赖 gotd/td/tg 等协议层类型;TL 类型只允许出现在 RPC/MTProto 边界。 +package domain diff --git a/internal/domain/help.go b/internal/domain/help.go new file mode 100644 index 00000000..bdf63c5d --- /dev/null +++ b/internal/domain/help.go @@ -0,0 +1,30 @@ +package domain + +// AppConfig 是客户端应用配置。 +type AppConfig struct { + Client string + Hash int + JSON []byte +} + +// CountryCode 是一个国家的电话区号规则。 +type CountryCode struct { + CountryCode string + Prefixes []string + Patterns []string +} + +// Country 是登录页国家/区号选择项。 +type Country struct { + ISO2 string + DefaultName string + Name string + Hidden bool + CountryCodes []CountryCode +} + +// CountriesList 是 help.getCountriesList 查询结果。 +type CountriesList struct { + Hash int + Countries []Country +} diff --git a/internal/domain/langpack.go b/internal/domain/langpack.go new file mode 100644 index 00000000..e90e6722 --- /dev/null +++ b/internal/domain/langpack.go @@ -0,0 +1,24 @@ +package domain + +// LangPack 是一份客户端语言包的查询结果。 +type LangPack struct { + LangPack string + LangCode string + FromVersion int + Version int + Strings []LangPackString +} + +// LangPackString 是语言包中的一个普通或复数形式字符串。 +type LangPackString struct { + Key string + Value string + Pluralized bool + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} diff --git a/internal/domain/media.go b/internal/domain/media.go new file mode 100644 index 00000000..38bb4839 --- /dev/null +++ b/internal/domain/media.go @@ -0,0 +1,331 @@ +package domain + +// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。 +// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。 +// +// 字段带 json tag 是为了 store 层可直接 json.Marshal 落 JSONB(消息 media 快照、 +// 文档/照片元数据)。它们是协议无关的纯数据,不是 tg 生成类型。 + +// MediaBackend 标识 blob 字节实际存放后端。第一阶段只有本地磁盘。 +type MediaBackend string + +const ( + // MediaBackendLocalFS 表示 blob 字节存在本地磁盘(object_key 为相对路径)。 + MediaBackendLocalFS MediaBackend = "localfs" +) + +// FileBlob 是一个可下载的二进制对象的索引项:location_key → 后端/对象键/大小/mime。 +// 真正的字节由 blob backend 按 ObjectKey 读写;本结构只描述定位与元数据。 +type FileBlob struct { + // LocationKey 是稳定的逻辑定位键,由 getFile 的 InputFileLocation 推导: + // doc: 文档主体 + // doc:: 文档缩略图(PhotoSize type) + // photo:: 照片某尺寸 + LocationKey string `json:"location_key"` + Backend MediaBackend `json:"backend"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + SHA256 []byte `json:"sha256,omitempty"` + MimeType string `json:"mime_type,omitempty"` +} + +// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG,组装后清理)。 +type UploadPart struct { + OwnerUserID int64 + FileID int64 + Part int + TotalParts int // big file 已知总数;small file 为 0 + Big bool + Bytes []byte +} + +// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。 +// rpc 层从 tg.InputFile/InputFileBig 转换得到;files 服务据此组装 blob。 +type UploadedFileRef struct { + OwnerUserID int64 + FileID int64 + Parts int + Name string + Big bool + MD5 string // small file 客户端 md5_checksum(hex),可校验;big file 为空 +} + +// DocumentSpec 描述从上传文件创建 Document 的元数据(来自 InputMediaUploadedDocument)。 +type DocumentSpec struct { + MimeType string + Attributes []DocumentAttribute + Thumb *UploadedFileRef // 可选缩略图上传,生成 doc::m + ForceFile bool +} + +// FileDownloadRequest 是 upload.getFile 解析后的下载请求; +// LocationKey 由 rpc 层从 tg.InputFileLocation 推导(doc: / photo:: 等)。 +type FileDownloadRequest struct { + LocationKey string + Offset int64 + Limit int +} + +// FileChunk 是 upload.getFile 返回的一段内容。 +type FileChunk struct { + Bytes []byte + MimeType string + Total int64 +} + +// PhotoSizeKind 标识 PhotoSize 的 TL 变体。 +type PhotoSizeKind string + +const ( + // PhotoSizeKindDefault → photoSize(可下载,type/w/h/size)。 + PhotoSizeKindDefault PhotoSizeKind = "size" + // PhotoSizeKindStripped → photoStrippedSize(内联字节,秒开模糊图)。 + PhotoSizeKindStripped PhotoSizeKind = "stripped" + // PhotoSizeKindCached → photoCachedSize(内联字节 + w/h)。 + PhotoSizeKindCached PhotoSizeKind = "cached" + // PhotoSizeKindPath → photoPathSize(内联 svg path 字节,矢量占位)。 + PhotoSizeKindPath PhotoSizeKind = "path" + // PhotoSizeKindProgressive → photoSizeProgressive(渐进式 jpeg 多段大小)。 + PhotoSizeKindProgressive PhotoSizeKind = "progressive" +) + +// 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 +} + +// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。 +func (s PhotoSize) Downloadable() bool { + return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive +} + +// DocumentAttributeKind 标识 TL DocumentAttribute 变体。 +type DocumentAttributeKind string + +const ( + DocAttrImageSize DocumentAttributeKind = "image_size" + DocAttrAnimated DocumentAttributeKind = "animated" + DocAttrSticker DocumentAttributeKind = "sticker" + DocAttrVideo DocumentAttributeKind = "video" + DocAttrAudio DocumentAttributeKind = "audio" + DocAttrFilename DocumentAttributeKind = "filename" + DocAttrCustomEmoji DocumentAttributeKind = "custom_emoji" +) + +// DocumentAttribute 是主路径用到的 TL DocumentAttribute 变体的并集。 +type DocumentAttribute struct { + Kind DocumentAttributeKind `json:"kind"` + + // image_size / video / sticker box + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + + // sticker / custom_emoji + Alt string `json:"alt,omitempty"` + Mask bool `json:"mask,omitempty"` + StickerSetID int64 `json:"sticker_set_id,omitempty"` + StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"` + Free bool `json:"free,omitempty"` // custom_emoji + TextColor bool `json:"text_color,omitempty"` // custom_emoji + + // video + Duration float64 `json:"duration,omitempty"` + RoundMessage bool `json:"round_message,omitempty"` + SupportsStreaming bool `json:"supports_streaming,omitempty"` + + // audio + AudioDuration int `json:"audio_duration,omitempty"` + Voice bool `json:"voice,omitempty"` + Title string `json:"title,omitempty"` + Performer string `json:"performer,omitempty"` + Waveform []byte `json:"waveform,omitempty"` + + // filename + FileName string `json:"file_name,omitempty"` +} + +// Document 是已存储的 Telegram 文档(贴纸、gif、文件、视频、音频、自定义 emoji……)。 +type Document struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + FileReference []byte `json:"file_reference,omitempty"` + Date int `json:"date,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + DCID int `json:"dc_id,omitempty"` + Attributes []DocumentAttribute `json:"attributes,omitempty"` + Thumbs []PhotoSize `json:"thumbs,omitempty"` +} + +// StickerSetRef 返回该文档归属的贴纸集引用(若有 sticker/custom_emoji 属性)。 +func (d Document) StickerSetRef() (id, accessHash int64, ok bool) { + for _, attr := range d.Attributes { + if attr.Kind == DocAttrSticker || attr.Kind == DocAttrCustomEmoji { + if attr.StickerSetID != 0 { + return attr.StickerSetID, attr.StickerSetAccessHash, true + } + } + } + return 0, 0, false +} + +// Photo 是已存储的 Telegram 照片(头像或图片消息)。 +type Photo struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + FileReference []byte `json:"file_reference,omitempty"` + Date int `json:"date,omitempty"` + DCID int `json:"dc_id,omitempty"` + HasStickers bool `json:"has_stickers,omitempty"` + Sizes []PhotoSize `json:"sizes,omitempty"` +} + +// MessageMediaKind 枚举消息可挂载的媒体载荷。 +type MessageMediaKind string + +const ( + MessageMediaKindNone MessageMediaKind = "" + MessageMediaKindPhoto MessageMediaKind = "photo" + MessageMediaKindDocument MessageMediaKind = "document" +) + +// 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"` +} + +// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty)。 +func (m *MessageMedia) IsZero() bool { + return m == nil || m.Kind == MessageMediaKindNone +} + +// StickerPack 是 emoji→文档 id 的映射条目(messages.stickerSet.packs)。 +type StickerPack struct { + Emoticon string `json:"emoticon"` + DocumentIDs []int64 `json:"document_ids"` +} + +// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。 +type StickerSetKind string + +const ( + StickerSetKindStickers StickerSetKind = "stickers" + StickerSetKindEmoji StickerSetKind = "emoji" + StickerSetKindMasks StickerSetKind = "masks" + // StickerSetKindSystem 是 TDesktop 通过 InputStickerSetDice/AnimatedEmoji 等系统集请求的内置集。 + StickerSetKindSystem StickerSetKind = "system" +) + +// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。 +type StickerSet struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + ShortName string `json:"short_name"` + Title string `json:"title"` + Count int `json:"count"` + Hash int `json:"hash"` + Kind StickerSetKind `json:"set_kind"` + Official bool `json:"official,omitempty"` + Animated bool `json:"animated,omitempty"` + Videos bool `json:"videos,omitempty"` + Emojis bool `json:"emojis,omitempty"` + Masks bool `json:"masks,omitempty"` + Installed bool `json:"installed,omitempty"` + Archived bool `json:"archived,omitempty"` + InstalledDate int `json:"installed_date,omitempty"` + ThumbDocumentID int64 `json:"thumb_document_id,omitempty"` + Thumbs []PhotoSize `json:"thumbs,omitempty"` + ThumbDCID int `json:"thumb_dc_id,omitempty"` + ThumbVersion int `json:"thumb_version,omitempty"` + DocumentIDs []int64 `json:"document_ids,omitempty"` + Packs []StickerPack `json:"packs,omitempty"` + SortOrder int `json:"sort_order,omitempty"` + // SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。 + SystemKey string `json:"system_key,omitempty"` +} + +// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile photo)。 +type ProfilePhotoRef struct { + PhotoID int64 + DCID int + Stripped []byte // photoStrippedSize 内联缩略图,可空 +} + +// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。 +func StrippedFromSizes(sizes []PhotoSize) []byte { + for _, s := range sizes { + if s.Kind == PhotoSizeKindStripped { + return s.Bytes + } + } + return nil +} + +// StickerSetRefKind 标识 InputStickerSet 的解析方式。 +type StickerSetRefKind string + +const ( + StickerSetRefByID StickerSetRefKind = "id" + StickerSetRefByShortName StickerSetRefKind = "short_name" + StickerSetRefBySystem StickerSetRefKind = "system" +) + +// StickerSetRef 是 rpc 层从 tg.InputStickerSet 转换得到的贴纸集引用。 +type StickerSetRef struct { + Kind StickerSetRefKind + ID int64 + AccessHash int64 + ShortName string + SystemKey string +} + +// AvailableReaction 描述 messages.getAvailableReactions 的一项(真实资源由文档 id 引用)。 +type AvailableReaction struct { + Reaction string `json:"reaction"` + Title string `json:"title"` + Inactive bool `json:"inactive,omitempty"` + Premium bool `json:"premium,omitempty"` + StaticIconID int64 `json:"static_icon_id,omitempty"` + AppearAnimationID int64 `json:"appear_animation_id,omitempty"` + SelectAnimationID int64 `json:"select_animation_id,omitempty"` + ActivateAnimationID int64 `json:"activate_animation_id,omitempty"` + EffectAnimationID int64 `json:"effect_animation_id,omitempty"` + AroundAnimationID int64 `json:"around_animation_id,omitempty"` + CenterIconID int64 `json:"center_icon_id,omitempty"` + Order int `json:"order,omitempty"` +} + +// DocumentIDs 收集该 reaction 引用的全部文档 id(去零去重,便于批量加载)。 +func (r AvailableReaction) DocumentIDs() []int64 { + raw := []int64{ + r.StaticIconID, r.AppearAnimationID, r.SelectAnimationID, + r.ActivateAnimationID, r.EffectAnimationID, r.AroundAnimationID, r.CenterIconID, + } + out := make([]int64, 0, len(raw)) + seen := make(map[int64]struct{}, len(raw)) + for _, id := range raw { + 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_errors.go b/internal/domain/media_errors.go new file mode 100644 index 00000000..003ed2e8 --- /dev/null +++ b/internal/domain/media_errors.go @@ -0,0 +1,16 @@ +package domain + +import "errors" + +// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go)。 +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") +) diff --git a/internal/domain/message.go b/internal/domain/message.go new file mode 100644 index 00000000..6462081c --- /dev/null +++ b/internal/domain/message.go @@ -0,0 +1,352 @@ +package domain + +// MessageEntityType 标识消息实体类型。 +type MessageEntityType string + +const ( + MessageEntityBold MessageEntityType = "bold" +) + +const ( + // MaxMessageTextLength matches the first-stage text message limit exposed to Telegram clients. + MaxMessageTextLength = 4096 + // MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default. + MaxMessageReplyQuoteLength = 1024 + // MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id. + MaxMessageReplyQuoteOffset = MaxMessageTextLength + // MaxMessageEntityCount limits styled text entity vectors in message text and quotes. + MaxMessageEntityCount = 256 + // MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。 + MaxMessageBoxID = 1<<31 - 1 + // MaxDeleteMessageIDs 限制单次 deleteMessages/updateDeleteMessages 的 owner 视角 id 数量。 + // 大批量历史清理走 deleteHistory 分批推进,避免单个 RPC 构造超大数组或 durable payload。 + MaxDeleteMessageIDs = 1000 + // MaxGetMessageIDs 限制 getMessages / channels.getMessages 精确 ID 批量。 + MaxGetMessageIDs = 100 + // MaxDeleteHistoryBatch 限制单次 deleteHistory 实际清理的 message box 数量。 + // affectedHistory.Offset > 0 时客户端可继续调用,服务端不一次性 RETURNING 全历史。 + MaxDeleteHistoryBatch = 1000 + // MaxForwardMessageIDs 限制单次 forwardMessages 的 owner 视角 id 数量。 + MaxForwardMessageIDs = 100 + // MaxMessageHistoryAddOffset 限制 history/search 的 add_offset 绝对值。 + // TDesktop 正常只使用小窗口偏移;服务端必须拒绝把客户端传入的超大值变成 SQL OFFSET 或 slice capacity。 + MaxMessageHistoryAddOffset = 100 +) + +// ClampMessageHistoryAddOffset bounds Telegram history/search add_offset to a small local window. +func ClampMessageHistoryAddOffset(v int) int { + if v > MaxMessageHistoryAddOffset { + return MaxMessageHistoryAddOffset + } + if v < -MaxMessageHistoryAddOffset { + return -MaxMessageHistoryAddOffset + } + return v +} + +// ValidateMessageReplyBounds validates reply fields that are independent of peer visibility. +func ValidateMessageReplyBounds(reply *MessageReply) error { + if reply == nil { + return nil + } + if reply.MessageID < 0 || reply.MessageID > MaxMessageBoxID { + return ErrReplyMessageIDInvalid + } + if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID { + return ErrReplyMessageIDInvalid + } + if reply.MessageID == 0 && reply.TopMessageID == 0 { + return ErrReplyMessageIDInvalid + } + if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset { + return ErrReplyMessageIDInvalid + } + return nil +} + +// MessageEntity 是业务层消息实体,不依赖 TL 类型。 +type MessageEntity struct { + Type MessageEntityType + Offset int + Length int +} + +// Message 是账号视角下的一条私聊消息。 +type Message struct { + ID int // 当前 owner 视角下的 message box id,暴露给 Telegram 客户端。 + UID int64 // 共享私聊消息主体 id,不暴露给客户端。 + RandomID int64 + OwnerUserID int64 + Peer Peer + From Peer + Date int + EditDate int + Out bool + Silent bool + NoForwards bool + Body string + Entities []MessageEntity + ReplyTo *MessageReply + Forward *MessageForward + Reactions *ChannelMessageReactions + Pts int + Media *MessageMedia +} + +// MessageReply describes a message reply/thread header without depending on TL types. +type MessageReply struct { + MessageID int + Peer Peer + TopMessageID int + ForumTopic bool + QuoteText string + QuoteEntities []MessageEntity + QuoteOffset int +} + +// MessageForward 描述一条转发消息的原始作者信息。 +type MessageForward struct { + From Peer + FromName string + Date int + ChannelPost int + SavedFrom Peer + SavedFromMsgID int +} + +// MessageList 是账号视角下的消息查询结果。 +type MessageList struct { + Messages []Message + Users []User + Count int + Hash int64 +} + +// MessageFilter 描述历史/搜索查询条件。 +type MessageFilter struct { + HasPeer bool + Peer Peer + Query string + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int + Hash int64 + NeedTotalCount bool +} + +// SendPrivateTextRequest 是私聊文本/媒体发送命令。 +type SendPrivateTextRequest struct { + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + Message string + Entities []MessageEntity + Media *MessageMedia + Silent bool + NoForwards bool + ReplyTo *MessageReply + Forward *MessageForward + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// SendPrivateTextResult 描述一次私聊文本发送的双端结果。 +type SendPrivateTextResult struct { + SenderMessage Message + RecipientMessage Message + SenderEvent UpdateEvent + RecipientEvent UpdateEvent + Duplicate bool +} + +// SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message. +type SetPrivateMessageReactionsRequest struct { + UserID int64 + Peer Peer + MessageID int + Reactions []MessageReaction + Big bool + AddToRecent bool + Date int +} + +// PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids. +type PrivateMessageReactionsRequest struct { + OwnerUserID int64 + Peer Peer + IDs []int +} + +// PrivateMessageReactionsResult describes private reaction updates in owner-visible boxes. +type PrivateMessageReactionsResult struct { + Messages []Message + Reactions ChannelMessageReactions +} + +// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。 +type ForwardPrivateMessagesRequest struct { + OwnerUserID int64 + FromPeer Peer + ToUserID int64 + MessageIDs []int + RandomIDs []int64 + Silent bool + NoForwards bool + DropAuthor bool + ReplyTo *MessageReply + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。 +type ForwardPrivateMessagesResult struct { + OwnerUserID int64 + SenderMessages []Message + RecipientMessages []Message + SenderEvents []UpdateEvent + RecipientEvents []UpdateEvent + Duplicates []bool +} + +// ReadHistoryRequest 是账号视角的 messages.readHistory 命令。 +type ReadHistoryRequest struct { + OwnerUserID int64 + Peer Peer + MaxID int + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// ReadHistoryResult 描述一次会话已读操作的业务结果。 +type ReadHistoryResult struct { + OwnerUserID int64 + Peer Peer + MaxID int + StillUnreadCount int + Changed bool + InboxEvent UpdateEvent + OutboxChanged bool + OutboxUserID int64 + OutboxEvent UpdateEvent +} + +// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages. +type ReadMessageContentsRequest struct { + OwnerUserID int64 + IDs []int +} + +// ReadMessageContentsResult contains owner-visible message IDs that existed and can be synced. +type ReadMessageContentsResult struct { + OwnerUserID int64 + MessageIDs []int +} + +// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。 +type OutboxReadDateRequest struct { + OwnerUserID int64 + Peer Peer + ID int +} + +// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。 +type EditMessageRequest struct { + OwnerUserID int64 + Peer Peer + ID int + Message string + Entities []MessageEntity + EditDate int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。 +type EditedMessageForUser struct { + UserID int64 + Message Message + Event UpdateEvent +} + +// EditMessageResult 描述消息编辑后的 owner 维度结果。 +type EditMessageResult struct { + OwnerUserID int64 + Edited []EditedMessageForUser +} + +// Self 返回当前请求账号的编辑结果。 +func (r EditMessageResult) Self() EditedMessageForUser { + for _, item := range r.Edited { + if item.UserID == r.OwnerUserID { + return item + } + } + return EditedMessageForUser{UserID: r.OwnerUserID} +} + +// Changed 表示本次编辑是否实际影响了任何 owner 视角。 +func (r EditMessageResult) Changed() bool { + return len(r.Edited) > 0 +} + +// DeleteMessagesRequest 是账号视角下按消息 ID 删除消息的命令。 +type DeleteMessagesRequest struct { + OwnerUserID int64 + IDs []int + Revoke bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。 +type DeleteHistoryRequest struct { + OwnerUserID int64 + Peer Peer + MaxID int + JustClear bool + Revoke bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// DeletedMessagesForUser 描述一次删除对某个 owner 视角造成的影响。 +type DeletedMessagesForUser struct { + UserID int64 + MessageIDs []int + Event UpdateEvent +} + +// DeleteMessagesResult 描述消息删除后的 owner 维度结果。 +type DeleteMessagesResult struct { + OwnerUserID int64 + Deleted []DeletedMessagesForUser + Offset int +} + +// Self 返回当前请求账号的删除结果。 +func (r DeleteMessagesResult) Self() DeletedMessagesForUser { + for _, item := range r.Deleted { + if item.UserID == r.OwnerUserID { + return item + } + } + return DeletedMessagesForUser{UserID: r.OwnerUserID} +} + +// Changed 表示本次删除是否实际影响了任何 owner 视角。 +func (r DeleteMessagesResult) Changed() bool { + for _, item := range r.Deleted { + if len(item.MessageIDs) > 0 { + return true + } + } + return false +} diff --git a/internal/domain/message_errors.go b/internal/domain/message_errors.go new file mode 100644 index 00000000..6686e2ce --- /dev/null +++ b/internal/domain/message_errors.go @@ -0,0 +1,12 @@ +package domain + +import "errors" + +var ( + ErrMessageIDInvalid = errors.New("message id invalid") + 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") +) diff --git a/internal/domain/message_test.go b/internal/domain/message_test.go new file mode 100644 index 00000000..5e0093b4 --- /dev/null +++ b/internal/domain/message_test.go @@ -0,0 +1,27 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestValidateMessageReplyBoundsRejectsQuoteOffsetAsTextOffset(t *testing.T) { + reply := &MessageReply{ + MessageID: 1, + QuoteText: "hello", + QuoteOffset: MaxMessageReplyQuoteOffset + 1, + } + if err := ValidateMessageReplyBounds(reply); !errors.Is(err, ErrReplyMessageIDInvalid) { + t.Fatalf("ValidateMessageReplyBounds err = %v, want ErrReplyMessageIDInvalid", err) + } +} + +func TestValidateMessageReplyBoundsAllowsForumTopicOnlyHeader(t *testing.T) { + reply := &MessageReply{ + TopMessageID: 10, + ForumTopic: true, + } + if err := ValidateMessageReplyBounds(reply); err != nil { + t.Fatalf("ValidateMessageReplyBounds err = %v, want nil", err) + } +} diff --git a/internal/domain/system.go b/internal/domain/system.go new file mode 100644 index 00000000..1951ac3e --- /dev/null +++ b/internal/domain/system.go @@ -0,0 +1,19 @@ +package domain + +const ( + // OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。 + OfficialSystemUserID int64 = 777000 +) + +// OfficialSystemUser 返回第一阶段内置的官方系统账号。 +func OfficialSystemUser() User { + return User{ + ID: OfficialSystemUserID, + AccessHash: 6599886787491911851, + Phone: "42777", + FirstName: "Telegram", + Username: "telegram", + Verified: true, + Support: true, + } +} diff --git a/internal/domain/temp_auth_key.go b/internal/domain/temp_auth_key.go new file mode 100644 index 00000000..a5da0e10 --- /dev/null +++ b/internal/domain/temp_auth_key.go @@ -0,0 +1,11 @@ +package domain + +// TempAuthKeyBinding 是 auth.bindTempAuthKey 的持久化记录。 +type TempAuthKeyBinding struct { + TempAuthKeyID [8]byte + PermAuthKeyID int64 + Nonce int64 + TempSessionID int64 + ExpiresAt int + EncryptedMessage []byte +} diff --git a/internal/domain/update.go b/internal/domain/update.go new file mode 100644 index 00000000..fb36a8e3 --- /dev/null +++ b/internal/domain/update.go @@ -0,0 +1,10 @@ +package domain + +// UpdateState 是账号的 update 状态(pts/qts/seq/date)。 +// 第一阶段空账号为零值;真实状态机属第二阶段。 +type UpdateState struct { + Pts int + Qts int + Date int + Seq int +} diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go new file mode 100644 index 00000000..32ebdbf4 --- /dev/null +++ b/internal/domain/update_event.go @@ -0,0 +1,58 @@ +package domain + +// UpdateEventType 标识 update 队列事件类型。 +type UpdateEventType string + +const ( + UpdateEventNewMessage UpdateEventType = "new_message" + UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox" + UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox" + 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" +) + +// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。 +type UpdateEvent struct { + UserID int64 + Type UpdateEventType + Pts int + PtsCount int + Date int + Message Message + Peer Peer + Peers []Peer + Bool bool + Settings PeerSettings + MessageIDs []int + MaxID int + StillUnreadCount int + Users []User + Channels []Channel + FilterID int + DialogFilter *DialogFolder + FilterOrder []int + FolderPeers []FolderPeerUpdate + TagsEnabled bool +} + +// UpdateDifference 是 updates.getDifference 的业务层结果。 +type UpdateDifference struct { + State UpdateState + Events []UpdateEvent + // Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice, + // 客户端据 State 继续翻页);false 表示已到当前连续末尾(updates.difference)。 + Partial bool +} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 00000000..fccf5ea7 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,63 @@ +package domain + +// UserIDSequenceBase 是普通用户 ID 的起始值。 +// +// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。 +// 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。 +const UserIDSequenceBase int64 = 1780243200 + +// User 是一个账号。第一阶段仅保留登录链路必须字段; +// access_hash 为任何 InputUser 校验所必须,不可省。 +type User struct { + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + About string + Username string + CountryCode string + Verified bool + Support bool + Contact bool + Mutual bool + // Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。 + PhotoID int64 + PhotoDCID int + PhotoStripped []byte + LastSeenAt int + Status UserStatus +} + +// UserStatusKind is a protocol-neutral account presence state. +type UserStatusKind int + +const ( + UserStatusUnknown UserStatusKind = iota + UserStatusOnline + UserStatusOffline + UserStatusRecently + UserStatusLastWeek + UserStatusLastMonth + UserStatusEmpty +) + +// UserStatus describes the currently visible presence state for a user. +// +// Expires and WasOnline are absolute Unix timestamps in seconds, matching +// Telegram's UserStatus semantics without leaking tg.* into domain. +type UserStatus struct { + Kind UserStatusKind + Expires int + WasOnline int +} + +// UserProfileUpdate 描述 account.updateProfile 的可选字段更新。 +type UserProfileUpdate struct { + FirstName string + HasFirstName bool + LastName string + HasLastName bool + About string + HasAbout bool +} diff --git a/internal/domain/user_errors.go b/internal/domain/user_errors.go new file mode 100644 index 00000000..8930b15d --- /dev/null +++ b/internal/domain/user_errors.go @@ -0,0 +1,12 @@ +package domain + +import "errors" + +var ( + ErrUsernameInvalid = errors.New("username invalid") + ErrUsernameOccupied = errors.New("username occupied") + ErrUsernameNotOccupied = errors.New("username not occupied") + ErrPhoneNotOccupied = errors.New("phone not occupied") + ErrFirstNameInvalid = errors.New("first name invalid") + ErrAboutTooLong = errors.New("about too long") +) diff --git a/internal/loadtest/doc.go b/internal/loadtest/doc.go new file mode 100644 index 00000000..4bb1f3ca --- /dev/null +++ b/internal/loadtest/doc.go @@ -0,0 +1,13 @@ +// Package loadtest 承载 message 模块的压测基线。 +// +// 这里只放 env-gated 的压测用例(真实 PostgreSQL + Redis),不被生产代码 import。 +// 目标与基线方法见 docs/message-module.md 的「Load Baseline」一节。 +// +// 运行方式(PowerShell): +// +// $env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable" +// $env:TELESRV_TEST_REDIS_ADDR = "localhost:6399" +// go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 +// +// 未设置上述两个环境变量时用例直接 Skip,因此对默认 `go test ./...` 无副作用。 +package loadtest diff --git a/internal/loadtest/send_load_test.go b/internal/loadtest/send_load_test.go new file mode 100644 index 00000000..6d836b1f --- /dev/null +++ b/internal/loadtest/send_load_test.go @@ -0,0 +1,478 @@ +package loadtest + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "math" + "os" + "sort" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + messageapp "telesrv/internal/app/messages" + "telesrv/internal/domain" + "telesrv/internal/mtprotoedge" + "telesrv/internal/rpc" + "telesrv/internal/store/postgres" + "telesrv/internal/store/redisstore" +) + +// 第一阶段单机 SLO 目标,来自 docs/message-module.md 的 Next Execution Plan。 +// 默认仅作为信息打印;设 TELESRV_LOAD_ENFORCE_SLO=1 时超标会 fail(用于回归门禁)。 +const ( + sloSendP99 = 150 * time.Millisecond + sloDiffP99 = 100 * time.Millisecond + sloThroughput = 200.0 // msg/s + drainTimeout = 60 * time.Second + diffSampleGoal = 500 +) + +// TestMessageSendBaseline 用真实 PostgreSQL + Redis 压测私聊文本发送热路径, +// 并发跑 outbox dispatcher 排空在线推送,最后采样 getDifference 读路径。 +// +// 这是 closed-loop 饱和压测:concurrency 个 worker 各自不停发,直到发满 messages 条。 +// 吞吐 = messages / wallclock,延迟分位反映该并发下的饱和延迟。 +// 固定到达率(open-loop)的版本留作后续细化(见 docs/message-module.md)。 +func TestMessageSendBaseline(t *testing.T) { + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + redisAddr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if dsn == "" || redisAddr == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN and TELESRV_TEST_REDIS_ADDR to run message load baseline") + } + + // 默认用户池取较大值:用户太少会把写集中到少数 dialog/message_box 行造成行锁争用, + // 拉高 send 尾延迟(这是小池假象,生产 20 万用户分散后争用极低)。 + users := envInt("TELESRV_LOAD_USERS", 1000) + if users < 2 { + users = 2 + } + concurrency := envInt("TELESRV_LOAD_CONCURRENCY", 32) + if concurrency < 1 { + concurrency = 1 + } + totalMsgs := envInt("TELESRV_LOAD_MESSAGES", 5000) + if totalMsgs < 1 { + totalMsgs = 1 + } + poolConns := envInt("TELESRV_LOAD_POOL_CONNS", 64) + workers := envInt("TELESRV_OUTBOX_WORKERS", 8) + outboxBatch := envInt("TELESRV_OUTBOX_BATCH", 100) + outboxInterval := envDuration("TELESRV_OUTBOX_INTERVAL", 50*time.Millisecond) + leaseTimeout := envDuration("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second) + enforceSLO := os.Getenv("TELESRV_LOAD_ENFORCE_SLO") == "1" + deferDispatch := os.Getenv("TELESRV_LOAD_DEFER_DISPATCH") == "1" + + ctx := context.Background() + if err := postgres.Migrate(dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := postgres.Open(ctx, dsn, postgres.WithMaxConns(poolConns)) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + t.Cleanup(pool.Close) + + rdb, err := redisstore.Open(ctx, redisAddr, os.Getenv("TELESRV_TEST_REDIS_PASSWORD"), 0) + if err != nil { + t.Fatalf("open redis: %v", err) + } + t.Cleanup(func() { _ = rdb.Close() }) + + // 装配与 main.go 一致的消息热路径:Redis 分配器 + 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)) + svc := messageapp.NewService(messageStore, dialogStore) + + // 创建独立的测试用户池;用随机 salt 隔离历史残留,结束按 FK 依赖序清理。 + ids := seedUsers(t, ctx, userStore, users) + t.Cleanup(func() { cleanup(t, pool, rdb, ids) }) + + // 在线推送 binder 用真实 SessionManager(零连接),PushToUserExceptSession 返回 0, + // 让 outbox 走完整 claim→ListAfter→MarkDelivered 的 PG 往返,测排空而非网络 fanout。 + binder := mtprotoedge.NewSessionManager(zap.NewNop()) + metrics := &loadMetrics{} + dispatcher := rpc.NewOutboxDispatcher(updateEventStore, dispatchOutboxStore, binder, zap.NewNop(), + rpc.WithOutboxWorkers(workers), + rpc.WithOutboxBatch(outboxBatch), + rpc.WithOutboxInterval(outboxInterval), + rpc.WithOutboxMetrics(metrics), + ) + dispCtx, stopDispatcher := context.WithCancel(ctx) + dispDone := make(chan struct{}) + startDispatcher := func() { + go func() { + dispatcher.Run(dispCtx) + close(dispDone) + }() + } + // deferDispatch=1 时先不投递,让发送把积压攒满,再在发送结束后启动 dispatcher, + // 以隔离测量「纯排空上限」(否则 dispatcher 实时跟上发送、积压近 0 量不出天花板)。 + if !deferDispatch { + startDispatcher() + } + + // 后台采样 outbox 积压(pending+dispatching),记录运行期峰值。 + var maxBacklog atomic.Int64 + sampleCtx, stopSampler := context.WithCancel(ctx) + sampleDone := make(chan struct{}) + go func() { + defer close(sampleDone) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-sampleCtx.Done(): + return + case <-ticker.C: + if n := backlog(ctx, pool, ids); n > maxBacklog.Load() { + maxBacklog.Store(n) + } + } + } + }() + + // 随机 RandomID 基址,保证 (sender,random_id) 幂等唯一且跨重跑不撞。 + randBase := int64(randomUint64(t) & 0x7fff_ffff_ffff) + nowUnix := int(time.Now().Unix()) + body := "telesrv load baseline message body" + + perWorkerLat := make([][]time.Duration, concurrency) + var sent, dup, sendErr atomic.Int64 + var counter atomic.Int64 + + // 预热:先并发发一批不计时的消息,热连接池(MinConns→MaxConns)与 PG plan 缓存, + // 让后续计时窗口反映稳态而非冷启动尾延迟(池过冷时首批 32 并发会挤少量连接)。 + warmup := envInt("TELESRV_LOAD_WARMUP", min(users*10, 1000)) + if warmup > 0 { + var wwg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wwg.Add(1) + go func() { + defer wwg.Done() + for { + n := counter.Add(1) + if n > int64(warmup) { + return + } + sid := ids[(n-1)%int64(users)] + rid := ids[n%int64(users)] + _, _ = svc.SendPrivateText(ctx, sid, domain.SendPrivateTextRequest{ + SenderUserID: sid, + RecipientUserID: rid, + RandomID: randBase + n, + Message: body, + Date: nowUnix, + }) + } + }() + } + wwg.Wait() + counter.Store(int64(warmup)) + } + + start := time.Now() + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + lat := make([]time.Duration, 0, totalMsgs/concurrency+1) + for { + n := counter.Add(1) + if n > int64(warmup+totalMsgs) { + break + } + senderID := ids[(n-1)%int64(users)] + recipientID := ids[n%int64(users)] + req := domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: randBase + n, + Message: body, + Date: nowUnix, + } + t0 := time.Now() + res, err := svc.SendPrivateText(ctx, senderID, req) + lat = append(lat, time.Since(t0)) + if err != nil { + sendErr.Add(1) + continue + } + if res.Duplicate { + dup.Add(1) + } + sent.Add(1) + } + perWorkerLat[w] = lat + }(w) + } + wg.Wait() + sendWall := time.Since(start) + deliveredAtSendEnd := metrics.delivered.Load() + if deferDispatch { + // 发送已把全部积压攒满,此刻才启动 dispatcher:drain 阶段即纯排空,drainRate 反映排空上限。 + startDispatcher() + } + + // 等 outbox 排空(积压回 0),记录排空耗时,再停采样和 dispatcher。 + // 用「发送结束后」单独排空的速率隔离 dispatcher 吞吐,去掉发送期对 PG 的争用。 + drainStart := time.Now() + drained := waitDrain(ctx, pool, ids, drainTimeout) + drainWall := time.Since(drainStart) + drainRate := float64(metrics.delivered.Load()-deliveredAtSendEnd) / drainWall.Seconds() + stopSampler() + <-sampleDone + stopDispatcher() + <-dispDone + + // 合并发送延迟样本并排序。 + latencies := make([]time.Duration, 0, totalMsgs) + for _, l := range perWorkerLat { + latencies = append(latencies, l...) + } + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + + // 采样 getDifference 读路径(ListAfter 从 pts=0 拉账号事件)。 + diffLat := sampleGetDifference(t, ctx, updateEventStore, ids) + sort.Slice(diffLat, func(i, j int) bool { return diffLat[i] < diffLat[j] }) + + okSent := sent.Load() + throughput := float64(okSent) / sendWall.Seconds() + sendP99 := percentile(latencies, 99) + diffP99 := percentile(diffLat, 99) + + t.Logf("==== message module load baseline ====") + t.Logf("config: users=%d concurrency=%d messages=%d pool=%d outbox(workers=%d batch=%d interval=%s lease=%s)", + users, concurrency, totalMsgs, poolConns, workers, outboxBatch, outboxInterval, leaseTimeout) + t.Logf("send: %d ok, %d dup, %d err in %s -> %.0f msg/s", + okSent, dup.Load(), sendErr.Load(), sendWall.Round(time.Millisecond), throughput) + t.Logf("send.lat p50=%s p90=%s p99=%s max=%s", + percentile(latencies, 50).Round(time.Microsecond), + percentile(latencies, 90).Round(time.Microsecond), + sendP99.Round(time.Microsecond), + percentile(latencies, 100).Round(time.Microsecond)) + t.Logf("outbox: delivered=%d failed=%d claimed=%d maxBacklog=%d drain=%s drainRate=%.0f rows/s drained=%v", + metrics.delivered.Load(), metrics.failed.Load(), metrics.claimed.Load(), + maxBacklog.Load(), drainWall.Round(time.Millisecond), drainRate, drained) + t.Logf("getDiff: samples=%d p50=%s p90=%s p99=%s max=%s", + len(diffLat), + percentile(diffLat, 50).Round(time.Microsecond), + percentile(diffLat, 90).Round(time.Microsecond), + diffP99.Round(time.Microsecond), + percentile(diffLat, 100).Round(time.Microsecond)) + t.Logf("SLO: send.p99 %s(<%s) %s | getDiff.p99 %s(<%s) %s | throughput %.0f(>=%.0f) %s", + sendP99.Round(time.Millisecond), sloSendP99, pass(sendP99 < sloSendP99), + diffP99.Round(time.Millisecond), sloDiffP99, pass(diffP99 < sloDiffP99), + throughput, sloThroughput, pass(throughput >= sloThroughput)) + t.Logf("=======================================") + + // 正确性硬断言:发送不应出错、不应有意外重复、outbox 必须排空且无终态失败。 + if sendErr.Load() != 0 { + t.Fatalf("send errors = %d, want 0", sendErr.Load()) + } + if dup.Load() != 0 { + t.Fatalf("duplicates = %d, want 0 (random_id 应唯一)", dup.Load()) + } + if okSent != int64(totalMsgs) { + t.Fatalf("sent ok = %d, want %d", okSent, totalMsgs) + } + if !drained { + t.Fatalf("outbox 未在 %s 内排空,残留积压 %d", drainTimeout, backlog(ctx, pool, ids)) + } + if metrics.failed.Load() != 0 { + t.Fatalf("outbox failed = %d, want 0", metrics.failed.Load()) + } + + // 性能 SLO:默认信息化,门禁模式(TELESRV_LOAD_ENFORCE_SLO=1)才硬失败。 + if enforceSLO { + if sendP99 >= sloSendP99 { + t.Errorf("send p99 %s >= SLO %s", sendP99, sloSendP99) + } + if diffP99 >= sloDiffP99 { + t.Errorf("getDifference p99 %s >= SLO %s", diffP99, sloDiffP99) + } + if throughput < sloThroughput { + t.Errorf("throughput %.0f msg/s < SLO %.0f msg/s", throughput, sloThroughput) + } + } +} + +// loadMetrics 实现 rpc.Metrics,统计 outbox claim/deliver/fail。 +type loadMetrics struct { + claimed atomic.Int64 + delivered atomic.Int64 + failed atomic.Int64 +} + +func (m *loadMetrics) MessageSend(time.Duration, bool, error) {} +func (m *loadMetrics) MessageRateLimited(int) {} +func (m *loadMetrics) OutboxClaimed(n int) { m.claimed.Add(int64(n)) } +func (m *loadMetrics) OutboxDelivered(time.Duration) { m.delivered.Add(1) } +func (m *loadMetrics) OutboxFailed(error) { m.failed.Add(1) } + +func seedUsers(t *testing.T, ctx context.Context, store *postgres.UserStore, n int) []int64 { + t.Helper() + salt := randomUint64(t) % 1_000_000 + ids := make([]int64, n) + errs := make([]error, n) + sem := make(chan struct{}, 16) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + sem <- struct{}{} + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + u, err := store.Create(ctx, domain.User{ + AccessHash: int64(i + 1), + Phone: fmt.Sprintf("+1555%06d%05d", salt, i), + FirstName: fmt.Sprintf("Load%05d", i), + }) + if err != nil { + errs[i] = err + return + } + ids[i] = u.ID + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("create load user %d: %v", i, err) + } + } + return ids +} + +// cleanup 按 FK 依赖序删除测试数据:outbox→events→boxes→private_messages→dialogs→users, +// 再清 Redis pts/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() + ctx := context.Background() + stmts := []string{ + "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", + "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", + "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", + "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", + "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", + "DELETE FROM users WHERE id = ANY($1::bigint[])", + } + for _, sql := range stmts { + if _, err := pool.Exec(ctx, sql, ids); err != nil { + t.Logf("cleanup %q: %v", sql, err) + } + } + keys := make([]string, 0, len(ids)*2) + for _, id := range ids { + keys = append(keys, + fmt.Sprintf("counter:pts:{%d}", id), + fmt.Sprintf("counter:box_id:{%d}", id), + ) + } + if err := rdb.Del(ctx, keys...).Err(); err != nil { + t.Logf("cleanup redis counters: %v", err) + } +} + +// backlog 返回测试用户集当前未投递(pending+dispatching)的 outbox 行数。 +func backlog(ctx context.Context, pool *pgxpool.Pool, ids []int64) int64 { + var n int64 + if err := pool.QueryRow(ctx, + "SELECT count(*) FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[]) AND status IN ('pending','dispatching')", + ids, + ).Scan(&n); err != nil { + return -1 + } + return n +} + +// waitDrain 轮询 backlog 直到归零或超时,返回是否排空。 +func waitDrain(ctx context.Context, pool *pgxpool.Pool, ids []int64, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if backlog(ctx, pool, ids) == 0 { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(50 * time.Millisecond) + } +} + +func sampleGetDifference(t *testing.T, ctx context.Context, events *postgres.UpdateEventStore, ids []int64) []time.Duration { + t.Helper() + out := make([]time.Duration, 0, diffSampleGoal) + for i := 0; len(out) < diffSampleGoal; i++ { + id := ids[i%len(ids)] + t0 := time.Now() + if _, err := events.ListAfter(ctx, id, 0, 100); err != nil { + t.Fatalf("getDifference ListAfter user %d: %v", id, err) + } + out = append(out, time.Since(t0)) + } + return out +} + +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + idx := int(math.Ceil(p/100*float64(len(sorted)))) - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func pass(ok bool) string { + if ok { + return "PASS" + } + return "WARN" +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envDuration(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} + +func randomUint64(t *testing.T) uint64 { + t.Helper() + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatalf("rand: %v", err) + } + return binary.LittleEndian.Uint64(b[:]) +} diff --git a/internal/mtprotoedge/conn.go b/internal/mtprotoedge/conn.go new file mode 100644 index 00000000..fe32acdf --- /dev/null +++ b/internal/mtprotoedge/conn.go @@ -0,0 +1,114 @@ +package mtprotoedge + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/proto" + "github.com/gotd/td/transport" +) + +// Conn 是一个已识别 session 的客户端连接,持有向其加密发送消息所需的全部上下文。 +// 由 SessionManager 管理,供请求响应与主动 push 共用。 +// +// Send 并发安全:所有出站消息先进 per-Conn outbound actor,由它串行分配 msg_id/seq_no、 +// 加密并写 transport,避免高并发 RPC 响应与 push 交错造成 MTProto 顺序错误。 +type outboundWriter interface { + Send(context.Context, *bin.Buffer) error +} + +type Conn struct { + transport transport.Conn + writer outboundWriter + cipher crypto.Cipher + msgID *proto.MessageIDGen + writeTimeout time.Duration + metrics Metrics + + authKeyID [8]byte + sessionID int64 + salt int64 + key crypto.AuthKey + + outbound chan outboundOp + outboundControl chan outboundOp + outboundStop chan struct{} + outboundDone chan struct{} + outboundClose sync.Once + + rpcQueue chan inboundRPC + rpcStop chan struct{} + rpcCancel context.CancelFunc + rpcClose sync.Once + rpcWG sync.WaitGroup + rpcTimeout time.Duration + // inflightRPCBytes 跟踪已入队未完成的 inbound RPC body 总字节,配合 maxInflightRPCBytes + // 给 RPC 队列设字节预算(不止限条数),防对抗客户端发大请求撑内存。 + inflightRPCBytes atomic.Int64 + // RPC worker 懒启动:首个 RPC 入队时才起 worker(ensureInboundRPCWorkers), + // 避免握手后静默 / 纯推送目标连接白白钉住 rpcMaxInflight 个 goroutine。 + rpcRootCtx context.Context + rpcMaxInflight int + rpcWorkersOnce sync.Once + + // sentContentMessages 只由 outbound actor 访问,用于生成 MTProto seq_no。 + sentContentMessages int32 + + identityMu sync.RWMutex + businessAuthKeyID [8]byte + businessAuthKeyResolved bool + userID atomic.Int64 + userIDResolved atomic.Bool + receivesUpdates atomic.Bool +} + +// AuthKeyID 返回连接的 auth_key_id。 +func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID } + +// BusinessAuthKeyID 返回业务视角的 auth_key_id。 +// +// temp auth_key 绑定后解析为 perm auth_key;第二个返回值表示本连接是否已完成解析, +// 即便解析结果等于原始 auth_key_id 也会返回 true,以避免每个 RPC 重复查绑定表。 +func (c *Conn) BusinessAuthKeyID() ([8]byte, bool) { + c.identityMu.RLock() + defer c.identityMu.RUnlock() + return c.businessAuthKeyID, c.businessAuthKeyResolved +} + +// SetBusinessAuthKeyID 缓存业务视角 auth_key_id。 +func (c *Conn) SetBusinessAuthKeyID(id [8]byte) { + c.identityMu.Lock() + changed := !c.businessAuthKeyResolved || c.businessAuthKeyID != id + c.businessAuthKeyID = id + c.businessAuthKeyResolved = true + c.identityMu.Unlock() + if changed { + c.userID.Store(0) + c.userIDResolved.Store(false) + } +} + +// SessionID 返回连接的 session_id。 +func (c *Conn) SessionID() int64 { return c.sessionID } + +// UserID 返回绑定的用户 id;未登录为 0。 +func (c *Conn) UserID() int64 { return c.userID.Load() } + +// UserIDResolved 返回 user_id 授权状态是否已为当前连接解析过。 +// +// resolved=true 且 userID=0 表示该 auth_key 当前未登录;这样登录前的多次 RPC +// 不会反复查询授权表,后续登录成功会由 BindUser 覆盖为真实用户。 +func (c *Conn) UserIDResolved() (userID int64, resolved bool) { + return c.userID.Load(), c.userIDResolved.Load() +} + +// ReceivesUpdates 报告该连接是否接收主动推送的 updates。 +func (c *Conn) ReceivesUpdates() bool { return c.receivesUpdates.Load() } + +// SetReceivesUpdates 设置该连接是否接收主动推送的 updates。 +// 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。 +func (c *Conn) SetReceivesUpdates(v bool) { c.receivesUpdates.Store(v) } diff --git a/internal/mtprotoedge/destroy_auth_key.go b/internal/mtprotoedge/destroy_auth_key.go new file mode 100644 index 00000000..98545ca2 --- /dev/null +++ b/internal/mtprotoedge/destroy_auth_key.go @@ -0,0 +1,33 @@ +package mtprotoedge + +import ( + "fmt" + + "github.com/gotd/td/bin" +) + +const ( + destroyAuthKeyRequestTypeID = 0xd1435160 + destroyAuthKeyOkTypeID = 0xf660e1d4 +) + +type destroyAuthKeyRequest struct{} + +func (*destroyAuthKeyRequest) Encode(b *bin.Buffer) error { + b.PutID(destroyAuthKeyRequestTypeID) + return nil +} + +func (*destroyAuthKeyRequest) Decode(b *bin.Buffer) error { + if err := b.ConsumeID(destroyAuthKeyRequestTypeID); err != nil { + return fmt.Errorf("decode destroy_auth_key: %w", err) + } + return nil +} + +type destroyAuthKeyOk struct{} + +func (*destroyAuthKeyOk) Encode(b *bin.Buffer) error { + b.PutID(destroyAuthKeyOkTypeID) + return nil +} diff --git a/internal/mtprotoedge/doc.go b/internal/mtprotoedge/doc.go new file mode 100644 index 00000000..da227d9f --- /dev/null +++ b/internal/mtprotoedge/doc.go @@ -0,0 +1,6 @@ +// Package mtprotoedge 是 MTProto 连接层:TCP/WS listener、密钥交换、auth key 查找与持久化、 +// 消息加解密、session/server-salt/msg-id/ack/container/gzip,以及 invokeWithLayer、initConnection、 +// invokeWithoutUpdates 等 wrapper 的 unwrap。 +// +// 它只把 MTProto 世界转换成「已解密、已识别 session 的 RPC 请求」,不得包含业务逻辑。 +package mtprotoedge diff --git a/internal/mtprotoedge/e2e_test.go b/internal/mtprotoedge/e2e_test.go new file mode 100644 index 00000000..0d572dce --- /dev/null +++ b/internal/mtprotoedge/e2e_test.go @@ -0,0 +1,90 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "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/auth" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +// TestTelegramClientEndToEnd 是连接层的最强端到端验证:用 gotd/td 的完整 +// telegram.Client(而非底层 cipher)连本地 mtprotoedge,client 自动经 +// invokeWithLayer(initConnection(help.getConfig)) 完成初始化,并取得含本地 DC 的 Config。 +func TestTelegramClientEndToEnd(t *testing.T) { + 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() + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), "12345"), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + } + 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) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + 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: zaptest.NewLogger(t).Named("client"), + SessionStorage: &session.StorageMemory{}, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + client := telegram.NewClient(1, "hash", opts) + + if err := client.Run(ctx, func(ctx context.Context) error { + cfg, err := tg.NewClient(client).HelpGetConfig(ctx) + if err != nil { + return err + } + 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") + } + return nil + }); err != nil { + t.Fatalf("telegram client run: %v", err) + } + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} diff --git a/internal/mtprotoedge/encrypted.go b/internal/mtprotoedge/encrypted.go new file mode 100644 index 00000000..399eaf7c --- /dev/null +++ b/internal/mtprotoedge/encrypted.go @@ -0,0 +1,702 @@ +package mtprotoedge + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tgerr" + "github.com/gotd/td/transport" + + "telesrv/internal/store" +) + +// connState 是单连接的 MTProto 运行态。 +type connState struct { + sentCreated bool + seen map[int64]clientMsgRecord // 已处理的 client msg_id,用于幂等和 msgs_state_req + order []int64 + minSeen int64 + maxSeen int64 +} + +type clientMsgRecord struct { + state byte + seqNo int32 + content bool +} + +func newConnState() *connState { + return &connState{ + seen: make(map[int64]clientMsgRecord), + minSeen: math.MaxInt64, + } +} + +const ( + maxTrackedClientMsgIDs = 400 + + msgStateUnknown byte = 1 + msgStateNotReceived byte = 2 + msgStateNotReceivedHigh byte = 3 + msgStateReceived byte = 4 + + badMsgIDTooLow = 16 + badMsgIDTooHigh = 17 + badMsgIDInvalidBits = 18 + badMsgSeqTooLow = 32 + badMsgSeqTooHigh = 33 + badMsgSeqNotEven = 34 + badMsgSeqNotOdd = 35 + badMsgContainer = 64 +) + +// 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} + + data, err := s.cipher.DecryptFromBuffer(key, b) + if err != nil { + return current, fmt.Errorf("decrypt: %w", err) + } + + if data.Salt != keyData.ServerSalt { + c := current + temp := false + if c == nil || c.sessionID != data.SessionID { + c = s.newConn(tc, key, data.SessionID, keyData.ServerSalt) + temp = true + } + err := s.sendBadServerSalt(ctx, c, data.MessageID, data.SeqNo, keyData.ServerSalt) + if temp { + c.Close() + } + return current, err + } + + // 首个加密消息或 session 变化时(重新)注册连接到 SessionManager。 + if current == nil || current.sessionID != data.SessionID { + if current != nil { + s.conns.Unregister(current) + current.Close() + } + current = s.newConn(tc, key, data.SessionID, keyData.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) + } + + body := data.Data() + typeID, err := (&bin.Buffer{Buf: body}).PeekID() + if err != nil { + return current, fmt.Errorf("peek encrypted payload type id: %w", err) + } + if code := validateClientEnvelope(s.clock.Now(), data.MessageID, data.SeqNo, typeID); code != 0 { + s.log.Debug("Sending bad_msg_notification", + zap.Int64("msg_id", data.MessageID), + zap.Int32("seq_no", data.SeqNo), + zap.Uint32("type_id", typeID), + zap.Int("code", code), + ) + return current, s.sendBadMsg(ctx, current, data.MessageID, data.SeqNo, code) + } + + 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 { + 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 + } + return current, s.sendAck(ctx, current, data.MessageID) + } + if code := cs.validateSeq(data.MessageID, data.SeqNo, content); code != 0 { + s.log.Debug("Sending bad_msg_notification", + zap.Int64("msg_id", data.MessageID), + zap.Int32("seq_no", data.SeqNo), + zap.Uint32("type_id", typeID), + zap.Int("code", code), + ) + return current, s.sendBadMsg(ctx, current, data.MessageID, data.SeqNo, code) + } + cs.track(data.MessageID, data.SeqNo, content, msgStateReceived) + + if !cs.sentCreated { + cs.sentCreated = true + s.log.Debug("Sending new_session_created", zap.Int64("msg_id", data.MessageID), zap.Int32("seq_no", data.SeqNo)) + if err := s.sendNewSessionCreated(ctx, current, data.MessageID); err != nil { + return current, err + } + } + + var acks []int64 + if err := s.dispatch(ctx, cs, current, data.MessageID, data.SeqNo, &bin.Buffer{Buf: body}, &acks); err != nil { + return current, err + } + if len(acks) > 0 { + if err := s.sendAck(ctx, current, acks...); err != nil { + return current, err + } + } + return current, nil +} + +// dispatch 处理一条明文消息:解包 container/gzip,处理服务消息,其余转 RPC 路由。 +// content-related 消息(ping、RPC)的 msg_id 会收集到 acks 以便统一确认。 +func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int64, seqNo int32, b *bin.Buffer, acks *[]int64) error { + id, err := b.PeekID() + if err != nil { + return fmt.Errorf("peek type id: %w", err) + } + ackContent := func() { + if clientMessageNeedsAck(id) { + *acks = append(*acks, msgID) + } + } + + switch id { + case proto.GZIPTypeID: + var gz proto.GZIP + if err := gz.Decode(b); err != nil { + return fmt.Errorf("decode gzip: %w", err) + } + return s.dispatch(ctx, cs, c, msgID, seqNo, &bin.Buffer{Buf: gz.Data}, acks) + + case proto.MessageContainerTypeID: + var container proto.MessageContainer + if err := container.Decode(b); err != nil { + return fmt.Errorf("decode container: %w", err) + } + if code := validateClientContainer(msgID, seqNo, container); code != 0 { + return s.sendBadMsg(ctx, c, msgID, seqNo, code) + } + for i := range container.Messages { + m := container.Messages[i] + typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID() + if err != nil { + return fmt.Errorf("peek container message type id: %w", err) + } + content := clientMessageNeedsAck(typeID) + if record, ok := cs.seenRecord(m.ID); ok { + if record.content { + *acks = append(*acks, m.ID) + } + continue + } + if code := cs.validateSeq(m.ID, int32(m.SeqNo), content); code != 0 { + return s.sendBadMsg(ctx, c, m.ID, int32(m.SeqNo), code) + } + cs.track(m.ID, int32(m.SeqNo), content, msgStateReceived) + if err := s.dispatch(ctx, cs, c, m.ID, int32(m.SeqNo), &bin.Buffer{Buf: m.Body}, acks); err != nil { + return err + } + } + return nil + + case mt.PingRequestTypeID: + var ping mt.PingRequest + if err := ping.Decode(b); err != nil { + return fmt.Errorf("decode ping: %w", err) + } + ackContent() + return s.sendPong(ctx, c, msgID, ping.PingID) + + case mt.PingDelayDisconnectRequestTypeID: + var ping mt.PingDelayDisconnectRequest + if err := ping.Decode(b); err != nil { + return fmt.Errorf("decode ping_delay_disconnect: %w", err) + } + ackContent() + return s.sendPong(ctx, c, msgID, ping.PingID) + + case mt.GetFutureSaltsRequestTypeID: + var req mt.GetFutureSaltsRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode get_future_salts: %w", err) + } + ackContent() + return s.sendFutureSalts(ctx, c, msgID, req.Num) + + case mt.MsgsAckTypeID: + var ack mt.MsgsAck + if err := ack.Decode(b); err != nil { + return fmt.Errorf("decode msgs_ack: %w", err) + } + c.AckServerMessages(ack.MsgIDs) + s.log.Debug("Received msgs_ack", zap.Int64s("msg_ids", ack.MsgIDs)) + return nil + + case mt.MsgsStateReqTypeID: + var req mt.MsgsStateReq + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode msgs_state_req: %w", err) + } + ackContent() + outgoing, err := c.OutgoingStateInfo(ctx, req.MsgIDs) + if err != nil { + return err + } + return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs))) + + case mt.MsgResendReqTypeID: + var req mt.MsgResendReq + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode msg_resend_req: %w", err) + } + ackContent() + outgoing, err := c.ResendMessages(ctx, req.MsgIDs) + if err != nil { + return err + } + return s.sendMsgsStateInfo(ctx, c, msgID, mergeStateInfo(outgoing, cs.stateInfo(req.MsgIDs))) + + case mt.MsgsStateInfoTypeID: + var info mt.MsgsStateInfo + if err := info.Decode(b); err != nil { + return fmt.Errorf("decode msgs_state_info: %w", err) + } + s.log.Debug("Received msgs_state_info", zap.Int64("req_msg_id", info.ReqMsgID), zap.Int("len", len(info.Info))) + return nil + + case mt.MsgsAllInfoTypeID: + var info mt.MsgsAllInfo + if err := info.Decode(b); err != nil { + return fmt.Errorf("decode msgs_all_info: %w", err) + } + s.log.Debug("Received msgs_all_info", zap.Int("msg_ids", len(info.MsgIDs)), zap.Int("len", len(info.Info))) + return nil + + case mt.DestroySessionRequestTypeID: + var req mt.DestroySessionRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode destroy_session: %w", err) + } + ackContent() + return s.sendDestroySession(ctx, c, req.SessionID) + + case mt.HTTPWaitRequestTypeID: + var req mt.HTTPWaitRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode http_wait: %w", err) + } + s.log.Debug("Received http_wait", + zap.Int("max_delay", req.MaxDelay), + zap.Int("wait_after", req.WaitAfter), + zap.Int("max_wait", req.MaxWait), + ) + return nil + + case mt.RPCDropAnswerRequestTypeID: + var req mt.RPCDropAnswerRequest + if err := req.Decode(b); err != nil { + return fmt.Errorf("decode rpc_drop_answer: %w", err) + } + ackContent() + s.log.Debug("Received rpc_drop_answer", zap.Int64("req_msg_id", req.ReqMsgID)) + return s.sendResult(ctx, c, msgID, &mt.RPCAnswerUnknown{}) + + case destroyAuthKeyRequestTypeID: + var req destroyAuthKeyRequest + if err := req.Decode(b); err != nil { + return err + } + ackContent() + s.log.Debug("Received destroy_auth_key", zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:]))) + return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyOk{}) + + default: + ackContent() + body := b.Copy() + return s.enqueueRPC(ctx, c, msgID, body) + } +} + +func mergeStateInfo(primary, fallback []byte) []byte { + if len(primary) == 0 { + return fallback + } + info := make([]byte, len(fallback)) + copy(info, fallback) + for i, state := range primary { + if i >= len(info) { + break + } + if state != 0 { + info[i] = state + } + } + return info +} + +func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, body []byte) error { + id, _ := (&bin.Buffer{Buf: body}).PeekID() + method := s.typeName(id) + err := c.enqueueInboundRPC(ctx, inboundRPC{ + method: method, + size: len(body), + run: func(taskCtx context.Context) error { + // body 已是 enqueueRPC 入参的独立副本(dispatch 里 b.Copy()),且每个任务只 run 一次, + // 无需再 append 拷贝;直接复用,省掉一份 inbound 在途内存。 + if err := s.handleRPC(taskCtx, c, msgID, &bin.Buffer{Buf: body}); err != nil { + s.log.Info("RPC async handler failed", + zap.Int64("msg_id", msgID), + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", c.sessionID), + zap.Error(err), + ) + return err + } + return nil + }, + }) + if errors.Is(err, ErrInboundRPCQueueFull) { + s.log.Debug("Inbound RPC queue full", + 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 s.sendResult(ctx, c, msgID, &mt.RPCError{ + ErrorCode: 420, + ErrorMessage: "FLOOD_WAIT_1", + }) + } + return err +} + +// handleRPC 把明文 RPC 请求交给 RPC 路由,并将结果或错误包成 rpc_result 回发。 +func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, b *bin.Buffer) error { + id, _ := b.PeekID() + method := s.typeName(id) + if s.rpc == nil { + s.log.Warn("No RPC handler configured; dropping request", zap.String("method", method)) + return nil + } + + 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) + + fields := []zap.Field{ + zap.String("method", method), + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", c.sessionID), + zap.Int64("msg_id", msgID), + zap.Duration("dur", dur), + } + if businessAuthKeyID, ok := c.BusinessAuthKeyID(); ok { + fields = append(fields, zap.String("business_auth_key_id", hex.EncodeToString(businessAuthKeyID[:]))) + } + if userID := c.UserID(); userID != 0 { + fields = append(fields, zap.Int64("user_id", userID)) + } + + if err != nil { + var rpcErr *tgerr.Error + if errors.As(err, &rpcErr) { + s.log.Info("RPC error", append(fields, zap.Int("code", rpcErr.Code), zap.String("error", rpcErr.Message))...) + return s.sendResult(ctx, c, msgID, &mt.RPCError{ + ErrorCode: rpcErr.Code, + ErrorMessage: rpcErr.Message, + }) + } + s.log.Info("RPC internal error", append(fields, zap.Error(err))...) + return s.sendResult(ctx, c, msgID, &mt.RPCError{ + ErrorCode: 500, + ErrorMessage: "INTERNAL", + }) + } + + s.log.Info("RPC handled", fields...) + return s.sendResult(ctx, c, msgID, result) +} + +// sendResult 把 RPC 结果包成 rpc_result 并加密回发。 +func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) error { + var buf bin.Buffer + if err := result.Encode(&buf); err != nil { + return fmt.Errorf("encode rpc result: %w", err) + } + return c.Send(ctx, proto.MessageServerResponse, &proto.Result{ + RequestMessageID: reqMsgID, + Result: buf.Raw(), + }) +} + +// sendPong 回复 mt.PingRequest / mt.PingDelayDisconnectRequest。 +func (s *Server) sendPong(ctx context.Context, c *Conn, reqMsgID, pingID int64) error { + return c.SendAsync(ctx, proto.MessageServerResponse, &mt.Pong{MsgID: reqMsgID, PingID: pingID}) +} + +// sendFutureSalts 回复 MTProto get_future_salts。 +// +// 第一阶段只维护当前 auth key 的权威 server_salt,因此返回当前 salt 的有效窗口。 +// 后续如引入 salt rotation,可在这里扩展为多条未来 salt。 +func (s *Server) sendFutureSalts(ctx context.Context, c *Conn, reqMsgID int64, num int) error { + if num < 0 { + num = 0 + } + if num > 1 { + num = 1 + } + now := int(s.clock.Now().Unix()) + salts := make([]mt.FutureSalt, 0, num) + if num == 1 { + salts = append(salts, mt.FutureSalt{ + ValidSince: now - 300, + ValidUntil: now + 24*60*60, + Salt: c.salt, + }) + } + return c.SendAsync(ctx, proto.MessageServerResponse, &mt.FutureSalts{ + ReqMsgID: reqMsgID, + Now: now, + Salts: salts, + }) +} + +// sendNewSessionCreated 在连接首个加密消息后通知客户端新 session 已建立。 +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, + ServerSalt: c.salt, + }) +} + +// 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}) +} + +// sendMsgsStateInfo 回复 msgs_state_req/msg_resend_req。 +func (s *Server) sendMsgsStateInfo(ctx context.Context, c *Conn, reqMsgID int64, info []byte) error { + return c.SendAsync(ctx, proto.MessageServerResponse, &mt.MsgsStateInfo{ReqMsgID: reqMsgID, Info: info}) +} + +func (s *Server) sendDestroySession(ctx context.Context, c *Conn, sessionID int64) error { + removed := false + if sessionID != c.sessionID { + removed = s.conns.DestroySessionForAuthKey(c.authKeyID, sessionID) + if err := s.sessions.Delete(ctx, sessionID); err != nil { + s.log.Debug("Delete session record failed", + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", sessionID), + zap.Error(err), + ) + } + } + if removed { + return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionOk{SessionID: sessionID}) + } + return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionNone{SessionID: sessionID}) +} + +// sendBadMsg 通知客户端消息存在协议层错误(msg_id/seqno 非法)。 +func (s *Server) sendBadMsg(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, code int) error { + return c.SendAsync(ctx, proto.MessageFromServer, &mt.BadMsgNotification{ + BadMsgID: badMsgID, + BadMsgSeqno: int(badSeqno), + ErrorCode: code, + }) +} + +// sendBadServerSalt 通知客户端修正 server_salt(error_code 48)。 +func (s *Server) sendBadServerSalt(ctx context.Context, c *Conn, badMsgID int64, badSeqno int32, newSalt int64) error { + return c.SendPriority(ctx, proto.MessageFromServer, &mt.BadServerSalt{ + BadMsgID: badMsgID, + BadMsgSeqno: int(badSeqno), + ErrorCode: 48, + NewServerSalt: newSalt, + }) +} + +// typeName 返回 TL TypeID 的可读名称,未知时回退到 hex。 +func (s *Server) typeName(id uint32) string { + if name := s.types.Get(id); name != "" { + return name + } + return fmt.Sprintf("%#x", id) +} + +func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint32) int { + if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { + return badMsgIDInvalidBits + } + msgTime := proto.MessageID(msgID).Time() + if msgTime.Before(now.Add(-300 * time.Second)) { + return badMsgIDTooLow + } + if msgTime.After(now.Add(30 * time.Second)) { + return badMsgIDTooHigh + } + if clientMessageNeedsAck(typeID) { + if seqNo%2 == 0 { + return badMsgSeqNotOdd + } + } else if seqNo%2 != 0 { + return badMsgSeqNotEven + } + return 0 +} + +func validateClientContainer(containerMsgID int64, containerSeqNo int32, container proto.MessageContainer) int { + for _, m := range container.Messages { + if m.ID >= containerMsgID || int32(m.SeqNo) > containerSeqNo { + return badMsgContainer + } + typeID, err := (&bin.Buffer{Buf: m.Body}).PeekID() + if err != nil { + return badMsgContainer + } + if typeID == proto.MessageContainerTypeID { + return badMsgContainer + } + if code := validateClientContainerEnvelope(m.ID, int32(m.SeqNo), typeID); code != 0 { + return badMsgContainer + } + } + return 0 +} + +func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int { + if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { + return badMsgIDInvalidBits + } + if clientMessageNeedsAck(typeID) { + if seqNo%2 == 0 { + return badMsgSeqNotOdd + } + } else if seqNo%2 != 0 { + return badMsgSeqNotEven + } + return 0 +} + +func clientMessageNeedsAck(typeID uint32) bool { + switch typeID { + case proto.MessageContainerTypeID, + mt.MsgsAckTypeID, + mt.HTTPWaitRequestTypeID, + mt.BadMsgNotificationTypeID, + mt.BadServerSaltTypeID, + mt.MsgsAllInfoTypeID, + mt.MsgsStateInfoTypeID, + mt.MsgDetailedInfoTypeID, + mt.MsgNewDetailedInfoTypeID: + return false + default: + return true + } +} + +func (cs *connState) seenRecord(msgID int64) (clientMsgRecord, bool) { + record, ok := cs.seen[msgID] + return record, ok +} + +func (cs *connState) validateSeq(msgID int64, seqNo int32, content bool) int { + if !content { + return 0 + } + for seenMsgID, record := range cs.seen { + if !record.content { + continue + } + if seenMsgID < msgID && record.seqNo >= seqNo { + return badMsgSeqTooLow + } + if seenMsgID > msgID && record.seqNo <= seqNo { + return badMsgSeqTooHigh + } + } + return 0 +} + +func (cs *connState) track(msgID int64, seqNo int32, content bool, state byte) { + cs.seen[msgID] = clientMsgRecord{ + state: state, + seqNo: seqNo, + content: content, + } + cs.order = append(cs.order, msgID) + if msgID < cs.minSeen { + cs.minSeen = msgID + } + if msgID > cs.maxSeen { + cs.maxSeen = msgID + } + if len(cs.order) > maxTrackedClientMsgIDs { + oldest := cs.order[0] + cs.order = cs.order[1:] + delete(cs.seen, oldest) + if oldest == cs.minSeen || oldest == cs.maxSeen { + cs.recomputeRange() + } + } +} + +func (cs *connState) stateInfo(msgIDs []int64) []byte { + info := make([]byte, len(msgIDs)) + if len(cs.seen) == 0 { + for i := range info { + info[i] = msgStateUnknown + } + return info + } + for i, id := range msgIDs { + if id < cs.minSeen { + info[i] = msgStateUnknown + continue + } + if id > cs.maxSeen { + info[i] = msgStateNotReceivedHigh + continue + } + record, ok := cs.seen[id] + if !ok { + info[i] = msgStateNotReceived + continue + } + info[i] = record.state + } + return info +} + +func (cs *connState) recomputeRange() { + cs.minSeen = math.MaxInt64 + cs.maxSeen = 0 + for id := range cs.seen { + if id < cs.minSeen { + cs.minSeen = id + } + if id > cs.maxSeen { + cs.maxSeen = id + } + } + if len(cs.seen) == 0 { + cs.minSeen = math.MaxInt64 + } +} diff --git a/internal/mtprotoedge/encrypted_test.go b/internal/mtprotoedge/encrypted_test.go new file mode 100644 index 00000000..248c6c75 --- /dev/null +++ b/internal/mtprotoedge/encrypted_test.go @@ -0,0 +1,435 @@ +package mtprotoedge + +import ( + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" +) + +// TestEncryptedPingPong 验证 M2/M4:握手后 client 加密 ping, +// server 回 new_session_created + pong + msgs_ack。 +func TestEncryptedPingPong(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) + const pingID int64 = 0x1234beef + pingMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncrypted(t, conn, cipher, auth, pingMsgID, &mt.PingRequest{PingID: pingID}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created") + pongBuf := mustHave(t, replies, mt.PongTypeID, "pong") + + var pong mt.Pong + if err := pong.Decode(pongBuf); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong.PingID != pingID { + t.Fatalf("pong.PingID = %#x, want %#x", pong.PingID, pingID) + } + if pong.MsgID != pingMsgID { + t.Fatalf("pong.MsgID = %d, want %d (req msg id)", pong.MsgID, pingMsgID) + } +} + +// TestDuplicateMsgIDIdempotent 验证 M4:相同 msg_id 的重复 content 请求被幂等处理, +// server 重发已缓存的 rpc_result,并重新 ack,不重复执行业务。 +func TestDuplicateMsgIDIdempotent(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) + msgID := clientMsgID.New(proto.MessageFromClient) + + sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4}) + first := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + mustHave(t, first, proto.ResultTypeID, "first rpc_result") + + // 相同 msg_id —— 幂等:重发已有 rpc_result,并重新 ack。 + sendEncrypted(t, conn, cipher, auth, msgID, &mt.RPCDropAnswerRequest{ReqMsgID: msgID - 4}) + second := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + mustHave(t, second, proto.ResultTypeID, "resent rpc_result") + mustHave(t, second, mt.MsgsAckTypeID, "second ack") +} + +// TestGetFutureSalts 验证 MTProto service message get_future_salts 由连接层直接响应, +// 不再落到业务 RPC fallback。 +func TestGetFutureSalts(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) + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.GetFutureSaltsRequest{Num: 32}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.FutureSaltsTypeID) + buf := mustHave(t, replies, mt.FutureSaltsTypeID, "future_salts") + + var salts mt.FutureSalts + if err := salts.Decode(buf); err != nil { + t.Fatalf("decode future_salts: %v", err) + } + if salts.ReqMsgID != reqMsgID { + t.Fatalf("future_salts.req_msg_id = %d, want %d", salts.ReqMsgID, reqMsgID) + } + if len(salts.Salts) != 1 { + t.Fatalf("future_salts len = %d, want 1", len(salts.Salts)) + } + if got := salts.Salts[0].Salt; got != auth.ServerSalt { + t.Fatalf("future salt = %#x, want server salt %#x", got, auth.ServerSalt) + } + if salts.Salts[0].ValidSince > salts.Now || salts.Salts[0].ValidUntil <= salts.Now { + t.Fatalf("future salt validity = [%d,%d], now %d", salts.Salts[0].ValidSince, salts.Salts[0].ValidUntil, salts.Now) + } +} + +// TestMsgsStateReq 验证 MTProto service message msgs_state_req 由连接层直接响应, +// 不再落到业务 RPC fallback。 +func TestMsgsStateReq(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) + asked := []int64{reqMsgID, reqMsgID - 4, reqMsgID + 4} + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.MsgsStateReq{MsgIDs: asked}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsStateInfoTypeID) + buf := mustHave(t, replies, mt.MsgsStateInfoTypeID, "msgs_state_info") + + var info mt.MsgsStateInfo + if err := info.Decode(buf); err != nil { + t.Fatalf("decode msgs_state_info: %v", err) + } + if info.ReqMsgID != reqMsgID { + t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID) + } + if len(info.Info) != len(asked) { + t.Fatalf("msgs_state_info len = %d, want %d", len(info.Info), len(asked)) + } + want := []byte{4, 1, 3} + for i, b := range info.Info { + if b != want[i] { + t.Fatalf("msgs_state_info[%d] = %d, want %d", i, b, want[i]) + } + } +} + +// TestMsgResendReq 验证 MTProto msg_resend_req 由连接层按状态查询兜底响应, +// 不会落入业务 RPC fallback。 +func TestMsgResendReq(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) + asked := []int64{reqMsgID, reqMsgID - 4, reqMsgID + 4} + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.MsgResendReq{MsgIDs: asked}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsStateInfoTypeID) + buf := mustHave(t, replies, mt.MsgsStateInfoTypeID, "msgs_state_info") + + var info mt.MsgsStateInfo + if err := info.Decode(buf); err != nil { + t.Fatalf("decode msgs_state_info: %v", err) + } + if info.ReqMsgID != reqMsgID { + t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID) + } + if len(info.Info) != len(asked) { + t.Fatalf("msgs_state_info len = %d, want %d", len(info.Info), len(asked)) + } + want := []byte{4, 1, 3} + for i, b := range info.Info { + if b != want[i] { + t.Fatalf("msgs_state_info[%d] = %d, want %d", i, b, want[i]) + } + } +} + +// TestDestroySession 验证 destroy_session 返回 raw DestroySessionRes, +// 避免客户端清理旧 session 时掉到 RPC fallback。 +func TestDestroySession(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) + targetSessionID := auth.SessionID + 4 + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.DestroySessionRequest{SessionID: targetSessionID}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.DestroySessionNoneTypeID) + buf := mustHave(t, replies, mt.DestroySessionNoneTypeID, "destroy_session_none") + + var res mt.DestroySessionNone + if err := res.Decode(buf); err != nil { + t.Fatalf("decode destroy_session_none: %v", err) + } + if res.SessionID != targetSessionID { + t.Fatalf("destroy_session_none.session_id = %d, want %d", res.SessionID, targetSessionID) + } +} + +// TestRPCDropAnswer 验证 rpc_drop_answer 以 rpc_result 包装 RpcDropAnswer 返回, +// 与 gotd/td 和 TDesktop 的请求/响应模型对齐。 +func TestRPCDropAnswer(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) + droppedReqID := reqMsgID - 4 + sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.RPCDropAnswerRequest{ReqMsgID: droppedReqID}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID) + buf := mustHave(t, replies, proto.ResultTypeID, "rpc_result") + + var result proto.Result + if err := result.Decode(buf); err != nil { + t.Fatalf("decode rpc_result: %v", err) + } + if result.RequestMessageID != reqMsgID { + t.Fatalf("rpc_result.req_msg_id = %d, want %d", result.RequestMessageID, reqMsgID) + } + answer, err := mt.DecodeRPCDropAnswer(&bin.Buffer{Buf: result.Result}) + if err != nil { + t.Fatalf("decode RpcDropAnswer: %v", err) + } + if _, ok := answer.(*mt.RPCAnswerUnknown); !ok { + t.Fatalf("RpcDropAnswer = %T, want *mt.RPCAnswerUnknown", answer) + } +} + +// TestHTTPWaitInContainerDoesNotNeedAck 验证 http_wait 在 container 中被协议层吞掉, +// 但同 container 内的 ping 仍按 content-related service request 回 ack。 +func TestHTTPWaitInContainerDoesNotNeedAck(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) + waitMsgID := clientMsgID.New(proto.MessageFromClient) + pingMsgID := clientMsgID.New(proto.MessageFromClient) + containerMsgID := clientMsgID.New(proto.MessageFromClient) + waitBody := mustEncodeTL(t, &mt.HTTPWaitRequest{MaxDelay: 0, WaitAfter: 0, MaxWait: 25_000}) + pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 7}) + sendEncrypted(t, conn, cipher, auth, containerMsgID, &proto.MessageContainer{ + Messages: []proto.Message{ + {ID: waitMsgID, SeqNo: 0, Bytes: len(waitBody), Body: waitBody}, + {ID: pingMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody}, + }, + }) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + mustHave(t, replies, mt.PongTypeID, "pong") + ackBuf := mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack") + var ack mt.MsgsAck + if err := ack.Decode(ackBuf); err != nil { + t.Fatalf("decode msgs_ack: %v", err) + } + if len(ack.MsgIDs) != 1 || ack.MsgIDs[0] != pingMsgID { + t.Fatalf("msgs_ack = %+v, want only ping msg_id %d", ack.MsgIDs, pingMsgID) + } +} + +// TestOldMessageInFreshContainerAccepted verifies TDesktop's bad_msg recovery +// path: an old request can be resent inside a fresh container msg_id. +func TestOldMessageInFreshContainerAccepted(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + oldMsgIDGen := proto.NewMessageIDGen(func() time.Time { + return time.Now().Add(-10 * time.Minute) + }) + freshMsgIDGen := proto.NewMessageIDGen(time.Now) + oldPingMsgID := oldMsgIDGen.New(proto.MessageFromClient) + containerMsgID := freshMsgIDGen.New(proto.MessageFromClient) + pingBody := mustEncodeTL(t, &mt.PingRequest{PingID: 42}) + + sendEncrypted(t, conn, cipher, auth, containerMsgID, &proto.MessageContainer{ + Messages: []proto.Message{ + {ID: oldPingMsgID, SeqNo: 1, Bytes: len(pingBody), Body: pingBody}, + }, + }) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + buf := mustHave(t, replies, mt.PongTypeID, "pong") + var pong mt.Pong + if err := pong.Decode(buf); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong.MsgID != oldPingMsgID || pong.PingID != 42 { + t.Fatalf("pong = %+v, want msg_id=%d ping_id=42", pong, oldPingMsgID) + } +} + +func TestPingDelayDisconnectOddSeqAccepted(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, 1, &mt.PingDelayDisconnectRequest{ + PingID: 9, + DisconnectDelay: 60, + }) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + buf := mustHave(t, replies, mt.PongTypeID, "pong") + var pong mt.Pong + if err := pong.Decode(buf); err != nil { + t.Fatalf("decode pong: %v", err) + } + if pong.MsgID != reqMsgID || pong.PingID != 9 { + t.Fatalf("pong = %+v, want msg_id=%d ping_id=9", pong, reqMsgID) + } +} + +// TestDestroyAuthKey 验证 MTProto service message destroy_auth_key 由连接层直接响应, +// 避免 TDesktop 清理旧 key 时落到业务 RPC fallback。 +func TestDestroyAuthKey(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) + sendEncrypted(t, conn, cipher, auth, reqMsgID, &destroyAuthKeyRequest{}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, destroyAuthKeyOkTypeID) + mustHave(t, replies, destroyAuthKeyOkTypeID, "destroy_auth_key_ok") +} + +// TestBadServerSalt 验证客户端带错 server_salt 时 server 返回 bad_server_salt, +// 并携带当前 auth key 的权威 salt。 +func TestBadServerSalt(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) + wrongSalt := auth.ServerSalt + 1 + sendEncryptedWithSalt(t, conn, cipher, auth, wrongSalt, reqMsgID, &mt.PingRequest{PingID: 1}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.BadServerSaltTypeID) + buf := mustHave(t, replies, mt.BadServerSaltTypeID, "bad_server_salt") + + var bad mt.BadServerSalt + if err := bad.Decode(buf); err != nil { + t.Fatalf("decode bad_server_salt: %v", err) + } + if bad.BadMsgID != reqMsgID { + t.Fatalf("bad_server_salt.bad_msg_id = %d, want %d", bad.BadMsgID, reqMsgID) + } + if bad.ErrorCode != 48 { + t.Fatalf("bad_server_salt.error_code = %d, want 48", bad.ErrorCode) + } + if bad.NewServerSalt != auth.ServerSalt { + t.Fatalf("bad_server_salt.new_server_salt = %#x, want %#x", bad.NewServerSalt, auth.ServerSalt) + } +} + +func TestBadMsgSeqOddExpected(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, &tg.HelpGetConfigRequest{}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != reqMsgID || bad.BadMsgSeqno != 0 || bad.ErrorCode != badMsgSeqNotOdd { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=0 code=%d", bad, reqMsgID, badMsgSeqNotOdd) + } +} + +func TestBadMsgSeqEvenExpected(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, 1, &mt.MsgsAck{MsgIDs: []int64{reqMsgID}}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != reqMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqNotEven { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=1 code=%d", bad, reqMsgID, badMsgSeqNotEven) + } +} + +func TestBadMsgSeqTooLow(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, 3, &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + + secondMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, secondMsgID, 1, &tg.HelpGetConfigRequest{}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != secondMsgID || bad.BadMsgSeqno != 1 || bad.ErrorCode != badMsgSeqTooLow { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=1 code=%d", bad, secondMsgID, badMsgSeqTooLow) + } +} + +func TestBadMsgSeqTooHigh(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) + lowMsgID := clientMsgID.New(proto.MessageFromClient) + highMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, highMsgID, 1, &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + + sendEncryptedWithSeq(t, conn, cipher, auth, lowMsgID, 3, &tg.HelpGetConfigRequest{}) + + bad := readBadMsgNotification(t, conn, cipher, auth.AuthKey) + if bad.BadMsgID != lowMsgID || bad.BadMsgSeqno != 3 || bad.ErrorCode != badMsgSeqTooHigh { + t.Fatalf("bad_msg = %+v, want msg_id=%d seq=3 code=%d", bad, lowMsgID, badMsgSeqTooHigh) + } +} + +func readBadMsgNotification(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) mt.BadMsgNotification { + t.Helper() + replies := collectReplies(t, conn, cipher, key, mt.BadMsgNotificationTypeID) + buf := mustHave(t, replies, mt.BadMsgNotificationTypeID, "bad_msg_notification") + var bad mt.BadMsgNotification + if err := bad.Decode(buf); err != nil { + t.Fatalf("decode bad_msg_notification: %v", err) + } + return bad +} + +func mustEncodeTL(t *testing.T, msg bin.Encoder) []byte { + t.Helper() + var b bin.Buffer + if err := msg.Encode(&b); err != nil { + t.Fatalf("encode TL: %v", err) + } + return b.Copy() +} diff --git a/internal/mtprotoedge/exchange.go b/internal/mtprotoedge/exchange.go new file mode 100644 index 00000000..d9703c50 --- /dev/null +++ b/internal/mtprotoedge/exchange.go @@ -0,0 +1,152 @@ +package mtprotoedge + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/transport" + + "telesrv/internal/store" +) + +// emptyAuthKeyID 是未加密消息(密钥交换)的 auth_key_id(全零)。 +var emptyAuthKeyID [8]byte + +// peekAuthKeyID 读取消息前 8 字节的 auth_key_id,不消费 buffer。 +func peekAuthKeyID(b *bin.Buffer) (id [8]byte, err error) { + err = b.PeekN(id[:], len(id)) + return id, err +} + +// handleExchange 在收到 auth_key_id==0 的首帧后执行服务端 MTProto 密钥交换。 +// +// first 是已读取的首帧(req_pq*),通过 bufferedConn 交还给 exchange 流程, +// 使其能从头读取握手消息。成功后将 auth key + server salt 落入 AuthKeyStore。 +func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first *bin.Buffer) (*bin.Buffer, error) { + if s.key.Zero() { + s.log.Error("Key exchange requested but server RSA key is not configured") + return nil, s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound) + } + + buffered := newBufferedConn(conn) + buffered.push(first) + + start := s.clock.Now() + res, err := exchange.NewExchanger(buffered, s.dc). + WithClock(s.clock). + WithRand(s.rand). + WithLogger(s.log.Named("exchange")). + Server(s.key). + Run(ctx) + 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 + } + } + var exErr *exchange.ServerExchangeError + if errors.As(err, &exErr) { + s.log.Info("Key exchange rejected", zap.Int32("code", exErr.Code), zap.Error(err)) + return nil, s.sendProtoError(ctx, conn, exErr.Code) + } + return nil, fmt.Errorf("key exchange: %w", err) + } + + s.metrics.HandshakeDone(s.clock.Now().Sub(start)) + s.log.Info("Key exchange completed", + zap.Object("auth_key", res.Key), + zap.Int64("server_salt", res.ServerSalt), + zap.Duration("dur", s.clock.Now().Sub(start)), + ) + + 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{ + ID: key.ID, + Value: [256]byte(key.Value), + ServerSalt: salt, + CreatedAt: createdAt, + } +} + +// sendProtoError 向客户端发送 transport 级协议错误(-code)。 +func (s *Server) sendProtoError(ctx context.Context, conn transport.Conn, code int32) error { + var buf bin.Buffer + buf.PutInt32(-code) + + ctx, cancel := context.WithTimeout(ctx, s.writeTimeout) + defer cancel() + if err := conn.Send(ctx, &buf); err != nil { + return fmt.Errorf("send proto error %d: %w", code, err) + } + return nil +} + +// 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 +} + +func newBufferedConn(conn transport.Conn) *bufferedConn { + return &bufferedConn{Conn: conn} +} + +func (c *bufferedConn) push(b *bin.Buffer) { + c.mu.Lock() + c.pending = append(c.pending, bin.Buffer{Buf: b.Copy()}) + c.mu.Unlock() +} + +// Recv 优先返回已 push 的帧(FIFO),耗尽后读取底层连接。 +func (c *bufferedConn) Recv(ctx context.Context, b *bin.Buffer) error { + c.mu.Lock() + if len(c.pending) > 0 { + e := c.pending[0] + c.pending = c.pending[1:] + c.last.ResetTo(e.Copy()) + c.mu.Unlock() + b.ResetTo(e.Buf) + return nil + } + c.mu.Unlock() + if err := c.Conn.Recv(ctx, b); err != nil { + return err + } + c.mu.Lock() + c.last.ResetTo(b.Copy()) + c.mu.Unlock() + return nil +} + +func (c *bufferedConn) lastFrame() *bin.Buffer { + c.mu.Lock() + defer c.mu.Unlock() + if c.last.Len() == 0 { + return nil + } + return &bin.Buffer{Buf: c.last.Copy()} +} diff --git a/internal/mtprotoedge/exchange_test.go b/internal/mtprotoedge/exchange_test.go new file mode 100644 index 00000000..d03c885e --- /dev/null +++ b/internal/mtprotoedge/exchange_test.go @@ -0,0 +1,171 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/exchange" + "github.com/gotd/td/mt" + tgproto "github.com/gotd/td/proto" + "github.com/gotd/td/transport" + + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +// TestKeyExchange 验证 M1:client 用 server 公钥完成 MTProto 密钥交换, +// 双方得到一致的 auth key 与 server salt,且 server 将其存入 AuthKeyStore。 +func TestKeyExchange(t *testing.T) { + 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) + } + + keys := memory.NewAuthKeyStore() + srv := New(Options{ + Logger: zaptest.NewLogger(t), + DC: dc, + RSAKey: rsaKey, + AuthKeys: keys, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + // client:TCP 拨号 + intermediate 握手,跑 client 端密钥交换。 + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("transport handshake: %v", err) + } + + pub := exchange.PublicKey{RSA: &rsaKey.PublicKey} + exchCtx, ec := context.WithTimeout(context.Background(), 10*time.Second) + defer ec() + res, err := exchange.NewExchanger(conn, dc). + WithRand(rand.Reader). + WithLogger(zaptest.NewLogger(t).Named("client")). + Client([]exchange.PublicKey{pub}). + Run(exchCtx) + if err != nil { + t.Fatalf("client exchange: %v", err) + } + + // server 在 Run 返回后落库,轮询等待。 + var saved store.AuthKeyData + found := false + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + saved, found, _ = keys.Get(context.Background(), res.AuthKey.ID) + if found { + break + } + time.Sleep(20 * time.Millisecond) + } + if !found { + t.Fatalf("server did not store auth key %x", res.AuthKey.ID) + } + if saved.Value != [256]byte(res.AuthKey.Value) { + t.Fatal("server auth key value mismatch") + } + if saved.ServerSalt != res.ServerSalt { + t.Fatalf("server salt mismatch: server=%d client=%d", saved.ServerSalt, res.ServerSalt) + } + + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop after ctx cancel") + } +} + +func TestReconnectFakeReqPQThenEncryptedFrame(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + + firstConn, auth, cipher := dialHandshake(t, addr, dc, pub) + _ = firstConn.Close() + + raw, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial reconnect: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("transport reconnect: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + var reqPayload bin.Buffer + nonce, err := randInt128ForTest() + if err != nil { + t.Fatalf("nonce: %v", err) + } + if err := (&mt.ReqPqMultiRequest{Nonce: nonce}).Encode(&reqPayload); err != nil { + t.Fatalf("encode req_pq_multi: %v", err) + } + var fakeReq bin.Buffer + if err := (tgproto.UnencryptedMessage{ + MessageID: int64(tgproto.NewMessageID(time.Now(), tgproto.MessageFromClient)), + MessageData: reqPayload.Raw(), + }).Encode(&fakeReq); err != nil { + t.Fatalf("encode fake req_pq: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := conn.Send(ctx, &fakeReq); err != nil { + cancel() + t.Fatalf("send fake req_pq: %v", err) + } + cancel() + + msgGen := tgproto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn, cipher, auth, msgGen.New(tgproto.MessageFromClient), &mt.PingRequest{PingID: 7}) + + var resPQFrame bin.Buffer + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + err = conn.Recv(ctx, &resPQFrame) + cancel() + if err != nil { + t.Fatalf("recv resPQ: %v", err) + } + var plain tgproto.UnencryptedMessage + if err := plain.Decode(&resPQFrame); err != nil { + t.Fatalf("decode resPQ frame: %v", err) + } + if id, err := (&bin.Buffer{Buf: plain.MessageData}).PeekID(); err != nil || id != mt.ResPQTypeID { + t.Fatalf("resPQ payload id = %#x err=%v, want %#x", id, err, mt.ResPQTypeID) + } + + got := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID) + mustHave(t, got, mt.PongTypeID, "pong after fake req_pq reconnect") +} + +func randInt128ForTest() (v bin.Int128, err error) { + _, err = rand.Read(v[:]) + return v, err +} diff --git a/internal/mtprotoedge/helpers_test.go b/internal/mtprotoedge/helpers_test.go new file mode 100644 index 00000000..88a384be --- /dev/null +++ b/internal/mtprotoedge/helpers_test.go @@ -0,0 +1,209 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/proto" + "github.com/gotd/td/transport" +) + +// startTestServer 生成 RSA key、监听随机端口并启动 Server,返回监听地址与公钥。 +// 通过 t.Cleanup 自动取消并校验优雅退出。opts 的 RSAKey/Logger/DC 会被补默认。 +func startTestServer(t *testing.T, opts Options) (addr string, pub exchange.PublicKey, srv *Server) { + t.Helper() + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + opts.RSAKey = rsaKey + if opts.Logger == nil { + opts.Logger = zaptest.NewLogger(t) + } + if opts.DC == 0 { + opts.DC = 2 + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + srv = New(opts) + ctx, cancel := context.WithCancel(context.Background()) + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + t.Cleanup(func() { + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Errorf("serve: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("server did not stop after ctx cancel") + } + }) + + return ln.Addr().String(), exchange.PublicKey{RSA: &rsaKey.PublicKey}, srv +} + +// dialHandshake 建立 TCP 连接、完成 intermediate 协商与 MTProto 密钥交换, +// 返回连接、握手结果与 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() }) + + 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")). + Client([]exchange.PublicKey{pub}). + Run(ctx) + if err != nil { + t.Fatalf("client exchange: %v", err) + } + return conn, auth, crypto.NewClientCipher(rand.Reader) +} + +// 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() + sendEncryptedWithSalt(t, conn, cipher, auth, auth.ServerSalt, msgID, msg) +} + +// sendEncryptedWithSalt 用指定 salt 加密并发送一条消息。 +func sendEncryptedWithSalt(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, salt, msgID int64, msg bin.Encoder) { + t.Helper() + body, seqNo := encodeClientMessageForTest(t, msg) + sendEncryptedWithSaltAndSeq(t, conn, cipher, auth, salt, msgID, seqNo, body) +} + +func sendEncryptedWithSeq(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, seqNo int32, msg bin.Encoder) { + t.Helper() + body := encodeClientMessageBodyForTest(t, msg) + sendEncryptedWithSaltAndSeq(t, conn, cipher, auth, auth.ServerSalt, msgID, seqNo, body) +} + +func sendEncryptedWithSaltAndSeq(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, salt, msgID int64, seqNo int32, body []byte) { + t.Helper() + var buf bin.Buffer + if err := cipher.Encrypt(auth.AuthKey, crypto.EncryptedMessageData{ + Salt: salt, + SessionID: auth.SessionID, + MessageID: msgID, + SeqNo: seqNo, + MessageDataLen: int32(len(body)), + MessageDataWithPadding: body, + }, &buf); err != nil { + t.Fatalf("encrypt: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Send(ctx, &buf); err != nil { + t.Fatalf("send: %v", err) + } +} + +func encodeClientMessageForTest(t *testing.T, msg bin.Encoder) ([]byte, int32) { + t.Helper() + raw := encodeClientMessageBodyForTest(t, msg) + typeID, err := (&bin.Buffer{Buf: raw}).PeekID() + if err != nil { + t.Fatalf("peek encrypted message type: %v", err) + } + if container, ok := msg.(*proto.MessageContainer); ok { + return raw, clientContainerSeqNoForTest(container) + } + if clientMessageNeedsAck(typeID) { + return raw, 1 + } + return raw, 0 +} + +func encodeClientMessageBodyForTest(t *testing.T, msg bin.Encoder) []byte { + t.Helper() + var body bin.Buffer + if err := msg.Encode(&body); err != nil { + t.Fatalf("encode encrypted message: %v", err) + } + return body.Copy() +} + +func clientContainerSeqNoForTest(container *proto.MessageContainer) int32 { + var maxSeq int32 + for _, msg := range container.Messages { + if seq := int32(msg.SeqNo); seq > maxSeq { + maxSeq = seq + } + } + if maxSeq%2 != 0 { + maxSeq++ + } + return maxSeq +} + +// collectReplies 读取并解密 server 回发的消息,按 TypeID 收集明文 buffer, +// 直到见到 wantID(含)或达到上限。用于断言一次请求触发的多条响应 +// (new_session_created / 业务响应 / msgs_ack)。 +func collectReplies(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey, wantID uint32) map[uint32]*bin.Buffer { + t.Helper() + got := make(map[uint32]*bin.Buffer) + for i := 0; i < 8; i++ { + _, id, plain := readServerMessage(t, conn, cipher, key) + got[id] = plain + if id == wantID { + break + } + } + return got +} + +func readServerMessage(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey) (*crypto.EncryptedMessageData, uint32, *bin.Buffer) { + t.Helper() + var buf bin.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := conn.Recv(ctx, &buf) + cancel() + if err != nil { + t.Fatalf("recv server message: %v", err) + } + data, err := cipher.DecryptFromBuffer(key, &buf) + if err != nil { + t.Fatalf("decrypt server message: %v", err) + } + plain := append([]byte(nil), data.Data()...) + id, err := (&bin.Buffer{Buf: plain}).PeekID() + if err != nil { + t.Fatalf("peek server message: %v", err) + } + return data, id, &bin.Buffer{Buf: plain} +} + +// mustHave 断言 replies 含指定 TypeID 的消息并返回其 buffer。 +func mustHave(t *testing.T, replies map[uint32]*bin.Buffer, id uint32, name string) *bin.Buffer { + t.Helper() + b, ok := replies[id] + if !ok { + t.Fatalf("missing %s (%#x)", name, id) + } + return b +} diff --git a/internal/mtprotoedge/inbound_rpc.go b/internal/mtprotoedge/inbound_rpc.go new file mode 100644 index 00000000..b2c6bd9b --- /dev/null +++ b/internal/mtprotoedge/inbound_rpc.go @@ -0,0 +1,195 @@ +package mtprotoedge + +import ( + "context" + "errors" + "time" +) + +// ErrInboundRPCQueueFull 表示单连接 RPC 队列已满。 +var ErrInboundRPCQueueFull = errors.New("inbound rpc queue full") + +// maxInflightRPCBytes 是单连接已入队未完成 inbound RPC body 的总字节上限。 +// 队列除按条数(queueSize)限制外,再按字节预算兜底:对抗客户端发满大请求时按字节先拒绝。 +const maxInflightRPCBytes = 32 << 20 // 32 MiB + +// rpcCloseWaitTimeout 是连接关闭时等待 inbound RPC worker 退出的上限。 +const rpcCloseWaitTimeout = 5 * time.Second + +type inboundRPC struct { + ctx context.Context + method string + enqueuedAt time.Time + size int + run func(context.Context) error +} + +func (c *Conn) startInboundRPCScheduler(maxInflight, queueSize int, timeout time.Duration) { + if c.metrics == nil { + c.metrics = NopMetrics{} + } + if maxInflight <= 0 { + maxInflight = 1 + } + if queueSize <= 0 { + queueSize = 1 + } + rootCtx, cancel := context.WithCancel(context.Background()) + c.rpcQueue = make(chan inboundRPC, queueSize) + c.rpcStop = make(chan struct{}) + c.rpcCancel = cancel + c.rpcTimeout = timeout + c.rpcRootCtx = rootCtx + c.rpcMaxInflight = maxInflight + // worker 懒启动:不在此处起 worker;首个 RPC 入队时由 ensureInboundRPCWorkers 起, + // 避免握手后静默 / 纯推送目标连接白白钉住 maxInflight 个 goroutine。 +} + +// ensureInboundRPCWorkers 懒启动 maxInflight 个 RPC worker(仅一次),在 enqueueInboundRPC +// 入队成功后调用。从不发 RPC 的连接(半开 / 纯推送)由此完全不起 worker。 +func (c *Conn) ensureInboundRPCWorkers() { + c.rpcWorkersOnce.Do(func() { + c.rpcWG.Add(c.rpcMaxInflight) + for i := 0; i < c.rpcMaxInflight; i++ { + go c.inboundRPCWorker(c.rpcRootCtx) + } + }) +} + +func (c *Conn) enqueueInboundRPC(ctx context.Context, task inboundRPC) error { + if ctx == nil { + ctx = context.Background() + } + if c.rpcQueue == nil || c.rpcStop == nil { + c.metrics.InboundRPCDropped(task.method, "scheduler_closed") + return ErrConnClosed + } + task.ctx = ctx + task.enqueuedAt = time.Now() + select { + case <-ctx.Done(): + c.metrics.InboundRPCDropped(task.method, "context_done") + return ctx.Err() + case <-c.rpcStop: + c.metrics.InboundRPCDropped(task.method, "scheduler_closed") + return ErrConnClosed + default: + } + // 字节预算:先预扣 size,超 maxInflightRPCBytes 则回滚并拒绝(与条数上限并列的第二道闸)。 + if task.size > 0 { + if c.inflightRPCBytes.Add(int64(task.size)) > maxInflightRPCBytes { + c.inflightRPCBytes.Add(-int64(task.size)) + c.metrics.InboundRPCDropped(task.method, "byte_budget") + return ErrInboundRPCQueueFull + } + } + select { + case c.rpcQueue <- task: + c.ensureInboundRPCWorkers() + c.metrics.InboundRPCQueued(task.method, len(c.rpcQueue), cap(c.rpcQueue)) + return nil + case <-ctx.Done(): + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "context_done") + return ctx.Err() + case <-c.rpcStop: + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "scheduler_closed") + return ErrConnClosed + default: + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "queue_full") + return ErrInboundRPCQueueFull + } +} + +// releaseInflightRPCBytes 归还字节预算。与 enqueueInboundRPC 的预扣严格配对: +// 入队失败时回滚、worker 执行完(runInboundRPC)或排空丢弃(drainInboundRPCQueue)时释放。 +func (c *Conn) releaseInflightRPCBytes(size int) { + if size > 0 { + c.inflightRPCBytes.Add(-int64(size)) + } +} + +func (c *Conn) inboundRPCWorker(rootCtx context.Context) { + defer c.rpcWG.Done() + for { + select { + case <-c.rpcStop: + return + default: + } + select { + case task := <-c.rpcQueue: + c.runInboundRPC(rootCtx, task) + case <-c.rpcStop: + return + } + } +} + +func (c *Conn) runInboundRPC(rootCtx context.Context, task inboundRPC) { + defer c.releaseInflightRPCBytes(task.size) + queueWait := time.Since(task.enqueuedAt) + c.metrics.InboundRPCStarted(task.method, queueWait) + ctx := task.ctx + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stopRoot := context.AfterFunc(rootCtx, cancel) + defer stopRoot() + if c.rpcTimeout > 0 { + var timeoutCancel context.CancelFunc + ctx, timeoutCancel = context.WithTimeout(ctx, c.rpcTimeout) + defer timeoutCancel() + } + _ = task.run(ctx) +} + +func (c *Conn) closeInboundRPCScheduler() { + if c.rpcStop == nil { + return + } + c.rpcClose.Do(func() { + if c.rpcCancel != nil { + c.rpcCancel() + } + close(c.rpcStop) + // 抢占懒启动 Once:若 worker 尚未起,封住其启动,避免后续 ensureInboundRPCWorkers 的 + // rpcWG.Add 与下面的 rpcWG.Wait 并发(WaitGroup 误用)。Once 互斥保证 Add happens-before Wait。 + c.rpcWorkersOnce.Do(func() {}) + c.drainInboundRPCQueue() + // 等 worker 退出,使关闭对 inbound 与 outbound(<-outboundDone)收敛对称;带超时防慢 handler 卡死。 + c.waitInboundWorkers(rpcCloseWaitTimeout) + }) +} + +// waitInboundWorkers 等所有 inbound RPC worker 退出,最长 timeout。超时则放弃等待, +// worker 在其阻塞的底层调用返回后自行退出(rpcCancel 已发,最终收敛)。 +func (c *Conn) waitInboundWorkers(timeout time.Duration) { + done := make(chan struct{}) + go func() { + c.rpcWG.Wait() + close(done) + }() + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } +} + +func (c *Conn) drainInboundRPCQueue() { + for { + select { + case task := <-c.rpcQueue: + c.releaseInflightRPCBytes(task.size) + c.metrics.InboundRPCDropped(task.method, "connection_closed") + default: + return + } + } +} diff --git a/internal/mtprotoedge/inbound_rpc_test.go b/internal/mtprotoedge/inbound_rpc_test.go new file mode 100644 index 00000000..c085b9ef --- /dev/null +++ b/internal/mtprotoedge/inbound_rpc_test.go @@ -0,0 +1,76 @@ +package mtprotoedge + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +func TestInboundRPCSchedulerBoundsConcurrentWork(t *testing.T) { + c := &Conn{metrics: NopMetrics{}} + c.startInboundRPCScheduler(2, 4, time.Second) + defer c.closeInboundRPCScheduler() + + var active atomic.Int64 + var maxActive atomic.Int64 + var done atomic.Int64 + started := make(chan struct{}, 6) + release := make(chan struct{}) + task := inboundRPC{ + method: "test.method", + run: func(ctx context.Context) error { + cur := active.Add(1) + for { + old := maxActive.Load() + if cur <= old || maxActive.CompareAndSwap(old, cur) { + break + } + } + started <- struct{}{} + select { + case <-release: + case <-ctx.Done(): + } + active.Add(-1) + done.Add(1) + return nil + }, + } + + for i := 0; i < 2; i++ { + if err := c.enqueueInboundRPC(context.Background(), task); err != nil { + t.Fatalf("enqueue active task %d: %v", i, err) + } + } + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for active rpc workers") + } + } + for i := 0; i < 4; i++ { + if err := c.enqueueInboundRPC(context.Background(), task); err != nil { + t.Fatalf("enqueue queued task %d: %v", i, err) + } + } + if err := c.enqueueInboundRPC(context.Background(), task); !errors.Is(err, ErrInboundRPCQueueFull) { + t.Fatalf("enqueue over capacity err = %v, want ErrInboundRPCQueueFull", err) + } + if got := maxActive.Load(); got != 2 { + t.Fatalf("max active = %d, want 2", got) + } + + close(release) + deadline := time.After(2 * time.Second) + for done.Load() != 6 { + select { + case <-deadline: + t.Fatalf("done = %d, want 6", done.Load()) + default: + time.Sleep(10 * time.Millisecond) + } + } +} diff --git a/internal/mtprotoedge/login_e2e_test.go b/internal/mtprotoedge/login_e2e_test.go new file mode 100644 index 00000000..dc991848 --- /dev/null +++ b/internal/mtprotoedge/login_e2e_test.go @@ -0,0 +1,457 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "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/langpack" + messageapp "telesrv/internal/app/messages" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +// TestLoginRegisterFlow 是登录注册闭环的端到端验证:telegram.Client 连本地 server, +// 依次 sendCode → signIn(需注册) → signUp → getUsers(self),验证注册后能用 self 查回自己。 +func TestLoginRegisterFlow(t *testing.T) { + const ( + dc = 2 + phone = "+8613800138000" + wantPhone = "8613800138000" + 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() + if err := helpStore.UpsertAppConfig(context.Background(), domain.AppConfig{ + Client: "tdesktop", + Hash: 4, + 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) + } + if err := helpStore.UpsertCountries(context.Background(), []domain.Country{ + {ISO2: "US", DefaultName: "United States", CountryCodes: []domain.CountryCode{{CountryCode: "1", Prefixes: []string{"1"}}}}, + }); err != nil { + t.Fatalf("seed countries: %v", err) + } + langPackStore := memory.NewLangPackStore() + if err := langPackStore.UpsertPack(context.Background(), domain.LangPack{ + LangPack: "tdesktop", + LangCode: "en", + Version: 1, + Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "English"}}, + }); err != nil { + t.Fatalf("seed langpack: %v", err) + } + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code), + 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(memory.NewDialogStore()), + LangPack: langpack.NewService(langPackStore), + } + 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) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + 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: zaptest.NewLogger(t).Named("client"), + SessionStorage: &session.StorageMemory{}, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + client := telegram.NewClient(1, "hash", opts) + + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + + // 1) sendCode → phone_code_hash + 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 { + t.Fatalf("sendCode result = %T, want *tg.AuthSentCode", sent) + } + hash := sentCode.PhoneCodeHash + + // 2) signIn → 新用户应得 SignUpRequired + signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + PhoneCode: code, + }) + if err != nil { + return err + } + if _, ok := signInRes.(*tg.AuthAuthorizationSignUpRequired); !ok { + t.Fatalf("signIn result = %T, want *tg.AuthAuthorizationSignUpRequired", signInRes) + } + + // 3) signUp → 创建用户并返回授权 + signUpRes, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + FirstName: "Test", + LastName: "User", + }) + if err != nil { + return err + } + authz, ok := signUpRes.(*tg.AuthAuthorization) + if !ok { + t.Fatalf("signUp result = %T, want *tg.AuthAuthorization", signUpRes) + } + newUser, ok := authz.User.(*tg.User) + if !ok { + t.Fatalf("signUp user = %T, want *tg.User", authz.User) + } + if !newUser.Self || newUser.FirstName != "Test" || newUser.Phone != wantPhone { + t.Fatalf("signUp user = %+v, want self FirstName=Test Phone=%s", newUser, wantPhone) + } + + // 4) getUsers(self) → 注册后能查回自己 + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}}) + if err != nil { + return err + } + if len(got) != 1 { + t.Fatalf("getUsers returned %d users, want 1", len(got)) + } + self, ok := got[0].(*tg.User) + if !ok { + t.Fatalf("getUsers[0] = %T, want *tg.User", got[0]) + } + if self.ID != newUser.ID || self.FirstName != "Test" || self.Phone != wantPhone { + t.Fatalf("getUsers self = %+v, want id=%d FirstName=Test Phone=%s", self, newUser.ID, wantPhone) + } + + // 5) 启动配置、账号安全和登录后的空账号 RPC 走业务服务并可编码。 + appConfig, err := raw.HelpGetAppConfig(ctx, 0) + 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) + } + countriesRes, err := raw.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: "en"}) + if err != nil { + return err + } + if countries, ok := countriesRes.(*tg.HelpCountriesList); !ok || len(countries.Countries) != 1 { + t.Fatalf("help.getCountriesList = %T %+v, want 1 country", countriesRes, countriesRes) + } + password, err := raw.AccountGetPassword(ctx) + if err != nil { + return err + } + if password.HasPassword || len(password.SecureRandom) == 0 { + t.Fatalf("account.getPassword = %+v, want no password with secure random", password) + } + state, err := raw.UpdatesGetState(ctx) + if err != nil { + return err + } + if state.Date == 0 { + t.Fatal("updates.getState Date is zero") + } + diff, err := raw.UpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{ + Pts: state.Pts, + Date: state.Date, + Qts: state.Qts, + }) + if err != nil { + return err + } + if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("updates.getDifference = %T, want *tg.UpdatesDifferenceEmpty", diff) + } + contactsRes, err := raw.ContactsGetContacts(ctx, 0) + if err != nil { + return err + } + if contacts, ok := contactsRes.(*tg.ContactsContacts); !ok || len(contacts.Contacts) != 0 { + t.Fatalf("contacts.getContacts = %T %+v, want empty *tg.ContactsContacts", contactsRes, contactsRes) + } + dialogsRes, err := raw.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + }) + if err != nil { + return err + } + if dialogs, ok := dialogsRes.(*tg.MessagesDialogs); !ok || len(dialogs.Dialogs) != 0 { + t.Fatalf("messages.getDialogs = %T %+v, want empty *tg.MessagesDialogs", dialogsRes, dialogsRes) + } + pinned, err := raw.MessagesGetPinnedDialogs(ctx, 0) + if err != nil { + return err + } + if len(pinned.Dialogs) != 0 || pinned.State.Date == 0 { + t.Fatalf("messages.getPinnedDialogs = %+v, want empty dialogs with state", pinned) + } + pack, err := raw.LangpackGetLangPack(ctx, &tg.LangpackGetLangPackRequest{ + LangPack: "tdesktop", + LangCode: "en", + }) + if err != nil { + return err + } + if pack.Version != 1 || len(pack.Strings) != 1 { + t.Fatalf("langpack.getLangPack = %+v, want version 1 with 1 string", pack) + } + strings, err := raw.LangpackGetStrings(ctx, &tg.LangpackGetStringsRequest{ + LangPack: "tdesktop", + LangCode: "en", + Keys: []string{"lng_language_name"}, + }) + if err != nil { + return err + } + if len(strings) != 1 { + t.Fatalf("langpack.getStrings returned %d strings, want 1", len(strings)) + } + return nil + }); err != nil { + t.Fatalf("login/register flow: %v", err) + } + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} + +func TestPrivateMessageRoundTripFlow(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) + activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions")) + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code), + 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), + 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) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) + + ctx, cancel := context.WithTimeout(context.Background(), 30*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: zaptest.NewLogger(t).Named("client"), + SessionStorage: storage, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + return telegram.NewClient(1, "hash", opts) + } + storageA := &session.StorageMemory{} + storageB := &session.StorageMemory{} + + 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 + } + } + + signUp := func(storage *session.StorageMemory, phone, firstName string) tg.User { + t.Helper() + client := newClient(storage) + var out tg.User + 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: code, + }); err != nil { + return err + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: phone, + PhoneCodeHash: hash, + FirstName: firstName, + }) + if err != nil { + return err + } + authz := res.(*tg.AuthAuthorization) + u := authz.User.(*tg.User) + out = *u + return nil + }); err != nil { + t.Fatalf("signUp %s: %v", firstName, err) + } + return out + } + + userA := signUp(storageA, "+15550001001", "Alice") + userB := signUp(storageB, "+15550001002", "Bob") + + sendAndRead := func(storage *session.StorageMemory, to tg.User, body string, randomID int64) { + t.Helper() + client := newClient(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + updates, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Message: body, + RandomID: randomID, + }) + if err != nil { + return err + } + gotUpdates, ok := updates.(*tg.Updates) + if !ok || len(gotUpdates.Updates) < 2 { + t.Fatalf("send updates = %T %+v, want message id + new message", updates, updates) + } + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: to.ID, AccessHash: to.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("history = %T %+v, want messages", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != body || !msg.Out { + t.Fatalf("latest history message = %#v, want outgoing %q", msgs[0], body) + } + return nil + }); err != nil { + t.Fatalf("send %q: %v", body, err) + } + } + + sendAndRead(storageA, userB, "hello bob", 1001) + + clientB := newClient(storageB) + if err := clientB.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(clientB) + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: userA.ID, AccessHash: userA.AccessHash}, + Limit: 10, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("bob history = %T %+v, want incoming message", history, history) + } + msg, ok := msgs[0].(*tg.Message) + if !ok || msg.Message != "hello bob" || msg.Out { + t.Fatalf("bob latest message = %#v, want incoming hello bob", msgs[0]) + } + return nil + }); err != nil { + t.Fatalf("bob read incoming: %v", err) + } + + sendAndRead(storageB, userA, "hi alice", 2001) + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} diff --git a/internal/mtprotoedge/metrics.go b/internal/mtprotoedge/metrics.go new file mode 100644 index 00000000..74e1e4f3 --- /dev/null +++ b/internal/mtprotoedge/metrics.go @@ -0,0 +1,67 @@ +package mtprotoedge + +import "time" + +// Metrics 接收连接层运行指标。实现可对接 Prometheus 等监控系统; +// 默认 NopMetrics(零开销)。第一阶段仅预留钩子,正式指标后续接入。 +type Metrics interface { + // ConnOpened 在接受一个连接时调用。 + ConnOpened() + // ConnClosed 在一个连接结束时调用。 + ConnClosed() + // HandshakeDone 在一次密钥交换成功完成时调用,d 为握手耗时。 + HandshakeDone(d time.Duration) + // RPCHandled 在一次 RPC 处理完成时调用:method 为 TL 方法名, + // d 为耗时,err 非 nil 表示失败。 + RPCHandled(method string, d time.Duration, err error) + // InboundRPCQueued 在 RPC 成功进入单连接 bounded queue 时调用。 + InboundRPCQueued(method string, len, cap int) + // InboundRPCStarted 在 RPC 从 bounded queue 取出开始执行时调用。 + InboundRPCStarted(method string, queueWait time.Duration) + // InboundRPCDropped 在 RPC 因队列满、连接关闭或调度错误被丢弃时调用。 + InboundRPCDropped(method, reason string) + // OutboundSend 在一条 server 出站消息完成写入或失败时调用。 + OutboundSend(typeID uint32, queueWait time.Duration, bytes int, err error) + // OutboundResend 在一次 msg_resend_req/重复 RPC 触发重发后调用。 + OutboundResend(count int, err error) + // OutboundDropped 在出站队列或状态跟踪因背压丢弃时调用。 + OutboundDropped(reason string) + // OutboundQueueWait 在出站入队等待超过阈值时调用。 + OutboundQueueWait(len, cap int) +} + +// NopMetrics 是 Metrics 的空实现。 +type NopMetrics struct{} + +// ConnOpened 实现 Metrics。 +func (NopMetrics) ConnOpened() {} + +// ConnClosed 实现 Metrics。 +func (NopMetrics) ConnClosed() {} + +// HandshakeDone 实现 Metrics。 +func (NopMetrics) HandshakeDone(time.Duration) {} + +// RPCHandled 实现 Metrics。 +func (NopMetrics) RPCHandled(string, time.Duration, error) {} + +// InboundRPCQueued 实现 Metrics。 +func (NopMetrics) InboundRPCQueued(string, int, int) {} + +// InboundRPCStarted 实现 Metrics。 +func (NopMetrics) InboundRPCStarted(string, time.Duration) {} + +// InboundRPCDropped 实现 Metrics。 +func (NopMetrics) InboundRPCDropped(string, string) {} + +// OutboundSend 实现 Metrics。 +func (NopMetrics) OutboundSend(uint32, time.Duration, int, error) {} + +// OutboundResend 实现 Metrics。 +func (NopMetrics) OutboundResend(int, error) {} + +// OutboundDropped 实现 Metrics。 +func (NopMetrics) OutboundDropped(string) {} + +// OutboundQueueWait 实现 Metrics。 +func (NopMetrics) OutboundQueueWait(int, int) {} diff --git a/internal/mtprotoedge/metrics_test.go b/internal/mtprotoedge/metrics_test.go new file mode 100644 index 00000000..2e9e44ed --- /dev/null +++ b/internal/mtprotoedge/metrics_test.go @@ -0,0 +1,73 @@ +package mtprotoedge + +import ( + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + "telesrv/internal/rpc" +) + +type countingMetrics struct { + connOpened atomic.Int64 + connClosed atomic.Int64 + handshakes atomic.Int64 + rpcs atomic.Int64 + inbound atomic.Int64 + outbound atomic.Int64 +} + +func (m *countingMetrics) ConnOpened() { m.connOpened.Add(1) } +func (m *countingMetrics) ConnClosed() { m.connClosed.Add(1) } +func (m *countingMetrics) HandshakeDone(time.Duration) { m.handshakes.Add(1) } +func (m *countingMetrics) RPCHandled(string, time.Duration, error) { m.rpcs.Add(1) } +func (m *countingMetrics) InboundRPCQueued(string, int, int) {} +func (m *countingMetrics) InboundRPCStarted(string, time.Duration) { m.inbound.Add(1) } +func (m *countingMetrics) InboundRPCDropped(string, string) {} +func (m *countingMetrics) OutboundSend(uint32, time.Duration, int, error) { + m.outbound.Add(1) +} +func (m *countingMetrics) OutboundResend(int, error) {} +func (m *countingMetrics) OutboundDropped(string) {} +func (m *countingMetrics) OutboundQueueWait(int, int) {} + +// TestMetricsHooks 验证 M5:连接、握手、RPC 的 metrics 钩子被正确调用。 +func TestMetricsHooks(t *testing.T) { + const dc = 2 + m := &countingMetrics{} + router := rpc.New(rpc.Config{DC: dc, IP: "127.0.0.1", Port: 2398}, rpc.Deps{}, zaptest.NewLogger(t), clock.System) + addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: router, Metrics: m}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID) + + if got := m.connOpened.Load(); got < 1 { + t.Errorf("ConnOpened called %d times, want >= 1", got) + } + if got := m.handshakes.Load(); got != 1 { + t.Errorf("HandshakeDone called %d times, want 1", got) + } + if got := m.rpcs.Load(); got != 1 { + t.Errorf("RPCHandled called %d times, want 1", got) + } + if got := m.inbound.Load(); got != 1 { + t.Errorf("InboundRPCStarted called %d times, want 1", got) + } + // new_session_created / ack 走 fire-and-forget(异步),可能在 client 收到 rpc_result 后 + // 才被 outbound actor 处理;轮询等其最终发送完成。M5 验证发送计数,不约束同步时序。 + deadline := time.Now().Add(2 * time.Second) + for m.outbound.Load() < 3 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if got := m.outbound.Load(); got < 3 { + t.Errorf("OutboundSend called %d times, want >= 3", got) + } +} diff --git a/internal/mtprotoedge/outbound.go b/internal/mtprotoedge/outbound.go new file mode 100644 index 00000000..2fe2a254 --- /dev/null +++ b/internal/mtprotoedge/outbound.go @@ -0,0 +1,647 @@ +package mtprotoedge + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" +) + +var ( + // ErrConnClosed 表示连接的出站 actor 已关闭。 + ErrConnClosed = errors.New("mtproto connection closed") + // ErrOutboundQueueFull 表示 best-effort update push 未能在预算内进入出站队列。 + ErrOutboundQueueFull = errors.New("mtproto outbound queue full") +) + +const ( + maxOutboundQueue = 1024 + maxTrackedServerMsgIDs = 4096 + maxTrackedAckedMsgIDs = 1024 + // maxTrackedServerBytes 是 pending(已发送待 ack、用于 resend)总 body 字节上限。 + // 与 maxTrackedServerMsgIDs 并列:客户端从不 ack 时,大响应体按字节滚动丢弃, + // 防 pending 被「4096 条 × 大 body」撑爆。 + maxTrackedServerBytes = 64 << 20 // 64 MiB +) + +type outboundOpKind byte + +const ( + outboundSend outboundOpKind = iota + 1 + outboundAck + outboundQueryState + outboundResend + outboundResendByRequest +) + +type outboundOp struct { + kind outboundOpKind + control bool + ctx context.Context + msgType proto.MessageType + msg bin.Encoder + ids []int64 + reqMsgID int64 + enqueuedAt time.Time + done chan outboundResult +} + +type outboundResult struct { + info []byte + resent bool + err error +} + +type outboundFrame struct { + msgID int64 + seqNo int32 + typeID uint32 + body []byte + reqMsgID int64 + sentAt time.Time + sends int +} + +type outboundState struct { + pending map[int64]*outboundFrame + order []int64 + byRequest map[int64]int64 + acked map[int64]struct{} + ackOrder []int64 + totalBytes int +} + +func newOutboundState() *outboundState { + return &outboundState{ + pending: make(map[int64]*outboundFrame), + byRequest: make(map[int64]int64), + acked: make(map[int64]struct{}), + } +} + +func (c *Conn) startOutbound() { + if c.metrics == nil { + c.metrics = NopMetrics{} + } + c.outbound = make(chan outboundOp, maxOutboundQueue) + c.outboundControl = make(chan outboundOp, maxOutboundQueue/4) + c.outboundStop = make(chan struct{}) + c.outboundDone = make(chan struct{}) + go c.outboundLoop() +} + +// Close 停止连接的出站 actor。它不关闭底层 transport;transport 生命周期仍由 serveConn 管理。 +func (c *Conn) Close() { + c.closeInboundRPCScheduler() + c.outboundClose.Do(func() { + if c.outboundStop != nil { + close(c.outboundStop) + <-c.outboundDone + } + }) +} + +// Send 加密并发送一条 server 消息。 +func (c *Conn) Send(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { + return c.send(ctx, t, msg, false) +} + +// SendPriority 加密并优先发送一条 server 控制消息。 +func (c *Conn) SendPriority(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { + return c.send(ctx, t, msg, true) +} + +// SendBestEffort 只等待消息进入普通 outbound 队列,不等待网络写完成。 +// 用于 updates fanout:队列拥塞时返回 ErrOutboundQueueFull,durable outbox/getDifference 负责兜底。 +func (c *Conn) SendBestEffort(ctx context.Context, t proto.MessageType, msg bin.Encoder, timeout time.Duration) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + writeCtx := context.Background() + if ctx != nil { + writeCtx = context.WithoutCancel(ctx) + } + op := outboundOp{ + kind: outboundSend, + ctx: writeCtx, + msgType: t, + msg: msg, + enqueuedAt: time.Now(), + } + if timeout == 0 { + select { + case c.outbound <- op: + return nil + case <-c.outboundStop: + return ErrConnClosed + default: + c.metrics.OutboundDropped("push_queue_full") + return ErrOutboundQueueFull + } + } + enqueueCtx := ctx + if enqueueCtx == nil { + enqueueCtx = context.Background() + } + var cancel context.CancelFunc + if timeout > 0 { + enqueueCtx, cancel = context.WithTimeout(enqueueCtx, timeout) + defer cancel() + } + if err := c.enqueueOutbound(enqueueCtx, op); err != nil { + if errors.Is(err, context.DeadlineExceeded) && timeout > 0 { + c.metrics.OutboundDropped("push_queue_timeout") + return ErrOutboundQueueFull + } + return err + } + return nil +} + +func (c *Conn) send(ctx context.Context, t proto.MessageType, msg bin.Encoder, control bool) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + op := outboundOp{ + kind: outboundSend, + control: control, + ctx: ctx, + msgType: t, + msg: msg, + enqueuedAt: time.Now(), + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return err + } + select { + case res := <-op.done: + return res.err + case <-ctx.Done(): + return ctx.Err() + case <-c.outboundStop: + return ErrConnClosed + } +} + +// SendAsync 入队一条 server 消息但不等待发送结果(fire-and-forget),用于读循环里的控制消息 +// (ack/pong/new_session_created/bad_msg/future_salts/state_info):避免读循环被 outbound 写 +// 阻塞而连带卡死。走优先(control)队列保证不被普通 push 拖后;队列满时丢弃并记 metrics——此时 +// 连接多已严重拥塞,控制消息丢失由客户端重传 / 读写超时兜底。返回非 nil 仅表示连接已关闭。 +func (c *Conn) SendAsync(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + op := outboundOp{ + kind: outboundSend, + control: true, + ctx: ctx, + msgType: t, + msg: msg, + enqueuedAt: time.Now(), + // done 为 nil:fire-and-forget,handleOutboundSend 的 finish 对 nil done 安全跳过。 + } + select { + case c.outboundControl <- op: + return nil + case <-c.outboundStop: + return ErrConnClosed + default: + c.metrics.OutboundDropped("control_queue_full") + return nil + } +} + +// AckServerMessages 接收客户端 msgs_ack,释放已确认的 server 出站消息。 +func (c *Conn) AckServerMessages(ids []int64) { + if len(ids) == 0 || c.outbound == nil || c.outboundControl == nil { + return + } + copied := append([]int64(nil), ids...) + op := outboundOp{kind: outboundAck, control: true, ids: copied} + select { + case c.outboundControl <- op: + case <-c.outboundStop: + default: + c.metrics.OutboundDropped("ack_queue_full") + } +} + +// OutgoingStateInfo 返回本连接出站消息的状态。返回值中 0 表示无出站侧意见, +// 调用方可继续用入站 connState 兜底。 +func (c *Conn) OutgoingStateInfo(ctx context.Context, ids []int64) ([]byte, error) { + if c.outbound == nil { + return nil, ErrConnClosed + } + op := outboundOp{ + kind: outboundQueryState, + control: true, + ctx: ctx, + ids: append([]int64(nil), ids...), + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return nil, err + } + select { + case res := <-op.done: + return res.info, res.err + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.outboundStop: + return nil, ErrConnClosed + } +} + +// ResendMessages 重发仍在 outgoing queue 中的 server 消息,并返回对应状态。 +func (c *Conn) ResendMessages(ctx context.Context, ids []int64) ([]byte, error) { + if c.outbound == nil { + return nil, ErrConnClosed + } + op := outboundOp{ + kind: outboundResend, + control: true, + ctx: ctx, + ids: append([]int64(nil), ids...), + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return nil, err + } + select { + case res := <-op.done: + return res.info, res.err + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.outboundStop: + return nil, ErrConnClosed + } +} + +// ResendByRequest 在重复 RPC 请求到达时,按原 client msg_id 找到并重发已有 rpc_result。 +func (c *Conn) ResendByRequest(ctx context.Context, reqMsgID int64) (bool, error) { + if c.outbound == nil { + return false, ErrConnClosed + } + op := outboundOp{ + kind: outboundResendByRequest, + control: true, + ctx: ctx, + reqMsgID: reqMsgID, + done: make(chan outboundResult, 1), + } + if err := c.enqueueOutbound(ctx, op); err != nil { + return false, err + } + select { + case res := <-op.done: + return res.resent, res.err + case <-ctx.Done(): + return false, ctx.Err() + case <-c.outboundStop: + return false, ErrConnClosed + } +} + +func (c *Conn) enqueueOutbound(ctx context.Context, op outboundOp) error { + if ctx == nil { + ctx = context.Background() + } + q := c.outbound + if op.control { + q = c.outboundControl + } + select { + case q <- op: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.outboundStop: + return ErrConnClosed + default: + } + c.metrics.OutboundQueueWait(len(q), cap(q)) + select { + case q <- op: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-c.outboundStop: + return ErrConnClosed + } +} + +func (c *Conn) outboundLoop() { + defer close(c.outboundDone) + state := newOutboundState() + for { + select { + case op := <-c.outboundControl: + c.handleOutboundOp(state, op) + continue + default: + } + select { + case <-c.outboundStop: + c.drainOutbound() + return + case op := <-c.outboundControl: + c.handleOutboundOp(state, op) + case op := <-c.outbound: + c.handleOutboundOp(state, op) + } + } +} + +func (c *Conn) drainOutbound() { + for { + select { + case op := <-c.outboundControl: + op.finish(outboundResult{err: ErrConnClosed}) + case op := <-c.outbound: + op.finish(outboundResult{err: ErrConnClosed}) + default: + return + } + } +} + +func (c *Conn) handleOutboundOp(state *outboundState, op outboundOp) { + switch op.kind { + case outboundSend: + c.handleOutboundSend(state, op) + case outboundAck: + state.ack(op.ids) + case outboundQueryState: + op.finish(outboundResult{info: state.stateInfo(op.ids)}) + case outboundResend: + info, err := c.handleOutboundResend(state, op.ctx, op.ids) + op.finish(outboundResult{info: info, err: err}) + case outboundResendByRequest: + resent, err := c.handleOutboundResendByRequest(state, op.ctx, op.reqMsgID) + op.finish(outboundResult{resent: resent, err: err}) + default: + op.finish(outboundResult{err: fmt.Errorf("unknown outbound op %d", op.kind)}) + } +} + +func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) { + frame, err := c.buildFrame(op.msgType, op.msg) + if err == nil { + err = c.writeFrame(op.ctx, frame) + } + if err == nil && frameNeedsAck(frame.typeID) { + if dropped := state.add(frame); dropped > 0 { + for i := 0; i < dropped; i++ { + c.metrics.OutboundDropped("tracked_queue_overflow") + } + } + } + queueWait := time.Since(op.enqueuedAt) + bytes := 0 + typeID := uint32(0) + if frame != nil { + bytes = len(frame.body) + typeID = frame.typeID + } + c.metrics.OutboundSend(typeID, queueWait, bytes, err) + op.finish(outboundResult{err: err}) +} + +func (c *Conn) handleOutboundResend(state *outboundState, ctx context.Context, ids []int64) ([]byte, error) { + info := make([]byte, len(ids)) + resent := 0 + for i, id := range ids { + if state.isKnown(id) { + info[i] = msgStateReceived + } + frame, ok := state.pending[id] + if !ok { + continue + } + if err := c.writeFrame(ctx, frame); err != nil { + c.metrics.OutboundResend(resent, err) + return info, err + } + frame.sentAt = time.Now() + frame.sends++ + resent++ + } + c.metrics.OutboundResend(resent, nil) + return info, nil +} + +func (c *Conn) handleOutboundResendByRequest(state *outboundState, ctx context.Context, reqMsgID int64) (bool, error) { + msgID, ok := state.byRequest[reqMsgID] + if !ok { + return false, nil + } + frame, ok := state.pending[msgID] + if !ok { + return false, nil + } + if err := c.writeFrame(ctx, frame); err != nil { + c.metrics.OutboundResend(0, err) + return false, err + } + frame.sentAt = time.Now() + frame.sends++ + c.metrics.OutboundResend(1, nil) + return true, nil +} + +func (op outboundOp) finish(res outboundResult) { + if op.done == nil { + return + } + select { + case op.done <- res: + default: + } +} + +func (c *Conn) buildFrame(t proto.MessageType, msg bin.Encoder) (*outboundFrame, error) { + if msg == nil { + return nil, errors.New("nil outbound message") + } + var body bin.Buffer + if err := msg.Encode(&body); err != nil { + return nil, fmt.Errorf("encode outbound: %w", err) + } + typeID, err := (&bin.Buffer{Buf: body.Raw()}).PeekID() + if err != nil { + return nil, fmt.Errorf("peek outbound type id: %w", err) + } + content := frameNeedsAck(typeID) + msgID := c.msgID.New(t) + return &outboundFrame{ + msgID: msgID, + seqNo: c.nextSeqNo(content), + typeID: typeID, + body: body.Copy(), + reqMsgID: outboundRequestMsgID(msg), + }, nil +} + +func (c *Conn) nextSeqNo(content bool) int32 { + seqNo := c.sentContentMessages * 2 + if content { + seqNo++ + c.sentContentMessages++ + } + return seqNo +} + +func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error { + if ctx == nil { + ctx = context.Background() + } + var out bin.Buffer + if err := c.cipher.Encrypt(c.key, crypto.EncryptedMessageData{ + Salt: c.salt, + SessionID: c.sessionID, + MessageID: frame.msgID, + SeqNo: frame.seqNo, + MessageDataLen: int32(len(frame.body)), + MessageDataWithPadding: frame.body, + }, &out); err != nil { + return fmt.Errorf("encrypt: %w", err) + } + + sendCtx := ctx + cancel := func() {} + if c.writeTimeout > 0 { + sendCtx, cancel = context.WithTimeout(ctx, c.writeTimeout) + } + defer cancel() + writer := c.writer + if writer == nil { + writer = c.transport + } + if err := writer.Send(sendCtx, &out); err != nil { + return fmt.Errorf("send: %w", err) + } + if frame.sentAt.IsZero() { + frame.sentAt = time.Now() + frame.sends = 1 + } + return nil +} + +func frameNeedsAck(typeID uint32) bool { + switch typeID { + case mt.MsgsAckTypeID, + mt.BadMsgNotificationTypeID, + mt.BadServerSaltTypeID, + mt.MsgsStateInfoTypeID, + mt.MsgsAllInfoTypeID, + mt.MsgDetailedInfoTypeID, + mt.MsgNewDetailedInfoTypeID, + proto.MessageContainerTypeID: + return false + default: + return true + } +} + +func outboundRequestMsgID(msg bin.Encoder) int64 { + switch v := msg.(type) { + case *proto.Result: + return v.RequestMessageID + default: + return 0 + } +} + +func (s *outboundState) add(frame *outboundFrame) int { + s.pending[frame.msgID] = frame + s.order = append(s.order, frame.msgID) + s.totalBytes += len(frame.body) + if frame.reqMsgID != 0 { + s.byRequest[frame.reqMsgID] = frame.msgID + } + return s.shrinkPending() +} + +func (s *outboundState) ack(ids []int64) { + for _, id := range ids { + frame, ok := s.pending[id] + if !ok { + continue + } + delete(s.pending, id) + s.totalBytes -= len(frame.body) + if frame.reqMsgID != 0 { + delete(s.byRequest, frame.reqMsgID) + } + s.markAcked(id) + } + if len(s.order) > maxTrackedServerMsgIDs*2 { + s.compactOrder() + } +} + +func (s *outboundState) stateInfo(ids []int64) []byte { + info := make([]byte, len(ids)) + for i, id := range ids { + if s.isKnown(id) { + info[i] = msgStateReceived + } + } + return info +} + +func (s *outboundState) isKnown(id int64) bool { + if _, ok := s.pending[id]; ok { + return true + } + _, ok := s.acked[id] + return ok +} + +func (s *outboundState) markAcked(id int64) { + if _, ok := s.acked[id]; ok { + return + } + s.acked[id] = struct{}{} + s.ackOrder = append(s.ackOrder, id) + for len(s.ackOrder) > maxTrackedAckedMsgIDs { + oldest := s.ackOrder[0] + s.ackOrder = s.ackOrder[1:] + delete(s.acked, oldest) + } +} + +func (s *outboundState) shrinkPending() int { + dropped := 0 + for (len(s.pending) > maxTrackedServerMsgIDs || s.totalBytes > maxTrackedServerBytes) && len(s.order) > 0 { + oldest := s.order[0] + s.order = s.order[1:] + frame, ok := s.pending[oldest] + if !ok { + continue + } + delete(s.pending, oldest) + s.totalBytes -= len(frame.body) + if frame.reqMsgID != 0 { + delete(s.byRequest, frame.reqMsgID) + } + dropped++ + } + return dropped +} + +func (s *outboundState) compactOrder() { + filtered := s.order[:0] + for _, id := range s.order { + if _, ok := s.pending[id]; ok { + filtered = append(filtered, id) + } + } + s.order = filtered +} diff --git a/internal/mtprotoedge/outbound_test.go b/internal/mtprotoedge/outbound_test.go new file mode 100644 index 00000000..4e2e2d75 --- /dev/null +++ b/internal/mtprotoedge/outbound_test.go @@ -0,0 +1,130 @@ +package mtprotoedge + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +func TestOutboundActorSerializesConcurrentSends(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) + sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + srv.Conns().SetReceivesUpdates(auth.SessionID, true) + + const sends = 64 + var wg sync.WaitGroup + errs := make(chan error, sends) + for i := 0; i < sends; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + errs <- srv.Conns().PushToSession(ctx, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("push: %v", err) + } + } + + var prevMsgID int64 + var prevSeqNo int32 = -1 + for i := 0; i < sends; i++ { + data, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if id != tg.UpdatesTooLongTypeID { + t.Fatalf("message %d type = %#x, want updatesTooLong", i, id) + } + if i > 0 && data.MessageID <= prevMsgID { + t.Fatalf("message %d msg_id = %d after %d, want strictly increasing", i, data.MessageID, prevMsgID) + } + if data.SeqNo%2 != 1 { + t.Fatalf("message %d seq_no = %d, want odd content-related seq_no", i, data.SeqNo) + } + if i > 0 && data.SeqNo <= prevSeqNo { + t.Fatalf("message %d seq_no = %d after %d, want increasing", i, data.SeqNo, prevSeqNo) + } + prevMsgID = data.MessageID + prevSeqNo = data.SeqNo + } +} + +func TestOutboundResendAndAckState(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) + sendEncrypted(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + srv.Conns().SetReceivesUpdates(auth.SessionID, true) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := srv.Conns().PushToSession(ctx, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + cancel() + t.Fatalf("push: %v", err) + } + cancel() + + original, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if id != tg.UpdatesTooLongTypeID { + t.Fatalf("pushed type = %#x, want updatesTooLong", id) + } + + resendReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, resendReqID, 3, &mt.MsgResendReq{MsgIDs: []int64{original.MessageID}}) + resent, resentType, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if resentType != tg.UpdatesTooLongTypeID { + t.Fatalf("resent type = %#x, want updatesTooLong", resentType) + } + if resent.MessageID != original.MessageID || resent.SeqNo != original.SeqNo { + t.Fatalf("resent frame = (msg_id=%d seq=%d), want original (msg_id=%d seq=%d)", + resent.MessageID, resent.SeqNo, original.MessageID, original.SeqNo) + } + _, stateType, stateBuf := readServerMessage(t, conn, cipher, auth.AuthKey) + if stateType != mt.MsgsStateInfoTypeID { + t.Fatalf("state type = %#x, want msgs_state_info", stateType) + } + assertStateInfo(t, stateBuf, resendReqID, []byte{msgStateReceived}) + _, ackType, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if ackType != mt.MsgsAckTypeID { + t.Fatalf("ack type = %#x, want msgs_ack", ackType) + } + + sendEncryptedWithSeq(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), 4, &mt.MsgsAck{MsgIDs: []int64{original.MessageID}}) + ackedResendReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, ackedResendReqID, 5, &mt.MsgResendReq{MsgIDs: []int64{original.MessageID}}) + _, ackedStateType, ackedStateBuf := readServerMessage(t, conn, cipher, auth.AuthKey) + if ackedStateType != mt.MsgsStateInfoTypeID { + t.Fatalf("after ack type = %#x, want msgs_state_info without resend", ackedStateType) + } + assertStateInfo(t, ackedStateBuf, ackedResendReqID, []byte{msgStateReceived}) +} + +func assertStateInfo(t *testing.T, b *bin.Buffer, reqMsgID int64, want []byte) { + t.Helper() + var info mt.MsgsStateInfo + if err := info.Decode(b); err != nil { + t.Fatalf("decode msgs_state_info: %v", err) + } + if info.ReqMsgID != reqMsgID { + t.Fatalf("msgs_state_info.req_msg_id = %d, want %d", info.ReqMsgID, reqMsgID) + } + if string(info.Info) != string(want) { + t.Fatalf("msgs_state_info.info = %v, want %v", []byte(info.Info), want) + } +} diff --git a/internal/mtprotoedge/rpc_test.go b/internal/mtprotoedge/rpc_test.go new file mode 100644 index 00000000..cd089b88 --- /dev/null +++ b/internal/mtprotoedge/rpc_test.go @@ -0,0 +1,145 @@ +package mtprotoedge + +import ( + "context" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/crypto" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/rpc" +) + +// TestRPCGetConfig 验证 M3:握手后 client 加密 help.getConfig, +// server 经 tg.ServerDispatcher 路由并回 rpc_result(含本地 DC),外加 new_session_created + ack。 +func TestRPCGetConfig(t *testing.T) { + const ( + dc = 2 + advIP = "127.0.0.1" + advPort = 12345 + ) + router := rpc.New(rpc.Config{DC: dc, IP: advIP, Port: advPort}, rpc.Deps{}, zaptest.NewLogger(t), clock.System) + addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: router}) + 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{}) + + replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID) + if _, ok := replies[mt.MsgsAckTypeID]; !ok { + for id, b := range collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) { + replies[id] = b + } + } + mustHave(t, replies, mt.NewSessionCreatedTypeID, "new_session_created") + mustHave(t, replies, mt.MsgsAckTypeID, "msgs_ack") + resultBuf := mustHave(t, replies, proto.ResultTypeID, "rpc_result") + + var res proto.Result + if err := res.Decode(resultBuf); err != nil { + t.Fatalf("decode rpc_result: %v", err) + } + if res.RequestMessageID != reqMsgID { + t.Fatalf("rpc_result req_msg_id = %d, want %d", res.RequestMessageID, reqMsgID) + } + + var cfg tg.Config + if err := cfg.Decode(&bin.Buffer{Buf: res.Result}); err != nil { + t.Fatalf("decode config: %v", err) + } + 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) + } +} + +func TestInboundRPCQueueFullReturnsFloodWait(t *testing.T) { + const dc = 2 + handler := &blockingRPC{ + started: make(chan struct{}, 1), + release: make(chan struct{}), + } + addr, pub, _ := startTestServer(t, Options{ + DC: dc, + RPC: handler, + RPCMaxInflight: 1, + RPCQueueSize: 1, + RPCTimeout: 5 * time.Second, + }) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + firstReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, firstReqID, 1, &tg.HelpGetConfigRequest{}) + select { + case <-handler.started: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first rpc to start") + } + + secondReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, secondReqID, 3, &tg.HelpGetConfigRequest{}) + thirdReqID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, thirdReqID, 5, &tg.HelpGetConfigRequest{}) + + result := readRPCResultForRequest(t, conn, cipher, auth.AuthKey, thirdReqID) + var rpcErr mt.RPCError + if err := rpcErr.Decode(&bin.Buffer{Buf: result.Result}); err != nil { + t.Fatalf("decode rpc_error: %v", err) + } + if rpcErr.ErrorCode != 420 || rpcErr.ErrorMessage != "FLOOD_WAIT_1" { + t.Fatalf("rpc_error = %d %q, want 420 FLOOD_WAIT_1", rpcErr.ErrorCode, rpcErr.ErrorMessage) + } + close(handler.release) +} + +type blockingRPC struct { + started chan struct{} + release chan struct{} +} + +func (h *blockingRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.Buffer) (bin.Encoder, error) { + select { + case h.started <- struct{}{}: + default: + } + select { + case <-h.release: + return &tg.Config{ThisDC: 2}, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +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++ { + _, id, plain := readServerMessage(t, conn, cipher, key) + if id != proto.ResultTypeID { + continue + } + var result proto.Result + if err := result.Decode(plain); err != nil { + t.Fatalf("decode rpc_result: %v", err) + } + if result.RequestMessageID == reqMsgID { + return result + } + } + t.Fatalf("missing rpc_result for req_msg_id %d", reqMsgID) + return proto.Result{} +} diff --git a/internal/mtprotoedge/rsakey.go b/internal/mtprotoedge/rsakey.go new file mode 100644 index 00000000..e33e9266 --- /dev/null +++ b/internal/mtprotoedge/rsakey.go @@ -0,0 +1,63 @@ +package mtprotoedge + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" +) + +// rsaKeyBits 是 server RSA 私钥位数。MTProto 要求 2048-bit。 +const rsaKeyBits = 2048 + +// LoadOrGenerateRSAKey 从 path 加载 PEM 编码的 server RSA 私钥; +// 不存在则生成 2048-bit 新密钥并持久化(含父目录)。 +// +// server RSA 私钥用于 MTProto 密钥交换;其公钥 fingerprint 需 patch 进 TDesktop +// (记录于 docs/tdesktop-patch-notes.md)。 +func LoadOrGenerateRSAKey(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + switch { + case err == nil: + key, perr := parseRSAKeyPEM(data) + if perr != nil { + return nil, fmt.Errorf("parse %q: %w", path, perr) + } + return key, nil + case errors.Is(err, os.ErrNotExist): + // 继续生成。 + default: + return nil, fmt.Errorf("read %q: %w", path, err) + } + + key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + if err != nil { + return nil, fmt.Errorf("generate rsa key: %w", err) + } + + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create key dir %q: %w", dir, err) + } + } + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + if err := os.WriteFile(path, pemBytes, 0o600); err != nil { + return nil, fmt.Errorf("write key %q: %w", path, err) + } + return key, nil +} + +func parseRSAKeyPEM(data []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("no PEM block found") + } + return x509.ParsePKCS1PrivateKey(block.Bytes) +} diff --git a/internal/mtprotoedge/server.go b/internal/mtprotoedge/server.go new file mode 100644 index 00000000..5cbcf0c0 --- /dev/null +++ b/internal/mtprotoedge/server.go @@ -0,0 +1,364 @@ +package mtprotoedge + +import ( + "context" + "crypto/rsa" + "errors" + "fmt" + "io" + "net" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/tg" + "github.com/gotd/td/tmap" + "github.com/gotd/td/transport" + + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +// RPCHandler 把解密后的 RPC 请求体路由到响应。由 internal/rpc 实现。 +// +// b 是明文 RPC 请求(已剥离 MTProto 外壳);返回的 bin.Encoder 会被包成 rpc_result。 +// 返回 *tgerr.Error 时连接层将其转为 rpc_error 回发;其他 error 视为连接级故障。 +type RPCHandler interface { + Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) +} + +// Options 配置 Server。 +type Options struct { + // Logger 日志器。默认 zap.NewNop()。 + Logger *zap.Logger + // Codec 传输 codec 构造器。nil 表示自动探测(intermediate/abridged/full)。 + Codec func() transport.Codec + // ObfuscatedTCP 先按 MTProto TCP obfuscation 解包,再自动探测 codec。 + // Telegram Desktop 的 tcpo_only endpoint 会走这个 64 字节前缀流程。 + ObfuscatedTCP bool + // ReadTimeout 单次读取超时。默认 5m。 + ReadTimeout time.Duration + // HandshakeIdleTimeout 是连接「建立 session 前」(握手 + 首个加密消息之前)的读超时, + // 比 ReadTimeout 短,用于快速回收握手后静默的半开 / 异常连接。默认 60s。 + HandshakeIdleTimeout time.Duration + // WriteTimeout 单次写入超时。默认 30s。 + WriteTimeout time.Duration + // RPCMaxInflight 是单连接同时处理的 RPC 上限。默认 32。 + RPCMaxInflight int + // RPCQueueSize 是单连接等待处理的 RPC 队列长度。默认 256。 + RPCQueueSize int + // RPCTimeout 是单个 RPC 在连接层的最大处理时长。默认 30s。 + RPCTimeout time.Duration + + // DC 是本 server 的 DC ID。默认 2。 + DC int + // RSAKey 是 server RSA 私钥,用于密钥交换。nil 时无法完成握手。 + RSAKey *rsa.PrivateKey + // AuthKeys 持久化 auth key。默认内存实现。 + AuthKeys store.AuthKeyStore + // Sessions 记录在线 MTProto session(持久化数据)。默认内存实现。 + Sessions store.SessionStore + // ActiveSessions 管理活跃连接。默认新建;传入时可让 RPC 层共享同一注册表。 + ActiveSessions *SessionManager + // RPC 是 typed RPC 路由。nil 时加密 RPC 被丢弃并记录。 + RPC RPCHandler + // Metrics 接收连接层指标。默认 NopMetrics。 + Metrics Metrics + // Clock 用于消息 ID 与时间戳。默认 clock.System。 + Clock clock.Clock + // Rand 随机源。默认 crypto.DefaultRand()。 + Rand io.Reader +} + +func (o *Options) setDefaults() { + if o.Logger == nil { + o.Logger = zap.NewNop() + } + if o.ReadTimeout == 0 { + o.ReadTimeout = 5 * time.Minute + } + if o.HandshakeIdleTimeout == 0 { + o.HandshakeIdleTimeout = 60 * time.Second + } + if o.WriteTimeout == 0 { + o.WriteTimeout = 30 * time.Second + } + if o.RPCMaxInflight <= 0 { + o.RPCMaxInflight = 32 + } + if o.RPCQueueSize <= 0 { + o.RPCQueueSize = 256 + } + if o.RPCTimeout == 0 { + o.RPCTimeout = 30 * time.Second + } + if o.DC == 0 { + o.DC = 2 + } + if o.AuthKeys == nil { + o.AuthKeys = memory.NewAuthKeyStore() + } + if o.Sessions == nil { + o.Sessions = memory.NewSessionStore() + } + if o.Metrics == nil { + o.Metrics = NopMetrics{} + } + if o.Clock == nil { + o.Clock = clock.System + } + if o.Rand == nil { + o.Rand = crypto.DefaultRand() + } +} + +// Server 是 MTProto 连接层(mtprotoedge)。 +// +// 职责见 doc.go。它把原始 TCP 字节流转换为「已解密、已识别 session 的 RPC 请求」: +// 接受连接、协商 codec、完成密钥交换、解密并分发加密消息到 RPC 路由,处理服务消息, +// 并把活跃连接注册到 SessionManager 以支持主动推送(updates 等)。不含业务逻辑。 +type Server struct { + log *zap.Logger + codec func() transport.Codec + obfuscated bool + readTimeout time.Duration + handshakeTimeout time.Duration + writeTimeout time.Duration + rpcInflight int + rpcQueueSize int + rpcTimeout time.Duration + + dc int + key exchange.PrivateKey + authKeys store.AuthKeyStore + sessions store.SessionStore + conns *SessionManager + rpc RPCHandler + metrics Metrics + cipher crypto.Cipher + clock clock.Clock + rand io.Reader + types *tmap.Map + + // sessionUID 是本进程 server session 唯一标识,写入 new_session_created。 + sessionUID int64 + + // onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。 + onFrame func(n int) +} + +// New 创建 Server。 +func New(opts Options) *Server { + opts.setDefaults() + conns := opts.ActiveSessions + if conns == nil { + conns = NewSessionManager(opts.Logger.Named("sessions")) + } + return &Server{ + log: opts.Logger, + codec: opts.Codec, + obfuscated: opts.ObfuscatedTCP, + readTimeout: opts.ReadTimeout, + handshakeTimeout: opts.HandshakeIdleTimeout, + writeTimeout: opts.WriteTimeout, + rpcInflight: opts.RPCMaxInflight, + rpcQueueSize: opts.RPCQueueSize, + rpcTimeout: opts.RPCTimeout, + dc: opts.DC, + key: exchange.PrivateKey{RSA: opts.RSAKey}, + authKeys: opts.AuthKeys, + sessions: opts.Sessions, + conns: conns, + rpc: opts.RPC, + metrics: opts.Metrics, + cipher: crypto.NewServerCipher(opts.Rand), + clock: opts.Clock, + rand: opts.Rand, + types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()), + sessionUID: opts.Clock.Now().UnixNano(), + } +} + +// Conns 返回活跃连接注册表,供业务层主动推送(updates 等)。 +func (s *Server) Conns() *SessionManager { + return s.conns +} + +// newConn 基于一次解密结果创建一个可发送的连接对象。 +func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt int64) *Conn { + c := &Conn{ + transport: tc, + writer: tc, + cipher: s.cipher, + msgID: proto.NewMessageIDGen(s.clock.Now), + writeTimeout: s.writeTimeout, + metrics: s.metrics, + authKeyID: key.ID, + sessionID: sessionID, + salt: salt, + key: key, + } + c.startOutbound() + c.startInboundRPCScheduler(s.rpcInflight, s.rpcQueueSize, s.rpcTimeout) + return c +} + +// Serve 在 ln 上运行 MTProto 连接循环,直到 ctx 取消或发生不可恢复错误。 +// ctx 取消时优雅退出:关闭 listener 并等待在途连接处理结束。 +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + transportListener := ln + if s.obfuscated { + transportListener = transport.ObfuscatedListener(ln) + } + l := transport.ListenCodec(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 阻塞。 + go func() { + <-ctx.Done() + _ = l.Close() + }() + + var wg sync.WaitGroup + defer wg.Wait() + + for { + conn, err := l.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)) + } + }() + } +} + +// serveConn 处理单个传输连接:读帧并按 auth_key_id 分流。 +// +// - auth_key_id == 0:未加密的密钥交换起始消息,执行握手并落地 auth key。 +// - auth_key_id 已注册:加密消息,解密、注册连接并分发到 RPC 路由。 +// - auth_key_id 未注册:回 AuthKeyNotFound,促使客户端重新握手。 +// +// 连接建立 session 后注册到 SessionManager,结束时注销。 +func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error) { + s.metrics.ConnOpened() + s.log.Debug("Connection accepted") + + var current *Conn + defer func() { + if current != nil { + s.conns.Unregister(current) + current.Close() + } + s.metrics.ConnClosed() + s.log.Debug("Connection closed", zap.Error(err)) + }() + + // ctx 取消或处理结束时关闭连接,解除 Recv 阻塞。 + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + cs := newConnState() + var b bin.Buffer + var replay *bin.Buffer + for { + if replay != nil { + b.ResetTo(replay.Copy()) + replay = nil + } else { + // 建立 session 前(current==nil,握手 + 首个加密消息之前)用较短的 handshakeTimeout + // 快速回收静默的半开 / 异常连接;建立 session 后用 readTimeout(客户端有 ping 心跳)。 + timeout := s.readTimeout + if current == nil { + timeout = s.handshakeTimeout + } + if err := s.recv(ctx, conn, &b, timeout); err != nil { + return err + } + if s.onFrame != nil { + s.onFrame(b.Len()) + } + } + + authKeyID, err := peekAuthKeyID(&b) + if err != nil { + return fmt.Errorf("peek auth key id: %w", err) + } + + if authKeyID == emptyAuthKeyID { + next, err := s.handleExchange(ctx, conn, &b) + if err != nil { + return err + } + replay = next + 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 + } + continue + } + + current, err = s.handleEncrypted(ctx, conn, cs, current, data, &b) + if err != nil { + return err + } + } +} + +func (s *Server) recv(ctx context.Context, conn transport.Conn, b *bin.Buffer, timeout time.Duration) error { + b.Reset() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return conn.Recv(ctx, b) +} + +// isClientDisconnect 判断错误是否为正常的客户端断开/服务关闭,不应作为异常记录。 +func isClientDisconnect(err error) bool { + switch { + case errors.Is(err, io.EOF), + errors.Is(err, net.ErrClosed), + errors.Is(err, context.Canceled), + errors.Is(err, context.DeadlineExceeded): + return true + } + var nerr *net.OpError + if errors.As(err, &nerr) && (nerr.Op == "read" || nerr.Op == "write") { + return true + } + return false +} diff --git a/internal/mtprotoedge/server_test.go b/internal/mtprotoedge/server_test.go new file mode 100644 index 00000000..394b3635 --- /dev/null +++ b/internal/mtprotoedge/server_test.go @@ -0,0 +1,165 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/mtproxy" + "github.com/gotd/td/mtproxy/obfuscator" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/transport" +) + +// TestServerAcceptAndCodec 验证 M0: +// server 能接受连接、自动协商 codec、读到客户端帧,并在 ctx 取消时优雅退出。 +func TestServerAcceptAndCodec(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)}) + 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) }() + + // 客户端:TCP 拨号 + intermediate 协议握手 + 发送一帧。 + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + t.Fatalf("handshake: %v", err) + } + + // payload 必须 ≠ 4 字节:codec 把恰好 4 字节的帧当作 transport 协议错误码(checkProtocolError)。 + // 真实 MTProto 帧远大于 4 字节,这里发 8 字节模拟一个普通帧。 + var b bin.Buffer + b.PutInt32(0x12345678) + b.PutInt32(0x0badf00d) + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + defer sc() + if err := conn.Send(sendCtx, &b); err != nil { + t.Fatalf("send: %v", err) + } + + select { + case n := <-frames: + if n <= 0 { + t.Fatalf("received empty frame, len = %d", n) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not receive frame in time") + } + + _ = conn.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") + } +} + +// TestServerAcceptObfuscatedAbridged 验证 TDesktop tcpo_only 连接形态: +// 先做 MTProto TCP obfuscation,再在解密后的流上使用 abridged codec。 +func TestServerAcceptObfuscatedAbridged(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}) + 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) }() + + bad, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("bad dial: %v", err) + } + _ = bad.Close() + time.Sleep(50 * time.Millisecond) + select { + case err := <-serveErr: + t.Fatalf("server stopped after bad obfuscated accept: %v", err) + default: + } + + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + obfs := obfuscator.Obfuscated2(rand.Reader, raw) + if err := obfs.Handshake((codec.Abridged{}).ObfuscatedTag(), 2, mtproxy.Secret{}); err != nil { + t.Fatalf("obfuscated handshake: %v", err) + } + conn, err := transport.NewProtocol(func() transport.Codec { + return transport.Abridged.CodecNoHeader() + }).Handshake(obfs) + if err != nil { + t.Fatalf("transport handshake: %v", err) + } + + var b bin.Buffer + b.PutInt32(0x12345678) + b.PutInt32(0x0badf00d) + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + defer sc() + if err := conn.Send(sendCtx, &b); err != nil { + t.Fatalf("send: %v", err) + } + + select { + case n := <-frames: + if n <= 0 { + t.Fatalf("received empty frame, len = %d", n) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not receive frame in time") + } + + _ = conn.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 new file mode 100644 index 00000000..a397b13d --- /dev/null +++ b/internal/mtprotoedge/session_manager.go @@ -0,0 +1,978 @@ +package mtprotoedge + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" +) + +// ErrSessionNotFound 表示目标 session 当前无活跃连接。 +var ErrSessionNotFound = errors.New("session not found") + +// ErrSessionAmbiguous 表示仅用 session_id 无法唯一定位连接。 +var ErrSessionAmbiguous = errors.New("session id is shared by multiple auth keys") + +const ( + maxPendingPushesPerSession = 32 + // pendingPushMaxAge:session 注册后迟迟不调 updates.getState(receivesUpdates 恒 false)时, + // 其暂存的主动推送最长保留时长。超过即丢整批并不再囤——正常 TDesktop 登录后秒级就会 + // getState 建立同步基线;长期不 ready 多为异常/对抗连接。丢弃不丢消息:getDifference 以 + // user_update_events durable log 兜底补齐。 + pendingPushMaxAge = 60 * time.Second +) + +type queuedPush struct { + t proto.MessageType + msg bin.Encoder + at time.Time +} + +type sessionKey struct { + authKeyID [8]byte + sessionID int64 +} + +// SessionLifecycleObserver receives active connection lifecycle events. +type SessionLifecycleObserver interface { + SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) +} + +// SessionManager 是活跃连接注册表,支持按 session / auth-key / user 查找并主动 push。 +// +// 它管理运行态的在线连接,与持久化的 store.SessionStore 互补:后者记录 session 数据, +// 前者持有可发送的活跃连接。所有方法并发安全。 +type SessionManager struct { + mu sync.RWMutex + 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 + 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 前暂存的主动推送 + + lifecycle SessionLifecycleObserver + log *zap.Logger +} + +// NewSessionManager 创建空的连接注册表。 +func NewSessionManager(log *zap.Logger) *SessionManager { + if log == nil { + log = zap.NewNop() + } + return &SessionManager{ + bySession: make(map[sessionKey]*Conn), + bySessionID: make(map[int64]map[[8]byte]*Conn), + byAuthKey: make(map[[8]byte]map[int64]*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), + log: log, + } +} + +// SetLifecycleObserver installs a best-effort active session lifecycle observer. +func (m *SessionManager) SetLifecycleObserver(observer SessionLifecycleObserver) { + m.mu.Lock() + m.lifecycle = observer + m.mu.Unlock() +} + +// Register 注册一个活跃连接。若同 raw auth_key_id + session_id 已存在(重连),旧连接被替换并移除索引。 +func (m *SessionManager) Register(c *Conn) { + m.mu.Lock() + + key := connSessionKey(c) + var replaced *Conn + if old, ok := m.bySession[key]; ok && old != c { + replaced = old + m.removeLocked(old, false) + } + m.bySession[key] = c + addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c) + addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c) + if uid := c.userID.Load(); uid != 0 { + c.userIDResolved.Store(true) + addUserIndex(m.byUser, uid, key, c) + } + m.log.Debug("Session registered", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", c.sessionID), + zap.Int("online", len(m.bySession)), + ) + m.mu.Unlock() + + if replaced != nil { + replaced.Close() + } +} + +// Unregister 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。 +func (m *SessionManager) Unregister(c *Conn) { + m.mu.Lock() + var ( + observer SessionLifecycleObserver + offlineUser int64 + lastForUser bool + ) + if cur, ok := m.bySession[connSessionKey(c)]; ok && cur == c { + offlineUser = m.removeLocked(c, true) + if offlineUser != 0 { + lastForUser = len(m.byUser[offlineUser]) == 0 + observer = m.lifecycle + } + m.log.Debug("Session unregistered", + zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), + zap.Int64("session_id", c.sessionID), + zap.Int("online", len(m.bySession)), + ) + } + m.mu.Unlock() + if observer != nil && offlineUser != 0 { + observer.SessionOffline(c.authKeyID, c.sessionID, offlineUser, lastForUser) + } +} + +// DestroySession 移除指定 session 的运行态索引,供 MTProto destroy_session 使用。 +func (m *SessionManager) DestroySession(sessionID int64) bool { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if !ambiguous { + m.dropPendingBySessionLocked(sessionID) + } + m.mu.Unlock() + return false + } + offlineUser := m.removeLocked(c, true) + lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0 + observer := m.lifecycle + m.log.Debug("Session destroyed", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", sessionID), + zap.Int("online", len(m.bySession)), + ) + m.mu.Unlock() + c.Close() + if observer != nil && offlineUser != 0 { + observer.SessionOffline(key.authKeyID, sessionID, offlineUser, lastForUser) + } + return true +} + +// DestroySessionForAuthKey 精确移除某个 raw auth_key_id 下的 session。 +func (m *SessionManager) DestroySessionForAuthKey(authKeyID [8]byte, sessionID int64) bool { + m.mu.Lock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + delete(m.pending, key) + m.mu.Unlock() + return false + } + offlineUser := m.removeLocked(c, true) + lastForUser := offlineUser != 0 && len(m.byUser[offlineUser]) == 0 + observer := m.lifecycle + m.log.Debug("Session destroyed", + zap.String("auth_key_id", sessionKeyLog(authKeyID)), + zap.Int64("session_id", sessionID), + zap.Int("online", len(m.bySession)), + ) + m.mu.Unlock() + c.Close() + if observer != nil && offlineUser != 0 { + observer.SessionOffline(authKeyID, sessionID, offlineUser, lastForUser) + } + return true +} + +// BindUser 缓存 session 的授权用户。userID=0 表示当前 auth_key 已确认未登录。 +// 登录后绑定非 0 userID,使其可经 PushToUser 收到推送。 +func (m *SessionManager) BindUser(sessionID, userID int64) { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if ambiguous { + m.log.Warn("Skip BindUser for ambiguous session_id", zap.Int64("session_id", sessionID)) + } + m.mu.Unlock() + return + } + m.bindUserLocked(c, key, userID) + m.mu.Unlock() +} + +// BindUserForAuthKey 缓存指定 raw auth_key_id + session_id 的授权用户。 +func (m *SessionManager) BindUserForAuthKey(authKeyID [8]byte, sessionID, userID int64) { + m.mu.Lock() + defer m.mu.Unlock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + return + } + m.bindUserLocked(c, key, userID) +} + +func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) { + if old := c.userID.Swap(userID); old != 0 { + removeUserIndex(m.byUser, old, key) + if old != userID { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } + } + c.userIDResolved.Store(true) + if userID != 0 { + addUserIndex(m.byUser, userID, key, c) + } else { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } +} + +// UserID 返回 session 当前缓存的登录用户 id。未绑定或离线时 ok=false。 +func (m *SessionManager) UserID(sessionID int64) (int64, bool) { + m.mu.RLock() + c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID) + m.mu.RUnlock() + if ambiguous || !ok { + return 0, false + } + userID := c.userID.Load() + if userID == 0 { + return 0, false + } + return userID, true +} + +// UserIDForAuthKey 返回指定 raw auth_key_id + session_id 当前缓存的登录用户 id。 +func (m *SessionManager) UserIDForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}] + m.mu.RUnlock() + if !ok { + return 0, false + } + userID := c.userID.Load() + if userID == 0 { + return 0, false + } + return userID, true +} + +// UserIDResolved 返回 session 的 user_id 授权状态是否已经查过。 +// resolved=true 且 userID=0 表示该 session 当前未登录。 +func (m *SessionManager) UserIDResolved(sessionID int64) (int64, bool) { + m.mu.RLock() + c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID) + m.mu.RUnlock() + if ambiguous || !ok { + return 0, false + } + return c.UserIDResolved() +} + +// UserIDResolvedForAuthKey 返回指定 raw auth_key_id + session_id 的 user_id 缓存状态。 +func (m *SessionManager) UserIDResolvedForAuthKey(authKeyID [8]byte, sessionID int64) (int64, bool) { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}] + m.mu.RUnlock() + if !ok { + return 0, false + } + return c.UserIDResolved() +} + +// BindAuthKey 缓存业务视角 auth_key_id(temp auth_key 解析后的 perm auth_key)。 +func (m *SessionManager) BindAuthKey(sessionID int64, authKeyID [8]byte) { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if ambiguous { + m.log.Warn("Skip BindAuthKey for ambiguous session_id", zap.Int64("session_id", sessionID)) + } + m.mu.Unlock() + return + } + m.bindAuthKeyLocked(c, key, authKeyID) + m.mu.Unlock() +} + +// BindAuthKeyForSession 缓存指定 raw auth_key_id + session_id 的业务 auth_key_id。 +func (m *SessionManager) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) { + m.mu.Lock() + defer m.mu.Unlock() + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + return + } + m.bindAuthKeyLocked(c, key, authKeyID) +} + +func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8]byte) { + oldAuthKeyID, resolved := c.BusinessAuthKeyID() + changed := !resolved || oldAuthKeyID != authKeyID + oldUserID := c.userID.Load() + c.SetBusinessAuthKeyID(authKeyID) + if changed { + if oldUserID != 0 { + removeUserIndex(m.byUser, oldUserID, key) + } + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + c.userID.Store(0) + c.userIDResolved.Store(false) + } +} + +// AuthKeyID 返回 session 缓存的业务视角 auth_key_id。 +// ok=false 表示该连接尚未完成 temp→perm 解析。 +func (m *SessionManager) AuthKeyID(sessionID int64) ([8]byte, bool) { + m.mu.RLock() + c, _, ok, ambiguous := m.uniqueSessionLocked(sessionID) + m.mu.RUnlock() + if ambiguous || !ok { + return [8]byte{}, false + } + return c.BusinessAuthKeyID() +} + +// AuthKeyIDForSession 返回指定 raw auth_key_id + session_id 缓存的业务 auth_key_id。 +func (m *SessionManager) AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool) { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID}] + m.mu.RUnlock() + if !ok { + return [8]byte{}, false + } + return c.BusinessAuthKeyID() +} + +// 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 { + if !connUsesBusinessAuthKey(c, authKeyID) { + continue + } + if old := c.userID.Swap(0); old != 0 { + removeUserIndex(m.byUser, old, key) + } + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + c.userIDResolved.Store(true) + count++ + } + return count +} + +// SetReceivesUpdates 标记 session 是否已完成 updates 同步入口。 +// +// TDesktop 登录后会先调用 updates.getState/getDifference 建立本地同步基线。 +// 在此之前收到的主动 updates 先暂存,待 session 可接收后再异步下发。 +func (m *SessionManager) SetReceivesUpdates(sessionID int64, receives bool) { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous || !ok { + if ambiguous { + m.log.Warn("Skip SetReceivesUpdates for ambiguous session_id", zap.Int64("session_id", sessionID)) + } + m.mu.Unlock() + return + } + c.receivesUpdates.Store(receives) + if !receives { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } + pending := m.takePendingLocked(key, receives) + m.mu.Unlock() + + if len(pending) > 0 { + go m.flushPending(key, pending) + } +} + +// SetReceivesUpdatesForAuthKey 标记指定 raw auth_key_id + session_id 是否接收主动 updates。 +func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64, receives bool) { + m.mu.Lock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + m.mu.Unlock() + return + } + c.receivesUpdates.Store(receives) + if !receives { + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + } + pending := m.takePendingLocked(key, receives) + m.mu.Unlock() + + if len(pending) > 0 { + go m.flushPending(key, pending) + } +} + +// PushToSession 向指定 session 推送一条消息。 +func (m *SessionManager) PushToSession(ctx context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + m.mu.Lock() + c, key, ok, ambiguous := m.uniqueSessionLocked(sessionID) + if ambiguous { + m.mu.Unlock() + return ErrSessionAmbiguous + } + if !ok { + m.mu.Unlock() + return ErrSessionNotFound + } + if !c.receivesUpdates.Load() { + m.queueLocked(key, t, msg) + m.mu.Unlock() + return nil + } + m.mu.Unlock() + return c.Send(ctx, t, msg) +} + +// PushToSessionForAuthKey 向指定 raw auth_key_id + session_id 推送一条消息。 +func (m *SessionManager) PushToSessionForAuthKey(ctx context.Context, authKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + m.mu.Lock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + if !ok { + m.mu.Unlock() + return ErrSessionNotFound + } + if !c.receivesUpdates.Load() { + m.queueLocked(key, t, msg) + m.mu.Unlock() + return nil + } + m.mu.Unlock() + return c.Send(ctx, t, msg) +} + +// PushToUser 向某 user 所有活跃连接推送,返回已发送或已暂存的连接数。 +// 发送在释放锁后进行,避免持锁阻塞于网络 IO。 +func (m *SessionManager) PushToUser(ctx context.Context, userID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + return m.PushToUserExceptAuthKeySession(ctx, userID, [8]byte{}, 0, t, msg) +} + +// PushToUserExceptSession 向某 user 所有活跃连接推送,但跳过指定 session。 +// 未完成 updates 同步入口的 session 会先暂存,等 SetReceivesUpdates(true) 后再发。 +func (m *SessionManager) PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + return m.pushToUser(ctx, userID, nil, excludeSessionID, t, msg) +} + +// PushToUserExceptAuthKeySession 向某 user 所有活跃连接推送,跳过指定业务 auth_key + session。 +func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + 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) { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error { + return c.Send(ctx, t, msg) + }) +} + +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) +} + +func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + return m.pushToUserBestEffort(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg, timeout) +} + +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) { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error { + return c.SendBestEffort(ctx, t, msg, timeout) + }) +} + +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) { + m.mu.Lock() + conns := make([]*Conn, 0, len(m.byUser[userID])) + queued := 0 + for key, c := range m.byUser[userID] { + if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) { + continue + } + if !c.receivesUpdates.Load() { + m.queueLocked(key, t, msg) + queued++ + continue + } + conns = append(conns, c) + } + m.mu.Unlock() + + var firstErr error + sent := 0 + for _, c := range conns { + if err := send(c); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + sent++ + } + return sent + queued, firstErr +} + +// Online 返回当前活跃连接数。 +func (m *SessionManager) Online() int { + m.mu.RLock() + defer m.mu.RUnlock() + 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 { + return false + } + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.byUser[userID]) > 0 +} + +// OnlineUserIDsForCandidates filters an explicit candidate set against the +// active user index. It avoids exporting or sorting the whole online map. +func (m *SessionManager) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 { + if len(candidateUserIDs) == 0 { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]int64, 0, minInt(len(candidateUserIDs), positiveLimitOrLen(limit, len(candidateUserIDs)))) + seen := make(map[int64]struct{}, len(candidateUserIDs)) + for _, userID := range candidateUserIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + if len(m.byUser[userID]) == 0 { + continue + } + out = append(out, userID) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +// TrackChannelInterest replaces the channel viewer set for one live session. +// Realtime transient fan-out uses this as the current active-viewer candidate +// set; durable channel updates use the broader membership index instead. +func (m *SessionManager) TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) { + if userID == 0 { + return + } + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.bySession[key] + if !ok || c.userID.Load() != userID { + return + } + m.clearChannelInterestsLocked(key) + if len(channelIDs) == 0 { + return + } + m.trackChannelIndexLocked(m.byChannel, m.bySessionChannels, key, userID, channelIDs) +} + +// ClearChannelInterest removes the active-viewer channel set for one live +// session while leaving its joined-channel membership index intact. +func (m *SessionManager) ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64) { + if userID == 0 { + return + } + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.bySession[key] + if !ok || c.userID.Load() != userID { + return + } + m.clearChannelInterestsLocked(key) +} + +// OnlineChannelUserIDs returns users with active sessions that have recently +// proven current interest in channelID. The result is intentionally unsorted and bounded. +func (m *SessionManager) OnlineChannelUserIDs(channelID int64, limit int) []int64 { + return m.onlineChannelUsers(m.byChannel, channelID, limit) +} + +// SetSessionChannelMemberships replaces the joined-channel index for one +// updates-ready session. This index is broader than TrackChannelInterest and is +// used for durable channel updates such as new/edit/delete message. +func (m *SessionManager) SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) { + key := sessionKey{authKeyID: rawAuthKeyID, sessionID: sessionID} + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.bySession[key] + if !ok { + return + } + m.clearChannelMembershipsLocked(key) + if userID == 0 || c.userID.Load() != userID { + return + } + m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, channelIDs) +} + +// AddUserChannelMembership adds channelID to every live session for userID. +// It is called after successful join/invite approval paths. +func (m *SessionManager) AddUserChannelMembership(userID, channelID int64) { + if userID == 0 || channelID == 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for key, c := range m.byUser[userID] { + if c == nil || c.userID.Load() != userID { + continue + } + m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, []int64{channelID}) + } +} + +// RemoveUserChannelMembership removes channelID from every live session for userID. +// It is called after leave/kick/ban/delete paths. +func (m *SessionManager) RemoveUserChannelMembership(userID, channelID int64) { + if userID == 0 || channelID == 0 { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for key := range m.byUser[userID] { + m.removeChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, channelID) + } +} + +// OnlineChannelMemberUserIDs returns users with active sessions that are indexed +// as joined members of channelID. The result is intentionally unsorted; callers +// still verify business membership before pushing. +func (m *SessionManager) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 { + return m.onlineChannelUsers(m.byMemberChannel, channelID, limit) +} + +func (m *SessionManager) onlineChannelUsers(index map[int64]map[sessionKey]int64, channelID int64, limit int) []int64 { + if channelID == 0 { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + sessions := index[channelID] + if len(sessions) == 0 { + return nil + } + out := make([]int64, 0, positiveLimitOrLen(limit, len(sessions))) + seen := make(map[int64]struct{}, len(sessions)) + for key, userID := range sessions { + if userID == 0 { + 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) removeLocked(c *Conn, dropPending bool) int64 { + key := connSessionKey(c) + delete(m.bySession, key) + removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID) + removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID) + uid := c.userID.Load() + if uid != 0 { + removeUserIndex(m.byUser, uid, key) + } + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + if dropPending { + delete(m.pending, key) + } + return uid +} + +func (m *SessionManager) clearChannelInterestsLocked(key sessionKey) { + m.clearChannelIndexLocked(m.byChannel, m.bySessionChannels, key) +} + +func (m *SessionManager) clearChannelMembershipsLocked(key sessionKey) { + m.clearChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key) +} + +func (m *SessionManager) trackChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, userID int64, channelIDs []int64) { + channels := reverse[key] + if channels == nil { + channels = make(map[int64]struct{}, len(channelIDs)) + reverse[key] = channels + } + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + channels[channelID] = struct{}{} + sessions := index[channelID] + if sessions == nil { + sessions = make(map[sessionKey]int64) + index[channelID] = sessions + } + sessions[key] = userID + } +} + +func (m *SessionManager) clearChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey) { + channels := reverse[key] + if len(channels) == 0 { + delete(reverse, key) + return + } + for channelID := range channels { + sessions := index[channelID] + delete(sessions, key) + if len(sessions) == 0 { + delete(index, channelID) + } + } + delete(reverse, key) +} + +func (m *SessionManager) removeChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey, channelID int64) { + channels := reverse[key] + delete(channels, channelID) + if len(channels) == 0 { + delete(reverse, key) + } + sessions := index[channelID] + delete(sessions, key) + if len(sessions) == 0 { + delete(index, channelID) + } +} + +func positiveLimitOrLen(limit, length int) int { + if limit > 0 && limit < length { + return limit + } + return length +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func (m *SessionManager) takePendingLocked(key sessionKey, ready bool) []queuedPush { + if !ready || len(m.pending[key]) == 0 { + return nil + } + pending := append([]queuedPush(nil), m.pending[key]...) + delete(m.pending, key) + return pending +} + +func (m *SessionManager) queueLocked(key sessionKey, t proto.MessageType, msg bin.Encoder) { + q := m.pending[key] + // 过期保护:最早一条暂存已超过 pendingPushMaxAge(session 迟迟未 ready)时,丢整批并 + // 不再囤这条,记 trace。避免「登录后从不 getState」的连接长期占用 pending 内存。 + if len(q) > 0 && time.Since(q[0].at) > pendingPushMaxAge { + m.log.Debug("Drop stale pending pushes (session not ready in time)", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Int("dropped", len(q)), + ) + delete(m.pending, key) + return + } + 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 + } + 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), + ) + } + } +} + +func (m *SessionManager) uniqueSessionLocked(sessionID int64) (*Conn, sessionKey, bool, bool) { + set := m.bySessionID[sessionID] + if len(set) == 0 { + return nil, sessionKey{}, false, false + } + if len(set) > 1 { + return nil, sessionKey{}, false, true + } + for authKeyID, c := range set { + return c, sessionKey{authKeyID: authKeyID, sessionID: sessionID}, true, false + } + return nil, sessionKey{}, false, false +} + +func (m *SessionManager) dropPendingBySessionLocked(sessionID int64) { + for key := range m.pending { + if key.sessionID == sessionID { + delete(m.pending, key) + } + } +} + +func addConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64, c *Conn) { + set := idx[key] + if set == nil { + set = make(map[int64]*Conn) + idx[key] = set + } + set[sessionID] = c +} + +func removeConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64) { + if set := idx[key]; set != nil { + delete(set, sessionID) + if len(set) == 0 { + delete(idx, key) + } + } +} + +func addSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte, c *Conn) { + set := idx[sessionID] + if set == nil { + set = make(map[[8]byte]*Conn) + idx[sessionID] = set + } + set[authKeyID] = c +} + +func removeSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte) { + if set := idx[sessionID]; set != nil { + delete(set, authKeyID) + if len(set) == 0 { + delete(idx, sessionID) + } + } +} + +func addUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey, c *Conn) { + set := idx[userID] + if set == nil { + set = make(map[sessionKey]*Conn) + idx[userID] = set + } + set[key] = c +} + +func removeUserIndex(idx map[int64]map[sessionKey]*Conn, userID int64, key sessionKey) { + if set := idx[userID]; set != nil { + delete(set, key) + if len(set) == 0 { + delete(idx, userID) + } + } +} + +func connSessionKey(c *Conn) sessionKey { + return sessionKey{authKeyID: c.authKeyID, sessionID: c.sessionID} +} + +func connUsesBusinessAuthKey(c *Conn, authKeyID [8]byte) bool { + id, resolved := c.BusinessAuthKeyID() + if resolved { + return id == authKeyID + } + return c.authKeyID == authKeyID +} + +func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID int64) bool { + if excludeSessionID == 0 { + return false + } + if c.sessionID != excludeSessionID { + return false + } + if excludeAuthKeyID == nil || *excludeAuthKeyID == ([8]byte{}) { + return true + } + return connUsesBusinessAuthKey(c, *excludeAuthKeyID) +} + +func sessionKeyLog(id [8]byte) string { + return fmt.Sprintf("%x", id) +} diff --git a/internal/mtprotoedge/session_manager_bench_test.go b/internal/mtprotoedge/session_manager_bench_test.go new file mode 100644 index 00000000..c7d54099 --- /dev/null +++ b/internal/mtprotoedge/session_manager_bench_test.go @@ -0,0 +1,215 @@ +package mtprotoedge + +import ( + "context" + "fmt" + "os" + "runtime" + "sort" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// 连接层 fan-out / churn 压测:聚焦 SessionManager 的锁争用,不走真实 socket / 加密。 +// +// 构造的 Conn 故意不 startOutbound:pushToUser 持锁快照 byUser 后,锁外对每个 conn 调 c.Send, +// 此时 c.outbound==nil 立即返回 ErrConnClosed(见 outbound.go),因此测量集中在「持锁段 + 分发开销」, +// 即分片要消除的全局锁热点。每条连接仅结构体内存(无 1024 容量的 outbound channel、无 goroutine), +// 故可注册到 20 万规模。 +// +// 用法: +// +// go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManager -benchmem -cpu 1,4,8 +// go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManagerPushConcurrent -mutexprofile mu.out +// TELESRV_LOAD_CONNS=200000 go test ./internal/mtprotoedge/ -run TestSessionManagerFanoutThroughput -v -timeout 300s + +func benchConn(sessionID int64, authKeyID [8]byte, userID int64) *Conn { + c := &Conn{sessionID: sessionID, authKeyID: authKeyID} + if userID != 0 { + c.userID.Store(userID) + c.userIDResolved.Store(true) + } + c.receivesUpdates.Store(true) // 走 fanout 的「收集 conns→锁外 Send」分支,而非 pending 暂存 + return c +} + +func authKeyIDFromInt(v uint64) [8]byte { + var id [8]byte + for i := 0; i < 8; i++ { + id[i] = byte(v >> (8 * i)) + } + return id +} + +// seedSessions 注册 conns 个连接,每个 user 绑定 connsPerUser 个连接(模拟多设备)。 +// 返回注册的 userID 列表(去重、有序范围 [1, userCount])。 +func seedSessions(sm *SessionManager, conns, connsPerUser int) (userCount int) { + if connsPerUser < 1 { + connsPerUser = 1 + } + for i := 0; i < conns; i++ { + userID := int64(i/connsPerUser) + 1 + sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID)) + } + return (conns + connsPerUser - 1) / connsPerUser +} + +// BenchmarkSessionManagerPushConcurrent 模拟 20 万在线下的真实热点:大量 goroutine 并发对 +// 不同 user pushToUser,全部抢同一把全局锁。-mutexprofile 会把 SessionManager.mu 顶上来。 +func BenchmarkSessionManagerPushConcurrent(b *testing.B) { + const conns = 200_000 + const connsPerUser = 2 + sm := NewSessionManager(zap.NewNop()) + userCount := seedSessions(sm, conns, connsPerUser) + msg := &tg.UpdatesTooLong{} + ctx := context.Background() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + var n uint64 + for pb.Next() { + n++ + userID := int64(n%uint64(userCount)) + 1 + _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) + } + }) +} + +// BenchmarkSessionManagerRegisterChurn 测连接建立/断开的锁成本:并发 Register+Unregister。 +// 20 万在线意味着持续的 connect/disconnect churn,每次都抢全局写锁。 +func BenchmarkSessionManagerRegisterChurn(b *testing.B) { + sm := NewSessionManager(zap.NewNop()) + var seq atomic.Uint64 + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + id := seq.Add(1) + c := benchConn(int64(id), authKeyIDFromInt(id), int64(id)) + sm.Register(c) + sm.Unregister(c) + } + }) +} + +// BenchmarkSessionManagerPushFanoutWidth 测单次 push 的 fanout 广度成本:一个 user 绑定很多连接, +// 单次 PushToUser 要持锁遍历全部。真实私聊 user 设备数少(2-4),此为上界参考。 +func BenchmarkSessionManagerPushFanoutWidth(b *testing.B) { + for _, width := range []int{1, 4, 16, 64} { + b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) { + sm := NewSessionManager(zap.NewNop()) + const userID = 1 + for i := 0; i < width; i++ { + sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID)) + } + msg := &tg.UpdatesTooLong{} + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) + } + }) + } +} + +// TestSessionManagerFanoutThroughput 是数据驱动吞吐测:注册 N 连接后,P 个 goroutine 持续并发 +// push,测全局锁下的实际 push 吞吐与 p99。默认小规模冒烟;设 TELESRV_LOAD_CONNS 放大到 20 万。 +func TestSessionManagerFanoutThroughput(t *testing.T) { + conns := envIntDefault("TELESRV_LOAD_CONNS", 20_000) + connsPerUser := envIntDefault("TELESRV_LOAD_CONNS_PER_USER", 2) + workers := envIntDefault("TELESRV_LOAD_PUSH_WORKERS", 0) // 0 → GOMAXPROCS + duration := time.Duration(envIntDefault("TELESRV_LOAD_SECONDS", 3)) * time.Second + if workers <= 0 { + workers = runtime.GOMAXPROCS(0) + } + + sm := NewSessionManager(zap.NewNop()) + t0 := time.Now() + userCount := seedSessions(sm, conns, connsPerUser) + seedWall := time.Since(t0) + if got := sm.Online(); got != conns { + t.Fatalf("online = %d, want %d", got, conns) + } + + msg := &tg.UpdatesTooLong{} + ctx := context.Background() + var ops atomic.Int64 + perWorkerLat := make([][]time.Duration, workers) + + deadline := time.Now().Add(duration) + var wg sync.WaitGroup + start := time.Now() + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + lat := make([]time.Duration, 0, 1<<16) + var n uint64 + for time.Now().Before(deadline) { + // 批量 256 次再查一次时钟,降低 time.Now 占比。 + for j := 0; j < 256; j++ { + n++ + userID := int64(n%uint64(userCount)) + 1 + s := time.Now() + _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) + lat = append(lat, time.Since(s)) + } + ops.Add(256) + } + perWorkerLat[w] = lat + }(w) + } + wg.Wait() + wall := time.Since(start) + + all := make([]time.Duration, 0, ops.Load()) + for _, l := range perWorkerLat { + all = append(all, l...) + } + sortDurations(all) + total := ops.Load() + thr := float64(total) / wall.Seconds() + + t.Logf("==== session_manager fan-out throughput ====") + t.Logf("config: conns=%d connsPerUser=%d users=%d pushWorkers=%d dur=%s seed=%s", + conns, connsPerUser, userCount, workers, duration, seedWall.Round(time.Millisecond)) + t.Logf("push: %d ops in %s -> %.0f push/s", total, wall.Round(time.Millisecond), thr) + t.Logf("push.lat p50=%s p90=%s p99=%s max=%s", + pct(all, 50), pct(all, 90), pct(all, 99), pct(all, 100)) + t.Logf("=============================================") +} + +func pct(sorted []time.Duration, p int) time.Duration { + if len(sorted) == 0 { + return 0 + } + idx := (p*len(sorted))/100 - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func sortDurations(d []time.Duration) { + sort.Slice(d, func(i, j int) bool { return d[i] < d[j] }) +} + +func envIntDefault(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/internal/mtprotoedge/session_manager_test.go b/internal/mtprotoedge/session_manager_test.go new file mode 100644 index 00000000..14070e97 --- /dev/null +++ b/internal/mtprotoedge/session_manager_test.go @@ -0,0 +1,296 @@ +package mtprotoedge + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestSessionManagerRegistry 验证注册表的注册/注销/查找语义(不涉及网络发送)。 +func TestSessionManagerRegistry(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + c := &Conn{sessionID: 42, authKeyID: [8]byte{1, 2, 3}} + c.receivesUpdates.Store(true) + + sm.Register(c) + if got := sm.Online(); got != 1 { + t.Fatalf("online = %d, want 1", got) + } + sm.BindAuthKey(42, [8]byte{1, 2, 3}) + sm.BindUser(42, 100) + if userID, ok := sm.UserID(42); !ok || userID != 100 { + t.Fatalf("cached user = %d ok %v, want 100/true", userID, ok) + } + sm.BindAuthKey(42, [8]byte{9}) + if userID, ok := sm.UserID(42); ok || userID != 0 { + t.Fatalf("cached user after auth key switch = %d ok %v, want 0/false", userID, ok) + } + if userID, resolved := sm.UserIDResolved(42); resolved || userID != 0 { + t.Fatalf("resolved user after auth key switch = %d resolved %v, want unresolved", userID, resolved) + } + sm.BindUser(42, 0) + if userID, resolved := sm.UserIDResolved(42); !resolved || userID != 0 { + t.Fatalf("negative user cache = %d resolved %v, want 0/true", userID, resolved) + } + + sm.Unregister(c) + if got := sm.Online(); got != 0 { + t.Fatalf("online after unregister = %d, want 0", got) + } + + err := sm.PushToSession(context.Background(), 42, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("push to missing session err = %v, want ErrSessionNotFound", err) + } +} + +func TestSessionManagerScopesSameSessionIDByAuthKey(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw1 := [8]byte{1} + raw2 := [8]byte{2} + perm1 := [8]byte{9} + c1 := &Conn{sessionID: 42, authKeyID: raw1} + c2 := &Conn{sessionID: 42, authKeyID: raw2} + + sm.Register(c1) + sm.Register(c2) + if got := sm.Online(); got != 2 { + t.Fatalf("online = %d, want 2", got) + } + + sm.BindAuthKeyForSession(raw1, 42, perm1) + sm.BindUserForAuthKey(raw1, 42, 100) + sm.BindUserForAuthKey(raw2, 42, 200) + + if userID, ok := sm.UserIDForAuthKey(raw1, 42); !ok || userID != 100 { + t.Fatalf("scoped user raw1 = %d ok %v, want 100/true", userID, ok) + } + if userID, ok := sm.UserIDForAuthKey(raw2, 42); !ok || userID != 200 { + t.Fatalf("scoped user raw2 = %d ok %v, want 200/true", userID, ok) + } + if _, ok := sm.UserID(42); ok { + t.Fatal("legacy UserID unexpectedly resolved ambiguous session_id") + } + if err := sm.PushToSession(context.Background(), 42, proto.MessageFromServer, &tg.UpdatesTooLong{}); !errors.Is(err, ErrSessionAmbiguous) { + t.Fatalf("ambiguous push err = %v, want ErrSessionAmbiguous", err) + } + + sm.BindUserForAuthKey(raw1, 42, 300) + sm.BindUserForAuthKey(raw2, 42, 300) + sent, err := sm.PushToUserExceptAuthKeySession(context.Background(), 300, perm1, 42, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if err != nil { + t.Fatalf("push except scoped session: %v", err) + } + if sent != 1 { + t.Fatalf("pushed to %d sessions, want 1", sent) + } + if _, ok := sm.pending[sessionKey{authKeyID: raw1, sessionID: 42}]; ok { + t.Fatal("excluded session received pending push") + } + if got := len(sm.pending[sessionKey{authKeyID: raw2, sessionID: 42}]); got != 1 { + t.Fatalf("raw2 pending pushes = %d, want 1", got) + } +} + +func TestSessionManagerChannelInterestIndex(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.TrackChannelInterest(raw, 42, 100, []int64{10, 10, 20}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel 10 online users = %v, want [100]", got) + } + sm.TrackChannelInterest(raw, 42, 100, []int64{20}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel 10 after viewer switch = %v, want empty", got) + } + if got := sm.OnlineChannelUserIDs(20, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel 20 after viewer switch = %v, want [100]", got) + } + sm.TrackChannelInterest(raw, 42, 100, []int64{10}) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel 10 online members before membership sync = %v, want empty", got) + } + sm.SetSessionChannelMemberships(raw, 42, 100, []int64{10, 30}) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel 10 online members = %v, want [100]", got) + } + if got := sm.OnlineChannelUserIDs(30, 10); len(got) != 0 { + t.Fatalf("channel 30 viewers = %v, want empty", got) + } + if got := sm.OnlineUserIDsForCandidates([]int64{0, 200, 100, 100}, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("candidate online users = %v, want [100]", got) + } + + sm.BindUserForAuthKey(raw, 42, 200) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel interest after user switch = %v, want empty", got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel membership after user switch = %v, want empty", got) + } + sm.TrackChannelInterest(raw, 42, 200, []int64{10}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 200 { + t.Fatalf("channel 10 after re-track = %v, want [200]", got) + } + sm.AddUserChannelMembership(200, 10) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 200 { + t.Fatalf("channel 10 membership after add = %v, want [200]", got) + } + sm.RemoveUserChannelMembership(200, 10) + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel membership after remove = %v, want empty", got) + } + sm.ClearChannelInterest(raw, 42, 200) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel interest after explicit clear = %v, want empty", got) + } + + sm.Unregister(c) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel interest after unregister = %v, want empty", got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("channel membership after unregister = %v, want empty", got) + } +} + +func TestSessionManagerClearsChannelIndexesOnAuthAndReadinessChanges(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw := [8]byte{1, 2, 3} + business := [8]byte{8} + c := &Conn{sessionID: 42, authKeyID: raw} + sm.Register(c) + sm.BindAuthKeyForSession(raw, 42, business) + sm.BindUserForAuthKey(raw, 42, 100) + + track := func() { + sm.TrackChannelInterest(raw, 42, 100, []int64{10}) + sm.SetSessionChannelMemberships(raw, 42, 100, []int64{10}) + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel viewers before cleanup = %v, want [100]", got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 1 || got[0] != 100 { + t.Fatalf("channel members before cleanup = %v, want [100]", got) + } + } + assertCleared := func(label string) { + if got := sm.OnlineChannelUserIDs(10, 10); len(got) != 0 { + t.Fatalf("%s viewers = %v, want empty", label, got) + } + if got := sm.OnlineChannelMemberUserIDs(10, 10); len(got) != 0 { + t.Fatalf("%s members = %v, want empty", label, got) + } + } + + track() + sm.SetReceivesUpdatesForAuthKey(raw, 42, false) + assertCleared("after receivesUpdates=false") + + track() + sm.BindAuthKeyForSession(raw, 42, [8]byte{9}) + assertCleared("after business auth key change") + + sm.BindAuthKeyForSession(raw, 42, business) + sm.BindUserForAuthKey(raw, 42, 100) + track() + if n := sm.UnbindAuthKey(business); n != 1 { + t.Fatalf("UnbindAuthKey count = %d, want 1", n) + } + assertCleared("after unbind auth key") +} + +// TestSessionManagerPush 验证主动推送端到端:两个 client 连接握手并建立 session 后, +// server 经 PushToSession / PushToUser 主动向其推送,client 收到。 +func TestSessionManagerPush(t *testing.T) { + const dc = 2 + addr, pub, srv := startTestServer(t, Options{DC: dc}) + + conn1, auth1, cipher1 := dialHandshake(t, addr, dc, pub) + conn2, auth2, cipher2 := dialHandshake(t, addr, dc, pub) + + // 各发一个 ping 建立 session,触发注册(并清掉 new_session_created/pong/ack)。 + msgGen := proto.NewMessageIDGen(time.Now) + sendEncrypted(t, conn1, cipher1, auth1, msgGen.New(proto.MessageFromClient), &mt.PingRequest{PingID: 1}) + collectReplies(t, conn1, cipher1, auth1.AuthKey, mt.PongTypeID) + sendEncrypted(t, conn2, cipher2, auth2, msgGen.New(proto.MessageFromClient), &mt.PingRequest{PingID: 2}) + collectReplies(t, conn2, cipher2, auth2.AuthKey, mt.PongTypeID) + + if got := srv.Conns().Online(); got != 2 { + t.Fatalf("online = %d, want 2", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1) PushToSession:session2 尚未进入 updates 同步入口时先暂存,ready 后下发。 + if err := srv.Conns().PushToSession(ctx, auth2.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + t.Fatalf("push to session: %v", err) + } + srv.Conns().SetReceivesUpdates(auth2.SessionID, true) + r2 := collectReplies(t, conn2, cipher2, auth2.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, r2, tg.UpdatesTooLongTypeID, "pushed updates on conn2") + + // 2) BindUser + PushToUser:按 user 维度推送给 conn1。 + srv.Conns().BindUser(auth1.SessionID, 100) + srv.Conns().SetReceivesUpdates(auth1.SessionID, true) + sent, err := srv.Conns().PushToUser(ctx, 100, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if err != nil { + t.Fatalf("push to user: %v", err) + } + if sent != 1 { + t.Fatalf("pushed to %d conns, want 1", sent) + } + r1 := collectReplies(t, conn1, cipher1, auth1.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, r1, tg.UpdatesTooLongTypeID, "pushed updates on conn1") + + // 3) PushToUserExceptSession:模拟 SyncUpdatesNotMe,跳过当前 session。 + srv.Conns().BindUser(auth1.SessionID, 200) + srv.Conns().BindUser(auth2.SessionID, 200) + sent, err = srv.Conns().PushToUserExceptSession(ctx, 200, auth2.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}) + if err != nil { + t.Fatalf("push to user except session: %v", err) + } + if sent != 1 { + t.Fatalf("pushed to %d conns, want 1 after excluding current session", sent) + } + r1 = collectReplies(t, conn1, cipher1, auth1.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, r1, tg.UpdatesTooLongTypeID, "pushed not-me updates on conn1") +} + +func BenchmarkSessionManagerOnlineCandidateFilter(b *testing.B) { + sm := NewSessionManager(zaptest.NewLogger(b)) + const online = 200_000 + rawPrefix := [8]byte{9} + for i := 1; i <= online; i++ { + raw := rawPrefix + raw[1] = byte(i) + raw[2] = byte(i >> 8) + raw[3] = byte(i >> 16) + raw[4] = byte(i >> 24) + c := &Conn{sessionID: int64(i), authKeyID: raw} + sm.Register(c) + sm.BindUserForAuthKey(raw, int64(i), int64(i)) + } + candidates := make([]int64, 0, 500) + for i := 0; i < 500; i++ { + candidates = append(candidates, int64(i*97+1)) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + got := sm.OnlineUserIDsForCandidates(candidates, 500) + if len(got) == 0 { + b.Fatal("no candidates matched") + } + } +} diff --git a/internal/rpc/account.go b/internal/rpc/account.go new file mode 100644 index 00000000..3b2937b2 --- /dev/null +++ b/internal/rpc/account.go @@ -0,0 +1,292 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// registerAccount 注册 account.* RPC handler。 +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.OnAccountGetPrivacy(func(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) { + return tdesktop.PrivacyRules(key), nil + }) + d.OnAccountGetAuthorizations(func(ctx context.Context) (*tg.AccountAuthorizations, error) { + return tdesktop.Authorizations(), nil + }) + d.OnAccountGetDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + return tdesktop.DefaultEmojiStatuses(), nil + }) + 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.OnAccountGetReactionsNotifySettings(r.onAccountGetReactionsNotifySettings) + d.OnAccountSetReactionsNotifySettings(r.onAccountSetReactionsNotifySettings) + d.OnAccountGetContactSignUpNotification(func(ctx context.Context) (bool, error) { + return false, nil + }) + d.OnAccountGetThemes(func(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) { + return tdesktop.AccountThemes(), 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.OnAccountGetPasskeys(func(ctx context.Context) (*tg.AccountPasskeys, error) { + return tdesktop.Passkeys(), 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.OnAccountGetAccountTTL(r.onAccountGetAccountTTL) + d.OnAccountUpdateStatus(r.onAccountUpdateStatus) +} + +func (r *Router) onAccountGetAccountTTL(ctx context.Context) (*tg.AccountDaysTTL, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.AccountDaysTTL{Days: 365}, nil +} + +func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, error) { + userID, authorized, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !authorized || userID == 0 { + return true, nil + } + status := r.setPresenceFromContext(ctx, userID, offline) + r.pushUserStatus(ctx, userID, status) + return true, nil +} + +type accountReactionSettingsService interface { + GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) + SetReactionsNotifySettings(ctx context.Context, userID int64, settings domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) +} + +func (r *Router) onAccountGetReactionsNotifySettings(ctx context.Context) (*tg.ReactionsNotifySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if svc, ok := r.deps.Account.(accountReactionSettingsService); ok { + settings, err := svc.GetReactionSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgReactionsNotifySettings(settings.Notify), nil + } + return tgReactionsNotifySettings(domain.DefaultAccountReactionSettings().Notify), nil +} + +func (r *Router) onAccountSetReactionsNotifySettings(ctx context.Context, settings tg.ReactionsNotifySettings) (*tg.ReactionsNotifySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + notify := domainReactionsNotifySettings(settings) + if svc, ok := r.deps.Account.(accountReactionSettingsService); ok { + next, err := svc.SetReactionsNotifySettings(ctx, userID, notify) + if err != nil { + return nil, internalErr() + } + return tgReactionsNotifySettings(next.Notify), nil + } + return tgReactionsNotifySettings(notify), nil +} + +func domainReactionsNotifySettings(settings tg.ReactionsNotifySettings) domain.ReactionsNotifySettings { + return domain.ReactionsNotifySettings{ + MessagesFrom: domainReactionNotifyFrom(settings.GetMessagesNotifyFrom), + StoriesFrom: domainReactionNotifyFrom(settings.GetStoriesNotifyFrom), + PollVotesFrom: domainReactionNotifyFrom(settings.GetPollVotesNotifyFrom), + ShowPreviews: settings.ShowPreviews, + } +} + +func domainReactionNotifyFrom(get func() (tg.ReactionNotificationsFromClass, bool)) domain.ReactionNotifyFrom { + if get == nil { + return domain.ReactionNotifyFromNone + } + value, ok := get() + if !ok || value == nil { + return domain.ReactionNotifyFromNone + } + switch value.(type) { + case *tg.ReactionNotificationsFromAll: + return domain.ReactionNotifyFromAll + case *tg.ReactionNotificationsFromContacts: + return domain.ReactionNotifyFromContacts + default: + return domain.ReactionNotifyFromNone + } +} + +func tgReactionsNotifySettings(settings domain.ReactionsNotifySettings) *tg.ReactionsNotifySettings { + out := &tg.ReactionsNotifySettings{ + Sound: &tg.NotificationSoundDefault{}, + ShowPreviews: settings.ShowPreviews, + } + if value := tgReactionNotifyFrom(settings.MessagesFrom); value != nil { + out.SetMessagesNotifyFrom(value) + } + if value := tgReactionNotifyFrom(settings.StoriesFrom); value != nil { + out.SetStoriesNotifyFrom(value) + } + if value := tgReactionNotifyFrom(settings.PollVotesFrom); value != nil { + out.SetPollVotesNotifyFrom(value) + } + return out +} + +func tgReactionNotifyFrom(value domain.ReactionNotifyFrom) tg.ReactionNotificationsFromClass { + switch value { + case domain.ReactionNotifyFromAll: + return &tg.ReactionNotificationsFromAll{} + case domain.ReactionNotifyFromContacts: + return &tg.ReactionNotificationsFromContacts{} + default: + return nil + } +} + +func (r *Router) onAccountUpdateProfile(ctx context.Context, req *tg.AccountUpdateProfileRequest) (tg.UserClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return nil, internalErr() + } + firstName, hasFirstName := req.GetFirstName() + lastName, hasLastName := req.GetLastName() + about, hasAbout := req.GetAbout() + u, err := svc.UpdateProfile(ctx, userID, domain.UserProfileUpdate{ + FirstName: firstName, + HasFirstName: hasFirstName, + LastName: lastName, + HasLastName: hasLastName, + About: about, + HasAbout: hasAbout, + }) + if err != nil { + return nil, profileErr(err) + } + r.pushUsernameUpdate(ctx, u) + return r.tgSelfUser(u), nil +} + +func (r *Router) onAccountCheckUsername(ctx context.Context, username string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return false, internalErr() + } + okUsername, err := svc.CheckUsername(ctx, userID, username) + if err != nil { + return false, usernameErr(err) + } + return okUsername, nil +} + +func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) (tg.UserClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return nil, internalErr() + } + u, err := svc.UpdateUsername(ctx, userID, username) + if err != nil { + return nil, usernameErr(err) + } + r.pushUsernameUpdate(ctx, u) + return r.tgSelfUser(u), nil +} + +func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { + if u.ID == 0 { + return + } + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUserName{ + UserID: u.ID, + FirstName: u.FirstName, + LastName: u.LastName, + Usernames: tgUsernames(u.Username), + }}, + Users: []tg.UserClass{r.tgSelfUser(u)}, + Date: int(r.clock.Now().Unix()), + }) +} + +func usernameErr(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.ErrUsernameNotOccupied): + return usernameNotOccupiedErr() + default: + return internalErr() + } +} + +func profileErr(err error) error { + switch { + case errors.Is(err, domain.ErrFirstNameInvalid): + return firstNameInvalidErr() + case errors.Is(err, domain.ErrAboutTooLong): + return aboutTooLongErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/aicompose.go b/internal/rpc/aicompose.go new file mode 100644 index 00000000..0fbf96fd --- /dev/null +++ b/internal/rpc/aicompose.go @@ -0,0 +1,16 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" +) + +// registerAiCompose 注册第一阶段 TDesktop 启动所需 aicompose.* RPC 兼容响应。 +func (r *Router) registerAiCompose(d *tg.ServerDispatcher) { + d.OnAicomposeGetTones(func(ctx context.Context, hash int64) (tg.AicomposeTonesClass, error) { + return tdesktop.AiComposeTones(), nil + }) +} diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go new file mode 100644 index 00000000..d69adb8e --- /dev/null +++ b/internal/rpc/auth.go @@ -0,0 +1,347 @@ +package rpc + +import ( + "context" + "encoding/binary" + "fmt" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// devCodeLength 是开发固定验证码长度,写入 auth.sentCode 的 type.length。 +const devCodeLength = 5 + +const loginMessagePushDelay = 2 * time.Second + +// registerAuth 注册 auth.* RPC handler。 +func (r *Router) registerAuth(d *tg.ServerDispatcher) { + d.OnAuthBindTempAuthKey(r.onAuthBindTempAuthKey) + d.OnAuthExportLoginToken(r.onAuthExportLoginToken) + d.OnAuthSendCode(r.onAuthSendCode) + d.OnAuthSignIn(r.onAuthSignIn) + d.OnAuthSignUp(r.onAuthSignUp) + d.OnAuthLogOut(r.onAuthLogOut) +} + +// onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。 +func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTempAuthKeyRequest) (bool, error) { + if r.deps.Auth == nil { + return true, nil + } + id, _ := RawAuthKeyIDFrom(ctx) + if id == ([8]byte{}) { + id, _ = AuthKeyIDFrom(ctx) + } + sessionID, _ := SessionIDFrom(ctx) + if err := r.deps.Auth.BindTempAuthKey(ctx, sessionID, domain.TempAuthKeyBinding{ + TempAuthKeyID: id, + PermAuthKeyID: req.PermAuthKeyID, + Nonce: req.Nonce, + ExpiresAt: req.ExpiresAt, + EncryptedMessage: append([]byte(nil), req.EncryptedMessage...), + }); err != nil { + return false, bindTempAuthKeyErr(err) + } + if r.deps.Sessions != nil { + if scoped, ok := r.scopedSessions(); ok { + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, authKeyIDFromInt64(req.PermAuthKeyID)) + } else { + r.deps.Sessions.BindAuthKey(sessionID, authKeyIDFromInt64(req.PermAuthKeyID)) + } + } + r.invalidateAuthUserCache(id) + 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 +} + +// 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) + if err != nil { + return nil, internalErr() + } + return &tg.AuthSentCode{ + Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength}, + PhoneCodeHash: hash, + }, nil +} + +// onAuthSignIn 处理 auth.signIn:校验验证码;用户不存在时返回 SignUpRequired。 +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) + if err != nil { + return nil, signInErr(err) + } + if needSignUp { + return &tg.AuthAuthorizationSignUpRequired{}, nil + } + 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) + r.recordAndScheduleLoginMessagePush(ctx, loginMessage) + r.pushSignInServiceNotificationToOthers(ctx, u) + 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) + if err != nil { + return nil, signInErr(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) + r.recordAndScheduleLoginMessagePush(ctx, loginMessage) + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + +// onAuthLogOut 处理 auth.logOut:解绑当前 auth_key 的授权。 +func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, authorized, userErr := r.currentUserID(ctx) + if err := r.deps.Auth.LogOut(ctx, id); err != nil { + return nil, internalErr() + } + r.invalidateAuthUserCache(id) + r.unbindAuthKey(id) + if userErr == nil && authorized && userID != 0 { + status := r.setPresenceFromContext(ctx, userID, true) + r.pushUserStatus(ctx, userID, status) + } + if err := r.clearAuthKeyState(ctx, id); err != nil { + return nil, internalErr() + } + return &tg.AuthLoggedOut{}, nil +} + +func (r *Router) clearAuthKeyStateOnUserChange(ctx context.Context, newUserID int64) error { + oldUserID, ok := UserIDFrom(ctx) + if !ok || oldUserID == 0 || oldUserID == newUserID { + return nil + } + id, ok := AuthKeyIDFrom(ctx) + if !ok { + return nil + } + return r.clearAuthKeyState(ctx, id) +} + +func (r *Router) clearAuthKeyState(ctx context.Context, authKeyID [8]byte) error { + if r.deps.Updates == nil { + return nil + } + return r.deps.Updates.ClearAuthKey(ctx, authKeyID) +} + +func (r *Router) bindSessionUser(ctx context.Context, userID int64) { + if r.deps.Sessions == nil { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if scoped, ok := r.scopedSessions(); ok { + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID) + r.announceSessionOnline(ctx, userID) + return + } + r.deps.Sessions.BindUser(sessionID, userID) + r.announceSessionOnline(ctx, userID) +} + +func (r *Router) unbindAuthKey(authKeyID [8]byte) { + if r.deps.Sessions == nil { + return + } + r.deps.Sessions.UnbindAuthKey(authKeyID) +} + +func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u domain.User) { + if r.deps.Sessions == nil || u.ID == 0 { + return + } + authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx) + sessionID, hasSessionID := SessionIDFrom(ctx) + if !hasAuthKeyID || !hasSessionID { + return + } + notification := r.tgSignInServiceNotification(ctx, u, authKeyID) + go func() { + pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if scoped, ok := r.scopedSessions(); ok { + if sent, err := scoped.PushToUserExceptAuthKeySession(pushCtx, u.ID, authKeyID, sessionID, proto.MessageFromServer, notification); err != nil { + r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err)) + } + return + } + if sent, err := r.deps.Sessions.PushToUserExceptSession(pushCtx, u.ID, sessionID, proto.MessageFromServer, notification); err != nil { + r.log.Debug("push sign-in service notification", zap.Int64("user_id", u.ID), zap.Int("sent", sent), zap.Error(err)) + } + }() +} + +func (r *Router) recordAndScheduleLoginMessagePush(ctx context.Context, msg domain.Message) { + authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx) + sessionID, hasSessionID := SessionIDFrom(ctx) + if !hasAuthKeyID || !hasSessionID || msg.ID == 0 { + return + } + event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, Date: msg.Date, Message: msg} + state := domain.UpdateState{Pts: 1, Date: msg.Date, Seq: 0} + if r.deps.Updates != nil { + recorded, st, err := r.deps.Updates.RecordNewMessage(ctx, authKeyID, msg.OwnerUserID, msg) + if err != nil { + r.log.Warn("record login message update", zap.Error(err)) + return + } + event = recorded + state = st + } + if r.deps.Sessions == nil { + return + } + // 提前从请求 ctx 取出 rawAuthKeyID(值类型),闭包只捕获该值、不捕获请求 ctx—— + // 避免延迟推送的 AfterFunc 在 loginMessagePushDelay 期间延长请求 ctx 链路的存活。 + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + time.AfterFunc(loginMessagePushDelay, func() { + pushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + r.pushLoginMessage(pushCtx, rawAuthKeyID, sessionID, event, state) + }) +} + +func (r *Router) pushLoginMessage(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, event domain.UpdateEvent, state domain.UpdateState) { + if r.deps.Sessions == nil || event.Message.ID == 0 { + return + } + updates := tgLoginMessageUpdates(event, state) + if updates == nil { + return + } + var err error + if scoped, ok := r.scopedSessions(); ok && rawAuthKeyID != ([8]byte{}) { + err = scoped.PushToSessionForAuthKey(ctx, rawAuthKeyID, sessionID, proto.MessageFromServer, updates) + } else { + err = r.deps.Sessions.PushToSession(ctx, sessionID, proto.MessageFromServer, updates) + } + if err != nil { + r.log.Debug("push login message", zap.Int64("session_id", sessionID), zap.Error(err)) + return + } + r.log.Debug("pushed login message", + zap.Int64("session_id", sessionID), + zap.Int("message_id", event.Message.ID), + zap.Int("pts", event.Pts), + zap.Int("seq", state.Seq), + ) +} + +func tgLoginMessageUpdates(event domain.UpdateEvent, state domain.UpdateState) *tg.Updates { + item := tgMessage(event.Message) + if item == nil { + return nil + } + if state.Date == 0 { + state.Date = event.Date + } + return &tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdateNewMessage{ + Message: item, + Pts: event.Pts, + PtsCount: event.PtsCount, + }, + }, + Users: []tg.UserClass{tgUser(domain.OfficialSystemUser())}, + Date: state.Date, + Seq: state.Seq, + } +} + +func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User, authKeyID [8]byte) *tg.Updates { + now := r.clock.Now() + client := "Unknown device" + if ci, ok := ClientInfoFrom(ctx); ok { + parts := []string{} + if ci.DeviceModel != "" { + parts = append(parts, ci.DeviceModel) + } + if ci.SystemVersion != "" { + parts = append(parts, ci.SystemVersion) + } + if ci.AppVersion != "" { + parts = append(parts, ci.AppVersion) + } + if len(parts) > 0 { + client = strings.Join(parts, " / ") + } + } + name := strings.TrimSpace(strings.TrimSpace(u.FirstName + " " + u.LastName)) + if name == "" { + name = u.Phone + } + if name == "" { + name = "there" + } + message := fmt.Sprintf("New login.\nDear %s, we detected a login into your account from a new device on %s.\n\nDevice: %s\nLocation: Unknown\n\nIf this wasn't you, you can terminate that session in Settings > Devices (or Privacy & Security > Active Sessions).", + name, + now.UTC().Format(time.RFC1123), + client, + ) + authID := int64(binary.LittleEndian.Uint64(authKeyID[:])) + update := &tg.UpdateServiceNotification{ + InboxDate: int(now.Unix()), + Type: fmt.Sprintf("auth%d_%d", authID, now.Unix()), + Message: message, + Media: &tg.MessageMediaEmpty{}, + Entities: signInNotificationEntities(message), + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: int(now.Unix()), + } +} + +func signInNotificationEntities(message string) []tg.MessageEntityClass { + terms := []string{"New login.", "Settings > Devices", "Privacy & Security > Active Sessions"} + out := make([]tg.MessageEntityClass, 0, len(terms)) + for _, term := range terms { + if offset := strings.Index(message, term); offset >= 0 { + out = append(out, &tg.MessageEntityBold{Offset: offset, Length: len(term)}) + } + } + return out +} + +func authKeyIDFromInt64(v int64) [8]byte { + var id [8]byte + binary.LittleEndian.PutUint64(id[:], uint64(v)) + return id +} diff --git a/internal/rpc/channel_interest.go b/internal/rpc/channel_interest.go new file mode 100644 index 00000000..1ba2a88a --- /dev/null +++ b/internal/rpc/channel_interest.go @@ -0,0 +1,182 @@ +package rpc + +import ( + "context" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const channelMembershipSyncPageSize = domain.MaxSynchronousChannelDialogFanout + +func (r *Router) trackChannelInterest(ctx context.Context, userID int64, channelIDs ...int64) { + if userID == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if len(channelIDs) == 0 { + provider.ClearChannelInterest(rawAuthKeyID, sessionID, userID) + return + } + provider.TrackChannelInterest(rawAuthKeyID, sessionID, userID, channelIDs) +} + +func (r *Router) clearChannelInterest(ctx context.Context, userID int64) { + r.trackChannelInterest(ctx, userID) +} + +func (r *Router) syncSessionChannelMemberships(ctx context.Context, userID int64) { + if userID == 0 || r.deps.Sessions == nil || r.deps.Channels == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + channelIDs := make([]int64, 0, channelMembershipSyncPageSize) + after := int64(0) + for { + page, err := r.deps.Channels.ActiveChannelIDsForUser(ctx, userID, after, channelMembershipSyncPageSize) + if err != nil { + r.log.Warn("sync session channel memberships failed", + zap.Int64("user_id", userID), + zap.Int64("after_channel_id", after), + zap.Error(err)) + return + } + if len(page) == 0 { + break + } + progressed := false + for _, channelID := range page { + if channelID == 0 { + continue + } + channelIDs = append(channelIDs, channelID) + if channelID > after { + after = channelID + progressed = true + } + } + if !progressed || len(page) < channelMembershipSyncPageSize { + break + } + } + provider.SetSessionChannelMemberships(rawAuthKeyID, sessionID, userID, channelIDs) +} + +func (r *Router) addOnlineChannelMemberships(channelID int64, userIDs ...int64) { + if channelID == 0 || len(userIDs) == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + provider.AddUserChannelMembership(userID, channelID) + } +} + +func (r *Router) removeOnlineChannelMemberships(channelID int64, userIDs ...int64) { + if channelID == 0 || len(userIDs) == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + provider.RemoveUserChannelMembership(userID, channelID) + } +} + +func (r *Router) removeOnlineChannelMembershipsForOnlineMembers(channelID int64) { + if channelID == 0 || r.deps.Sessions == nil { + return + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return + } + r.removeOnlineChannelMemberships(channelID, provider.OnlineChannelMemberUserIDs(channelID, 0)...) +} + +func channelMemberUserIDs(members []domain.ChannelMember) []int64 { + if len(members) == 0 { + return nil + } + ids := make([]int64, 0, len(members)) + for _, member := range members { + if member.UserID == 0 || member.Status != domain.ChannelMemberActive { + continue + } + ids = append(ids, member.UserID) + } + return ids +} + +func channelIDsFromDialogs(list domain.DialogList) []int64 { + if len(list.Dialogs) == 0 && len(list.Channels) == 0 { + return nil + } + ids := make([]int64, 0, len(list.Dialogs)+len(list.Channels)) + seen := make(map[int64]struct{}, len(list.Dialogs)+len(list.Channels)) + for _, d := range list.Dialogs { + if d.Peer.Type != domain.PeerTypeChannel || d.Peer.ID == 0 { + continue + } + if _, ok := seen[d.Peer.ID]; ok { + continue + } + seen[d.Peer.ID] = struct{}{} + ids = append(ids, d.Peer.ID) + } + for _, ch := range list.Channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + ids = append(ids, ch.ID) + } + return ids +} diff --git a/internal/rpc/channels.go b/internal/rpc/channels.go new file mode 100644 index 00000000..5a56c7f4 --- /dev/null +++ b/internal/rpc/channels.go @@ -0,0 +1,4017 @@ +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.Event, 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.Event, 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.Event, 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.Event, 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, 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, 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, event domain.ChannelUpdateEvent, 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)) + } + updates := []tg.UpdateClass{update, &tg.UpdateChannel{ChannelID: channel.ID}} + if event.Pts > 0 { + tooLong := &tg.UpdateChannelTooLong{ChannelID: channel.ID} + tooLong.SetPts(event.Pts) + updates = append(updates, tooLong) + } + return &tg.Updates{ + Updates: updates, + 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/contacts.go b/internal/rpc/contacts.go new file mode 100644 index 00000000..7e3c7a3c --- /dev/null +++ b/internal/rpc/contacts.go @@ -0,0 +1,562 @@ +package rpc + +import ( + "context" + "errors" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/app/contacts" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +const ( + maxContactImportBatch = 500 + maxContactDeleteBatch = 500 + maxContactNameLength = 128 + maxContactPhoneLength = 64 + maxContactNoteLength = 4096 + maxContactSearchQLen = 256 + maxContactSearchLimit = 50 +) + +// registerContacts 注册 contacts.* RPC handler。 +func (r *Router) registerContacts(d *tg.ServerDispatcher) { + d.OnContactsGetContacts(r.onContactsGetContacts) + d.OnContactsGetContactIDs(r.onContactsGetContactIDs) + d.OnContactsGetStatuses(r.onContactsGetStatuses) + d.OnContactsImportContacts(r.onContactsImportContacts) + d.OnContactsAddContact(r.onContactsAddContact) + d.OnContactsDeleteContacts(r.onContactsDeleteContacts) + d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote) + d.OnContactsSearch(r.onContactsSearch) + d.OnContactsResolveUsername(r.onContactsResolveUsername) + d.OnContactsResolvePhone(r.onContactsResolvePhone) + d.OnContactsGetTopPeers(func(ctx context.Context, req *tg.ContactsGetTopPeersRequest) (tg.ContactsTopPeersClass, error) { + return tdesktop.TopPeers(), nil + }) + d.OnContactsGetBlocked(func(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) { + if req.Limit > 50 { + return nil, limitInvalidErr() + } + return tdesktop.BlockedContacts(), nil + }) + d.OnContactsGetSponsoredPeers(func(ctx context.Context, q string) (tg.ContactsSponsoredPeersClass, error) { + if utf8.RuneCountInString(q) > maxContactSearchQLen { + return nil, limitInvalidErr() + } + return &tg.ContactsSponsoredPeersEmpty{}, nil + }) +} + +func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.ContactsContactsClass, error) { + if r.deps.Contacts == nil { + return &tg.ContactsContacts{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, notModified, err := r.deps.Contacts.GetContacts(ctx, userID, hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return &tg.ContactsContactsNotModified{}, nil + } + return tgContacts(r.withContactListPresence(list)), nil +} + +func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus, error) { + if r.deps.Contacts == nil { + return []tg.ContactStatus{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, _, err := r.deps.Contacts.GetContacts(ctx, userID, 0) + if err != nil { + return nil, internalErr() + } + contactUserIDs := make([]int64, 0, len(list.Contacts)) + out := make([]tg.ContactStatus, 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{}{} + contactUserIDs = append(contactUserIDs, id) + } + usersByID := make(map[int64]domain.User, len(contactUserIDs)) + if len(contactUserIDs) > 0 && r.deps.Users != nil { + users, err := r.deps.Users.ByIDs(ctx, userID, contactUserIDs) + if err != nil { + return nil, internalErr() + } + for _, u := range users { + if u.ID != 0 { + usersByID[u.ID] = u + } + } + } + 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{}{} + u := contact.User + if current, ok := usersByID[id]; ok { + u.LastSeenAt = current.LastSeenAt + u.Status = current.Status + } + out = append(out, tg.ContactStatus{ + UserID: id, + Status: tgUserStatus(r.userPresenceStatusForUser(u)), + }) + } + return out, nil +} + +func (r *Router) onContactsGetContactIDs(ctx context.Context, hash int64) ([]int, error) { + if r.deps.Contacts == nil { + return nil, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return nil, nil + } + return ids, nil +} + +func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputPhoneContact) (*tg.ContactsImportedContacts, error) { + if r.deps.Contacts == nil { + return &tg.ContactsImportedContacts{}, nil + } + if len(input) > maxContactImportBatch { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + items := make([]domain.ContactInput, 0, len(input)) + for _, item := range input { + note, entities := contactNote(item.GetNote()) + if !validContactInput(item.Phone, item.FirstName, item.LastName, note, len(entities)) { + return nil, limitInvalidErr() + } + items = append(items, domain.ContactInput{ + ClientID: item.ClientID, + Phone: item.Phone, + FirstName: item.FirstName, + LastName: item.LastName, + Note: note, + NoteEntities: entities, + }) + } + res, err := r.deps.Contacts.ImportContacts(ctx, userID, items) + if err != nil { + return nil, internalErr() + } + out := &tg.ContactsImportedContacts{ + Imported: make([]tg.ImportedContact, 0, len(res.Imported)), + Users: make([]tg.UserClass, 0, len(res.Contacts)), + } + for _, imported := range res.Imported { + out.Imported = append(out.Imported, tg.ImportedContact{UserID: imported.UserID, ClientID: imported.ClientID}) + } + for _, contact := range res.Contacts { + out.Users = append(out.Users, r.tgUser(contact.User)) + } + out.RetryContacts = append(out.RetryContacts, res.RetryContacts...) + for _, contact := range res.Contacts { + if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil { + return nil, internalErr() + } + } + if err := r.recordContactsReset(ctx, userID); err != nil { + return nil, internalErr() + } + r.pushContactsReset(ctx, userID) + return out, nil +} + +func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddContactRequest) (tg.UpdatesClass, error) { + if r.deps.Contacts == nil { + return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.ID) + if err != nil { + return nil, contactErr(err) + } + if !found { + return nil, contactIDInvalidErr() + } + note, entities := contactNote(req.GetNote()) + if !validContactInput(req.Phone, req.FirstName, req.LastName, note, len(entities)) { + return nil, limitInvalidErr() + } + contact, err := r.deps.Contacts.AddContact(ctx, userID, domain.ContactInput{ + ContactUserID: target.ID, + Phone: req.Phone, + FirstName: req.FirstName, + LastName: req.LastName, + Note: note, + NoteEntities: entities, + AddPhonePrivacyException: req.AddPhonePrivacyException, + }) + if err != nil { + return nil, contactErr(err) + } + updates := r.contactPeerSettingsUpdates(ctx, userID, contact.User, domain.PeerSettings{ShareContact: true}, true) + updates.Updates = append(updates.Updates, &tg.UpdateContactsReset{}) + if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID}, domain.PeerSettings{ShareContact: true}); err != nil { + return nil, internalErr() + } + if err := r.recordContactsReset(ctx, userID); err != nil { + return nil, internalErr() + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates) + return updates, nil +} + +func (r *Router) onContactsDeleteContacts(ctx context.Context, ids []tg.InputUserClass) (tg.UpdatesClass, error) { + if r.deps.Contacts == nil { + return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil + } + if len(ids) > maxContactDeleteBatch { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + contactIDs := make([]int64, 0, len(ids)) + users := make([]tg.UserClass, 0, len(ids)+1) + if r.deps.Users != nil { + if u, err := r.deps.Users.Self(ctx, userID); err == nil { + users = append(users, r.tgSelfUser(u)) + } + } + seen := map[int64]struct{}{userID: {}} + for _, id := range ids { + u, found, err := r.userFromInput(ctx, userID, id) + if err != nil { + return nil, contactErr(err) + } + if !found || u.ID == userID { + continue + } + contactIDs = append(contactIDs, u.ID) + u.Contact = false + u.Mutual = false + if _, ok := seen[u.ID]; !ok { + users = append(users, r.tgUser(u)) + seen[u.ID] = struct{}{} + } + } + if _, err := r.deps.Contacts.DeleteContacts(ctx, userID, contactIDs); err != nil { + return nil, internalErr() + } + updates := make([]tg.UpdateClass, 0, len(contactIDs)) + for _, id := range contactIDs { + updates = append(updates, &tg.UpdatePeerSettings{ + Peer: &tg.PeerUser{UserID: id}, + Settings: tgPeerSettings(domain.PeerSettings{AddContact: true, BlockContact: true}), + }) + } + if len(contactIDs) > 0 { + for _, id := range contactIDs { + if err := r.recordPeerSettings(ctx, userID, domain.Peer{Type: domain.PeerTypeUser, ID: id}, domain.PeerSettings{AddContact: true, BlockContact: true}); err != nil { + return nil, internalErr() + } + } + updates = append(updates, &tg.UpdateContactsReset{}) + if err := r.recordContactsReset(ctx, userID); err != nil { + return nil, internalErr() + } + } + out := &tg.Updates{Updates: updates, Users: users, Date: int(r.clock.Now().Unix())} + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) + return out, nil +} + +func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.ContactsUpdateContactNoteRequest) (bool, error) { + if r.deps.Contacts == nil { + return true, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.ID) + if err != nil { + return false, contactErr(err) + } + if !found { + return false, contactIDInvalidErr() + } + if utf8.RuneCountInString(req.Note.Text) > maxContactNoteLength || len(req.Note.Entities) > maxMessageEntityCount { + return false, limitInvalidErr() + } + if _, err := r.deps.Contacts.UpdateContactNote(ctx, userID, target.ID, req.Note.Text, domainMessageEntities(req.Note.Entities)); err != nil { + return false, contactErr(err) + } + if err := r.recordContactsReset(ctx, userID); err != nil { + return false, internalErr() + } + r.pushContactsReset(ctx, userID) + return true, nil +} + +func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchRequest) (*tg.ContactsFound, error) { + if r.deps.Contacts == nil && r.deps.Channels == nil { + return &tg.ContactsFound{}, nil + } + query := normalizeSearchQuery(req.Q) + if query == "" { + return nil, searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) < 3 { + return nil, queryTooShortErr() + } + if utf8.RuneCountInString(query) > maxContactSearchQLen { + return nil, limitInvalidErr() + } + limit := req.Limit + if limit <= 0 || limit > maxContactSearchLimit { + limit = maxContactSearchLimit + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res := domain.UserSearchResult{} + if r.deps.Contacts != nil { + userRes, err := r.deps.Contacts.Search(ctx, userID, query, limit) + if err != nil { + return nil, internalErr() + } + res = userRes + } + if r.deps.Channels != nil { + channelRes, err := r.deps.Channels.SearchPublicChannels(ctx, userID, query, limit) + if err != nil { + return nil, channelInvalidErr(err) + } + res.MyChannelResults = channelRes.MyResults + res.ChannelResults = channelRes.Results + } + return tgContactsFound(userID, r.withUserSearchPresence(res)), nil +} + +func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.ContactsResolveUsernameRequest) (*tg.ContactsResolvedPeer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if svc, ok := r.deps.Users.(UserIdentityService); ok { + u, found, err := svc.ResolveUsername(ctx, userID, req.Username) + if err != nil { + return nil, usernameErr(err) + } + if found { + return r.tgResolvedUserPeer(userID, u), nil + } + } + if r.deps.Channels != nil { + ch, found, err := r.deps.Channels.ResolvePublicUsername(ctx, userID, req.Username) + if err != nil { + return nil, usernameErr(err) + } + if found { + return tgResolvedChannelPeer(userID, ch), nil + } + } + return nil, usernameNotOccupiedErr() +} + +func (r *Router) onContactsResolvePhone(ctx context.Context, phone string) (*tg.ContactsResolvedPeer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return nil, phoneNotOccupiedErr() + } + u, found, err := svc.ResolvePhone(ctx, userID, phone) + if err != nil { + if errors.Is(err, domain.ErrPhoneNotOccupied) { + return nil, phoneNotOccupiedErr() + } + return nil, internalErr() + } + if !found { + return nil, phoneNotOccupiedErr() + } + return r.tgResolvedUserPeer(userID, u), nil +} + +func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.ContactsResolvedPeer { + var user tg.UserClass + if u.ID == currentUserID { + user = r.tgSelfUser(u) + } else { + user = r.tgUser(u) + } + return &tg.ContactsResolvedPeer{ + Peer: &tg.PeerUser{UserID: u.ID}, + Users: []tg.UserClass{user}, + } +} + +func tgResolvedChannelPeer(currentUserID int64, ch domain.Channel) *tg.ContactsResolvedPeer { + return &tg.ContactsResolvedPeer{ + Peer: &tg.PeerChannel{ChannelID: ch.ID}, + Chats: []tg.ChatClass{tgChannelChat(currentUserID, ch, nil)}, + } +} + +func normalizeSearchQuery(query string) string { + query = strings.TrimSpace(query) + query = strings.TrimPrefix(query, "@") + return strings.TrimSpace(query) +} + +func validContactInput(phone, firstName, lastName, note string, entities int) bool { + if utf8.RuneCountInString(phone) > maxContactPhoneLength { + return false + } + if utf8.RuneCountInString(firstName) > maxContactNameLength || utf8.RuneCountInString(lastName) > maxContactNameLength { + return false + } + if utf8.RuneCountInString(note) > maxContactNoteLength || entities > maxMessageEntityCount { + return false + } + return true +} + +func contactNote(note tg.TextWithEntities, ok bool) (string, []domain.MessageEntity) { + if !ok { + return "", nil + } + return note.Text, domainMessageEntities(note.Entities) +} + +func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, peerUser domain.User, settings domain.PeerSettings, includeSelf bool) *tg.Updates { + users := make([]tg.UserClass, 0, 2) + if includeSelf && r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + } + } + users = append(users, r.tgUser(peerUser)) + return &tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdatePeerSettings{ + Peer: &tg.PeerUser{UserID: peerUser.ID}, + Settings: tgPeerSettings(settings), + }, + }, + Users: users, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) pushContactsReset(ctx context.Context, userID int64) { + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateContactsReset{}}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) +} + +func (r *Router) recordContactsReset(ctx context.Context, userID int64) error { + if r.deps.Updates == nil || userID == 0 { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + _, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, sessionID) + return err +} + +func (r *Router) recordPeerSettings(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) + _, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID) + return err +} + +type reliableUpdateDispatchReporter interface { + UsesReliableDispatch() bool +} + +func (r *Router) hasReliableUpdateDispatch() bool { + reporter, ok := r.deps.Updates.(reliableUpdateDispatchReporter) + return ok && reporter.UsesReliableDispatch() +} + +func (r *Router) pushUserUpdatesIfNoReliableDispatch(ctx context.Context, userID int64, updates *tg.Updates) { + if r.hasReliableUpdateDispatch() { + return + } + r.pushUserUpdates(ctx, userID, updates) +} + +func (r *Router) pushUserUpdates(ctx context.Context, userID int64, updates *tg.Updates) int { + return r.pushUserMessage(ctx, userID, "push user updates", updates) +} + +func tgPeerSettings(settings domain.PeerSettings) tg.PeerSettings { + if settings.HiddenPeerSettingsBar { + return tg.PeerSettings{} + } + return tg.PeerSettings{ + AddContact: settings.AddContact, + BlockContact: settings.BlockContact, + ShareContact: settings.ShareContact, + NeedContactsException: settings.NeedContactsException, + } +} + +func contactErr(err error) error { + switch { + case errors.Is(err, contacts.ErrContactNameEmpty): + return contactNameEmptyErr() + case errors.Is(err, contacts.ErrContactIDInvalid): + return contactIDInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/context.go b/internal/rpc/context.go new file mode 100644 index 00000000..e140eb33 --- /dev/null +++ b/internal/rpc/context.go @@ -0,0 +1,96 @@ +package rpc + +import "context" + +type ctxKey int + +const ( + layerKey ctxKey = iota + clientInfoKey + rawAuthKeyIDKey + authKeyIDKey + sessionIDKey + userIDKey +) + +// ClientInfo 是 initConnection 携带的客户端信息。 +type ClientInfo struct { + APIID int + DeviceModel string + SystemVersion string + AppVersion string + SystemLangCode string + LangPack string + LangCode string +} + +// WithLayer 在 ctx 注入客户端 layer(来自 invokeWithLayer)。 +func WithLayer(ctx context.Context, layer int) context.Context { + return context.WithValue(ctx, layerKey, layer) +} + +// LayerFrom 返回 ctx 中的客户端 layer,未设置时为 0。 +func LayerFrom(ctx context.Context) int { + if v, ok := ctx.Value(layerKey).(int); ok { + return v + } + return 0 +} + +// WithClientInfo 在 ctx 注入客户端信息(来自 initConnection)。 +func WithClientInfo(ctx context.Context, info ClientInfo) context.Context { + return context.WithValue(ctx, clientInfoKey, info) +} + +// ClientInfoFrom 返回 ctx 中的客户端信息。 +func ClientInfoFrom(ctx context.Context) (ClientInfo, bool) { + v, ok := ctx.Value(clientInfoKey).(ClientInfo) + return v, ok +} + +// WithRawAuthKeyID 在 ctx 注入连接实际使用的 auth_key_id。 +func WithRawAuthKeyID(ctx context.Context, id [8]byte) context.Context { + return context.WithValue(ctx, rawAuthKeyIDKey, id) +} + +// RawAuthKeyIDFrom 返回连接实际使用的 auth_key_id。 +func RawAuthKeyIDFrom(ctx context.Context) ([8]byte, bool) { + v, ok := ctx.Value(rawAuthKeyIDKey).([8]byte) + return v, ok +} + +// WithAuthKeyID 在 ctx 注入业务视角的 auth_key_id;temp auth_key 绑定后会解析为 perm auth_key。 +func WithAuthKeyID(ctx context.Context, id [8]byte) context.Context { + return context.WithValue(ctx, authKeyIDKey, id) +} + +// AuthKeyIDFrom 返回 ctx 中业务视角的 auth_key_id。已握手连接均有(即便尚未登录)。 +func AuthKeyIDFrom(ctx context.Context) ([8]byte, bool) { + v, ok := ctx.Value(authKeyIDKey).([8]byte) + return v, ok +} + +// WithSessionID 在 ctx 注入调用方的 MTProto session_id。 +func WithSessionID(ctx context.Context, id int64) context.Context { + return context.WithValue(ctx, sessionIDKey, id) +} + +// SessionIDFrom 返回 ctx 中调用方的 MTProto session_id。 +func SessionIDFrom(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(sessionIDKey).(int64) + return v, ok +} + +// WithUserID 在 ctx 注入当前已登录用户 id。 +func WithUserID(ctx context.Context, id int64) context.Context { + return context.WithValue(ctx, userIDKey, id) +} + +// UserIDFrom 返回 ctx 中当前已登录用户 id。 +func UserIDFrom(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(userIDKey).(int64) + if !ok || v == 0 { + return 0, false + } + return v, true +} diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go new file mode 100644 index 00000000..01bc6211 --- /dev/null +++ b/internal/rpc/convert.go @@ -0,0 +1,1919 @@ +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} + 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 := tgUsers(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(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 := tgUsers(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 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 { + 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) + } + } + } + // 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.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, + 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, + ID: m.ID, + FromID: from, + PeerID: peer, + Date: m.Date, + Message: m.Body, + Entities: tgMessageEntities(m.Entities), + } + 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") + } + return &tg.AccountPassword{ + HasRecovery: settings.HasRecovery, + HasSecureValues: settings.HasSecureValues, + HasPassword: settings.HasPassword, + Hint: settings.Hint, + EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, + NewAlgo: &tg.PasswordKdfAlgoUnknown{}, + NewSecureAlgo: &tg.SecurePasswordKdfAlgoUnknown{}, + SecureRandom: settings.SecureRandom, + LoginEmailPattern: settings.LoginEmailPattern, + } +} + +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_media.go b/internal/rpc/convert_media.go new file mode 100644 index 00000000..c4c71d08 --- /dev/null +++ b/internal/rpc/convert_media.go @@ -0,0 +1,385 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件集中 domain media 值对象 → tg.* 的转换;tg.* 只在 rpc 层出现。 +// 供 reaction / sticker 资源 RPC 与消息 media 共用。 + +// tgMessageMedia 把消息 media 快照转成 tg.MessageMediaClass;空载荷回退 MessageMediaEmpty。 +func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass { + if m.IsZero() { + return &tg.MessageMediaEmpty{} + } + switch m.Kind { + case domain.MessageMediaKindPhoto: + out := &tg.MessageMediaPhoto{Spoiler: m.Spoiler} + if m.Photo != nil { + out.Photo = tgPhoto(*m.Photo) + } + if m.TTLSeconds > 0 { + out.TTLSeconds = m.TTLSeconds + } + return out + case domain.MessageMediaKindDocument: + out := &tg.MessageMediaDocument{ + Spoiler: m.Spoiler, + Nopremium: m.Nopremium, + Voice: m.Voice, + Round: m.Round, + Video: m.Video, + } + if m.Document != nil { + out.Document = tgDocument(*m.Document) + } + if m.TTLSeconds > 0 { + out.TTLSeconds = m.TTLSeconds + } + return out + default: + return &tg.MessageMediaEmpty{} + } +} + +// tgChatPhoto 由 domain.Channel 反范式头像字段构造 ChatPhoto(频道/群头像缩略)。 +func tgChatPhoto(ch domain.Channel) tg.ChatPhotoClass { + if ch.PhotoID == 0 { + return &tg.ChatPhotoEmpty{} + } + p := &tg.ChatPhoto{PhotoID: ch.PhotoID, DCID: ch.PhotoDCID} + if len(ch.PhotoStripped) > 0 { + p.SetStrippedThumb(ch.PhotoStripped) + } + return p +} + +// tgChannelChatPhotoFull 为 channelFull.chat_photo 构造完整 Photo(合成 a/c 尺寸; +// getFile 按 photo:: 解析,忽略 access_hash,故合成尺寸也可下载)。 +func tgChannelChatPhotoFull(ch domain.Channel) tg.PhotoClass { + if ch.PhotoID == 0 { + return &tg.PhotoEmpty{} + } + photo := &tg.Photo{ID: ch.PhotoID, DCID: ch.PhotoDCID, Sizes: syntheticAvatarSizes()} + if len(ch.PhotoStripped) > 0 { + photo.Sizes = append([]tg.PhotoSizeClass{&tg.PhotoStrippedSize{Type: "i", Bytes: ch.PhotoStripped}}, photo.Sizes...) + } + return photo +} + +func syntheticAvatarSizes() []tg.PhotoSizeClass { + return []tg.PhotoSizeClass{ + &tg.PhotoSize{Type: "a", W: 160, H: 160, Size: 0}, + &tg.PhotoSize{Type: "c", W: 640, H: 640, Size: 0}, + } +} + +// tgPhoto 把 domain.Photo 转成 tg.PhotoClass。 +func tgPhoto(p domain.Photo) tg.PhotoClass { + if p.ID == 0 { + return &tg.PhotoEmpty{} + } + return &tg.Photo{ + ID: p.ID, + AccessHash: p.AccessHash, + FileReference: p.FileReference, + Date: p.Date, + Sizes: tgPhotoSizes(p.Sizes), + DCID: p.DCID, + HasStickers: p.HasStickers, + } +} + +// tgDocument 把 domain.Document 转成 tg.DocumentClass。 +func tgDocument(d domain.Document) tg.DocumentClass { + if d.ID == 0 { + return &tg.DocumentEmpty{} + } + return &tg.Document{ + ID: d.ID, + AccessHash: d.AccessHash, + FileReference: d.FileReference, + Date: d.Date, + MimeType: d.MimeType, + Size: d.Size, + Thumbs: tgDocumentThumbs(d.Thumbs), + DCID: d.DCID, + Attributes: tgDocumentAttributes(d.Attributes), + } +} + +func tgDocuments(docs []domain.Document) []tg.DocumentClass { + out := make([]tg.DocumentClass, 0, len(docs)) + for _, d := range docs { + out = append(out, tgDocument(d)) + } + return out +} + +func tgDocumentThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass { + if len(sizes) == 0 { + return nil + } + out := make([]tg.PhotoSizeClass, 0, len(sizes)) + for _, s := range sizes { + if s.Kind == domain.PhotoSizeKindCached && len(s.Bytes) > 0 { + size := s.Size + if size == 0 { + size = len(s.Bytes) + } + if s.Type != "" && s.W > 0 && s.H > 0 && size > 0 { + out = append(out, &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: size}) + } + continue + } + out = append(out, tgPhotoSize(s)) + } + return compactPhotoSizeClasses(out) +} + +func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass { + if len(sizes) == 0 { + return nil + } + out := make([]tg.PhotoSizeClass, 0, len(sizes)) + for _, s := range sizes { + out = append(out, tgPhotoSize(s)) + } + return compactPhotoSizeClasses(out) +} + +func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass { + switch s.Kind { + case domain.PhotoSizeKindDefault: + return &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size} + case domain.PhotoSizeKindStripped: + return &tg.PhotoStrippedSize{Type: s.Type, Bytes: s.Bytes} + case domain.PhotoSizeKindCached: + return &tg.PhotoCachedSize{Type: s.Type, W: s.W, H: s.H, Bytes: s.Bytes} + case domain.PhotoSizeKindPath: + return &tg.PhotoPathSize{Type: s.Type, Bytes: s.Bytes} + case domain.PhotoSizeKindProgressive: + return &tg.PhotoSizeProgressive{Type: s.Type, W: s.W, H: s.H, Sizes: s.Sizes} + default: + return nil + } +} + +func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass { + out := in[:0] + for _, s := range in { + if s != nil { + out = append(out, s) + } + } + return out +} + +func tgDocumentAttributes(attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass { + out := make([]tg.DocumentAttributeClass, 0, len(attrs)) + for _, a := range attrs { + switch a.Kind { + case domain.DocAttrImageSize: + out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H}) + case domain.DocAttrAnimated: + out = append(out, &tg.DocumentAttributeAnimated{}) + case domain.DocAttrSticker: + out = append(out, &tg.DocumentAttributeSticker{ + Mask: a.Mask, + Alt: a.Alt, + Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash), + }) + case domain.DocAttrVideo: + out = append(out, &tg.DocumentAttributeVideo{ + RoundMessage: a.RoundMessage, + SupportsStreaming: a.SupportsStreaming, + Duration: a.Duration, + W: a.W, + H: a.H, + }) + case domain.DocAttrAudio: + attr := &tg.DocumentAttributeAudio{ + Voice: a.Voice, + Duration: a.AudioDuration, + Title: a.Title, + Performer: a.Performer, + } + if len(a.Waveform) > 0 { + attr.SetWaveform(a.Waveform) + } + out = append(out, attr) + case domain.DocAttrFilename: + out = append(out, &tg.DocumentAttributeFilename{FileName: a.FileName}) + case domain.DocAttrCustomEmoji: + out = append(out, &tg.DocumentAttributeCustomEmoji{ + Free: a.Free, + TextColor: a.TextColor, + Alt: a.Alt, + Stickerset: tgInputStickerSetFromIDs(a.StickerSetID, a.StickerSetAccessHash), + }) + } + } + return out +} + +func tgInputStickerSetFromIDs(id, accessHash int64) tg.InputStickerSetClass { + if id == 0 { + return &tg.InputStickerSetEmpty{} + } + return &tg.InputStickerSetID{ID: id, AccessHash: accessHash} +} + +// ---- available reactions ---- + +// tgAvailableReactions 用真实文档构造 messages.availableReactions;docByID 由 handler 预加载。 +func tgAvailableReactions(reactions []domain.AvailableReaction, docByID map[int64]domain.Document, hash int) *tg.MessagesAvailableReactions { + out := &tg.MessagesAvailableReactions{Hash: hash, Reactions: make([]tg.AvailableReaction, 0, len(reactions))} + doc := func(id int64) tg.DocumentClass { + if d, ok := docByID[id]; ok { + return tgDocument(d) + } + return &tg.DocumentEmpty{ID: id} + } + for _, r := range reactions { + ar := tg.AvailableReaction{ + Inactive: r.Inactive, + Premium: r.Premium, + Reaction: r.Reaction, + Title: r.Title, + StaticIcon: doc(r.StaticIconID), + AppearAnimation: doc(r.AppearAnimationID), + SelectAnimation: doc(r.SelectAnimationID), + ActivateAnimation: doc(r.ActivateAnimationID), + EffectAnimation: doc(r.EffectAnimationID), + } + if r.AroundAnimationID != 0 { + ar.SetAroundAnimation(doc(r.AroundAnimationID)) + } + if r.CenterIconID != 0 { + ar.SetCenterIcon(doc(r.CenterIconID)) + } + out.Reactions = append(out.Reactions, ar) + } + return out +} + +// reactionDocumentIDs 收集一组 reaction 引用的全部文档 id(用于批量预加载)。 +func reactionDocumentIDs(reactions []domain.AvailableReaction) []int64 { + seen := make(map[int64]struct{}) + out := make([]int64, 0, len(reactions)*7) + for _, r := range reactions { + for _, id := range r.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 { + out := tg.StickerSet{ + Archived: set.Archived, + Official: set.Official, + Masks: set.Masks, + Emojis: set.Emojis, + ID: set.ID, + AccessHash: set.AccessHash, + Title: set.Title, + ShortName: set.ShortName, + Count: set.Count, + Hash: set.Hash, + } + if set.Installed { + date := set.InstalledDate + if date == 0 { + date = 1 + } + out.SetInstalledDate(date) + } + if thumbs := tgStickerSetThumbs(set.Thumbs); len(thumbs) > 0 { + out.SetThumbs(thumbs) + out.SetThumbDCID(set.ThumbDCID) + out.SetThumbVersion(set.ThumbVersion) + } + if set.ThumbDocumentID != 0 { + out.SetThumbDocumentID(set.ThumbDocumentID) + } + return out +} + +func tgStickerSetThumbs(sizes []domain.PhotoSize) []tg.PhotoSizeClass { + if len(sizes) == 0 { + return nil + } + filtered := make([]domain.PhotoSize, 0, len(sizes)) + for _, s := range sizes { + if s.Downloadable() { + continue + } + filtered = append(filtered, s) + } + return tgPhotoSizes(filtered) +} + +func tgStickerSets(sets []domain.StickerSet) []tg.StickerSet { + out := make([]tg.StickerSet, 0, len(sets)) + for _, s := range sets { + out = append(out, tgStickerSet(s)) + } + return out +} + +func tgStickerPacks(packs []domain.StickerPack) []tg.StickerPack { + out := make([]tg.StickerPack, 0, len(packs)) + for _, p := range packs { + out = append(out, tg.StickerPack{Emoticon: p.Emoticon, Documents: append([]int64(nil), p.DocumentIDs...)}) + } + return out +} + +// tgMessagesStickerSet 构造完整 messages.stickerSet(set + packs + documents)。 +func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet { + return &tg.MessagesStickerSet{ + Set: tgStickerSet(set), + Packs: tgStickerPacks(set.Packs), + Keywords: []tg.StickerKeyword{}, + Documents: tgDocuments(docs), + } +} + +// stickerSetRefFromInput 把 tg.InputStickerSet 转成 domain.StickerSetRef。 +func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef, bool) { + switch in := input.(type) { + case *tg.InputStickerSetID: + return domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: in.ID, AccessHash: in.AccessHash}, true + case *tg.InputStickerSetShortName: + return domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: in.ShortName}, true + case *tg.InputStickerSetAnimatedEmoji: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji"}, true + case *tg.InputStickerSetAnimatedEmojiAnimations: + 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.InputStickerSetDice: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true + default: + return domain.StickerSetRef{}, false + } +} + +// mediaCatalogHash 用一组 int64(文档/集合 id)算稳定 hash,供 *NotModified 缓存判定。 +func mediaCatalogHash(values []int64) int64 { + var hash uint64 + for _, v := range values { + hash ^= uint64(v) + hash = hash*0x4f25 + uint64(v) + } + return int64(hash & 0x7fffffffffffffff) +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go new file mode 100644 index 00000000..49c07bd8 --- /dev/null +++ b/internal/rpc/deps.go @@ -0,0 +1,333 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + + "telesrv/internal/domain" +) + +// 本文件按「消费者定义接口」惯例,在 rpc 包定义 Router 依赖的业务服务接口。 +// app/* 的 Service 实现它们;微服务化时 gRPC client 同样可实现,rpc 层无需改动。 +// 接口方法只用 domain 类型与基本类型,不依赖 app 具体包——这是 rpc↔业务的契约边界。 + +// AuthService 抽象登录/注册业务。 +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) + SendCode(ctx context.Context, phone string) (string, error) + SignIn(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) + LogOut(ctx context.Context, authKeyID [8]byte) error +} + +// SessionBinder 抽象登录后 session 与 user 的在线绑定。 +type SessionBinder interface { + BindAuthKey(sessionID int64, authKeyID [8]byte) + AuthKeyID(sessionID int64) ([8]byte, bool) + BindUser(sessionID, userID int64) + UserID(sessionID int64) (int64, bool) + UserIDResolved(sessionID int64) (userID int64, resolved bool) + UnbindAuthKey(authKeyID [8]byte) int + SetReceivesUpdates(sessionID int64, receives bool) + PushToSession(ctx context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error + PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) +} + +// ScopedSessionBinder 是 SessionBinder 的精确版本:所有定位都带 raw auth_key_id + session_id。 +// 生产 mtprotoedge.SessionManager 实现它;测试替身和旧实现可以只实现 SessionBinder。 +type ScopedSessionBinder interface { + BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) + AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int64) ([8]byte, bool) + BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) + UserIDForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (int64, bool) + UserIDResolvedForAuthKey(rawAuthKeyID [8]byte, sessionID int64) (userID int64, resolved bool) + SetReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64, receives bool) + PushToSessionForAuthKey(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error + PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) +} + +// 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) +} + +// ScopedBestEffortSessionBinder 是带 raw auth_key_id 精确排除当前设备的 best-effort 版本。 +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) +} + +// 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) + ClearChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64) + OnlineChannelUserIDs(channelID int64, limit int) []int64 + SetSessionChannelMemberships(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) + AddUserChannelMembership(userID, channelID int64) + RemoveUserChannelMembership(userID, channelID int64) + OnlineChannelMemberUserIDs(channelID int64, 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) +} + +// UsersService 抽象用户查询。 +type UsersService interface { + Self(ctx context.Context, userID int64) (domain.User, error) + ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) + ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, 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) + ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) + ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) +} + +// AccountService 抽象账号设置查询。 +type AccountService interface { + GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) +} + +// HelpService 抽象启动配置与国家区号目录。 +type HelpService interface { + GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) + GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) +} + +// UpdatesService 抽象 update 状态查询。 +type UpdatesService interface { + GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) + CurrentState(ctx context.Context, 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) + 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) + 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) + 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) +} + +// ContactsService 抽象通讯录查询。 +type ContactsService interface { + GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) + ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) + AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) + 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) + UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) + GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) +} + +// DialogsService 抽象会话列表查询。 +type DialogsService interface { + 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 + 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 + 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) + PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) + GetDialogFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) + SaveDialogFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error + DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error + ReorderDialogFolders(ctx context.Context, userID int64, order []int) error + ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error + EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error +} + +// MessagesService 抽象消息历史、搜索与已读。 +type MessagesService interface { + SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, 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) + 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) + EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, 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) +} + +// ChannelsService 抽象超级群/频道业务。 +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) + 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) + 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) + EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) + EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (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) + 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) + SetPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) + SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + 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) + SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetSlowMode(ctx context.Context, userID, channelID int64, seconds 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) + SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) + SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) + SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) + ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) + GetChannelForChangeInfo(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) + SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) + 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) + 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 + 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) + EditForumTopic(ctx context.Context, userID int64, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) + UpdatePinnedForumTopic(ctx context.Context, userID int64, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) + ReorderPinnedForumTopics(ctx context.Context, userID int64, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) + DeleteForumTopicHistory(ctx context.Context, userID int64, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) + GetForumTopics(ctx context.Context, userID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) + 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) + 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) + 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) + ListExportedInvites(ctx context.Context, userID int64, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) + GetExportedInvite(ctx context.Context, userID int64, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) + EditExportedInvite(ctx context.Context, userID int64, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) + DeleteExportedInvite(ctx context.Context, userID int64, req domain.DeleteChannelInviteRequest) error + DeleteRevokedExportedInvites(ctx context.Context, userID int64, req domain.DeleteRevokedChannelInvitesRequest) error + ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) + ListInviteImporters(ctx context.Context, userID int64, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) + PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) + HideChatJoinRequest(ctx context.Context, userID int64, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) + HideAllChatJoinRequests(ctx context.Context, userID int64, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) + CommonChannels(ctx context.Context, userID int64, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) + LeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) + InactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) + ChannelRecommendations(ctx context.Context, userID int64, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) + DiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) + 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) + 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) + 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) + ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, 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) + ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) + InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) + FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) +} + +// FilesService 抽象文件上传分片、下载与媒体(document/photo)组装。 +// 方法只用 domain 类型;rpc 层负责 tg.InputFileLocation / InputMedia ↔ domain 转换。 +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) + // 资源读取(reaction / sticker / document)。 + ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, 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) + CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) + CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, 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) + SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) + CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) + GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error) + DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) +} + +// LangPackService 抽象客户端语言包查询。 +type LangPackService interface { + GetLangPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) + GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) + GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) +} + +// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。 +type Deps struct { + Auth AuthService + Account AccountService + Help HelpService + Users UsersService + Updates UpdatesService + Contacts ContactsService + Dialogs DialogsService + Messages MessagesService + Channels ChannelsService + Files FilesService + LangPack LangPackService + Sessions SessionBinder + Limiter RateLimiter + Metrics Metrics +} diff --git a/internal/rpc/doc.go b/internal/rpc/doc.go new file mode 100644 index 00000000..eb8041f7 --- /dev/null +++ b/internal/rpc/doc.go @@ -0,0 +1,5 @@ +// Package rpc 是按 TypeID 路由的 RPC 层:封装 tg.ServerDispatcher(或等价薄封装), +// 在 handler 边界把 gotd/td/tg 类型转换为内部 domain command/query,统一 tgerr.Error 到 +// rpc_error 的映射,注入 auth_key_id/session_id/user_id/layer/设备/语言 等上下文, +// 并对未知 RPC 进入 compatibility trace(不静默吞掉,记入 docs/compatibility-matrix.md)。 +package rpc diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go new file mode 100644 index 00000000..986811ec --- /dev/null +++ b/internal/rpc/errors.go @@ -0,0 +1,194 @@ +package rpc + +import ( + "errors" + "fmt" + + "github.com/gotd/td/tgerr" + + "telesrv/internal/app/auth" + "telesrv/internal/domain" +) + +// 本文件集中构造所有 rpc_error:error_code 遵循 Telegram 约定(4xx 客户端 / 5xx 服务端), +// error_message 为 SCREAMING_SNAKE_CASE。统一用 tgerr.New,由它从 message 解析 Type/Argument +// (正确处理 FLOOD_WAIT_X 等带参数错误),避免手写 &tgerr.Error{} 时重复且易错。 + +// internalErr 是兜底的服务端错误。 +func internalErr() error { return tgerr.New(500, "INTERNAL_SERVER_ERROR") } + +// notImplementedErr 表示 RPC 未实现(落兼容矩阵),让客户端继续运行而非断连。 +func notImplementedErr() error { return tgerr.New(500, "NOT_IMPLEMENTED") } + +// wrapperTooDeepErr 表示 invokeWithLayer/initConnection 等 wrapper 嵌套过深。 +func wrapperTooDeepErr() error { return tgerr.New(400, "WRAPPER_TOO_DEEP") } + +// inputConstructorInvalidErr 表示客户端传入的 TL 构造器不在当前 RPC 接受范围内。 +func inputConstructorInvalidErr() error { return tgerr.New(400, "INPUT_CONSTRUCTOR_INVALID") } + +// folderIDInvalidErr 表示客户端传入多个 folder peer 或非法 folder。 +func folderIDInvalidErr() error { return tgerr.New(400, "FOLDER_ID_INVALID") } + +func filterIDInvalidErr() error { return tgerr.New(400, "FILTER_ID_INVALID") } + +func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") } + +// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。 +func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") } + +func parentPeerInvalidErr() error { return tgerr.New(400, "PARENT_PEER_INVALID") } + +func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_PEER_INVALID") } + +func limitInvalidErr() error { return tgerr.New(400, "LIMIT_INVALID") } + +func secondsInvalidErr() error { return tgerr.New(400, "SECONDS_INVALID") } + +func ttlPeriodInvalidErr() error { return tgerr.New(400, "TTL_PERIOD_INVALID") } + +func chatInvalidErr() error { return tgerr.New(500, "CHAT_INVALID") } + +func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") } + +func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") } + +// 文件上传 / 下载相关错误。 +func filePartInvalidErr() error { return tgerr.New(400, "FILE_PART_INVALID") } +func filePartsInvalidErr() error { return tgerr.New(400, "FILE_PARTS_INVALID") } +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 mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") } + +func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") } + +func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") } + +func mediaCaptionTooLongErr() error { return tgerr.New(400, "MEDIA_CAPTION_TOO_LONG") } + +func replyMarkupInvalidErr() error { return tgerr.New(400, "REPLY_MARKUP_INVALID") } + +func shortcutInvalidErr() error { return tgerr.New(400, "SHORTCUT_INVALID") } + +func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") } + +func paymentUnsupportedErr() error { return tgerr.New(406, "PAYMENT_UNSUPPORTED") } + +func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") } + +func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") } + +func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") } + +func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") } + +func replyToMonoforumPeerInvalidErr() error { return tgerr.New(400, "REPLY_TO_MONOFORUM_PEER_INVALID") } + +func optionsTooMuchErr() error { return tgerr.New(400, "OPTIONS_TOO_MUCH") } + +func optionInvalidErr() error { return tgerr.New(400, "OPTION_INVALID") } + +func pollOptionInvalidErr() error { return tgerr.New(400, "POLL_OPTION_INVALID") } + +func pollAnswerInvalidErr() error { return tgerr.New(400, "POLL_ANSWER_INVALID") } + +func reactionInvalidErr() error { return tgerr.New(400, "REACTION_INVALID") } + +func todoItemsEmptyErr() error { return tgerr.New(400, "TODO_ITEMS_EMPTY") } + +func todoNotModifiedErr() error { return tgerr.New(400, "TODO_NOT_MODIFIED") } + +func searchQueryEmptyErr() error { return tgerr.New(400, "SEARCH_QUERY_EMPTY") } + +func queryTooShortErr() error { return tgerr.New(400, "QUERY_TOO_SHORT") } + +func usernameInvalidErr() error { return tgerr.New(400, "USERNAME_INVALID") } + +func usernameOccupiedErr() error { return tgerr.New(400, "USERNAME_OCCUPIED") } + +func usernameNotOccupiedErr() error { return tgerr.New(400, "USERNAME_NOT_OCCUPIED") } + +func usernameNotModifiedErr() error { return tgerr.New(400, "USERNAME_NOT_MODIFIED") } + +func phoneNotOccupiedErr() error { return tgerr.New(400, "PHONE_NOT_OCCUPIED") } + +func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") } + +func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") } + +func firstNameInvalidErr() error { return tgerr.New(400, "FIRSTNAME_INVALID") } + +func aboutTooLongErr() error { return tgerr.New(400, "ABOUT_TOO_LONG") } + +func contactIDInvalidErr() error { return tgerr.New(400, "CONTACT_ID_INVALID") } + +func contactNameEmptyErr() error { return tgerr.New(400, "CONTACT_NAME_EMPTY") } + +// messageEmptyErr 表示发送空文本。 +func messageEmptyErr() error { return tgerr.New(400, "MESSAGE_EMPTY") } + +// messageTooLongErr 表示文本超出当前阶段限制。 +func messageTooLongErr() error { return tgerr.New(400, "MESSAGE_TOO_LONG") } + +func messageIDInvalidErr() error { return tgerr.New(400, "MESSAGE_ID_INVALID") } + +func msgIDInvalidErr() error { return tgerr.New(400, "MSG_ID_INVALID") } + +func messageAuthorRequiredErr() error { return tgerr.New(403, "MESSAGE_AUTHOR_REQUIRED") } + +func messageNotModifiedErr() error { return tgerr.New(400, "MESSAGE_NOT_MODIFIED") } + +func messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") } + +func replyMessageIDInvalidErr() error { return tgerr.New(400, "REPLY_MESSAGE_ID_INVALID") } + +func chatForwardsRestrictedErr() error { return tgerr.New(400, "CHAT_FORWARDS_RESTRICTED") } + +func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVALID") } + +func persistentTimestampInvalidErr() error { return tgerr.New(400, "PERSISTENT_TIMESTAMP_INVALID") } + +func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSING") } + +func topicTitleEmptyErr() error { return tgerr.New(400, "TOPIC_TITLE_EMPTY") } +func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") } + +// randomIDEmptyErr 表示发送消息缺少 random_id。 +func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") } + +// scheduleDateInvalidErr 表示当前阶段不支持定时消息。 +func scheduleDateInvalidErr() error { return tgerr.New(400, "SCHEDULE_DATE_INVALID") } + +// floodWaitErr 表示触发写操作限流。 +func floodWaitErr(seconds int) error { + if seconds <= 0 { + seconds = 1 + } + return tgerr.New(420, fmt.Sprintf("FLOOD_WAIT_%d", seconds)) +} + +// signInErr 把登录业务错误映射为客户端可识别的 rpc_error。 +func signInErr(err error) error { + switch { + case errors.Is(err, auth.ErrCodeInvalid): + return tgerr.New(400, "PHONE_CODE_INVALID") + case errors.Is(err, auth.ErrCodeExpired): + return tgerr.New(400, "PHONE_CODE_EXPIRED") + case errors.Is(err, domain.ErrFirstNameInvalid): + return firstNameInvalidErr() + default: + return internalErr() + } +} + +// bindTempAuthKeyErr 映射 PFS temp auth key 绑定错误。 +func bindTempAuthKeyErr(err error) error { + switch { + case errors.Is(err, auth.ErrEncryptedMessageInvalid): + return tgerr.New(400, "ENCRYPTED_MESSAGE_INVALID") + default: + return internalErr() + } +} diff --git a/internal/rpc/folders.go b/internal/rpc/folders.go new file mode 100644 index 00000000..7fc60d61 --- /dev/null +++ b/internal/rpc/folders.go @@ -0,0 +1,67 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) registerFolders(d *tg.ServerDispatcher) { + d.OnFoldersEditPeerFolders(r.onFoldersEditPeerFolders) +} + +func (r *Router) onFoldersEditPeerFolders(ctx context.Context, folderPeers []tg.InputFolderPeer) (tg.UpdatesClass, error) { + if len(folderPeers) > maxDialogInputPeers { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + updates := make([]domain.FolderPeerUpdate, 0, len(folderPeers)) + seen := make(map[domain.Peer]struct{}, len(folderPeers)) + for _, item := range folderPeers { + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + return nil, folderIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, item.Peer) + if err != nil { + return nil, err + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + updates = append(updates, domain.FolderPeerUpdate{Peer: peer, FolderID: item.FolderID}) + } + if len(updates) == 0 { + return &tg.Updates{Date: int(r.clock.Now().Unix()), Seq: 0}, nil + } + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.EditPeerFolders(ctx, userID, updates); err != nil { + return nil, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventFolderPeers, + FolderPeers: updates, + PtsCount: 1, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordFolderPeers(ctx, authKeyID, userID, updates, sessionID) + if err != nil { + return nil, internalErr() + } + } + out := tgUpdateForOutboxEvent(event) + if out == nil { + out = &tg.Updates{Date: event.Date, Seq: 0} + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) + return out, nil +} diff --git a/internal/rpc/help.go b/internal/rpc/help.go new file mode 100644 index 00000000..8d2b713e --- /dev/null +++ b/internal/rpc/help.go @@ -0,0 +1,63 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" +) + +// registerHelp 注册 help.* RPC handler(DC 配置、最近 DC)。 +func (r *Router) registerHelp(d *tg.ServerDispatcher) { + d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) { + return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now()), nil + }) + d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) { + return tdesktop.NearestDC(r.cfg.DC), nil + }) + d.OnHelpGetAppConfig(func(ctx context.Context, hash int) (tg.HelpAppConfigClass, error) { + if r.deps.Help == nil { + return tdesktop.AppConfig(hash), nil + } + cfg, notModified, err := r.deps.Help.GetAppConfig(ctx, hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return &tg.HelpAppConfigNotModified{}, nil + } + return &tg.HelpAppConfig{Hash: cfg.Hash, Config: tgJSONValue(cfg.JSON)}, nil + }) + d.OnHelpGetCountriesList(func(ctx context.Context, req *tg.HelpGetCountriesListRequest) (tg.HelpCountriesListClass, error) { + if r.deps.Help == nil { + return tdesktop.CountriesList(req.Hash), nil + } + list, notModified, err := r.deps.Help.GetCountries(ctx, req.LangCode, req.Hash) + if err != nil { + return nil, internalErr() + } + if notModified { + return &tg.HelpCountriesListNotModified{}, nil + } + return tgCountriesList(list), nil + }) + d.OnHelpGetTimezonesList(func(ctx context.Context, hash int) (tg.HelpTimezonesListClass, error) { + return tdesktop.TimezonesList(hash), nil + }) + d.OnHelpGetPeerColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) { + return tdesktop.PeerColors(), nil + }) + d.OnHelpGetPeerProfileColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) { + return tdesktop.PeerColors(), nil + }) + d.OnHelpGetPromoData(func(ctx context.Context) (tg.HelpPromoDataClass, error) { + return tdesktop.PromoData(r.clock.Now()), nil + }) + 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 + }) +} diff --git a/internal/rpc/langpack.go b/internal/rpc/langpack.go new file mode 100644 index 00000000..5914ef5a --- /dev/null +++ b/internal/rpc/langpack.go @@ -0,0 +1,41 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" +) + +// registerLangpack 注册 langpack.* RPC handler。 +func (r *Router) registerLangpack(d *tg.ServerDispatcher) { + d.OnLangpackGetLangPack(func(ctx context.Context, req *tg.LangpackGetLangPackRequest) (*tg.LangPackDifference, error) { + if r.deps.LangPack == nil { + return &tg.LangPackDifference{LangCode: req.LangCode}, nil + } + pack, err := r.deps.LangPack.GetLangPack(ctx, req.LangPack, req.LangCode) + if err != nil { + return nil, internalErr() + } + return tgLangPackDifference(pack), nil + }) + d.OnLangpackGetDifference(func(ctx context.Context, req *tg.LangpackGetDifferenceRequest) (*tg.LangPackDifference, error) { + 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) + if err != nil { + return nil, internalErr() + } + return tgLangPackDifference(pack), nil + }) + d.OnLangpackGetStrings(func(ctx context.Context, req *tg.LangpackGetStringsRequest) ([]tg.LangPackStringClass, error) { + if r.deps.LangPack == nil { + return nil, nil + } + pack, err := r.deps.LangPack.GetStrings(ctx, req.LangPack, req.LangCode, req.Keys) + if err != nil { + return nil, internalErr() + } + return tgLangPackStrings(pack.Strings), nil + }) +} diff --git a/internal/rpc/messages.go b/internal/rpc/messages.go new file mode 100644 index 00000000..b490a956 --- /dev/null +++ b/internal/rpc/messages.go @@ -0,0 +1,6365 @@ +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.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(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 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(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) + 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, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, internalErr() + } + } + affected, err := r.affectedMessages(ctx, id, userID) + if err != nil { + return nil, err + } + if contentIDs := readMessageContentIDs(read.MessageIDs); len(contentIDs) > 0 { + r.pushUserUpdates(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(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, + 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() + } + 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, + Silent: req.Silent, + NoForwards: req.Noforwards, + ReplyTo: replyTo, + Forward: forward, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + 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) + 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, + }) + 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 + 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.GetHistory(ctx, userID, domain.MessageFilter{ + HasPeer: true, + Peer: fromPeer, + Limit: 1, + MaxID: id, + MinID: id - 1, + }) + if err != nil || len(list.Messages) != 1 || list.Messages[0].ID != id { + return nil, domain.ErrMessageIDInvalid + } + msg := list.Messages[0] + 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...), + 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.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: fromPeer.ID, + Limit: 1, + MaxID: id, + MinID: id - 1, + }) + 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.Body == "" || msg.Action != nil { + 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...), + 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() + } + 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() + } + 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) + } + 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 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, + 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 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/metrics.go b/internal/rpc/metrics.go new file mode 100644 index 00000000..ea74f44e --- /dev/null +++ b/internal/rpc/metrics.go @@ -0,0 +1,25 @@ +package rpc + +import "time" + +// Metrics 接收 RPC 业务层指标。默认 NopMetrics,后续可对接 Prometheus。 +type Metrics interface { + MessageSend(d time.Duration, duplicate bool, err error) + MessageRateLimited(retryAfterSeconds int) + OutboxClaimed(count int) + OutboxDelivered(d time.Duration) + OutboxFailed(err error) +} + +// NopMetrics 是 Metrics 的空实现。 +type NopMetrics struct{} + +func (NopMetrics) MessageSend(time.Duration, bool, error) {} + +func (NopMetrics) MessageRateLimited(int) {} + +func (NopMetrics) OutboxClaimed(int) {} + +func (NopMetrics) OutboxDelivered(time.Duration) {} + +func (NopMetrics) OutboxFailed(error) {} diff --git a/internal/rpc/outbox_dispatcher.go b/internal/rpc/outbox_dispatcher.go new file mode 100644 index 00000000..ee7a9b83 --- /dev/null +++ b/internal/rpc/outbox_dispatcher.go @@ -0,0 +1,369 @@ +package rpc + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + defaultOutboxBatch = 100 + defaultOutboxInterval = 200 * time.Millisecond + defaultOutboxWorkers = 2 +) + +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 +} + +// OutboxOption 调整 OutboxDispatcher 的运行参数。 +type OutboxOption func(*OutboxDispatcher) + +// WithOutboxBatch 设置每次 claim 的最大条数;<=0 时保持默认。 +func WithOutboxBatch(n int) OutboxOption { + return func(d *OutboxDispatcher) { + if n > 0 { + d.batch = n + } + } +} + +// WithOutboxInterval 设置两次 claim 之间的轮询间隔;<=0 时保持默认。 +func WithOutboxInterval(interval time.Duration) OutboxOption { + return func(d *OutboxDispatcher) { + if interval > 0 { + d.interval = interval + } + } +} + +// WithOutboxWorkers 设置并发 claim worker 数;<=0 时保持默认。 +func WithOutboxWorkers(n int) OutboxOption { + return func(d *OutboxDispatcher) { + if n > 0 { + d.workers = n + } + } +} + +// WithOutboxPushTimeout 设置 updates fanout 入队等待时间;<=0 时使用同步可靠推送。 +func WithOutboxPushTimeout(timeout time.Duration) OutboxOption { + return func(d *OutboxDispatcher) { + if timeout > 0 { + d.pushTimeout = timeout + } + } +} + +// WithOutboxMetrics 注入指标实现;nil 时保持 NopMetrics。 +func WithOutboxMetrics(m Metrics) OutboxOption { + return func(d *OutboxDispatcher) { + if m != nil { + d.metrics = m + } + } +} + +// NewOutboxDispatcher 创建在线 update 推送 worker。batch/interval 默认值见 +// defaultOutboxBatch/defaultOutboxInterval,可经 WithOutbox* 选项覆盖(生产由 config 注入)。 +func NewOutboxDispatcher(events store.UpdateEventStore, outbox store.DispatchOutboxStore, sessions SessionBinder, log *zap.Logger, opts ...OutboxOption) *OutboxDispatcher { + if log == nil { + log = zap.NewNop() + } + d := &OutboxDispatcher{ + events: events, + outbox: outbox, + sessions: sessions, + log: log, + metrics: NopMetrics{}, + batch: defaultOutboxBatch, + interval: defaultOutboxInterval, + workers: defaultOutboxWorkers, + } + for _, opt := range opts { + if opt != nil { + opt(d) + } + } + return d +} + +// Run 启动 workers 个并发 worker 持续 claim pending outbox;ctx 退出时全部停止并等待退出。 +func (d *OutboxDispatcher) Run(ctx context.Context) { + if d == nil || d.events == nil || d.outbox == nil || d.sessions == nil { + return + } + workers := d.workers + if workers < 1 { + workers = 1 + } + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + d.runWorker(ctx) + }() + } + wg.Wait() +} + +// runWorker 是单个 claim 循环;多 worker 靠 ClaimPending 的 SKIP LOCKED 互不重叠。 +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: + } + } +} + +// batchEventLoader 是 UpdateEventStore 的可选批量能力:一次取多条 (user,pts) 事件。 +type batchEventLoader interface { + BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) +} + +// batchOutboxMarker 是 DispatchOutboxStore 的可选批量能力:一次标记多行 delivered。 +type batchOutboxMarker interface { + MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error +} + +// DispatchOnce claim 一批 outbox 并投递,测试可直接调用。 +// store 同时具备批量取事件 + 批量标记能力时走批量路径(每批 ~3 次 PG 往返),否则逐条回退。 +func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) { + items, err := d.outbox.ClaimPending(ctx, d.batch) + if err != nil { + d.log.Warn("claim dispatch outbox", zap.Error(err)) + return + } + if len(items) == 0 { + return + } + 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 + } + } + for _, item := range items { + d.dispatchItem(ctx, item) + } +} + +type outboxEventKey struct { + userID int64 + pts int +} + +// dispatchBatch 批量加载已 claim 事件、逐条 push、批量标记 delivered;失败项单独退避重试。 +func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.DispatchOutboxItem, loader batchEventLoader, marker batchOutboxMarker) { + cursors := make([]store.EventCursor, len(items)) + for i, item := range items { + cursors[i] = store.EventCursor{UserID: item.TargetUserID, Pts: item.Pts} + } + events, err := loader.BatchByCursor(ctx, cursors) + if err != nil { + // 批量取失败则整批回退逐条路径,让每条各自重试/标失败,不丢进度。 + d.log.Warn("batch load dispatch events", zap.Error(err)) + for _, item := range items { + d.dispatchItem(ctx, item) + } + return + } + byKey := make(map[outboxEventKey]domain.UpdateEvent, len(events)) + for _, event := range events { + byKey[outboxEventKey{event.UserID, event.Pts}] = event + } + start := time.Now() + delivered := make([]store.DispatchOutboxItem, 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) + if update == nil { + delivered = append(delivered, item) + continue + } + if _, retriable, err := d.pushOutboxUpdate(ctx, item, update); err != nil { + if retriable { + // 出站队列拥塞:留 dispatching 行靠租约过期重投,不计入 attempts 升级。 + // 不加入 delivered,故不会被 MarkDeliveredBatch 删除。 + continue + } + d.markDispatchFailed(ctx, item, err) + continue + } + delivered = append(delivered, item) + } + if len(delivered) == 0 { + return + } + if err := marker.MarkDeliveredBatch(ctx, delivered); err != nil { + // 批量标记失败则逐条标记,避免整批已投递却卡在 dispatching 等租约过期重投。 + d.log.Warn("mark dispatch delivered batch", zap.Error(err)) + for _, item := range delivered { + if markErr := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); markErr != nil { + d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(markErr)) + } + } + } + per := time.Since(start) / time.Duration(len(delivered)) + for range delivered { + d.metrics.OutboxDelivered(per) + } +} + +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) + if err != nil { + d.markDispatchFailed(ctx, item, err) + return + } + if len(events) == 0 || events[0].Pts != item.Pts { + d.markDispatchFailed(ctx, item, errMissingOutboxEvent) + return + } + update := tgUpdateForOutboxEvent(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)) + return + } + d.metrics.OutboxDelivered(time.Since(start)) + return + } + sent, retriable, err := d.pushOutboxUpdate(ctx, item, update) + if err != nil { + if retriable { + // 出站队列拥塞:保留 dispatching 行,靠租约过期(defaultDispatchLease)重新 claim 重投, + // 不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed。 + d.log.Debug("dispatch outbox deferred (push queue full)", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Int("pts", item.Pts), + ) + return + } + d.markDispatchFailed(ctx, item, err) + return + } + if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil { + d.log.Warn("mark dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err)) + return + } + d.metrics.OutboxDelivered(time.Since(start)) + d.log.Debug("dispatch outbox delivered", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Int("pts", item.Pts), + zap.Int("sessions", sent), + ) +} + +// pushOutboxUpdate 投递一条 outbox update,返回 (送达的在线 session 数, 是否可重试, err)。 +// best-effort 路径(pushTimeout>0)的失败只可能是出站队列拥塞(慢消费者入队超时),属暂时性、 +// 可重试:调用方应保留 dispatching 行靠租约过期重投,而非计入 attempts 升级为 failed。 +// 可靠路径的失败是真实投递错误,retriable=false,按原逻辑退避升级。 +func (d *OutboxDispatcher) pushOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, update *tg.Updates) (sent int, retriable bool, err error) { + var zeroAuthKeyID [8]byte + if d.pushTimeout > 0 { + if scoped, ok := d.sessions.(ScopedBestEffortSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID { + sent, err = scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout) + return sent, err != nil, err + } + if bestEffort, ok := d.sessions.(BestEffortSessionBinder); ok { + sent, err = bestEffort.PushToUserExceptSessionBestEffort(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update, d.pushTimeout) + return sent, err != nil, err + } + } + if scoped, ok := d.sessions.(ScopedSessionBinder); ok && item.ExcludeAuthKeyID != zeroAuthKeyID { + sent, err = scoped.PushToUserExceptAuthKeySession(ctx, item.TargetUserID, item.ExcludeAuthKeyID, item.ExcludeSessionID, proto.MessageFromServer, update) + return sent, false, err + } + sent, err = d.sessions.PushToUserExceptSession(ctx, item.TargetUserID, item.ExcludeSessionID, proto.MessageFromServer, update) + return sent, false, err +} + +func (d *OutboxDispatcher) markDispatchFailed(ctx context.Context, item store.DispatchOutboxItem, err error) { + if err == nil { + err = errMissingOutboxEvent + } + if markErr := d.outbox.MarkFailed(ctx, item.TargetUserID, item.ID, err.Error()); markErr != nil { + d.log.Warn("mark dispatch failed", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Error(markErr), + ) + return + } + d.metrics.OutboxFailed(err) + d.log.Debug("dispatch outbox failed", + zap.Int64("target_user_id", item.TargetUserID), + zap.Int64("outbox_id", item.ID), + zap.Int("pts", item.Pts), + zap.Error(err), + ) +} + +func tgUpdateForOutboxEvent(event domain.UpdateEvent) *tg.Updates { + switch event.Type { + case domain.UpdateEventNewMessage: + return tgPrivateMessageUpdates(event, event.Message, 0, false, tgUsers(event.Users), tgChannels(event.UserID, event.Channels)) + case domain.UpdateEventReadHistoryInbox, domain.UpdateEventReadHistoryOutbox: + var update tg.UpdateClass + if event.Type == domain.UpdateEventReadHistoryOutbox { + update = tgReadHistoryOutboxUpdate(event) + } else { + update = tgReadHistoryInboxUpdate(event) + } + if update == nil { + return nil + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: event.Date, + Seq: 0, // 私聊不维护账号级 seq,恒 0 + } + case domain.UpdateEventNoop: + return nil + default: + update := tgOtherUpdateFromEvent(event) + if update == nil { + return nil + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: event.Date, + Seq: 0, + } + } +} diff --git a/internal/rpc/outbox_dispatcher_test.go b/internal/rpc/outbox_dispatcher_test.go new file mode 100644 index 00000000..74c0d2d5 --- /dev/null +++ b/internal/rpc/outbox_dispatcher_test.go @@ -0,0 +1,458 @@ +package rpc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestOutboxDispatcherPushesNewMessageAndMarksDelivered(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}} + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ + ID: msg.From.ID, + FirstName: "Sender", + }}, + }}} + sessions := &captureSessions{} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if !outbox.delivered || outbox.deliveredUserID != msg.OwnerUserID || outbox.deliveredID != 55 { + t.Fatalf("delivered = %v user=%d id=%d, want outbox delivered", outbox.delivered, outbox.deliveredUserID, outbox.deliveredID) + } + if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 || sessions.messageType != proto.MessageFromServer { + t.Fatalf("push target = user %d exclude %d type %v, want outbox target/exclude", sessions.userID, sessions.sessionID, sessions.messageType) + } + updates, ok := sessions.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", sessions.message) + } + if len(updates.Updates) != 1 || len(updates.Users) != 1 { + t.Fatalf("updates = %+v, want one update and sender user", updates) + } + update, ok := updates.Updates[0].(*tg.UpdateNewMessage) + if !ok || update.Pts != msg.Pts { + t.Fatalf("update = %#v, want UpdateNewMessage pts=%d", updates.Updates[0], msg.Pts) + } + if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 { + t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed) + } +} + +func TestOutboxDispatcherUsesScopedAuthKeyExclusion(t *testing.T) { + var excludeAuthKeyID [8]byte + excludeAuthKeyID[0] = 7 + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 57, + TargetUserID: 1000000002, + Pts: 9, + EventType: domain.UpdateEventPeerSettings, + ExcludeAuthKeyID: excludeAuthKeyID, + ExcludeSessionID: 99, + }}} + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: 1000000002, + Type: domain.UpdateEventPeerSettings, + Pts: 9, + PtsCount: 1, + Date: 1700000302, + Peer: peer, + }}} + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t)) + dispatcher.DispatchOnce(context.Background()) + + if sessions.scopedAuthKeyID != excludeAuthKeyID || sessions.sessionID != 99 || sessions.userID != 1000000002 { + t.Fatalf("scoped push = auth %x session %d user %d, want precise outbox exclusion", sessions.scopedAuthKeyID, sessions.sessionID, sessions.userID) + } +} + +// TestOutboxDispatcherBatchPath 覆盖生产批量路径:store 同时具备 BatchByCursor + MarkDeliveredBatch +// 时,DispatchOnce 一次批量取事件、推送、再批量标记 delivered,而非逐条。 +func TestOutboxDispatcherBatchPath(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + events := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }}}} + outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}}} + sessions := &captureSessions{} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if len(events.batchCursors) != 1 || events.batchCursors[0] != (store.EventCursor{UserID: msg.OwnerUserID, Pts: msg.Pts}) { + t.Fatalf("batch cursors = %+v, want one cursor for (%d,%d)", events.batchCursors, msg.OwnerUserID, msg.Pts) + } + if sessions.userID != msg.OwnerUserID || sessions.sessionID != 99 { + t.Fatalf("push target = user %d exclude %d, want batch push to outbox target", sessions.userID, sessions.sessionID) + } + if len(outbox.deliveredBatch) != 1 || outbox.deliveredBatch[0].ID != 55 { + t.Fatalf("delivered batch = %+v, want one item id=55", outbox.deliveredBatch) + } + if outbox.delivered { + t.Fatalf("batch path should not call per-item MarkDelivered") + } + if metrics.claimed != 1 || metrics.delivered != 1 || metrics.failed != 0 { + t.Fatalf("metrics = claimed %d delivered %d failed %d, want 1/1/0", metrics.claimed, metrics.delivered, metrics.failed) + } +} + +func TestOutboxDispatcherUsesBestEffortPush(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }}} + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}} + sessions := &captureBestEffortSessions{captureSessions: &captureSessions{}} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond)) + dispatcher.DispatchOnce(context.Background()) + + if !sessions.bestEffort || sessions.timeout != 50*time.Millisecond { + t.Fatalf("best-effort push = %v timeout %v, want true/50ms", sessions.bestEffort, sessions.timeout) + } + if !outbox.delivered || outbox.failed { + t.Fatalf("outbox delivered=%v failed=%v, want delivered after accepted best-effort push", outbox.delivered, outbox.failed) + } +} + +type captureBestEffortSessions struct { + *captureSessions + bestEffort bool + timeout time.Duration +} + +func (s *captureBestEffortSessions) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + s.bestEffort = true + s.timeout = timeout + return s.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg) +} + +// batchEventStore 给 captureUpdateEventStore 加上 BatchByCursor 批量能力。 +type batchEventStore struct { + *captureUpdateEventStore + batchCursors []store.EventCursor +} + +func (s *batchEventStore) BatchByCursor(_ context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) { + s.batchCursors = cursors + out := make([]domain.UpdateEvent, 0, len(cursors)) + for _, c := range cursors { + for _, event := range s.events { + if event.UserID == c.UserID && event.Pts == c.Pts { + out = append(out, event) + } + } + } + return out, nil +} + +// batchDispatchOutbox 给 captureDispatchOutbox 加上 MarkDeliveredBatch 批量能力。 +type batchDispatchOutbox struct { + *captureDispatchOutbox + deliveredBatch []store.DispatchOutboxItem +} + +func (s *batchDispatchOutbox) MarkDeliveredBatch(_ context.Context, items []store.DispatchOutboxItem) error { + s.deliveredBatch = append(s.deliveredBatch, items...) + return nil +} + +type captureUpdateEventStore struct { + events []domain.UpdateEvent +} + +func (s *captureUpdateEventStore) Append(context.Context, int64, domain.UpdateEvent) error { + return 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 { + if event.Pts > pts { + out = append(out, event) + if len(out) == limit { + break + } + } + } + return out, nil +} + +func (s *captureUpdateEventStore) Current(context.Context, int64) (int, error) { + maxPts := 0 + for _, event := range s.events { + if event.Pts > maxPts { + maxPts = event.Pts + } + } + return maxPts, nil +} + +func (s *captureUpdateEventStore) MaxContiguousPts(context.Context, int64) (int, error) { + present := make(map[int]struct{}, len(s.events)) + for _, event := range s.events { + present[event.Pts] = struct{}{} + } + contiguous := 0 + for { + if _, ok := present[contiguous+1]; !ok { + break + } + contiguous++ + } + 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 + deliveredUserID int64 + deliveredID int64 + failed bool + failedError string +} + +type captureScopedSessions struct { + *captureSessions + scopedAuthKeyID [8]byte +} + +func (s *captureScopedSessions) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) { + s.BindAuthKey(sessionID, authKeyID) + s.scopedAuthKeyID = rawAuthKeyID +} + +func (s *captureScopedSessions) AuthKeyIDForSession([8]byte, int64) ([8]byte, bool) { + return s.AuthKeyID(0) +} + +func (s *captureScopedSessions) BindUserForAuthKey(rawAuthKeyID [8]byte, sessionID, userID int64) { + s.BindUser(sessionID, userID) + s.scopedAuthKeyID = rawAuthKeyID +} + +func (s *captureScopedSessions) UserIDForAuthKey([8]byte, int64) (int64, bool) { + return s.UserID(0) +} + +func (s *captureScopedSessions) UserIDResolvedForAuthKey([8]byte, int64) (int64, bool) { + return s.UserIDResolved(0) +} + +func (s *captureScopedSessions) SetReceivesUpdatesForAuthKey([8]byte, int64, bool) {} + +func (s *captureScopedSessions) PushToSessionForAuthKey(_ context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + 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) +} + +func (s *captureDispatchOutbox) ClaimPending(context.Context, int) ([]store.DispatchOutboxItem, error) { + items := s.items + s.items = nil + return items, nil +} + +func (s *captureDispatchOutbox) MarkDelivered(_ context.Context, targetUserID, id int64) error { + s.delivered = true + s.deliveredUserID = targetUserID + s.deliveredID = id + return nil +} + +func (s *captureDispatchOutbox) MarkFailed(_ context.Context, _ int64, _ int64, lastError string) error { + s.failed = true + s.failedError = lastError + return nil +} + +func (s *captureDispatchOutbox) DeleteFailed(context.Context, time.Duration, int) (int, error) { + return 0, nil +} + +func TestOutboxDispatcherUsesNoopAsDelivered(t *testing.T) { + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 56, + TargetUserID: 1000000002, + Pts: 8, + EventType: domain.UpdateEventNoop, + }}} + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: 1000000002, + Type: domain.UpdateEventNoop, + Pts: 8, + Date: 1700000301, + }}} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, &captureSessions{}, zaptest.NewLogger(t), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if !outbox.delivered || outbox.failed { + t.Fatalf("noop delivered=%v failed=%v, want delivered without push", outbox.delivered, outbox.failed) + } + if metrics.delivered != 1 { + t.Fatalf("noop delivered metrics = %d, want 1", metrics.delivered) + } +} + +type captureOutboxMetrics struct { + claimed int + delivered int + failed int +} + +func (m *captureOutboxMetrics) MessageSend(time.Duration, bool, error) {} + +func (m *captureOutboxMetrics) MessageRateLimited(int) {} + +func (m *captureOutboxMetrics) OutboxClaimed(count int) { + m.claimed += count +} + +func (m *captureOutboxMetrics) OutboxDelivered(time.Duration) { + m.delivered++ +} + +func (m *captureOutboxMetrics) OutboxFailed(error) { + m.failed++ +} + +// queueFullBestEffortSessions 模拟出站队列拥塞:best-effort 推送总是失败(入队超时 / 队列满)。 +type queueFullBestEffortSessions struct { + *captureSessions + attempts int +} + +func (s *queueFullBestEffortSessions) PushToUserExceptSessionBestEffort(_ context.Context, _ int64, _ int64, _ proto.MessageType, _ bin.Encoder, _ time.Duration) (int, error) { + s.attempts++ + return 0, errors.New("mtproto outbound queue full") +} + +// TestOutboxDispatcherDefersOnPushQueueFull 验证 best-effort 推送因出站队列拥塞失败时,dispatcher +// 既不标记 delivered(任务保留,靠 dispatching 租约过期重投,满足至少一次投递语义),也不标记 +// failed(拥塞不计入 attempts 升级,避免正常满 fan-out 负载把可靠 update 误打成 failed)。 +func TestOutboxDispatcherDefersOnPushQueueFull(t *testing.T) { + msg := domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300, + Body: "hello", + Pts: 7, + } + events := &captureUpdateEventStore{events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }}} + outbox := &captureDispatchOutbox{items: []store.DispatchOutboxItem{{ + ID: 55, + TargetUserID: msg.OwnerUserID, + Pts: msg.Pts, + EventType: domain.UpdateEventNewMessage, + ExcludeSessionID: 99, + }}} + sessions := &queueFullBestEffortSessions{captureSessions: &captureSessions{}} + metrics := &captureOutboxMetrics{} + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxPushTimeout(50*time.Millisecond), WithOutboxMetrics(metrics)) + dispatcher.DispatchOnce(context.Background()) + + if sessions.attempts != 1 { + t.Fatalf("best-effort push attempts = %d, want 1(应走 best-effort 推送路径)", sessions.attempts) + } + if outbox.delivered { + t.Fatalf("outbox delivered=true, want 未投递(拥塞应保留 dispatching 行靠租约重投)") + } + if outbox.failed { + t.Fatalf("outbox failed=true, want 未失败(拥塞不计入 attempts 升级)") + } + if metrics.failed != 0 { + t.Fatalf("metrics.failed=%d, want 0(拥塞不算投递失败)", metrics.failed) + } +} diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go new file mode 100644 index 00000000..39f7aa92 --- /dev/null +++ b/internal/rpc/payments.go @@ -0,0 +1,22 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" +) + +// registerPayments 注册第一阶段 TDesktop 启动所需 payments.* RPC 兼容响应。 +func (r *Router) registerPayments(d *tg.ServerDispatcher) { + 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.OnPaymentsGetSavedStarGift(func(ctx context.Context, stargift []tg.InputSavedStarGiftClass) (*tg.PaymentsSavedStarGifts, error) { + return tdesktop.SavedStarGifts(), nil + }) +} diff --git a/internal/rpc/photos.go b/internal/rpc/photos.go new file mode 100644 index 00000000..04c32ab1 --- /dev/null +++ b/internal/rpc/photos.go @@ -0,0 +1,250 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// registerPhotos 注册 photos.* RPC handler(头像上传 / 切换 / 查询 / 删除)。 +func (r *Router) registerPhotos(d *tg.ServerDispatcher) { + d.OnPhotosUploadProfilePhoto(r.onPhotosUploadProfilePhoto) + d.OnPhotosUpdateProfilePhoto(r.onPhotosUpdateProfilePhoto) + d.OnPhotosGetUserPhotos(r.onPhotosGetUserPhotos) + d.OnPhotosDeletePhotos(r.onPhotosDeletePhotos) +} + +func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosUploadProfilePhotoRequest) (*tg.PhotosPhoto, 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, photoInvalidErr() + } + file, hasFile := req.GetFile() + if !hasFile { + // 仅 fallback / video / emoji markup 等本阶段不支持的头像变体。 + return nil, photoInvalidErr() + } + ref, ok := uploadedFileRef(userID, file) + if !ok { + return nil, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.UploadProfilePhoto(ctx, domain.PeerTypeUser, userID, ref, int(r.clock.Now().Unix())) + if err != nil { + return nil, photoUploadErr(err) + } + return r.photosPhotoForSelf(ctx, userID, photo), nil +} + +func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosUpdateProfilePhotoRequest) (*tg.PhotosPhoto, 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, photoInvalidErr() + } + switch in := req.ID.(type) { + case *tg.InputPhoto: + photo, found, err := r.deps.Files.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, userID, in.ID, int(r.clock.Now().Unix())) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return r.photosPhotoForSelf(ctx, userID, photo), nil + default: + // InputPhotoEmpty:移除当前头像(停用现有当前照片)。 + if cur, found, err := r.deps.Files.CurrentProfilePhoto(ctx, domain.PeerTypeUser, userID); err == nil && found { + _, _ = r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, []int64{cur.ID}) + } + return r.photosPhotoForSelf(ctx, userID, domain.Photo{}), nil + } +} + +func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) { + if r.deps.Files == nil { + return &tg.PhotosPhotos{}, nil + } + currentUserID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || currentUserID == 0 { + return nil, userIDInvalidErr() + } + target, found, err := r.userFromInput(ctx, currentUserID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, userIDInvalidErr() + } + offset := req.Offset + if offset < 0 { + offset = 0 + } + limit := req.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + photos, total, err := r.deps.Files.GetProfilePhotos(ctx, domain.PeerTypeUser, target.ID, offset, limit, req.MaxID) + if err != nil { + return nil, internalErr() + } + tgPhotos := make([]tg.PhotoClass, 0, len(photos)) + for _, p := range photos { + tgPhotos = append(tgPhotos, tgPhoto(p)) + } + users := []tg.UserClass{r.tgUser(target)} + if total > len(photos)+offset { + return &tg.PhotosPhotosSlice{Count: total, Photos: tgPhotos, Users: users}, nil + } + return &tg.PhotosPhotos{Photos: tgPhotos, Users: users}, nil +} + +func (r *Router) onPhotosDeletePhotos(ctx context.Context, id []tg.InputPhotoClass) ([]int64, error) { + if r.deps.Files == nil { + return []int64{}, nil + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, photoInvalidErr() + } + ids := make([]int64, 0, len(id)) + for _, in := range id { + if photo, isPhoto := in.(*tg.InputPhoto); isPhoto && photo.ID != 0 { + ids = append(ids, photo.ID) + } + } + if len(ids) == 0 { + return []int64{}, nil + } + if _, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids); err != nil { + return nil, internalErr() + } + return ids, nil +} + +// photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user),并在头像变更后 +// 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto +// 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。 +func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo) *tg.PhotosPhoto { + out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} + if r.deps.Users == nil { + return out + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return out + } + out.Users = append(out.Users, r.tgSelfUser(self)) + r.pushSelfPhotoUpdate(ctx, self) + return out +} + +// 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)。 +func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) { + if self.ID == 0 { + return + } + r.pushUserUpdates(ctx, self.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}}, + Users: []tg.UserClass{r.tgSelfUser(self)}, + Date: int(r.clock.Now().Unix()), + }) +} + +// uploadedFileRef 把 tg.InputFile / InputFileBig 转成 domain.UploadedFileRef。 +func uploadedFileRef(ownerUserID int64, file tg.InputFileClass) (domain.UploadedFileRef, bool) { + switch f := file.(type) { + case *tg.InputFile: + if f.ID == 0 || f.Parts <= 0 { + return domain.UploadedFileRef{}, false + } + return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, MD5: f.MD5Checksum}, true + case *tg.InputFileBig: + if f.ID == 0 || f.Parts <= 0 { + return domain.UploadedFileRef{}, false + } + return domain.UploadedFileRef{OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Name: f.Name, Big: true}, true + default: + return domain.UploadedFileRef{}, false + } +} + +// resolveInputChatPhoto 把 tg.InputChatPhoto 解析为 *domain.Photo(nil=清除头像)。 +// 支持新上传(InputChatUploadedPhoto)与引用已有照片(InputChatPhoto{InputPhoto})。 +func (r *Router) resolveInputChatPhoto(ctx context.Context, userID int64, input tg.InputChatPhotoClass) (*domain.Photo, error) { + switch in := input.(type) { + case *tg.InputChatPhotoEmpty: + return nil, nil + case *tg.InputChatUploadedPhoto: + file, ok := in.GetFile() + if !ok { + return nil, photoInvalidErr() + } + if r.deps.Files == nil { + return nil, photoInvalidErr() + } + ref, ok := uploadedFileRef(userID, file) + if !ok { + return nil, fileReferenceInvalidErr() + } + // 频道/群头像用 avatar 尺寸('a'/'c'),匹配 InputPeerPhotoFileLocation 下载路径。 + photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) + if err != nil { + return nil, photoUploadErr(err) + } + return &photo, nil + case *tg.InputChatPhoto: + switch id := in.ID.(type) { + case *tg.InputPhoto: + if r.deps.Files == nil { + return nil, photoInvalidErr() + } + photo, found, err := r.deps.Files.GetPhoto(ctx, id.ID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return &photo, nil + default: + return nil, nil // InputPhotoEmpty → 清除 + } + default: + return nil, photoInvalidErr() + } +} + +func photoUploadErr(err error) error { + switch { + case errors.Is(err, domain.ErrFilePartsInvalid): + return filePartsInvalidErr() + case errors.Is(err, domain.ErrPhotoInvalid): + return photoInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/photos_push_test.go b/internal/rpc/photos_push_test.go new file mode 100644 index 00000000..5e1db46b --- /dev/null +++ b/internal/rpc/photos_push_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" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestUploadProfilePhotoPushesUpdateToOtherDevices 守护审计修复:换头像后必须向该账号其它在线 +// 设备推送(updateUser 信号 + Updates.Users 带新 self user),否则其它设备头像不刷新。 +// 原因:updateUserName 不含 photo 无法刷新头像,唯有带 user 对象最可靠(见 photos.go +// pushSelfPhotoUpdate 注释)。曾完全不推送,本测试防回归。 +func TestUploadProfilePhotoPushesUpdateToOtherDevices(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + sessions := &captureSessions{} + files := &fakeFiles{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Files: files, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + 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 { + t.Fatalf("uploadProfilePhoto: %v", err) + } + + snap := sessions.snapshot() + if snap.userID != owner.ID { + t.Fatalf("push target 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) + } + hasUserUpdate := false + for _, u := range updates.Updates { + if uu, ok := u.(*tg.UpdateUser); ok && uu.UserID == owner.ID { + hasUserUpdate = true + } + } + if !hasUserUpdate { + t.Fatalf("updates = %+v, want UpdateUser for self", updates.Updates) + } + if len(updates.Users) == 0 { + t.Fatal("pushed updates missing self user — other devices cannot refresh avatar") + } +} diff --git a/internal/rpc/premium.go b/internal/rpc/premium.go new file mode 100644 index 00000000..b6b83d92 --- /dev/null +++ b/internal/rpc/premium.go @@ -0,0 +1,142 @@ +package rpc + +import ( + "context" + + "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) + d.OnPremiumGetMyBoosts(r.onPremiumGetMyBoosts) + d.OnPremiumApplyBoost(r.onPremiumApplyBoost) + d.OnPremiumGetUserBoosts(r.onPremiumGetUserBoosts) +} + +func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeerClass) (*tg.PremiumBoostsStatus, error) { + if _, _, err := r.premiumBoostChannelView(ctx, peer, false); err != nil { + return nil, err + } + return emptyPremiumBoostsStatus(), 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 { + return nil, limitInvalidErr() + } + if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, true); err != nil { + return nil, err + } + return emptyPremiumBoostsList(), nil +} + +func (r *Router) onPremiumGetMyBoosts(ctx context.Context) (*tg.PremiumMyBoosts, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return emptyPremiumMyBoosts(), nil +} + +func (r *Router) onPremiumApplyBoost(ctx context.Context, req *tg.PremiumApplyBoostRequest) (*tg.PremiumMyBoosts, error) { + slots, ok := req.GetSlots() + if !ok { + return nil, tgerr400("BOOSTS_EMPTY") + } + if len(slots) == 0 { + return nil, tgerr400("SLOTS_EMPTY") + } + if len(slots) > maxPremiumApplyBoostSlots { + return nil, limitInvalidErr() + } + for _, slot := range slots { + if slot < 0 { + return nil, tgerr400("SLOTS_INVALID") + } + } + if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, false); err != nil { + return nil, err + } + return emptyPremiumMyBoosts(), nil +} + +func (r *Router) onPremiumGetUserBoosts(ctx context.Context, req *tg.PremiumGetUserBoostsRequest) (*tg.PremiumBoostsList, error) { + 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 { + return nil, err + } + _ = view + return emptyPremiumBoostsList(), nil +} + +func (r *Router) premiumBoostChannelView(ctx context.Context, peer tg.InputPeerClass, requireAdmin bool) (int64, domain.ChannelView, error) { + ref, ok := premiumBoostChannelRef(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 requireAdmin && view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return userID, view, nil +} + +func premiumBoostChannelRef(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 emptyPremiumBoostsStatus() *tg.PremiumBoostsStatus { + return &tg.PremiumBoostsStatus{ + Level: 0, + CurrentLevelBoosts: 0, + Boosts: 0, + BoostURL: "", + } +} + +func emptyPremiumBoostsList() *tg.PremiumBoostsList { + return &tg.PremiumBoostsList{ + Count: 0, + Boosts: []tg.Boost{}, + Users: []tg.UserClass{}, + } +} + +func emptyPremiumMyBoosts() *tg.PremiumMyBoosts { + return &tg.PremiumMyBoosts{ + MyBoosts: []tg.MyBoost{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/rpc/presence.go b/internal/rpc/presence.go new file mode 100644 index 00000000..29ab780c --- /dev/null +++ b/internal/rpc/presence.go @@ -0,0 +1,490 @@ +package rpc + +import ( + "context" + "sync" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const userOnlineTTL = 5 * time.Minute +const presenceDialogFanoutCandidateLimit = 512 + +type presenceSessionKey struct { + rawAuthKeyID [8]byte + sessionID int64 +} + +type presenceSessionState struct { + userID int64 + status domain.UserStatus +} + +type presenceTracker struct { + mu sync.RWMutex + bySession map[presenceSessionKey]presenceSessionState + byUser map[int64]map[presenceSessionKey]domain.UserStatus +} + +func newPresenceTracker() *presenceTracker { + return &presenceTracker{ + bySession: make(map[presenceSessionKey]presenceSessionState), + byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus), + } +} + +func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) { + if p == nil || userID == 0 { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if old, ok := p.bySession[key]; ok { + p.removeSessionLocked(key, old.userID) + } + p.bySession[key] = presenceSessionState{userID: userID, status: status} + sessions := p.byUser[userID] + if sessions == nil { + sessions = make(map[presenceSessionKey]domain.UserStatus) + p.byUser[userID] = sessions + } + sessions[key] = status +} + +func (p *presenceTracker) clearSession(key presenceSessionKey) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if old, ok := p.bySession[key]; ok { + p.removeSessionLocked(key, old.userID) + } +} + +func (p *presenceTracker) removeSessionLocked(key presenceSessionKey, userID int64) { + delete(p.bySession, key) + sessions := p.byUser[userID] + delete(sessions, key) + if len(sessions) == 0 { + delete(p.byUser, userID) + } +} + +func (p *presenceTracker) statusFor(userID int64, now int) (domain.UserStatus, bool) { + if p == nil || userID == 0 { + return domain.UserStatus{}, false + } + p.mu.RLock() + defer p.mu.RUnlock() + sessions := p.byUser[userID] + if len(sessions) == 0 { + return domain.UserStatus{}, false + } + known := false + var bestOnline domain.UserStatus + var bestOffline domain.UserStatus + for _, status := range sessions { + status = normalizePresenceStatus(status, now) + switch status.Kind { + case domain.UserStatusOnline: + if bestOnline.Expires == 0 || status.Expires > bestOnline.Expires { + bestOnline = status + } + known = true + case domain.UserStatusOffline: + if bestOffline.WasOnline == 0 || status.WasOnline > bestOffline.WasOnline { + bestOffline = status + } + known = true + case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty: + if !known { + bestOffline = status + known = true + } + } + } + if bestOnline.Kind == domain.UserStatusOnline { + return bestOnline, true + } + if known { + return bestOffline, true + } + return domain.UserStatus{}, false +} + +func normalizePresenceStatus(status domain.UserStatus, now int) domain.UserStatus { + if status.Kind == domain.UserStatusOnline && status.Expires <= now { + wasOnline := status.WasOnline + if wasOnline == 0 || wasOnline > status.Expires { + wasOnline = status.Expires + } + return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: wasOnline} + } + return status +} + +func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool) domain.UserStatus { + now := int(r.clock.Now().Unix()) + status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} + if !offline { + status = domain.UserStatus{ + Kind: domain.UserStatusOnline, + Expires: now + int(userOnlineTTL/time.Second), + WasOnline: now, + } + } + if key, ok := presenceSessionKeyFromContext(ctx); ok { + r.presence.setSessionStatus(key, userID, status) + } + r.persistLastSeen(ctx, userID, now) + return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now}) +} + +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) +} + +func (r *Router) userPresenceStatus(userID int64) domain.UserStatus { + return r.userPresenceStatusForUser(domain.User{ID: userID}) +} + +func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus { + userID := u.ID + if userID == 0 { + return domain.UserStatus{Kind: domain.UserStatusRecently} + } + now := int(r.clock.Now().Unix()) + if status, ok := r.presence.statusFor(userID, now); ok { + return status + } + if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok && provider.IsUserOnline(userID) { + return domain.UserStatus{ + Kind: domain.UserStatusOnline, + Expires: now + int(userOnlineTTL/time.Second), + WasOnline: now, + } + } + if u.LastSeenAt > 0 { + return domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: u.LastSeenAt} + } + return domain.UserStatus{Kind: domain.UserStatusRecently} +} + +type userLastSeenUpdater interface { + UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error +} + +func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int) { + if userID == 0 || lastSeenAt <= 0 || r.deps.Users == nil { + return + } + updater, ok := r.deps.Users.(userLastSeenUpdater) + if !ok { + return + } + if err := updater.UpdateLastSeen(ctx, 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)) + } +} + +// SessionOffline is called by mtprotoedge when an active connection disappears. +// Business-side effects stay here: mtprotoedge only reports lifecycle facts. +func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) { + if userID == 0 { + return + } + ctx := WithSessionID(WithRawAuthKeyID(context.Background(), rawAuthKeyID), sessionID) + key := presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID} + 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) + r.pushUserStatus(ctx, userID, status) +} + +func presenceSessionKeyFromContext(ctx context.Context) (presenceSessionKey, bool) { + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return presenceSessionKey{}, false + } + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + return presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}, true + } + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + return presenceSessionKey{rawAuthKeyID: authKeyID, sessionID: sessionID}, true + } + return presenceSessionKey{sessionID: sessionID}, true +} + +func (r *Router) withUserPresence(u domain.User) domain.User { + if u.ID == 0 { + return u + } + u.Status = r.userPresenceStatusForUser(u) + return u +} + +func (r *Router) withUsersPresence(users []domain.User) []domain.User { + if len(users) == 0 { + return users + } + out := append([]domain.User(nil), users...) + for i := range out { + out[i] = r.withUserPresence(out[i]) + } + return out +} + +func (r *Router) withContactListPresence(list domain.ContactList) domain.ContactList { + if len(list.Contacts) == 0 { + return list + } + 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) + } + 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) + return list +} + +func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.UserSearchResult { + res.MyResults = r.withUsersPresence(res.MyResults) + res.Results = r.withUsersPresence(res.Results) + return res +} + +func (r *Router) tgUser(u domain.User) *tg.User { + return tgUser(r.withUserPresence(u)) +} + +func (r *Router) tgSelfUser(u domain.User) *tg.User { + return tgSelfUser(r.withUserPresence(u)) +} + +func (r *Router) tgUsers(users []domain.User) []tg.UserClass { + return tgUsers(r.withUsersPresence(users)) +} + +func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain.UserStatus) { + if userID == 0 { + return + } + update := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUserStatus{ + UserID: userID, + Status: tgUserStatus(status), + }}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } + r.pushUserUpdates(ctx, userID, update) + for _, recipientID := range r.onlinePresenceRecipientIDs(ctx, userID) { + if recipientID == userID { + continue + } + r.pushUserMessage(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 { + return + } + updates := make([]tg.UpdateClass, 0, len(peerIDs)) + for _, peerID := range peerIDs { + status := r.userPresenceStatus(peerID) + if status.Kind != domain.UserStatusOnline { + continue + } + updates = append(updates, &tg.UpdateUserStatus{ + UserID: peerID, + Status: tgUserStatus(status), + }) + } + if len(updates) == 0 { + return + } + r.pushCurrentSessionMessage(ctx, "push online peer statuses", &tg.Updates{ + Updates: updates, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) +} + +func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int64 { + if userID == 0 { + return nil + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return nil + } + seen := map[int64]struct{}{} + candidates := make([]int64, 0) + add := func(id int64) { + if id == 0 || id == userID { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + candidates = append(candidates, id) + } + if r.deps.Contacts != nil { + ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) + if err == nil && !notModified { + for _, id := range ids { + add(int64(id)) + } + } + } + if r.deps.Dialogs != nil { + list, err := r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{Limit: presenceDialogFanoutCandidateLimit}) + if err == nil { + for _, dialog := range list.Dialogs { + if dialog.Peer.Type == domain.PeerTypeUser { + add(dialog.Peer.ID) + } + } + } + } + if len(candidates) == 0 { + return nil + } + 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 +} diff --git a/internal/rpc/push.go b/internal/rpc/push.go new file mode 100644 index 00000000..c4920f21 --- /dev/null +++ b/internal/rpc/push.go @@ -0,0 +1,73 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + "go.uber.org/zap" +) + +func (r *Router) pushUserMessage(ctx context.Context, userID int64, logMessage string, msg bin.Encoder) int { + if r.deps.Sessions == nil || userID == 0 || msg == nil { + return 0 + } + sessionID, _ := SessionIDFrom(ctx) + if timeout := r.cfg.OutboundPushTimeout; timeout > 0 { + authKeyID, _ := AuthKeyIDFrom(ctx) + if scoped, ok := r.deps.Sessions.(ScopedBestEffortSessionBinder); ok { + if sent, err := scoped.PushToUserExceptAuthKeySessionBestEffort(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg, timeout); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err)) + return sent + } else { + return sent + } + } + if bestEffort, ok := r.deps.Sessions.(BestEffortSessionBinder); ok { + if sent, err := bestEffort.PushToUserExceptSessionBestEffort(ctx, userID, sessionID, proto.MessageFromServer, msg, timeout); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Duration("timeout", timeout), zap.Error(err)) + return sent + } else { + return sent + } + } + } + if scoped, ok := r.scopedSessions(); ok { + authKeyID, _ := AuthKeyIDFrom(ctx) + if sent, err := scoped.PushToUserExceptAuthKeySession(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err)) + return sent + } else { + return sent + } + } + if sent, err := r.deps.Sessions.PushToUserExceptSession(ctx, userID, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err)) + return sent + } else { + return sent + } +} + +func (r *Router) pushCurrentSessionMessage(ctx context.Context, logMessage string, msg bin.Encoder) { + if r.deps.Sessions == nil || msg == nil { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if scoped, ok := r.scopedSessions(); ok { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + if err := scoped.PushToSessionForAuthKey(ctx, rawAuthKeyID, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("session_id", sessionID), zap.Error(err)) + } + return + } + if err := r.deps.Sessions.PushToSession(ctx, sessionID, proto.MessageFromServer, msg); err != nil { + r.log.Debug(logMessage, zap.Int64("session_id", sessionID), zap.Error(err)) + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go new file mode 100644 index 00000000..d8a4977d --- /dev/null +++ b/internal/rpc/router.go @@ -0,0 +1,416 @@ +package rpc + +import ( + "context" + "encoding/hex" + "fmt" + "sync" + "time" + + "go.uber.org/zap" + "golang.org/x/sync/singleflight" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" +) + +// maxWrapperDepth 限制 invokeWithLayer/initConnection 等 wrapper 的嵌套深度,防御恶意构造。 +const maxWrapperDepth = 4 + +var ( + tlTypeNamesOnce sync.Once + tlTypeNames map[uint32]string +) + +// Config 是 Router 所需的服务端信息。 +type Config struct { + DC int + IP string // 对外公布的 DC IP(写入 DCOptions) + Port int // 对外公布的 DC 端口 + OutboundPushTimeout time.Duration +} + +// 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。 +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 +} + +type clientInfoSessionKey struct { + rawAuthKeyID [8]byte + sessionID int64 +} + +type authUserCacheEntry struct { + userID int64 + found bool +} + +// 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()} + d := tg.NewServerDispatcher(r.fallback) + + r.registerHelp(d) + r.registerAuth(d) + r.registerUsers(d) + r.registerUpdates(d) + r.registerAccount(d) + r.registerMessages(d) + r.registerChannels(d) + r.registerUpload(d) + r.registerPhotos(d) + r.registerFolders(d) + r.registerContacts(d) + r.registerLangpack(d) + r.registerStories(d) + r.registerPayments(d) + r.registerStats(d) + r.registerPremium(d) + r.registerAiCompose(d) + + r.dispatcher = d + return r +} + +// Dispatch 路由一条 RPC 请求:先剥离 invokeWithLayer / initConnection / +// invokeWithoutUpdates 等 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) { + ctx = WithRawAuthKeyID(ctx, authKeyID) + effectiveAuthKeyID, err := r.effectiveAuthKeyID(ctx, authKeyID, sessionID) + if err != nil { + return nil, internalErr() + } + ctx = WithAuthKeyID(ctx, effectiveAuthKeyID) + ctx = WithSessionID(ctx, sessionID) + userID, hasUserID, err := r.effectiveUserID(ctx, authKeyID, effectiveAuthKeyID, sessionID) + if err != nil { + return nil, internalErr() + } + if hasUserID { + ctx = WithUserID(ctx, userID) + } + if info, ok := r.clientInfoForSession(ctx); ok { + ctx = WithClientInfo(ctx, info) + } + return r.dispatch(ctx, b, 0) +} + +func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64) ([8]byte, error) { + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + if id, ok := scoped.AuthKeyIDForSession(rawAuthKeyID, sessionID); ok { + return id, nil + } + } else if id, ok := r.deps.Sessions.AuthKeyID(sessionID); ok { + return id, nil + } + } + effective := rawAuthKeyID + if r.deps.Auth != nil { + resolved, ok, err := r.deps.Auth.ResolveAuthKey(ctx, rawAuthKeyID) + if err != nil { + return [8]byte{}, err + } + if ok { + effective = resolved + } + } + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, effective) + } else { + 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) { + if userID, ok := UserIDFrom(ctx); ok { + if scoped, ok := r.scopedSessions(); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID) + } else if r.deps.Sessions != nil { + r.deps.Sessions.BindUser(sessionID, userID) + } + return userID, true, nil + } + if r.deps.Sessions != nil { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); 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 + } + 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 + } + } 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 { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, userID) + } else { + r.deps.Sessions.BindUser(sessionID, userID) + } + r.announceSessionOnline(ctx, userID) + } else { + if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, 0) + } else { + r.deps.Sessions.BindUser(sessionID, 0) + } + } + } + return userID, found, nil +} + +func (r *Router) lookupAuthUser(ctx context.Context, authKeyID [8]byte) (int64, bool, error) { + if userID, found, ok := r.cachedAuthUser(authKeyID); ok { + return userID, found, nil + } + key := string(authKeyID[:]) + v, err, _ := r.authUserSF.Do(key, func() (any, error) { + if userID, found, ok := r.cachedAuthUser(authKeyID); ok { + return authUserCacheEntry{userID: userID, found: found}, nil + } + userID, found, err := r.deps.Auth.UserID(ctx, authKeyID) + if err != nil { + return authUserCacheEntry{}, err + } + r.setAuthUserCache(authKeyID, userID, found) + return authUserCacheEntry{userID: userID, found: found}, nil + }) + if err != nil { + return 0, false, err + } + entry := v.(authUserCacheEntry) + return entry.userID, entry.found, nil +} + +func (r *Router) cachedAuthUser(authKeyID [8]byte) (int64, bool, bool) { + r.authUserMu.RLock() + defer r.authUserMu.RUnlock() + entry, ok := r.authUsers[authKeyID] + if !ok { + return 0, false, false + } + return entry.userID, entry.found, 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) + } + r.authUsers[authKeyID] = authUserCacheEntry{userID: userID, found: found} +} + +func (r *Router) invalidateAuthUserCache(authKeyID [8]byte) { + r.authUserMu.Lock() + delete(r.authUsers, authKeyID) + r.authUserMu.Unlock() + r.authUserSF.Forget(string(authKeyID[:])) +} + +func (r *Router) scopedSessions() (ScopedSessionBinder, bool) { + if r.deps.Sessions == nil { + return nil, false + } + scoped, ok := r.deps.Sessions.(ScopedSessionBinder) + return scoped, ok +} + +func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.Encoder, error) { + if depth > maxWrapperDepth { + return nil, wrapperTooDeepErr() + } + + id, err := b.PeekID() + if err != nil { + return nil, err + } + + switch id { + case tg.InvokeWithLayerRequestTypeID: + if err := b.ConsumeID(id); err != nil { + return nil, err + } + layer, err := b.Int() + if err != nil { + return nil, fmt.Errorf("decode invokeWithLayer layer: %w", err) + } + // query 紧跟 layer,buffer 剩余即内层请求。 + return r.dispatch(WithLayer(ctx, layer), b, depth+1) + + case tg.InvokeWithoutUpdatesRequestTypeID: + if err := b.ConsumeID(id); err != nil { + return nil, err + } + return r.dispatch(ctx, b, depth+1) + + case tg.InitConnectionRequestTypeID: + req := &tg.InitConnectionRequest{Query: &rawObject{}} + if err := req.Decode(b); err != nil { + return nil, fmt.Errorf("decode initConnection: %w", err) + } + info := ClientInfo{ + APIID: req.APIID, + DeviceModel: req.DeviceModel, + SystemVersion: req.SystemVersion, + AppVersion: req.AppVersion, + SystemLangCode: req.SystemLangCode, + LangPack: req.LangPack, + LangCode: req.LangCode, + } + ctx = WithClientInfo(ctx, info) + r.rememberClientInfo(ctx, info) + r.log.Debug("initConnection", + zap.Int("api_id", req.APIID), + zap.String("device", req.DeviceModel), + zap.String("app", req.AppVersion), + zap.Int("layer", LayerFrom(ctx)), + ) + inner, ok := req.Query.(*rawObject) + if !ok { + return nil, fmt.Errorf("initConnection query: unexpected type %T", req.Query) + } + return r.dispatch(ctx, &bin.Buffer{Buf: inner.data}, depth+1) + + default: + start := time.Now() + enc, err := r.dispatcher.Handle(ctx, b) + dur := time.Since(start) + fields := append([]zap.Field{ + zap.String("method", tlTypeName(id)), + zap.String("type_id", fmt.Sprintf("%#x", id)), + zap.Duration("dur", dur), + }, r.contextLogFields(ctx)...) + if err != nil || dur > 100*time.Millisecond { + if err != nil { + fields = append(fields, zap.Error(err)) + } + r.log.Info("RPC inner handled", fields...) + } else { + r.log.Debug("RPC inner handled", fields...) + } + return enc, err + } +} + +func tlTypeName(id uint32) string { + tlTypeNamesOnce.Do(func() { + names := tg.NamesMap() + tlTypeNames = make(map[uint32]string, len(names)) + for name, typeID := range names { + tlTypeNames[typeID] = name + } + }) + if name, ok := tlTypeNames[id]; ok { + return name + } + return fmt.Sprintf("%#x", id) +} + +func (r *Router) rememberClientInfo(ctx context.Context, info ClientInfo) { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + r.clientInfoMu.Lock() + defer r.clientInfoMu.Unlock() + if r.clientInfo == nil { + r.clientInfo = make(map[clientInfoSessionKey]ClientInfo) + } + r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] = info +} + +func (r *Router) clientInfoForSession(ctx context.Context) (ClientInfo, bool) { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return ClientInfo{}, false + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return ClientInfo{}, false + } + r.clientInfoMu.RLock() + defer r.clientInfoMu.RUnlock() + info, ok := r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] + return info, ok +} + +// fallback 处理未注册的 RPC:记录到 compatibility trace(落兼容矩阵), +// 返回 NOT_IMPLEMENTED rpc_error 让客户端继续运行而非断连。 +func (r *Router) fallback(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + id, _ := b.PeekID() + fields := append([]zap.Field{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))} + if sessionID, ok := SessionIDFrom(ctx); ok { + fields = append(fields, zap.Int64("session_id", sessionID)) + } + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + fields = append(fields, zap.String("raw_auth_key_id", hex.EncodeToString(rawAuthKeyID[:]))) + } + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + fields = append(fields, zap.String("auth_key_id", hex.EncodeToString(authKeyID[:]))) + } + if userID, ok := UserIDFrom(ctx); ok { + fields = append(fields, zap.Int64("user_id", userID)) + } + return fields +} + +// rawObject 在解码 wrapper 时按原样捕获内层 query 的 TL 字节,供递归分发。 +// 它实现 bin.Object(Encode/Decode),但只搬运字节、不解释内容。 +type rawObject struct { + data []byte +} + +func (o *rawObject) Decode(b *bin.Buffer) error { + o.data = append(o.data[:0], b.Buf...) + b.Skip(len(b.Buf)) + return nil +} + +func (o *rawObject) Encode(b *bin.Buffer) error { + b.Put(o.data) + return nil +} diff --git a/internal/rpc/router_test.go b/internal/rpc/router_test.go new file mode 100644 index 00000000..8121d6b8 --- /dev/null +++ b/internal/rpc/router_test.go @@ -0,0 +1,10137 @@ +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: "account.getPassword", req: &tg.AccountGetPasswordRequest{}}, + {name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}}, + {name: "account.getPrivacy", req: &tg.AccountGetPrivacyRequest{Key: &tg.InputPrivacyKeyStatusTimestamp{}}}, + {name: "account.getAuthorizations", req: &tg.AccountGetAuthorizationsRequest{}}, + {name: "account.getDefaultEmojiStatuses", req: &tg.AccountGetDefaultEmojiStatusesRequest{}}, + {name: "account.getCollectibleEmojiStatuses", req: &tg.AccountGetCollectibleEmojiStatusesRequest{}}, + {name: "account.getDefaultGroupPhotoEmojis", req: &tg.AccountGetDefaultGroupPhotoEmojisRequest{}}, + {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.getSavedMusicIds", req: &tg.AccountGetSavedMusicIDsRequest{}}, + {name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}}, + {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.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.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: "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) != 2 { + t.Fatalf("updates len = %d, want create + invite service messages", 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]) + } + 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[1].(*tg.UpdateNewChannelMessage) + if !ok || inviteMsg.Pts != 2 || inviteMsg.PtsCount != 1 { + t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[1]) + } + 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) != 2 { + t.Fatalf("updates len = %d, want create + invite service messages", 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) + 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}, + } { + if _, err := r.onMessagesSendMessage(WithUserID(ctx, item.userID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: item.text, + RandomID: item.random, + }); err != nil { + t.Fatalf("send %q: %v", item.text, err) + } + } + + 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)) + } +} + +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) + + 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) < 3 { + t.Fatalf("admin updates empty, want participant update") + } else if _, ok := updates.Updates[0].(*tg.UpdateChannelParticipant); !ok { + t.Fatalf("admin update[0] = %T, want updateChannelParticipant", updates.Updates[0]) + } else if tooLong, ok := updates.Updates[2].(*tg.UpdateChannelTooLong); !ok { + t.Fatalf("admin update[2] = %T, want updateChannelTooLong", updates.Updates[2]) + } else if pts, ok := tooLong.GetPts(); !ok || pts == 0 { + t.Fatalf("admin updateChannelTooLong pts = %d ok=%v, want set pts", pts, ok) + } + adminDiff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference after admin: %v", err) + } + adminFullDiff, ok := adminDiff.(*tg.UpdatesChannelDifference) + if !ok || len(adminFullDiff.OtherUpdates) == 0 { + t.Fatalf("admin diff = %T %+v, want participant other update", adminDiff, adminDiff) + } else if _, ok := adminFullDiff.OtherUpdates[0].(*tg.UpdateChannelParticipant); !ok { + t.Fatalf("admin diff update[0] = %T, want updateChannelParticipant", adminFullDiff.OtherUpdates[0]) + } + 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) != 1 { + t.Fatalf("import updates = %+v, want chat and join service update", updates) + } + 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 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 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 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) 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 *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) 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 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/send_media.go b/internal/rpc/send_media.go new file mode 100644 index 00000000..9457a5c6 --- /dev/null +++ b/internal/rpc/send_media.go @@ -0,0 +1,539 @@ +package rpc + +import ( + "context" + "errors" + "strconv" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件实现 messages.uploadMedia / sendMedia / sendMultiMedia 的 photo/document/sticker 主路径, +// 并抽取 sendOutgoing 作为「已校验的一条出站消息(文本或媒体)落地」的共享实现,私聊与频道共用。 + +// outgoingSend 是 sendOutgoing 的入参:一条已校验的出站消息。 +type outgoingSend struct { + randomID int64 + message string + entities []tg.MessageEntityClass + media *domain.MessageMedia + silent bool + noforwards bool + replyToInput tg.InputReplyToClass + sendAsInput tg.InputPeerClass + clearDraft bool +} + +// 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 + } + 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 + } + 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()), + }) + if err != nil { + return nil, false, channelInvalidErr(err) + } + updates := r.channelMessageUpdates(ctx, userID, res, p.randomID) + 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.pushChannelDiscussionUpdate(ctx, userID, res.Discussion) + } + if p.clearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + return updates, res.Duplicate, nil + } + if peer.Type != domain.PeerTypeUser { + return nil, false, peerIDInvalidErr() + } + if r.deps.Messages == nil { + return nil, false, peerIDInvalidErr() + } + if r.deps.Users != nil && peer.ID != userID { + if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil { + return nil, false, internalErr() + } else if !found { + return nil, false, peerIDInvalidErr() + } + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) + if err != nil { + return nil, false, err + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: peer.ID, + RandomID: p.randomID, + Message: p.message, + Entities: domainMessageEntities(p.entities), + Media: p.media, + Silent: p.silent, + NoForwards: p.noforwards, + ReplyTo: replyTo, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, false, messageSendErr(err) + } + users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + if p.clearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, p.randomID, true, users, chats), res.Duplicate, nil +} + +// onMessagesUploadMedia 解析 InputMedia(上传或引用),返回可复用的 tg.MessageMedia。 +func (r *Router) onMessagesUploadMedia(ctx context.Context, req *tg.MessagesUploadMediaRequest) (tg.MessageMediaClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, mediaInvalidErr() + } + if len(req.BusinessConnectionID) > maxBusinessConnIDLength { + return nil, limitInvalidErr() + } + if _, ok := req.Peer.(*tg.InputPeerEmpty); !ok { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + } + if _, ok := req.Media.(*tg.InputMediaEmpty); ok { + return &tg.MessageMediaEmpty{}, nil + } + media, err := r.resolveInputMedia(ctx, userID, req.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + return tgMessageMedia(media), nil +} + +// onMessagesSendMedia 发送一条带媒体的消息(photo/document/sticker),私聊与频道均支持。 +func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMediaRequest) (tg.UpdatesClass, error) { + if req.RandomID == 0 { + return nil, randomIDEmptyErr() + } + if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { + return nil, mediaCaptionTooLongErr() + } + if len(req.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { + return nil, scheduleDateInvalidErr() + } + if req.Media == nil { + return nil, mediaInvalidErr() + } + // InputMediaEmpty / WebPage:退化为纯文本发送(复用 sendMessage 校验与流程)。 + switch req.Media.(type) { + case *tg.InputMediaEmpty, *tg.InputMediaWebPage: + return r.onMessagesSendMessage(ctx, sendMessageRequestFromSendMedia(req)) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + 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) + } + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + media, err := r.resolveInputMedia(ctx, userID, req.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + updates, _, err := r.sendOutgoing(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, + }) + if err != nil { + return nil, err + } + return updates, nil +} + +// onMessagesSendMultiMedia 发送相册(多条媒体)。本阶段不绑定 grouped_id(各条作为独立消息呈现)。 +func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesSendMultiMediaRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + 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 + } + for _, item := range req.MultiMedia { + if item.RandomID == 0 { + return nil, randomIDEmptyErr() + } + if utf8.RuneCountInString(item.Message) > maxSendMessageTextLength { + return nil, mediaCaptionTooLongErr() + } + if len(item.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + if item.Media == nil { + return nil, mediaInvalidErr() + } + } + + 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 { + media, err := r.resolveInputMedia(ctx, userID, item.Media) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaInvalidErr() + } + result, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: item.RandomID, + message: item.Message, + entities: item.Entities, + media: media, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + }) + if err != nil { + return nil, err + } + if upd, ok := result.(*tg.Updates); ok { + combined = append(combined, upd.Updates...) + for _, u := range upd.Users { + if id := userClassID(u); id != 0 { + usersByID[id] = u + } + } + for _, c := range upd.Chats { + if id := chatClassID(c); id != 0 { + chatsByID[id] = c + } + } + if upd.Date != 0 { + date = upd.Date + } + } + } + return &tg.Updates{ + Updates: combined, + Users: mapValuesUsers(usersByID), + Chats: mapValuesChats(chatsByID), + Date: date, + }, nil +} + +// 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.InputMediaUploadedPhoto: + if in.File == nil { + return nil, mediaInvalidErr() + } + ref, ok := uploadedFileRef(userID, in.File) + if !ok { + return nil, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.CreatePhotoFromUpload(ctx, ref) + if err != nil { + return nil, mediaUploadErr(err) + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil + case *tg.InputMediaUploadedDocument: + if in.File == nil { + return nil, mediaInvalidErr() + } + ref, ok := uploadedFileRef(userID, in.File) + if !ok { + return nil, fileReferenceInvalidErr() + } + spec := domain.DocumentSpec{ + MimeType: in.MimeType, + Attributes: domainDocumentAttributes(in.Attributes), + ForceFile: in.ForceFile, + } + if thumb, ok := in.GetThumb(); ok { + if tref, ok := uploadedFileRef(userID, thumb); ok { + spec.Thumb = &tref + } + } + doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec) + if err != nil { + return nil, mediaUploadErr(err) + } + return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil + case *tg.InputMediaPhoto: + photoID, ok := inputPhotoID(in.ID) + 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, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil + case *tg.InputMediaDocument: + docIDs, ok := inputDocumentCandidateIDs(in.ID) + if !ok { + return nil, mediaInvalidErr() + } + var doc domain.Document + found := false + for _, docID := range docIDs { + var err error + doc, found, err = r.deps.Files.GetDocument(ctx, docID) + if err != nil { + return nil, internalErr() + } + if found { + break + } + } + if !found { + return nil, mediaInvalidErr() + } + return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil + default: + // geo / contact / poll / venue / dice / story / 等本阶段不支持。 + return nil, mediaInvalidErr() + } +} + +// 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} + for _, attr := range doc.Attributes { + switch attr.Kind { + case domain.DocAttrVideo: + media.Video = true + if attr.RoundMessage { + media.Round = true + } + case domain.DocAttrAudio: + if attr.Voice { + media.Voice = true + } + } + } + return media +} + +func inputPhotoID(input tg.InputPhotoClass) (int64, bool) { + if p, ok := input.(*tg.InputPhoto); ok && 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 { + return d.ID, true + } + return 0, false +} + +func inputDocumentCandidateIDs(input tg.InputDocumentClass) ([]int64, bool) { + if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 { + return []int64{d.ID}, true + } + return nil, false +} + +// domainDocumentAttributes 把 tg.DocumentAttribute 反向转为 domain(InputMediaUploadedDocument 用)。 +func domainDocumentAttributes(attrs []tg.DocumentAttributeClass) []domain.DocumentAttribute { + out := make([]domain.DocumentAttribute, 0, len(attrs)) + for _, a := range attrs { + switch v := a.(type) { + case *tg.DocumentAttributeImageSize: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: v.W, H: v.H}) + case *tg.DocumentAttributeAnimated: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}) + case *tg.DocumentAttributeSticker: + attr := domain.DocumentAttribute{Kind: domain.DocAttrSticker, Alt: v.Alt, Mask: v.Mask} + if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok { + attr.StickerSetID = id + attr.StickerSetAccessHash = hash + } + out = append(out, attr) + case *tg.DocumentAttributeVideo: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: v.W, H: v.H, Duration: v.Duration, RoundMessage: v.RoundMessage, SupportsStreaming: v.SupportsStreaming}) + case *tg.DocumentAttributeAudio: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: v.Duration, Voice: v.Voice, Title: v.Title, Performer: v.Performer, Waveform: v.Waveform}) + case *tg.DocumentAttributeFilename: + out = append(out, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: v.FileName}) + case *tg.DocumentAttributeCustomEmoji: + attr := domain.DocumentAttribute{Kind: domain.DocAttrCustomEmoji, Alt: v.Alt, Free: v.Free, TextColor: v.TextColor} + if id, hash, ok := inputStickerSetIDs(v.Stickerset); ok { + attr.StickerSetID = id + attr.StickerSetAccessHash = hash + } + out = append(out, attr) + } + } + return out +} + +func inputStickerSetIDs(input tg.InputStickerSetClass) (int64, int64, bool) { + if s, ok := input.(*tg.InputStickerSetID); ok { + return s.ID, s.AccessHash, true + } + return 0, 0, false +} + +// sendMessageRequestFromSendMedia 把 sendMedia(空媒体)的字段映射到 sendMessage 请求。 +func sendMessageRequestFromSendMedia(req *tg.MessagesSendMediaRequest) *tg.MessagesSendMessageRequest { + return &tg.MessagesSendMessageRequest{ + Silent: req.Silent, + Background: req.Background, + ClearDraft: req.ClearDraft, + Noforwards: req.Noforwards, + UpdateStickersetsOrder: req.UpdateStickersetsOrder, + InvertMedia: req.InvertMedia, + AllowPaidFloodskip: req.AllowPaidFloodskip, + Peer: req.Peer, + ReplyTo: req.ReplyTo, + Message: req.Message, + RandomID: req.RandomID, + ReplyMarkup: req.ReplyMarkup, + Entities: req.Entities, + ScheduleDate: req.ScheduleDate, + ScheduleRepeatPeriod: req.ScheduleRepeatPeriod, + SendAs: req.SendAs, + QuickReplyShortcut: req.QuickReplyShortcut, + Effect: req.Effect, + AllowPaidStars: req.AllowPaidStars, + SuggestedPost: req.SuggestedPost, + } +} + +func mediaUploadErr(err error) error { + switch { + case errors.Is(err, domain.ErrFilePartsInvalid): + return filePartsInvalidErr() + case errors.Is(err, domain.ErrPhotoInvalid): + return photoInvalidErr() + case errors.Is(err, domain.ErrDocumentInvalid): + return mediaInvalidErr() + default: + return internalErr() + } +} + +func userClassID(u tg.UserClass) int64 { + if v, ok := u.(*tg.User); ok { + return v.ID + } + return 0 +} + +func chatClassID(c tg.ChatClass) int64 { + switch v := c.(type) { + case *tg.Channel: + return v.ID + case *tg.Chat: + return v.ID + } + return 0 +} + +func mapValuesUsers(m map[int64]tg.UserClass) []tg.UserClass { + out := make([]tg.UserClass, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} + +func mapValuesChats(m map[int64]tg.ChatClass) []tg.ChatClass { + out := make([]tg.ChatClass, 0, len(m)) + for _, v := range m { + out = append(out, v) + } + return out +} diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go new file mode 100644 index 00000000..7fda7e8c --- /dev/null +++ b/internal/rpc/send_media_test.go @@ -0,0 +1,238 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// 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 +} + +func (f *fakeFiles) SaveFilePart(context.Context, int64, int64, int, []byte) (bool, error) { + return true, nil +} +func (f *fakeFiles) SaveBigFilePart(context.Context, int64, int64, int, int, []byte) (bool, error) { + return true, nil +} +func (f *fakeFiles) GetFile(context.Context, domain.FileDownloadRequest) (domain.FileChunk, bool, error) { + return domain.FileChunk{}, false, nil +} +func (f *fakeFiles) ListAvailableReactions(context.Context) ([]domain.AvailableReaction, error) { + return append([]domain.AvailableReaction(nil), f.reactions...), nil +} +func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) { + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if d, ok := f.docs[id]; ok { + out = append(out, d) + } + } + return out, nil +} +func (f *fakeFiles) ResolveStickerSet(context.Context, domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + return domain.StickerSet{}, nil, false, nil +} +func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + sets := f.sets[kind] + 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 +} +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 +} +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) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) { + p, ok := f.photos[id] + return p, ok, nil +} +func (f *fakeFiles) GetDocument(_ context.Context, id int64) (domain.Document, bool, error) { + 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) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) { + return domain.Photo{}, false, nil +} +func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) { + return domain.Photo{}, false, nil +} +func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) { + return nil, 0, nil +} +func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) { + return 0, nil +} + +func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550009002", FirstName: "Friend"}) + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 555: { + ID: 555, + AccessHash: 5, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 99, StickerSetAccessHash: 7}}, + }, + }, + photos: map[int64]domain.Photo{}, + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Messages: appmessages.NewService(messageStore, dialogStore), + Files: files, + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + return r, owner, friend +} + +func newMessageFromUpdates(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 nm, ok := u.(*tg.UpdateNewMessage); ok { + msg, ok := nm.Message.(*tg.Message) + if !ok { + t.Fatalf("expected *tg.Message, got %T", nm.Message) + } + return msg + } + } + t.Fatal("no UpdateNewMessage found") + return nil +} + +func TestSendMediaPrivateSticker(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.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}}, + RandomID: 1001, + }) + if err != nil { + t.Fatalf("sendMedia sticker: %v", err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaDocument) + if !ok { + t.Fatalf("expected MessageMediaDocument, got %T", msg.Media) + } + doc, ok := media.Document.(*tg.Document) + if !ok { + t.Fatalf("expected tg.Document, got %T", media.Document) + } + if want := int64(555); doc.ID != want { + t.Errorf("document id = %d, want %d", doc.ID, want) + } + if doc.DCID != 2 { + t.Errorf("document dc_id = %d, want 2", doc.DCID) + } + hasSticker := false + for _, a := range doc.Attributes { + if _, ok := a.(*tg.DocumentAttributeSticker); ok { + hasSticker = true + } + } + if !hasSticker { + t.Error("document missing sticker attribute") + } +} + +func TestSendMediaPrivateUploadedPhoto(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.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 42, Parts: 1, Name: "p.jpg"}}, + Message: "caption", + RandomID: 1002, + }) + if err != nil { + t.Fatalf("sendMedia photo: %v", err) + } + msg := newMessageFromUpdates(t, updates) + if msg.Message != "caption" { + t.Errorf("caption = %q, want %q", msg.Message, "caption") + } + media, ok := msg.Media.(*tg.MessageMediaPhoto) + if !ok { + t.Fatalf("expected MessageMediaPhoto, got %T", msg.Media) + } + photo, ok := media.Photo.(*tg.Photo) + if !ok { + t.Fatalf("expected tg.Photo, got %T", media.Photo) + } + if photo.ID != 777 { + t.Errorf("photo id = %d, want 777", photo.ID) + } +} + +func TestUploadMediaReturnsReusableMedia(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.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}}, + }) + if err != nil { + t.Fatalf("uploadMedia: %v", err) + } + if _, ok := media.(*tg.MessageMediaDocument); !ok { + t.Fatalf("expected MessageMediaDocument, got %T", media) + } +} + +func TestStickerSetDoesNotExposeUnserviceableDownloadThumb(t *testing.T) { + set := tgStickerSet(domain.StickerSet{ + ID: 99, + AccessHash: 7, + Title: "Set", + ShortName: "set", + ThumbDCID: 2, + ThumbVersion: 123, + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}}, + {Kind: domain.PhotoSizeKindDefault, Type: "a", W: 100, H: 100, Size: 4096}, + }, + }) + thumbs, ok := set.GetThumbs() + if !ok || len(thumbs) != 1 { + t.Fatalf("thumbs = %#v, want only non-downloadable path thumb", thumbs) + } + if _, ok := thumbs[0].(*tg.PhotoPathSize); !ok { + t.Fatalf("thumb[0] = %T, want PhotoPathSize", thumbs[0]) + } +} diff --git a/internal/rpc/stats.go b/internal/rpc/stats.go new file mode 100644 index 00000000..ff4307b0 --- /dev/null +++ b/internal/rpc/stats.go @@ -0,0 +1,211 @@ +package rpc + +import ( + "context" + "fmt" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const ( + maxStatsPublicForwardsLimit = 100 + maxStatsOffsetLength = 128 + maxStatsGraphTokenLength = 128 +) + +func (r *Router) registerStats(d *tg.ServerDispatcher) { + d.OnStatsGetBroadcastStats(r.onStatsGetBroadcastStats) + d.OnStatsGetMegagroupStats(r.onStatsGetMegagroupStats) + d.OnStatsGetMessageStats(r.onStatsGetMessageStats) + d.OnStatsGetMessagePublicForwards(r.onStatsGetMessagePublicForwards) + d.OnStatsLoadAsyncGraph(r.onStatsLoadAsyncGraph) + d.OnStatsGetStoryStats(r.onStatsGetStoryStats) + d.OnStatsGetStoryPublicForwards(r.onStatsGetStoryPublicForwards) + d.OnStatsGetPollStats(r.onStatsGetPollStats) +} + +func (r *Router) onStatsGetBroadcastStats(ctx context.Context, req *tg.StatsGetBroadcastStatsRequest) (*tg.StatsBroadcastStats, error) { + view, err := r.statsChannelView(ctx, req.Channel) + if err != nil { + return nil, err + } + if !view.Channel.Broadcast { + return nil, tgerr400("BROADCAST_REQUIRED") + } + return r.emptyBroadcastStats(), nil +} + +func (r *Router) onStatsGetMegagroupStats(ctx context.Context, req *tg.StatsGetMegagroupStatsRequest) (*tg.StatsMegagroupStats, error) { + view, err := r.statsChannelView(ctx, req.Channel) + if err != nil { + return nil, err + } + if !view.Channel.Megagroup { + return nil, tgerr400("MEGAGROUP_REQUIRED") + } + return r.emptyMegagroupStats(), nil +} + +func (r *Router) onStatsGetMessageStats(ctx context.Context, req *tg.StatsGetMessageStatsRequest) (*tg.StatsMessageStats, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if _, err := r.statsChannelView(ctx, req.Channel); err != nil { + return nil, err + } + return &tg.StatsMessageStats{ + ViewsGraph: r.emptyStatsGraph("Views"), + ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), + }, nil +} + +func (r *Router) onStatsGetMessagePublicForwards(ctx context.Context, req *tg.StatsGetMessagePublicForwardsRequest) (*tg.StatsPublicForwards, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength { + return nil, limitInvalidErr() + } + if _, err := r.statsChannelView(ctx, req.Channel); err != nil { + return nil, err + } + return emptyStatsPublicForwards(), nil +} + +func (r *Router) onStatsLoadAsyncGraph(_ context.Context, req *tg.StatsLoadAsyncGraphRequest) (tg.StatsGraphClass, error) { + if len(req.Token) > maxStatsGraphTokenLength { + return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil + } + return &tg.StatsGraphError{Error: "GRAPH_INVALID_RELOAD"}, nil +} + +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 err := r.validateStatsPeer(ctx, req.Peer); err != nil { + return nil, err + } + return &tg.StatsStoryStats{ + ViewsGraph: r.emptyStatsGraph("Views"), + ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), + }, nil +} + +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.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength { + return nil, limitInvalidErr() + } + if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + return nil, err + } + return emptyStatsPublicForwards(), nil +} + +func (r *Router) onStatsGetPollStats(ctx context.Context, req *tg.StatsGetPollStatsRequest) (*tg.StatsPollStats, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + return nil, err + } + return &tg.StatsPollStats{VotesGraph: r.emptyStatsGraph("Votes")}, nil +} + +func (r *Router) statsChannelView(ctx context.Context, input tg.InputChannelClass) (domain.ChannelView, error) { + _, view, err := r.channelView(ctx, input) + if err != nil { + return domain.ChannelView{}, err + } + if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return view, nil +} + +func (r *Router) validateStatsPeer(ctx context.Context, peer tg.InputPeerClass) error { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return internalErr() + } + _, err = r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + return err +} + +func (r *Router) emptyBroadcastStats() *tg.StatsBroadcastStats { + return &tg.StatsBroadcastStats{ + Period: r.statsDateRange(), + Followers: tg.StatsAbsValueAndPrev{}, + ViewsPerPost: tg.StatsAbsValueAndPrev{}, + SharesPerPost: tg.StatsAbsValueAndPrev{}, + ReactionsPerPost: tg.StatsAbsValueAndPrev{}, + ViewsPerStory: tg.StatsAbsValueAndPrev{}, + SharesPerStory: tg.StatsAbsValueAndPrev{}, + ReactionsPerStory: tg.StatsAbsValueAndPrev{}, + EnabledNotifications: tg.StatsPercentValue{}, + GrowthGraph: r.emptyStatsGraph("Growth"), + FollowersGraph: r.emptyStatsGraph("Followers"), + MuteGraph: r.emptyStatsGraph("Muted"), + TopHoursGraph: r.emptyStatsGraph("Hours"), + InteractionsGraph: r.emptyStatsGraph("Interactions"), + IvInteractionsGraph: r.emptyStatsGraph("Instant Views"), + ViewsBySourceGraph: r.emptyStatsGraph("Views"), + NewFollowersBySourceGraph: r.emptyStatsGraph("Followers"), + LanguagesGraph: r.emptyStatsGraph("Languages"), + ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), + StoryInteractionsGraph: r.emptyStatsGraph("Stories"), + StoryReactionsByEmotionGraph: r.emptyStatsGraph("Story Reactions"), + } +} + +func (r *Router) emptyMegagroupStats() *tg.StatsMegagroupStats { + return &tg.StatsMegagroupStats{ + Period: r.statsDateRange(), + Members: tg.StatsAbsValueAndPrev{}, + Messages: tg.StatsAbsValueAndPrev{}, + Viewers: tg.StatsAbsValueAndPrev{}, + Posters: tg.StatsAbsValueAndPrev{}, + GrowthGraph: r.emptyStatsGraph("Growth"), + MembersGraph: r.emptyStatsGraph("Members"), + NewMembersBySourceGraph: r.emptyStatsGraph("Members"), + LanguagesGraph: r.emptyStatsGraph("Languages"), + MessagesGraph: r.emptyStatsGraph("Messages"), + ActionsGraph: r.emptyStatsGraph("Actions"), + TopHoursGraph: r.emptyStatsGraph("Hours"), + WeekdaysGraph: r.emptyStatsGraph("Weekdays"), + TopPosters: []tg.StatsGroupTopPoster{}, + TopAdmins: []tg.StatsGroupTopAdmin{}, + TopInviters: []tg.StatsGroupTopInviter{}, + } +} + +func (r *Router) statsDateRange() tg.StatsDateRangeDays { + now := int(r.clock.Now().Unix()) + return tg.StatsDateRangeDays{MinDate: now - 86400, MaxDate: now} +} + +func (r *Router) emptyStatsGraph(label string) *tg.StatsGraph { + nowMillis := r.clock.Now().UnixMilli() + prevMillis := nowMillis - 86400000 + data := fmt.Sprintf( + `{"columns":[["x",%d,%d],["y0",0,0]],"types":{"x":"x","y0":"line"},"names":{"y0":%q},"colors":{"y0":"blue#4a90e2"}}`, + prevMillis, + nowMillis, + label, + ) + return &tg.StatsGraph{JSON: tg.DataJSON{Data: data}} +} + +func emptyStatsPublicForwards() *tg.StatsPublicForwards { + return &tg.StatsPublicForwards{ + Count: 0, + Forwards: []tg.PublicForwardClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/rpc/stickers.go b/internal/rpc/stickers.go new file mode 100644 index 00000000..3cd39619 --- /dev/null +++ b/internal/rpc/stickers.go @@ -0,0 +1,148 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// 本文件把 reaction / sticker 资源 RPC 接到真实 seed 数据(documents / sticker_sets / +// available_reactions);Files 服务缺失或资源未导入时回退到 tdesktop 兼容 stub。 + +func (r *Router) onMessagesGetAvailableReactions(ctx context.Context, hash int) (tg.MessagesAvailableReactionsClass, error) { + if r.deps.Files == nil { + return tdesktop.AvailableReactions(hash), nil + } + reactions, err := r.deps.Files.ListAvailableReactions(ctx) + if err != nil { + return nil, internalErr() + } + if len(reactions) == 0 { + return tdesktop.AvailableReactions(hash), nil + } + catalogHash := availableReactionsHash(reactions) + if hash == catalogHash { + return &tg.MessagesAvailableReactionsNotModified{}, nil + } + docs, err := r.deps.Files.GetDocuments(ctx, reactionDocumentIDs(reactions)) + if err != nil { + return nil, internalErr() + } + return tgAvailableReactions(reactions, 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 + } + ref, ok := stickerSetRefFromInput(req.Stickerset) + if !ok { + return tdesktop.StickerSet(req), nil + } + set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, ref) + if err != nil { + return nil, internalErr() + } + if !found { + // 未 seed 的系统集 / 未知短名:回退兼容 stub,避免破坏客户端。 + return tdesktop.StickerSet(req), nil + } + if req.Hash != 0 && req.Hash == set.Hash { + return &tg.MessagesStickerSetNotModified{}, nil + } + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onMessagesGetAllStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) { + return r.allStickersForKind(ctx, hash, domain.StickerSetKindStickers) +} + +func (r *Router) onMessagesGetEmojiStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) { + return r.allStickersForKind(ctx, hash, domain.StickerSetKindEmoji) +} + +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() + } + if len(sets) == 0 { + return messagesAllStickersEmpty(hash), nil + } + catalogHash := stickerSetsCatalogHash(sets) + if hash == catalogHash { + return &tg.MessagesAllStickersNotModified{}, nil + } + return &tg.MessagesAllStickers{Hash: catalogHash, Sets: tgStickerSets(sets)}, nil +} + +func documentsByID(docs []domain.Document) map[int64]domain.Document { + m := make(map[int64]domain.Document, len(docs)) + for _, d := range docs { + m[d.ID] = d + } + return m +} + +// availableReactionsHash 用 reaction 的核心字段算稳定 hash(供 *NotModified 缓存判定)。 +func availableReactionsHash(reactions []domain.AvailableReaction) int { + values := make([]int64, 0, len(reactions)*10) + for _, r := range reactions { + values = append(values, + int64(len([]rune(r.Reaction))), + boolHashValue(r.Inactive), + boolHashValue(r.Premium), + r.StaticIconID, + r.AppearAnimationID, + r.SelectAnimationID, + r.ActivateAnimationID, + r.EffectAnimationID, + r.AroundAnimationID, + r.CenterIconID, + ) + } + return int(tdesktopCountHash(values) & 0x7fffffff) +} + +func stickerSetsCatalogHash(sets []domain.StickerSet) int64 { + values := make([]int64, 0, len(sets)) + for _, set := range sets { + if set.ID == 0 { + return 0 + } + if set.Archived { + continue + } + values = append(values, int64(set.Hash)) + } + return int64(tdesktopCountHash(values)) +} + +func boolHashValue(v bool) int64 { + if v { + return 1 + } + return 0 +} + +func tdesktopCountHash(values []int64) uint64 { + var hash uint64 + for _, value := range values { + hash = tdesktopHashUpdate(hash, value) + } + return hash +} + +func tdesktopHashUpdate(hash uint64, value int64) uint64 { + hash ^= hash >> 21 + hash ^= hash << 35 + hash ^= hash >> 4 + hash += uint64(value) + return hash +} diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go new file mode 100644 index 00000000..0cf53245 --- /dev/null +++ b/internal/rpc/stickers_test.go @@ -0,0 +1,188 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func TestStickerSetsCatalogHashMatchesTDesktopFormula(t *testing.T) { + sets := []domain.StickerSet{ + {ID: 10, Hash: 123}, + {ID: 11, Hash: 456}, + } + got := stickerSetsCatalogHash(sets) + const want int64 = 4284229878340 + if got != want { + t.Fatalf("stickerSetsCatalogHash() = %d, want %d", got, want) + } + if old := mediaCatalogHash([]int64{10, 123, 11, 456}); old == got { + t.Fatalf("test fixture no longer distinguishes old media hash from TDesktop hash: %d", got) + } +} + +func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) { + ctx := context.Background() + files := &fakeFiles{ + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: { + { + ID: 10, + AccessHash: 100, + ShortName: "one", + Title: "One", + Count: 1, + Hash: 123, + Installed: true, + }, + { + ID: 11, + AccessHash: 110, + ShortName: "two", + Title: "Two", + Count: 1, + Hash: 456, + Installed: true, + }, + }, + }, + } + r := &Router{deps: Deps{Files: files}} + + first, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("first getAllStickers: %v", err) + } + full, ok := first.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("first getAllStickers = %T, want *tg.MessagesAllStickers", first) + } + const wantHash int64 = 4284229878340 + if full.Hash != wantHash { + t.Fatalf("first hash = %d, want %d", full.Hash, wantHash) + } + + second, err := r.onMessagesGetAllStickers(ctx, full.Hash) + if err != nil { + t.Fatalf("second getAllStickers: %v", err) + } + if _, ok := second.(*tg.MessagesAllStickersNotModified); !ok { + t.Fatalf("second getAllStickers = %T, want *tg.MessagesAllStickersNotModified", second) + } +} + +func TestMessagesGetAvailableReactionsNotModified(t *testing.T) { + ctx := context.Background() + reactions := []domain.AvailableReaction{ + { + Reaction: "👍", + Title: "Like", + StaticIconID: 101, + AppearAnimationID: 102, + SelectAnimationID: 103, + ActivateAnimationID: 104, + EffectAnimationID: 105, + AroundAnimationID: 106, + CenterIconID: 107, + }, + } + files := &fakeFiles{reactions: reactions} + r := &Router{deps: Deps{Files: files}} + + first, err := r.onMessagesGetAvailableReactions(ctx, 0) + if err != nil { + t.Fatalf("first getAvailableReactions: %v", err) + } + full, ok := first.(*tg.MessagesAvailableReactions) + if !ok { + t.Fatalf("first getAvailableReactions = %T, want *tg.MessagesAvailableReactions", first) + } + if full.Hash == 0 { + t.Fatal("first getAvailableReactions returned zero hash") + } + + second, err := r.onMessagesGetAvailableReactions(ctx, full.Hash) + if err != nil { + t.Fatalf("second getAvailableReactions: %v", err) + } + if _, ok := second.(*tg.MessagesAvailableReactionsNotModified); !ok { + t.Fatalf("second getAvailableReactions = %T, want *tg.MessagesAvailableReactionsNotModified", second) + } +} + +func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) { + doc := tgDocument(domain.Document{ + ID: 100, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + Thumbs: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindCached, Type: "m", W: 128, H: 128, Bytes: []byte("webp")}, + }, + }) + full, ok := doc.(*tg.Document) + if !ok { + t.Fatalf("tgDocument = %T, want *tg.Document", doc) + } + if len(full.Thumbs) != 1 { + t.Fatalf("thumbs = %d, want 1", len(full.Thumbs)) + } + size, ok := full.Thumbs[0].(*tg.PhotoSize) + if !ok { + t.Fatalf("thumb = %T, want *tg.PhotoSize", full.Thumbs[0]) + } + if size.Type != "m" || size.W != 128 || size.H != 128 || size.Size != 4 { + t.Fatalf("thumb size = %+v, want downloadable m 128x128 size=4", size) + } +} + +func TestTGDocumentUsesDomainDocumentID(t *testing.T) { + const documentID int64 = 1382305375846410902 + + doc := tgDocument(domain.Document{ + ID: documentID, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + }) + full, ok := doc.(*tg.Document) + if !ok { + t.Fatalf("tgDocument = %T, want *tg.Document", doc) + } + if full.ID != documentID { + t.Fatalf("document id = %d, want %d", full.ID, documentID) + } +} + +func TestMessagesGetCustomEmojiDocumentsUsesDomainIDs(t *testing.T) { + const documentID int64 = 1382305375846410902 + ctx := WithUserID(context.Background(), 1780269504) + r := &Router{deps: Deps{Files: &fakeFiles{ + docs: map[int64]domain.Document{ + documentID: { + ID: documentID, + AccessHash: 1, + DCID: 2, + MimeType: "application/x-tgsticker", + }, + }, + }}} + + docs, err := r.onMessagesGetCustomEmojiDocuments(ctx, []int64{documentID}) + if err != nil { + t.Fatalf("getCustomEmojiDocuments: %v", err) + } + if len(docs) != 1 { + t.Fatalf("docs = %d, want 1", len(docs)) + } + doc, ok := docs[0].(*tg.Document) + if !ok { + t.Fatalf("doc = %T, want *tg.Document", docs[0]) + } + if doc.ID != documentID { + t.Fatalf("doc id = %d, want %d", doc.ID, documentID) + } +} diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go new file mode 100644 index 00000000..b59d8001 --- /dev/null +++ b/internal/rpc/stories.go @@ -0,0 +1,49 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// registerStories 注册第一阶段 TDesktop 启动所需 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.OnStoriesSendReaction(r.onStoriesSendReaction) +} + +func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendReactionRequest) (tg.UpdatesClass, error) { + if req.StoryID <= 0 || req.StoryID > domain.MaxMessageBoxID { + return nil, storyIDInvalidErr() + } + 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 err := validateReactionClass(req.Reaction); 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 { + return nil, internalErr() + } + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go new file mode 100644 index 00000000..f3ef3a3c --- /dev/null +++ b/internal/rpc/update_peer_refs.go @@ -0,0 +1,283 @@ +package rpc + +import ( + "context" + + "telesrv/internal/domain" +) + +func (r *Router) enrichUpdateEvents(ctx context.Context, viewerUserID int64, events []domain.UpdateEvent) []domain.UpdateEvent { + if len(events) == 0 { + return events + } + out := append([]domain.UpdateEvent(nil), events...) + for i := range out { + if out[i].Type == domain.UpdateEventMessageReactions { + out[i] = r.enrichMessageReactionEvent(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) + } + 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))...) + } + return out +} + +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 + } + peer := event.Message.Peer + if peer.Type == "" || peer.ID == 0 { + peer = event.Peer + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return event + } + res, err := r.deps.Messages.GetMessageReactions(ctx, viewerUserID, domain.PrivateMessageReactionsRequest{ + OwnerUserID: viewerUserID, + Peer: peer, + IDs: []int{event.Message.ID}, + }) + if err != nil { + return event + } + for _, msg := range res.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 +} + +func (r *Router) enrichChannelDifference(ctx context.Context, viewerUserID int64, diff domain.ChannelDifference) domain.ChannelDifference { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, event := range diff.Events { + collectChannelUpdatePeerRefs(event, diff.Channel.ID, userIDs, channelIDs) + } + for _, msg := range diff.NewMessages { + collectChannelMessagePeerRefs(msg, diff.Channel.ID, userIDs, channelIDs) + } + 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))...) + return diff +} + +func (r *Router) enrichChannelHistory(ctx context.Context, viewerUserID int64, history domain.ChannelHistory) domain.ChannelHistory { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, msg := range history.Messages { + collectChannelMessagePeerRefs(msg, history.Channel.ID, userIDs, channelIDs) + } + for _, topic := range history.Topics { + if topic.CreatorUserID != 0 { + 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))...) + return history +} + +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.ReplyTo != nil { + addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) + } + if msg.Reactions != nil { + for _, reaction := range msg.Reactions.Recent { + if reaction.UserID != 0 { + userIDs[reaction.UserID] = struct{}{} + } + } + } +} + +func collectChannelUpdatePeerRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + if event.SenderUserID != 0 { + userIDs[event.SenderUserID] = struct{}{} + } + for _, id := range event.UserIDs { + if id != 0 { + userIDs[id] = struct{}{} + } + } + for _, member := range []domain.ChannelMember{event.Previous, event.Participant} { + if member.UserID != 0 { + userIDs[member.UserID] = struct{}{} + } + if member.InviterUserID != 0 { + userIDs[member.InviterUserID] = struct{}{} + } + } + collectChannelMessagePeerRefs(event.Message, currentChannelID, userIDs, channelIDs) +} + +func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + if msg.SenderUserID != 0 { + userIDs[msg.SenderUserID] = struct{}{} + } + addDomainPeerRef(msg.From, currentChannelID, userIDs, channelIDs) + if msg.SendAs != nil { + addDomainPeerRef(*msg.SendAs, currentChannelID, userIDs, channelIDs) + } + if msg.Forward != nil { + addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs) + } + if msg.ReplyTo != nil { + addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) + } + if msg.Action != nil { + for _, id := range msg.Action.UserIDs { + if id != 0 { + userIDs[id] = struct{}{} + } + } + } + if msg.Reactions != nil { + for _, reaction := range msg.Reactions.Recent { + if reaction.UserID != 0 { + userIDs[reaction.UserID] = struct{}{} + } + } + } +} + +func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID != 0 { + userIDs[peer.ID] = struct{}{} + } + case domain.PeerTypeChannel: + if peer.ID != 0 && peer.ID != currentChannelID { + channelIDs[peer.ID] = struct{}{} + } + } +} + +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 +} + +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{}{} + } + } + for _, u := range extra { + if u.ID == 0 { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + out = append(out, u) + } + return out +} + +func mergeDomainChannels(base []domain.Channel, extra ...domain.Channel) []domain.Channel { + out := append([]domain.Channel(nil), base...) + seen := make(map[int64]struct{}, len(out)+len(extra)) + for _, ch := range out { + if ch.ID != 0 { + seen[ch.ID] = struct{}{} + } + } + for _, ch := range extra { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + out = append(out, ch) + } + return out +} + +func mapKeys(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) + } + } + return out +} diff --git a/internal/rpc/updates.go b/internal/rpc/updates.go new file mode 100644 index 00000000..95e7cac3 --- /dev/null +++ b/internal/rpc/updates.go @@ -0,0 +1,107 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const updatesTooLongNudgeDelay = 300 * time.Millisecond + +// registerUpdates 注册 updates.* RPC handler。 +func (r *Router) registerUpdates(d *tg.ServerDispatcher) { + d.OnUpdatesGetState(r.onUpdatesGetState) + d.OnUpdatesGetDifference(r.onUpdatesGetDifference) +} + +// onUpdatesGetState 处理 updates.getState(第一阶段返回零状态)。 +func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Updates == nil { + r.markSessionReceivesUpdates(ctx, userID) + return &tg.UpdatesState{Date: int(r.clock.Now().Unix())}, nil + } + st, err := r.deps.Updates.GetState(ctx, id, userID) + if err != nil { + return nil, internalErr() + } + current, err := r.deps.Updates.CurrentState(ctx, userID) + if err != nil { + return nil, internalErr() + } + r.markSessionReceivesUpdates(ctx, userID) + if current.Pts > st.Pts { + r.scheduleCurrentSessionDifferenceNudge(ctx) + } + return ptr(tgUpdateState(st)), nil +} + +func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetDifferenceRequest) (tg.UpdatesDifferenceClass, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Updates == nil { + now := int(r.clock.Now().Unix()) + r.markSessionReceivesUpdates(ctx, userID) + return &tg.UpdatesDifferenceEmpty{Date: now}, nil + } + st, err := r.deps.Updates.GetDifference(ctx, id, userID, domain.UpdateState{ + Pts: req.Pts, + Qts: req.Qts, + Date: req.Date, + }) + if err != nil { + return nil, internalErr() + } + r.markSessionReceivesUpdates(ctx, userID) + if len(st.Events) == 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) markSessionReceivesUpdates(ctx context.Context, userID int64) { + if r.deps.Sessions == nil { + return + } + r.syncSessionChannelMemberships(ctx, userID) + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return + } + if scoped, ok := r.scopedSessions(); ok { + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + scoped.SetReceivesUpdatesForAuthKey(rawAuthKeyID, sessionID, true) + return + } + } + 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/upload.go b/internal/rpc/upload.go new file mode 100644 index 00000000..00cf537d --- /dev/null +++ b/internal/rpc/upload.go @@ -0,0 +1,188 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// registerUpload 注册 upload.* RPC handler(分片上传 + 文件下载)。 +func (r *Router) registerUpload(d *tg.ServerDispatcher) { + d.OnUploadSaveFilePart(r.onUploadSaveFilePart) + d.OnUploadSaveBigFilePart(r.onUploadSaveBigFilePart) + d.OnUploadGetFile(r.onUploadGetFile) + d.OnUploadGetFileHashes(r.onUploadGetFileHashes) +} + +func (r *Router) onUploadSaveFilePart(ctx context.Context, req *tg.UploadSaveFilePartRequest) (bool, error) { + if r.deps.Files == nil { + return false, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !ok || userID == 0 { + return false, fileIDInvalidErr() + } + if req.FilePart < 0 { + return false, filePartInvalidErr() + } + saved, err := r.deps.Files.SaveFilePart(ctx, userID, req.FileID, req.FilePart, req.Bytes) + if err != nil { + return false, fileSaveErr(err) + } + return saved, nil +} + +func (r *Router) onUploadSaveBigFilePart(ctx context.Context, req *tg.UploadSaveBigFilePartRequest) (bool, error) { + if r.deps.Files == nil { + return false, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !ok || userID == 0 { + return false, fileIDInvalidErr() + } + if req.FilePart < 0 { + return false, filePartInvalidErr() + } + saved, err := r.deps.Files.SaveBigFilePart(ctx, userID, req.FileID, req.FilePart, req.FileTotalParts, req.Bytes) + if err != nil { + return false, fileSaveErr(err) + } + return saved, nil +} + +func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileRequest) (tg.UploadFileClass, error) { + if r.deps.Files == nil { + return nil, notImplementedErr() + } + key, ok := fileLocationKey(req.Location) + if !ok { + return nil, locationInvalidErr() + } + chunk, found, err := r.deps.Files.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: key, + Offset: req.Offset, + Limit: req.Limit, + }) + if err != nil { + return nil, internalErr() + } + if found { + return &tg.UploadFile{ + Type: storageFileType(chunk.MimeType, chunk.Bytes), + Mtime: 0, + Bytes: chunk.Bytes, + }, nil + } + return nil, locationInvalidErr() +} + +// onUploadGetFileHashes 返回空 hash 列表:本阶段不做 CDN/分片完整性校验,客户端据空列表直接信任数据。 +func (r *Router) onUploadGetFileHashes(ctx context.Context, req *tg.UploadGetFileHashesRequest) ([]tg.FileHash, error) { + return []tg.FileHash{}, nil +} + +// fileLocationKey 把 tg.InputFileLocation 推导为 file_blobs 的 location_key。 +// 约定: +// +// doc: 文档主体 +// doc:: 文档缩略图 +// photo:: 照片某尺寸(头像 big→c / small→a) +func fileLocationKey(location tg.InputFileLocationClass) (string, bool) { + switch loc := location.(type) { + case *tg.InputDocumentFileLocation: + if loc.ID == 0 { + return "", false + } + if loc.ThumbSize == "" { + return fmt.Sprintf("doc:%d", loc.ID), true + } + return fmt.Sprintf("doc:%d:%s", loc.ID, loc.ThumbSize), true + case *tg.InputPhotoFileLocation: + if loc.ID == 0 || loc.ThumbSize == "" { + return "", false + } + return fmt.Sprintf("photo:%d:%s", loc.ID, loc.ThumbSize), true + case *tg.InputPeerPhotoFileLocation: + if loc.PhotoID == 0 { + return "", false + } + size := "a" + if loc.Big { + size = "c" + } + return fmt.Sprintf("photo:%d:%s", loc.PhotoID, size), true + default: + // InputFileLocation(legacy volume/local/secret) / InputStickerSetThumb 等本阶段不生成对应资源。 + return "", false + } +} + +// storageFileType 映射 storage.FileType,优先信任字节魔数以兼容历史上写错 mime 的 seed blob。 +func storageFileType(mime string, data []byte) tg.StorageFileTypeClass { + switch sniffImageType(data) { + case "jpeg": + return &tg.StorageFileJpeg{} + case "png": + return &tg.StorageFilePng{} + case "gif": + return &tg.StorageFileGif{} + case "webp": + return &tg.StorageFileWebp{} + } + switch { + case strings.Contains(mime, "webp"): + return &tg.StorageFileWebp{} + case strings.Contains(mime, "jpeg"), strings.Contains(mime, "jpg"): + return &tg.StorageFileJpeg{} + case strings.Contains(mime, "png"): + return &tg.StorageFilePng{} + case strings.Contains(mime, "gif"): + return &tg.StorageFileGif{} + case strings.Contains(mime, "mp4"), strings.Contains(mime, "quicktime"), strings.Contains(mime, "video"): + return &tg.StorageFileMov{} + } + return &tg.StorageFileUnknown{} +} + +// sniffImageType 用魔数探测常见图片类型。 +func sniffImageType(data []byte) string { + if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "jpeg" + } + if len(data) >= 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' { + return "png" + } + if len(data) >= 6 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' { + return "gif" + } + if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' { + return "webp" + } + return "" +} + +// fileSaveErr 把 files 服务的分片错误映射为 rpc_error。 +func fileSaveErr(err error) error { + switch { + case errors.Is(err, domain.ErrFilePartInvalid): + return filePartInvalidErr() + case errors.Is(err, domain.ErrFilePartsInvalid): + return filePartsInvalidErr() + case errors.Is(err, domain.ErrFilePartTooBig): + return filePartTooBigErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/upload_test.go b/internal/rpc/upload_test.go new file mode 100644 index 00000000..52d6c70c --- /dev/null +++ b/internal/rpc/upload_test.go @@ -0,0 +1,34 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +func TestStorageFileTypePrefersMagicOverMime(t *testing.T) { + webp := []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P'} + if _, ok := storageFileType("image/jpeg", webp).(*tg.StorageFileWebp); !ok { + t.Fatalf("webp bytes mislabeled as jpeg should return StorageFileWebp") + } +} + +func TestStorageFileTypeFallsBackToMime(t *testing.T) { + if _, ok := storageFileType("image/png", nil).(*tg.StorageFilePng); !ok { + t.Fatalf("png mime without bytes should return StorageFilePng") + } +} + +func TestFileLocationKeyUsesDocumentID(t *testing.T) { + key, ok := fileLocationKey(&tg.InputDocumentFileLocation{ + ID: 1382305375846410902, + ThumbSize: "m", + }) + if !ok { + t.Fatal("fileLocationKey returned !ok") + } + const want = "doc:1382305375846410902:m" + if key != want { + t.Fatalf("key = %q, want %q", key, want) + } +} diff --git a/internal/rpc/users.go b/internal/rpc/users.go new file mode 100644 index 00000000..8dbbbcc3 --- /dev/null +++ b/internal/rpc/users.go @@ -0,0 +1,187 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/app/users" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +const maxSavedMusicLimit = 100 + +// registerUsers 注册 users.* RPC handler。 +func (r *Router) registerUsers(d *tg.ServerDispatcher) { + d.OnUsersGetUsers(r.onUsersGetUsers) + d.OnUsersGetFullUser(r.onUsersGetFullUser) + d.OnUsersGetSavedMusic(r.onUsersGetSavedMusic) + d.OnUsersGetSavedMusicByID(r.onUsersGetSavedMusicByID) +} + +// onUsersGetUsers 处理 users.getUsers:支持 self 和已知 user peer(含 777000 官方账号)。 +func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) ([]tg.UserClass, error) { + currentUserID, authorized, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + out := make([]tg.UserClass, 0, len(ids)) + 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)) + case *tg.InputUser: + if !authorized { + 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) { + continue + } + out = append(out, r.tgUser(u)) + } + } + return out, nil +} + +func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (*tg.UsersUserFull, error) { + if r.deps.Users == nil { + return emptyUserFull(), nil + } + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + u, found, err := r.userFromInput(ctx, currentUserID, id) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + return emptyUserFull(), nil + } + return nil, internalErr() + } + if !found { + return emptyUserFull(), nil + } + user := r.tgUser(u) + if _, ok := id.(*tg.InputUserSelf); ok { + user = r.tgSelfUser(u) + } + full := tg.UserFull{ + ID: u.ID, + About: u.About, + Settings: tg.PeerSettings{}, + NotifySettings: *tdesktop.NotifySettings(), + } + if r.deps.Channels != nil && u.ID != currentUserID { + common, err := r.deps.Channels.CommonChannels(ctx, currentUserID, domain.CommonChannelsRequest{ + UserID: currentUserID, + TargetUserID: u.ID, + Limit: 1, + CountOnly: true, + }) + if err != nil { + return nil, internalErr() + } + full.CommonChatsCount = common.Count + } + return &tg.UsersUserFull{ + FullUser: full, + Users: []tg.UserClass{user}, + }, 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 { + return nil, err + } + return &tg.UsersSavedMusic{ + Count: 0, + Documents: []tg.DocumentClass{}, + }, nil +} + +func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetSavedMusicByIDRequest) (tg.UsersSavedMusicClass, error) { + if req == nil || len(req.Documents) > maxSavedMusicLimit { + return nil, limitInvalidErr() + } + if err := r.validateInputUser(ctx, req.ID); err != nil { + return nil, err + } + return &tg.UsersSavedMusic{ + Count: 0, + Documents: []tg.DocumentClass{}, + }, nil +} + +func emptyUserFull() *tg.UsersUserFull { + return &tg.UsersUserFull{ + FullUser: tg.UserFull{ + Settings: tg.PeerSettings{}, + NotifySettings: *tdesktop.NotifySettings(), + }, + } +} + +func (r *Router) userFromInput(ctx context.Context, currentUserID int64, id tg.InputUserClass) (domain.User, bool, error) { + switch v := id.(type) { + case *tg.InputUserSelf: + u, err := r.deps.Users.Self(ctx, currentUserID) + return u, err == nil, err + case *tg.InputUser: + u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID) + if err != nil || !found { + return domain.User{}, found, err + } + if v.AccessHash != 0 && v.AccessHash != u.AccessHash { + return domain.User{}, false, nil + } + return u, true, nil + default: + return domain.User{}, false, nil + } +} + +func (r *Router) validateInputUser(ctx context.Context, id tg.InputUserClass) error { + if r.deps.Users == nil { + return nil + } + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return internalErr() + } + _, found, err := r.userFromInput(ctx, currentUserID, id) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + return userIDInvalidErr() + } + return internalErr() + } + if !found { + return userIDInvalidErr() + } + return nil +} diff --git a/internal/store/account.go b/internal/store/account.go new file mode 100644 index 00000000..f339cebe --- /dev/null +++ b/internal/store/account.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// PasswordStore 持久化账号 2FA/SRP 配置。 +type PasswordStore interface { + GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) + Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error +} + +// AccountReactionSettingsStore persists account-level reaction preferences. +type AccountReactionSettingsStore interface { + GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) + SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error +} diff --git a/internal/store/allocator.go b/internal/store/allocator.go new file mode 100644 index 00000000..c15e3f9b --- /dev/null +++ b/internal/store/allocator.go @@ -0,0 +1,26 @@ +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) + CurrentBoxID(ctx context.Context, userID int64) (int, error) +} + +// CounterSource 用于 Redis 计数器冷启动时从 PostgreSQL durable log 恢复当前值。 +type CounterSource interface { + Current(ctx context.Context, userID int64) (int, error) +} diff --git a/internal/store/authkey.go b/internal/store/authkey.go new file mode 100644 index 00000000..3d40979f --- /dev/null +++ b/internal/store/authkey.go @@ -0,0 +1,22 @@ +package store + +import "context" + +// AuthKeyData 是一条持久化的 MTProto auth key 记录。 +// +// 不依赖 td 协议类型:连接层在边界做 crypto.AuthKey ↔ AuthKeyData 转换。 +type AuthKeyData struct { + ID [8]byte // auth_key_id(key 的 SHA1 低 64 位) + Value [256]byte // 2048-bit auth key + ServerSalt int64 // 密钥交换产出的初始 server salt + CreatedAt int64 // unix 秒 + // 用户绑定不在此处:auth_key 是协议产物,授权(auth_key↔user + 设备信息)由 authorization 承载(P2)。 +} + +// AuthKeyStore 持久化 auth key。实现见 store/memory(测试替身)、store/postgres。 +type AuthKeyStore interface { + // Save 保存或覆盖一条 auth key 记录。 + 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) +} diff --git a/internal/store/authorization.go b/internal/store/authorization.go new file mode 100644 index 00000000..e14f1f7f --- /dev/null +++ b/internal/store/authorization.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// AuthorizationStore 持久化设备授权(auth_key ↔ user 绑定)。实现见 store/memory(测试替身)、store/postgres。 +type AuthorizationStore interface { + Bind(ctx context.Context, a domain.Authorization) error + ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) + ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) + Delete(ctx context.Context, authKeyID [8]byte) error +} diff --git a/internal/store/channel.go b/internal/store/channel.go new file mode 100644 index 00000000..f73ab249 --- /dev/null +++ b/internal/store/channel.go @@ -0,0 +1,140 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ChannelStore persists Telegram channels/supergroups and their single-copy messages. +type ChannelStore interface { + CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) + GetChannel(ctx context.Context, viewerUserID, channelID 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) + 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) + EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) + EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (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) + ListAdminedPublicChannels(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) + SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) + SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + 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) + SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) + SetSlowMode(ctx context.Context, userID, channelID int64, seconds 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) + SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) + SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) + SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) + 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) + GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, 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 + 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) + ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) + DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) + 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) + 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) + ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, 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) + GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) + EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) + DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error + DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error + ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) + ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) + PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) + HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) + HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) + ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) + GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) + ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) + ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) + ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) + 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 + 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 + ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, 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) + 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) + ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) + ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) + 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) + 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) + ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, 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) +} + +// ChannelIDAllocator allocates channel IDs. +type ChannelIDAllocator interface { + NextChannelID(ctx context.Context) (int64, error) + 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) + CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) +} diff --git a/internal/store/code.go b/internal/store/code.go new file mode 100644 index 00000000..ff5623d2 --- /dev/null +++ b/internal/store/code.go @@ -0,0 +1,20 @@ +package store + +import ( + "context" + "time" +) + +// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 关联)。 +type PhoneCode struct { + Phone string + Code string +} + +// CodeStore 暂存登录验证码:phone_code_hash → 手机号 + 验证码,带 TTL。 +// 实现见 store/memory(测试替身)、store/redisstore。 +type CodeStore interface { + Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error + Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error) + Del(ctx context.Context, phoneCodeHash string) error +} diff --git a/internal/store/contact.go b/internal/store/contact.go new file mode 100644 index 00000000..71e06c04 --- /dev/null +++ b/internal/store/contact.go @@ -0,0 +1,17 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ContactStore 持久化用户通讯录。 +type ContactStore interface { + ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) + Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) + 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) + Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) +} diff --git a/internal/store/dialog.go b/internal/store/dialog.go new file mode 100644 index 00000000..3872cf3b --- /dev/null +++ b/internal/store/dialog.go @@ -0,0 +1,32 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// DialogStore 持久化用户会话摘要。 +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 + SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) 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 + SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) + ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, 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) + GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) + UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error + DeleteFolder(ctx context.Context, userID int64, folderID int) error + 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 +} diff --git a/internal/store/dispatch_outbox.go b/internal/store/dispatch_outbox.go new file mode 100644 index 00000000..6f71cbdf --- /dev/null +++ b/internal/store/dispatch_outbox.go @@ -0,0 +1,27 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// DispatchOutboxItem 是待投递给在线 session 的 update 任务。 +type DispatchOutboxItem struct { + ID int64 + TargetUserID int64 + Pts int + EventType domain.UpdateEventType + ExcludeAuthKeyID [8]byte + ExcludeSessionID int64 + Attempts int +} + +// DispatchOutboxStore 持久化 transactional outbox。 +type DispatchOutboxStore interface { + ClaimPending(ctx context.Context, limit int) ([]DispatchOutboxItem, error) + MarkDelivered(ctx context.Context, targetUserID, id int64) error + MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error + DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) +} diff --git a/internal/store/doc.go b/internal/store/doc.go new file mode 100644 index 00000000..dbb9c1fe --- /dev/null +++ b/internal/store/doc.go @@ -0,0 +1,13 @@ +// Package store 定义存储接口与协议层 DTO,不含具体实现。 +// +// 布局:主包只放接口(AuthKeyStore / SessionStore / UserStore / AuthorizationStore / +// CodeStore / UpdateStateStore / UpdateEventStore 等)与协议 DTO;三种后端实现各自独立成对称子包: +// - store/memory —— 内存实现,测试替身与本地兜底 +// - store/postgres —— PostgreSQL(pgx + sqlc 生成查询 + golang-migrate 迁移) +// - store/redisstore —— Redis(go-redis) +// +// 类型边界:接口签名分两类—— +// - 协议产物用 store 自有 DTO:AuthKeyData、SessionData、PhoneCode(不依赖 tg.*,也非业务实体); +// - 业务实体直接用 domain:UserStore / AuthorizationStore / MessageStore / UpdateEventStore +// 收发 domain.User / domain.Authorization / domain.Message / domain.UpdateEvent。 +package store diff --git a/internal/store/help.go b/internal/store/help.go new file mode 100644 index 00000000..1684739c --- /dev/null +++ b/internal/store/help.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// AppConfigStore 持久化 help.getAppConfig 数据。 +type AppConfigStore interface { + GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error) + UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error +} + +// CountryStore 持久化 help.getCountriesList 数据。 +type CountryStore interface { + ListCountries(ctx context.Context, langCode string) (domain.CountriesList, error) + UpsertCountries(ctx context.Context, countries []domain.Country) error +} diff --git a/internal/store/langpack.go b/internal/store/langpack.go new file mode 100644 index 00000000..db5c61e0 --- /dev/null +++ b/internal/store/langpack.go @@ -0,0 +1,14 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// LangPackStore 持久化客户端语言包。 +type LangPackStore interface { + GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) + GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) + UpsertPack(ctx context.Context, pack domain.LangPack) error +} diff --git a/internal/store/media.go b/internal/store/media.go new file mode 100644 index 00000000..ef299a0c --- /dev/null +++ b/internal/store/media.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// MediaStore 持久化媒体元数据:上传分片、blob 索引、文档/照片注册表、 +// 贴纸集、可用 reaction、头像历史。blob 字节本身由 blob backend 按 object_key 读写, +// 本接口只管 file_blobs 索引行。 +type MediaStore interface { + // 上传分片(transient:组装成 blob 后即清理)。 + SaveFilePart(ctx context.Context, part domain.UploadPart) error + LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) + DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error + + // blob 索引。 + PutFileBlob(ctx context.Context, blob domain.FileBlob) error + GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) + + // 文档 / 照片注册表。 + PutDocument(ctx context.Context, doc domain.Document) error + GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) + GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) + PutPhoto(ctx context.Context, photo domain.Photo) error + GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) + + // 贴纸集 / 可用 reaction。 + PutStickerSet(ctx context.Context, set domain.StickerSet) error + GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) + GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) + GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) + ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) + CountStickerSets(ctx context.Context) (int, error) + PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error + ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) + 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 + CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) + CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (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) + DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) +} diff --git a/internal/store/memory/channel.go b/internal/store/memory/channel.go new file mode 100644 index 00000000..0be16f9b --- /dev/null +++ b/internal/store/memory/channel.go @@ -0,0 +1,6799 @@ +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 := s.appendParticipantEventLocked(channel, req.UserID, previous, member, req.Date) + channel = s.channels[req.ChannelID] + 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 := s.appendParticipantEventLocked(channel, req.UserID, previous, member, req.Date) + channel = s.channels[req.ChannelID] + 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 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 { + out = append(out, cloneChannelMessage(msg)) + } + } + 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 + } + 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) 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)) + } + 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)) + } + } + 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) 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) appendParticipantEventLocked(channel domain.Channel, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { + pts := s.nextChannelPtsLocked(channel.ID) + channel.Pts = pts + s.channels[channel.ID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateParticipant, + Pts: pts, + PtsCount: 1, + Date: date, + SenderUserID: actorUserID, + UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), + Previous: previous, + Participant: participant, + } + s.events[channel.ID] = append(s.events[channel.ID], event) + return cloneChannelEvent(event) +} + +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 (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 + } + 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 + } + 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) + } +} + +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_test.go b/internal/store/memory/channel_test.go new file mode 100644 index 00000000..7e2fae39 --- /dev/null +++ b/internal/store/memory/channel_test.go @@ -0,0 +1,714 @@ +package memory + +import ( + "context" + "errors" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelRealtimeRecipientsAreCapped(t *testing.T) { + store := NewChannelStore() + memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25) + for i := range memberIDs { + memberIDs[i] = int64(10_000 + i) + } + + created, err := store.CreateChannel(context.Background(), domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "large realtime cap", + Megagroup: true, + MemberUserIDs: memberIDs, + Date: 1_700_000_100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if got := len(created.Recipients); got != domain.MaxChannelRealtimeFanout { + t.Fatalf("create recipients = %d, want capped %d", got, domain.MaxChannelRealtimeFanout) + } + + recipients, err := store.ListActiveChannelMemberIDs(context.Background(), 1, created.Channel.ID, 0) + if err != nil { + t.Fatalf("list active members: %v", err) + } + if got := len(recipients); got != domain.MaxChannelRealtimeFanout { + t.Fatalf("listed active members = %d, want capped %d", got, domain.MaxChannelRealtimeFanout) + } +} + +func TestPendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "pending join requests", + Megagroup: true, + MemberUserIDs: []int64{2, 3, 4}, + Date: 1_700_000_150, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID := created.Channel.ID + if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: 1, + ChannelID: channelID, + MemberID: 2, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 1_700_000_151, + }); err != nil { + t.Fatalf("promote invite admin: %v", err) + } + if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: 1, + ChannelID: channelID, + MemberID: 4, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 1_700_000_152, + }); err != nil { + t.Fatalf("promote change-info admin: %v", err) + } + invite, err := store.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: 1, + ChannelID: channelID, + Title: "approval", + RequestNeeded: true, + Date: 1_700_000_153, + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ { + _, err := store.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: int64(10 + i), + Hash: invite.Invite.Hash, + Date: 1_700_000_160 + i, + }) + if !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err) + } + } + pending, err := store.PendingJoinRequests(ctx, channelID, 99) + if err != nil { + t.Fatalf("pending join requests: %v", err) + } + if pending.Count != domain.MaxChannelPendingJoinRecentRequesters+2 || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters { + t.Fatalf("pending summary = %+v, want bounded recent with full count", pending) + } + if pending.RecentRequesters[0] != 16 || pending.RecentRequesters[len(pending.RecentRequesters)-1] != 12 { + t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters) + } + admins, err := store.ListChannelInviteAdminMemberIDs(ctx, channelID, 0) + if err != nil { + t.Fatalf("invite admins: %v", err) + } + want := []int64{1, 2, 4} + if !reflect.DeepEqual(admins, want) { + t.Fatalf("invite admins = %+v, want %+v", admins, want) + } +} + +func TestCommonChannelsOnlySharedMegagroups(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + first, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "common one", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_170, + }) + if err != nil { + t.Fatalf("create first common channel: %v", err) + } + second, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "common two", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_171, + }) + if err != nil { + t.Fatalf("create second common channel: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast excluded", + Broadcast: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_172, + }); err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + left, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "left excluded", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_173, + }) + if err != nil { + t.Fatalf("create left channel: %v", err) + } + if _, err := store.LeaveChannel(ctx, left.Channel.ID, 2, 1_700_000_174); err != nil { + t.Fatalf("leave channel: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "not shared", + Megagroup: true, + MemberUserIDs: []int64{3}, + Date: 1_700_000_175, + }); err != nil { + t.Fatalf("create non-shared channel: %v", err) + } + + page, err := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: 1, + TargetUserID: 2, + 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 := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: 1, + TargetUserID: 2, + 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 := store.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: 1, + TargetUserID: 2, + 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 TestLeftChannelsReturnsPagedLeftMemberships(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + older, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "older left", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_180, + }) + if err != nil { + t.Fatalf("create older channel: %v", err) + } + newer, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "newer left broadcast", + Broadcast: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_181, + }) + if err != nil { + t.Fatalf("create newer channel: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "active excluded", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_182, + }); err != nil { + t.Fatalf("create active channel: %v", err) + } + if _, err := store.LeaveChannel(ctx, older.Channel.ID, 2, 1_700_000_183); err != nil { + t.Fatalf("leave older channel: %v", err) + } + if _, err := store.LeaveChannel(ctx, newer.Channel.ID, 2, 1_700_000_184); err != nil { + t.Fatalf("leave newer channel: %v", err) + } + + page, err := store.ListLeftChannels(ctx, 2, 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 := store.ListLeftChannels(ctx, 2, 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 := store.ListLeftChannels(ctx, 2, 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 := store.ListLeftChannels(ctx, 2, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err) + } +} + +func TestDiscussionGroupLinksAreBidirectionalAndReplaceOldLinks(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast", + Broadcast: true, + Date: 1_700_000_190, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + firstGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "first group", + Megagroup: true, + Date: 1_700_000_191, + }) + if err != nil { + t.Fatalf("create first group: %v", err) + } + secondGroup, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "second group", + Megagroup: true, + Date: 1_700_000_192, + }) + if err != nil { + t.Fatalf("create second group: %v", err) + } + if _, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast excluded", + Broadcast: true, + Date: 1_700_000_193, + }); err != nil { + t.Fatalf("create excluded broadcast: %v", err) + } + + candidates, err := store.ListDiscussionGroups(ctx, 1, 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 candidates = %+v, want creator megagroups newest id first", candidates) + } + + linked, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("link first group: %v", err) + } + if len(linked.Channels) != 2 { + t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels) + } + gotBroadcast, err := store.GetChannelByID(ctx, broadcast.Channel.ID) + if err != nil { + t.Fatalf("get linked broadcast: %v", err) + } + gotFirst, err := store.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 %+v group %+v, want bidirectional ids", gotBroadcast, gotFirst) + } + + replaced, err := store.SetDiscussionGroup(ctx, 1, 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, _ = store.GetChannelByID(ctx, broadcast.Channel.ID) + gotFirst, _ = store.GetChannelByID(ctx, firstGroup.Channel.ID) + gotSecond, err := store.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) + } + + unlinked, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("unlink from group side: %v", err) + } + if len(unlinked.Channels) != 2 { + t.Fatalf("unlink changed channels = %+v, want broadcast and group", unlinked.Channels) + } + gotBroadcast, _ = store.GetChannelByID(ctx, broadcast.Channel.ID) + gotSecond, _ = store.GetChannelByID(ctx, secondGroup.Channel.ID) + if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 { + t.Fatalf("unlink = broadcast %d second %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID) + } + if _, err := store.SetDiscussionGroup(ctx, 1, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) { + t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err) + } + if _, err := store.SetPreHistoryHidden(ctx, 1, firstGroup.Channel.ID, true); err != nil { + t.Fatalf("hide first group prehistory: %v", err) + } + if _, err := store.SetDiscussionGroup(ctx, 1, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) { + t.Fatalf("hidden group link err = %v, want ErrMegagroupPrehistoryHidden", err) + } +} + +func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "bounded delete history", + Megagroup: true, + Date: 1_700_000_200, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + + totalMessages := domain.MaxDeleteHistoryBatch + 2 + for i := 0; i < totalMessages; i++ { + if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: int64(10_000 + i), + Message: "bulk", + Date: 1_700_000_201 + i, + }); err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + } + + first, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1_700_001_300, + }) + if err != nil { + t.Fatalf("delete first batch: %v", err) + } + if first.Offset != 1 || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch || first.Event.PtsCount != domain.MaxDeleteHistoryBatch { + t.Fatalf("first batch = %+v, want capped page with offset", first) + } + + second, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1_700_001_301, + }) + 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) + } +} + +func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "monotonic local clear", + Megagroup: true, + Date: 1_700_000_250, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + first, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 30_001, + Message: "first visible", + Date: 1_700_000_251, + }) + if err != nil { + t.Fatalf("send first message: %v", err) + } + second, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 30_002, + Message: "second visible", + Date: 1_700_000_252, + }) + if err != nil { + t.Fatalf("send second message: %v", err) + } + + high, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: second.Message.ID, + Date: 1_700_000_253, + }) + if err != nil { + t.Fatalf("clear high watermark: %v", err) + } + if high.AvailableMinID != second.Message.ID { + t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID) + } + + stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: first.Message.ID, + Date: 1_700_000_254, + }) + if err != nil { + t.Fatalf("clear stale low watermark: %v", err) + } + if stale.AvailableMinID != second.Message.ID { + t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID) + } + + 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 { + t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages) + } + dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get channel dialog: %v", err) + } + if len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) + } + if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 { + t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID) + } +} + +func TestChannelListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "single copy dialog top", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: 2, + ChannelID: created.Channel.ID, + MaxID: created.Message.ID, + Date: 1_700_000_301, + }); err != nil { + t.Fatalf("read initial service message: %v", err) + } + + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 88, + Message: "visible without write fanout", + Date: 1_700_000_302, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + list, err := store.ListChannelDialogs(ctx, 2, 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 TestChannelUnreadExcludesOwnOutgoing(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "own outgoing unread", + Megagroup: true, + Date: 1_700_000_360, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 36_001, + Message: "own outgoing only", + Date: 1_700_000_361, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + store.mu.Lock() + member := store.members[created.Channel.ID][1] + member.ReadInboxMaxID = sent.Message.ID - 1 + store.members[created.Channel.ID][1] = member + dialog := store.dialogs[1][created.Channel.ID] + dialog.ReadInboxMaxID = sent.Message.ID - 1 + dialog.UnreadCount = 99 + store.dialogs[1][created.Channel.ID] = dialog + store.mu.Unlock() + + dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 0 { + t.Fatalf("dialogs = %+v, want own outgoing excluded from unread", dialogs.Dialogs) + } + read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 1_700_000_362, + }) + if err != nil { + t.Fatalf("read channel history: %v", err) + } + if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 { + t.Fatalf("read result = %+v, want no own-outgoing unread", read) + } +} + +func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "visible unread reaction", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 40_001, + Message: "react to this", + Date: 1_700_000_401, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: 2, + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f525", + }}, + Date: 1_700_000_402, + }); err != nil { + t.Fatalf("set channel reaction: %v", err) + } + 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 != 1 { + t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs) + } + unread, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions: %v", err) + } + if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID { + t.Fatalf("unread reactions = %+v, want sent message", unread.Messages) + } + if unread.Messages[0].Reactions == nil || !hasUnreadChannelReaction(*unread.Messages[0].Reactions) { + t.Fatalf("unread message reactions = %+v, want unread recent reaction", unread.Messages[0].Reactions) + } + + read, err := store.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + 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 || hasUnreadChannelReaction(*read.Messages[0].Reactions) { + t.Fatalf("read messages = %+v, want reaction returned as read", read.Messages) + } + unreadAfter, err := store.ListChannelUnreadReactions(ctx, 1, domain.ChannelUnreadReactionsFilter{ + ChannelID: created.Channel.ID, + 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 := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get dialogs after read contents: %v", err) + } + if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs) + } +} + +func hasUnreadChannelReaction(reactions domain.ChannelMessageReactions) bool { + for _, recent := range reactions.Recent { + if recent.Unread { + return true + } + } + return false +} diff --git a/internal/store/memory/dialog_test.go b/internal/store/memory/dialog_test.go new file mode 100644 index 00000000..72629375 --- /dev/null +++ b/internal/store/memory/dialog_test.go @@ -0,0 +1,229 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestDialogStoreFiltersAndPaginates(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + list := domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000, Pinned: true}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800}, + }, + Messages: []domain.Message{ + {ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "pinned"}, + {ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "first"}, + {ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "second"}, + }, + } + if err := store.SaveList(ctx, userID, list); err != nil { + t.Fatalf("SaveList: %v", err) + } + + first, err := store.ListByUser(ctx, userID, domain.DialogFilter{ExcludePinned: true, Limit: 1}) + if err != nil { + t.Fatalf("ListByUser first page: %v", err) + } + if first.Count != 2 || len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != 2 || len(first.Messages) != 1 || first.Messages[0].ID != 9 { + t.Fatalf("first page = %+v, want peer 2 with count 2 and top message", first) + } + + next, err := store.ListByUser(ctx, userID, domain.DialogFilter{ + ExcludePinned: true, + OffsetDate: first.Dialogs[0].TopMessageDate, + OffsetID: first.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: first.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListByUser next page: %v", err) + } + if next.Count != 2 || len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != 3 || len(next.Messages) != 1 || next.Messages[0].ID != 8 { + t.Fatalf("next page = %+v, want peer 3 with count 2 and top message", next) + } +} + +func TestDialogStoreFoldersAndCustomFilters(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + contactPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1} + archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2} + strangerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 3} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: contactPeer, TopMessage: 10, TopMessageDate: 1000}, + {Peer: archivedPeer, TopMessage: 9, TopMessageDate: 900, FolderID: domain.DialogArchiveFolderID}, + {Peer: strangerPeer, TopMessage: 8, TopMessageDate: 800, UnreadCount: 1}, + }, + Users: []domain.User{ + {ID: contactPeer.ID, Contact: true}, + {ID: archivedPeer.ID, Contact: true}, + {ID: strangerPeer.ID}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + main, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser main: %v", err) + } + if len(main.Dialogs) != 2 || main.Dialogs[0].Peer != contactPeer || main.Dialogs[1].Peer != strangerPeer { + t.Fatalf("main dialogs = %+v, want non-archived dialogs", main.Dialogs) + } + archive, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser archive: %v", err) + } + if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer != archivedPeer { + t.Fatalf("archive dialogs = %+v, want archived peer", archive.Dialogs) + } + + folder := domain.DialogFolder{ + ID: 2, + Contacts: true, + ExcludeArchived: true, + IncludePeers: []domain.DialogFolderPeer{{Peer: strangerPeer}}, + } + if err := store.UpsertFolder(ctx, userID, folder); err != nil { + t.Fatalf("UpsertFolder: %v", err) + } + custom, err := store.ListByUser(ctx, userID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser custom: %v", err) + } + if len(custom.Dialogs) != 2 || custom.Dialogs[0].Peer != contactPeer || custom.Dialogs[1].Peer != strangerPeer { + t.Fatalf("custom dialogs = %+v, want contact plus explicit stranger excluding archived", custom.Dialogs) + } +} + +func TestUserStoreStartsAtTimestampBase(t *testing.T) { + ctx := context.Background() + store := NewUserStore() + u, err := store.Create(ctx, domain.User{ + AccessHash: 1, + Phone: "15550000001", + FirstName: "Test", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if u.ID != domain.UserIDSequenceBase { + t.Fatalf("user id = %d, want base %d", u.ID, domain.UserIDSequenceBase) + } +} + +func TestDialogStoreOffsetDateOnlyKeepsEnterpriseCountAndHash(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, TopMessage: 8, TopMessageDate: 800}, + }, + Messages: []domain.Message{ + {ID: 10, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Body: "first"}, + {ID: 9, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, Body: "second"}, + {ID: 8, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3}, Body: "third"}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser all: %v", err) + } + page, err := store.ListByUser(ctx, userID, domain.DialogFilter{OffsetDate: 900, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser offset date: %v", err) + } + if page.Count != 3 || page.Hash != all.Hash { + t.Fatalf("page summary = count %d hash %d, want full count/hash %d/%d", page.Count, page.Hash, all.Count, all.Hash) + } + if len(page.Dialogs) != 1 || page.Dialogs[0].Peer.ID != 3 || len(page.Messages) != 1 || page.Messages[0].ID != 8 { + t.Fatalf("page = %+v, want only dialog after offset date", page) + } +} + +func TestDialogStoreEmptyPageKeepsEnterpriseCountAndHash(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, TopMessage: 10, TopMessageDate: 1000}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, TopMessage: 9, TopMessageDate: 900}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + all, err := store.ListByUser(ctx, userID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser all: %v", err) + } + empty, err := store.ListByUser(ctx, userID, domain.DialogFilter{ + OffsetDate: 900, + OffsetID: 9, + HasOffsetPeer: true, + OffsetPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, + Limit: 10, + }) + if err != nil { + t.Fatalf("ListByUser empty page: %v", err) + } + if empty.Count != 2 || empty.Hash != all.Hash || len(empty.Dialogs) != 0 { + t.Fatalf("empty page = %+v, want no page rows but full count/hash", empty) + } +} + +func TestDialogStoreListByPeersReturnsExistingAndPlaceholders(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + official := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + missing := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: official, TopMessage: 10, TopMessageDate: 1000, UnreadCount: 1}, + }, + Messages: []domain.Message{ + {ID: 10, Peer: official, From: official, Body: "login"}, + }, + Users: []domain.User{domain.OfficialSystemUser()}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + got, err := store.ListByPeers(ctx, userID, []domain.Peer{official, missing, official}) + if err != nil { + t.Fatalf("ListByPeers: %v", err) + } + if got.Count != 2 || len(got.Dialogs) != 2 { + t.Fatalf("dialogs = %+v, want existing official and missing placeholder", got) + } + if got.Dialogs[0].Peer != official || got.Dialogs[0].TopMessage != 10 { + t.Fatalf("first dialog = %+v, want official top message", got.Dialogs[0]) + } + if got.Dialogs[1].Peer != missing || got.Dialogs[1].TopMessage != 0 { + t.Fatalf("second dialog = %+v, want missing placeholder", got.Dialogs[1]) + } + if len(got.Messages) != 1 || got.Messages[0].ID != 10 { + t.Fatalf("messages = %+v, want only official top message", got.Messages) + } + if len(got.Users) != 1 || got.Users[0].ID != domain.OfficialSystemUserID { + t.Fatalf("users = %+v, want official user", got.Users) + } +} diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go new file mode 100644 index 00000000..28860be1 --- /dev/null +++ b/internal/store/memory/memory.go @@ -0,0 +1,2883 @@ +// 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 +} + +// NewContactStore 创建内存 ContactStore。 +func NewContactStore() *ContactStore { + return &ContactStore{m: make(map[int64]domain.ContactList)} +} + +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) 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) 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 +} + +// 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 := sender + if req.SenderUserID != req.RecipientUserID { + 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) + } + s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender) + if req.SenderUserID != req.RecipientUserID { + s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient) + } + if s.dialogs != nil { + s.upsertMemoryDialogsLocked(sender, recipient) + } + 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, + }) + 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 + } + s.mu.RLock() + for _, msg := range s.m[req.OwnerUserID] { + if _, ok := wanted[msg.ID]; ok { + res.MessageIDs = append(res.MessageIDs, msg.ID) + } + } + s.mu.RUnlock() + sort.Ints(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 + } + 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) 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() + settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) + return settings, ok, nil +} + +func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error { + settings.SecureRandom = append([]byte(nil), settings.SecureRandom...) + s.mu.Lock() + s.m[userID] = settings + s.mu.Unlock() + return nil +} + +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 { + s.mu.Lock() + 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 +} + +// 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_test.go b/internal/store/memory/message_test.go new file mode 100644 index 00000000..37dd776c --- /dev/null +++ b/internal/store/memory/message_test.go @@ -0,0 +1,531 @@ +package memory + +import ( + "context" + "errors" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + req := domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 99, + Message: "hello", + Date: 1700000100, + } + + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if got.SenderMessage.ID != 1 || got.SenderMessage.OwnerUserID != req.SenderUserID || !got.SenderMessage.Out || got.SenderMessage.Pts != 1 { + t.Fatalf("sender message = %+v, want first outgoing box with pts=1", got.SenderMessage) + } + if got.RecipientMessage.ID != 1 || got.RecipientMessage.OwnerUserID != req.RecipientUserID || got.RecipientMessage.Out || got.RecipientMessage.Pts != 1 { + t.Fatalf("recipient message = %+v, want first incoming box with pts=1", 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) + } + + second, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.SenderUserID, + RecipientUserID: req.RecipientUserID, + RandomID: 100, + Message: "again", + Date: 1700000110, + }) + if err != nil { + t.Fatalf("SendPrivateText second: %v", err) + } + if second.SenderMessage.ID != 2 || second.SenderMessage.Pts != 2 || second.RecipientMessage.ID != 2 || second.RecipientMessage.Pts != 2 { + t.Fatalf("second send = %+v/%+v, want per-owner box_id and pts to advance", second.SenderMessage, second.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) + } + + 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) + } + recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 2 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages)) + } +} + +func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + aliceID := int64(1000000001) + bobID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 101, + Message: "react to me", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + res, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{ + UserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + MessageID: sent.RecipientMessage.ID, + Reactions: []domain.MessageReaction{ + reaction, + }, + Big: true, + Date: 1700000200, + }) + 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)) + } + + aliceReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ + OwnerUserID: aliceID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + IDs: []int{sent.SenderMessage.ID}, + }) + if err != nil { + t.Fatalf("alice GetMessageReactions: %v", err) + } + if len(aliceReactions.Messages) != 1 || aliceReactions.Messages[0].Reactions == nil { + t.Fatalf("alice reactions = %+v, want one enriched message", aliceReactions) + } + 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 { + t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got) + } + + bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ + OwnerUserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + IDs: []int{sent.RecipientMessage.ID}, + }) + if err != nil { + t.Fatalf("bob GetMessageReactions: %v", err) + } + 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 { + t.Fatalf("bob recent reactions = %+v, want my reaction", got) + } +} + +func TestMessageStoreSendPrivateTextReplyAndForwardMetadata(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + aliceID := int64(1000000001) + bobID := int64(1000000002) + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 501, + Message: "first", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("seed SendPrivateText: %v", err) + } + reply, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 502, + Message: "reply", + Silent: true, + NoForwards: true, + ReplyTo: &domain.MessageReply{ + MessageID: first.SenderMessage.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + QuoteText: "fir", + QuoteOffset: 0, + }, + Date: 1700000110, + }) + if err != nil { + t.Fatalf("reply SendPrivateText: %v", err) + } + if reply.SenderMessage.ReplyTo == nil || reply.SenderMessage.ReplyTo.MessageID != first.SenderMessage.ID { + t.Fatalf("sender reply = %+v, want sender-side message id", reply.SenderMessage.ReplyTo) + } + if reply.RecipientMessage.ReplyTo == nil || reply.RecipientMessage.ReplyTo.MessageID != first.RecipientMessage.ID { + t.Fatalf("recipient reply = %+v, want translated recipient-side message id", reply.RecipientMessage.ReplyTo) + } + if !reply.SenderMessage.Silent || !reply.SenderMessage.NoForwards { + t.Fatalf("reply flags = silent %v noforwards %v, want true/true", reply.SenderMessage.Silent, reply.SenderMessage.NoForwards) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 504, + Message: "bad quote offset", + ReplyTo: &domain.MessageReply{ + MessageID: first.SenderMessage.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + QuoteText: "fir", + QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, + }, + Date: 1700000115, + }); !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) + } + + forwarded, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: aliceID, + FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + ToUserID: bobID, + MessageIDs: []int{first.SenderMessage.ID}, + RandomIDs: []int64{503}, + ReplyTo: &domain.MessageReply{ + MessageID: first.SenderMessage.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + }, + Date: 1700000120, + }) + if err != nil { + t.Fatalf("ForwardPrivateMessages: %v", err) + } + if len(forwarded.SenderMessages) != 1 || forwarded.SenderMessages[0].Forward == nil || forwarded.SenderMessages[0].Forward.From.ID != aliceID { + t.Fatalf("forwarded messages = %+v, want original author header", forwarded.SenderMessages) + } + if forwarded.SenderMessages[0].ReplyTo == nil || forwarded.SenderMessages[0].ReplyTo.MessageID != first.SenderMessage.ID { + t.Fatalf("forward reply = %+v, want target dialog reply header", forwarded.SenderMessages[0].ReplyTo) + } + if _, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: aliceID, + FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + ToUserID: aliceID, + MessageIDs: []int{reply.SenderMessage.ID}, + RandomIDs: []int64{504}, + Date: 1700000130, + }); err != domain.ErrChatForwardsRestricted { + t.Fatalf("forward protected err=%v, want ErrChatForwardsRestricted", err) + } +} + +func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + aliceID := int64(1000000001) + bobID := int64(1000000002) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: aliceID} + + for i := 1; i <= 6; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: int64(600 + i), + Message: "history", + Date: 1700000000 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + + around, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 6, + }) + 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) + } + + forward, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 3, + }) + 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) + } + + hugePositive, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + AddOffset: 1 << 30, + Limit: 3, + }) + if err != nil { + t.Fatalf("huge positive add_offset history: %v", err) + } + if len(hugePositive.Messages) != 0 { + t.Fatalf("huge positive add_offset ids = %v, want bounded empty page", messageIDs(hugePositive.Messages)) + } + + hugeNegative, err := messages.ListByUser(ctx, bobID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -1 << 30, + Limit: 3, + }) + if err != nil { + t.Fatalf("huge negative add_offset history: %v", err) + } + if got := messageIDs(hugeNegative.Messages); !sameInts(got, []int{6, 5, 4}) { + t.Fatalf("huge negative add_offset ids = %v, want clamped forward page", got) + } +} + +func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 101, + Message: "hello", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipientID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, + Date: 1700000200, + }) + if err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if !read.Changed || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.Pts != 2 || read.InboxEvent.MaxID != sent.RecipientMessage.ID { + t.Fatalf("inbox read = %+v, want recipient inbox pts=2 max recipient id", read) + } + if !read.OutboxChanged || read.OutboxUserID != senderID || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID { + t.Fatalf("outbox read = %+v, want sender outbox receipt with sender message id", read) + } + date, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{ + OwnerUserID: senderID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, + ID: sent.SenderMessage.ID, + }) + if err != nil || date != 1700000200 { + t.Fatalf("outbox read date = %d err=%v, want read date", date, err) + } +} + +func TestMessageStoreReadMessageContentsReturnsExistingOwnerIDs(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1001, + RecipientUserID: 1002, + RandomID: 88, + Message: "voice placeholder", + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + got, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: 1002, + IDs: []int{sent.RecipientMessage.ID, domain.MaxMessageBoxID}, + }) + if err != nil { + t.Fatalf("ReadMessageContents: %v", err) + } + if !reflect.DeepEqual(got.MessageIDs, []int{sent.RecipientMessage.ID}) { + t.Fatalf("MessageIDs = %v, want existing recipient id", got.MessageIDs) + } + if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: 1002, + IDs: []int{0}, + }); !errors.Is(err, domain.ErrMessageIDInvalid) { + t.Fatalf("invalid id error = %v, want ErrMessageIDInvalid", err) + } +} + +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 +} + +func TestMessageStoreEditMessageUpdatesBothBoxes(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 102, + Message: "before", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: senderID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, + ID: sent.SenderMessage.ID, + Message: "after", + EditDate: 1700000200, + }) + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if len(edited.Edited) != 2 || edited.Self().Message.Body != "after" || edited.Self().Event.Type != domain.UpdateEventEditMessage { + t.Fatalf("edited = %+v, want both owner boxes and self edit event", edited) + } + recipientHistory, 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(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after" || recipientHistory.Messages[0].EditDate != 1700000200 { + t.Fatalf("recipient history = %+v, want edited body/date", recipientHistory.Messages) + } +} + +func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipientID} + + for i := 0; i < 2; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: int64(100 + i), + Message: "hello", + Date: 1700000200 + i, + }); err != nil { + t.Fatalf("seed send %d: %v", i, err) + } + } + deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: senderID, + Peer: peer, + Date: 1700000300, + }) + 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", self) + } + senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, 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(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 := dialogs.ListByUser(ctx, senderID, 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 dialog deleted after full history delete", senderDialogs.Dialogs) + } + + rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 200, + Message: "rebuilt", + Date: 1700000400, + }) + if err != nil { + t.Fatalf("send after delete: %v", err) + } + senderDialogs, err = dialogs.ListByUser(ctx, senderID, 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 one dialog with new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID) + } + + preservedOwner := int64(1000000003) + preservedPeerID := int64(1000000004) + preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID} + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: preservedOwner, + RecipientUserID: preservedPeerID, + RandomID: 300, + Message: "clear but keep dialog", + Date: 1700000500, + }); err != nil { + t.Fatalf("seed preserved send: %v", err) + } + if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: preservedOwner, + Peer: preservedPeer, + JustClear: true, + Date: 1700000600, + }); err != nil { + t.Fatalf("DeleteHistory just_clear: %v", err) + } + preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("preserved dialogs: %v", err) + } + if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 { + t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages) + } +} diff --git a/internal/store/message.go b/internal/store/message.go new file mode 100644 index 00000000..ea842f8e --- /dev/null +++ b/internal/store/message.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// MessageStore 持久化账号视角下的消息。 +type MessageStore interface { + Create(ctx context.Context, msg domain.Message) (domain.Message, error) + SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) + ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) + ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) + ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) + 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) + EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, 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) +} diff --git a/internal/store/postgres/account.go b/internal/store/postgres/account.go new file mode 100644 index 00000000..33b51784 --- /dev/null +++ b/internal/store/postgres/account.go @@ -0,0 +1,126 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// PasswordStore 用 PostgreSQL 实现 store.PasswordStore。 +type PasswordStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewPasswordStore 基于 pgx 连接池(或事务)创建 PasswordStore。 +func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore { + return &PasswordStore{db: db, q: sqlcgen.New(db)} +} + +func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) { + row, err := s.q.GetPasswordByUser(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PasswordSettings{}, false, nil + } + return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err) + } + return domain.PasswordSettings{ + HasRecovery: row.HasRecovery, + HasSecureValues: row.HasSecureValues, + HasPassword: row.HasPassword, + Hint: row.Hint, + EmailUnconfirmedPattern: row.EmailUnconfirmedPattern, + LoginEmailPattern: row.LoginEmailPattern, + SecureRandom: append([]byte(nil), row.SecureRandom...), + }, true, nil +} + +func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error { + if err := s.q.UpsertPassword(ctx, sqlcgen.UpsertPasswordParams{ + UserID: userID, + HasRecovery: settings.HasRecovery, + HasSecureValues: settings.HasSecureValues, + HasPassword: settings.HasPassword, + Hint: settings.Hint, + EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, + LoginEmailPattern: settings.LoginEmailPattern, + SecureRandom: settings.SecureRandom, + }); err != nil { + return fmt.Errorf("upsert account password: %w", err) + } + return nil +} + +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, + default_reaction_type, default_reaction_value, + paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id +FROM account_reaction_settings +WHERE user_id = $1`, userID) + var messagesFrom, storiesFrom, pollVotesFrom string + var defaultType, defaultValue string + var paidKind string + var paidPeerType sql.NullString + var paidPeerID sql.NullInt64 + settings := domain.DefaultAccountReactionSettings() + if err := row.Scan( + &messagesFrom, &storiesFrom, &pollVotesFrom, &settings.Notify.ShowPreviews, + &defaultType, &defaultValue, &paidKind, &paidPeerType, &paidPeerID, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountReactionSettings{}, false, nil + } + return domain.AccountReactionSettings{}, false, fmt.Errorf("get account reaction settings: %w", err) + } + 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} + 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} + settings.PaidPrivacy.Peer = &peer + } + return settings, true, nil +} + +func (s *PasswordStore) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error { + var paidPeerType any + var paidPeerID any + if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && settings.PaidPrivacy.Peer != nil { + paidPeerType = string(settings.PaidPrivacy.Peer.Type) + paidPeerID = settings.PaidPrivacy.Peer.ID + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO account_reaction_settings ( + user_id, messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews, + default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +ON CONFLICT (user_id) DO UPDATE SET + messages_notify_from = EXCLUDED.messages_notify_from, + stories_notify_from = EXCLUDED.stories_notify_from, + poll_votes_notify_from = EXCLUDED.poll_votes_notify_from, + show_previews = EXCLUDED.show_previews, + default_reaction_type = EXCLUDED.default_reaction_type, + default_reaction_value = EXCLUDED.default_reaction_value, + paid_privacy_kind = EXCLUDED.paid_privacy_kind, + paid_privacy_peer_type = EXCLUDED.paid_privacy_peer_type, + paid_privacy_peer_id = EXCLUDED.paid_privacy_peer_id, + 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.PaidPrivacy.Kind), paidPeerType, paidPeerID, + ); err != nil { + return fmt.Errorf("save account reaction settings: %w", err) + } + return nil +} diff --git a/internal/store/postgres/auth_login_integration_test.go b/internal/store/postgres/auth_login_integration_test.go new file mode 100644 index 00000000..a760b33e --- /dev/null +++ b/internal/store/postgres/auth_login_integration_test.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "context" + "crypto/rand" + "fmt" + "strings" + "testing" + "time" + + appauth "telesrv/internal/app/auth" + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + phone := fmt.Sprintf("1555%d31", 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: %v", err) + } + if _, _, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345"); err != nil || !needSignUp { + t.Fatalf("SignIn needSignUp = %v err = %v, want need sign-up", needSignUp, err) + } + + u, msg, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Test") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if u.Phone != phone || msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") { + t.Fatalf("sign-up user/message = user %+v message %+v, want login message", u, msg) + } + + systemUser, found, err := users.ByID(ctx, domain.OfficialSystemUserID) + if err != nil || !found || !systemUser.Verified || !systemUser.Support { + t.Fatalf("official system user = %+v found=%v err=%v, want seeded verified support user", systemUser, found, err) + } + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID { + t.Fatalf("dialogs = %+v, want official login dialog", list.Dialogs) + } + if len(list.Messages) != 1 || list.Messages[0].ID != msg.ID || !strings.Contains(list.Messages[0].Body, "Login code: 12345") { + t.Fatalf("messages = %+v, want returned login message", list.Messages) + } + if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID || !list.Users[0].Verified || !list.Users[0].Support { + t.Fatalf("users = %+v, want official support user", list.Users) + } +} diff --git a/internal/store/postgres/authkey.go b/internal/store/postgres/authkey.go new file mode 100644 index 00000000..faba897c --- /dev/null +++ b/internal/store/postgres/authkey.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// AuthKeyStore 用 PostgreSQL 实现 store.AuthKeyStore。 +type AuthKeyStore struct { + q *sqlcgen.Queries +} + +// NewAuthKeyStore 基于 pgx 连接池(或事务)创建 AuthKeyStore。 +func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore { + return &AuthKeyStore{q: sqlcgen.New(db)} +} + +// Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT; +// created_at 交由 DB 默认值(now()),故传入的 CreatedAt 不落库。 +func (s *AuthKeyStore) Save(ctx context.Context, k store.AuthKeyData) error { + if err := s.q.UpsertAuthKey(ctx, sqlcgen.UpsertAuthKeyParams{ + AuthKeyID: authKeyIDToInt64(k.ID), + Body: k.Value[:], + ServerSalt: k.ServerSalt, + }); err != nil { + return fmt.Errorf("upsert auth key: %w", err) + } + return nil +} + +// Get 实现 store.AuthKeyStore。不存在时 found=false。 +func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, bool, error) { + row, err := s.q.GetAuthKey(ctx, authKeyIDToInt64(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return store.AuthKeyData{}, false, nil + } + return store.AuthKeyData{}, false, fmt.Errorf("get auth key: %w", err) + } + if len(row.Body) != len(store.AuthKeyData{}.Value) { + return store.AuthKeyData{}, false, fmt.Errorf("auth key body length = %d, want 256", len(row.Body)) + } + data := store.AuthKeyData{ID: id, ServerSalt: row.ServerSalt} + copy(data.Value[:], row.Body) + if row.CreatedAt.Valid { + data.CreatedAt = row.CreatedAt.Time.Unix() + } + return data, true, nil +} + +// authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64(MTProto 定义即 SHA1 低 64 位)。 +func authKeyIDToInt64(id [8]byte) int64 { + return int64(binary.LittleEndian.Uint64(id[:])) +} + +func authKeyIDFromInt64(v int64) [8]byte { + var id [8]byte + binary.LittleEndian.PutUint64(id[:], uint64(v)) + return id +} diff --git a/internal/store/postgres/authkey_integration_test.go b/internal/store/postgres/authkey_integration_test.go new file mode 100644 index 00000000..3523b5cc --- /dev/null +++ b/internal/store/postgres/authkey_integration_test.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "crypto/rand" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/store" +) + +// testPool 连接 TELESRV_TEST_POSTGRES_DSN 指向的库(迁移到最新),未设则跳过。 +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test") + } + if err := Migrate(dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := Open(context.Background(), dsn) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +// TestAuthKeyStoreRoundTrip 验证 auth_key 落 PG 后,用全新 store 实例(模拟进程重启、无内存缓存)能原样读回。 +// 这是「server 重启保住 auth_key」的直接证明。 +func TestAuthKeyStoreRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + var id [8]byte + var val [256]byte + if _, err := rand.Read(id[:]); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(val[:]); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(id)) + }) + + want := store.AuthKeyData{ID: id, Value: val, ServerSalt: 0x0badf00d} + if err := NewAuthKeyStore(pool).Save(ctx, want); err != nil { + t.Fatalf("save: %v", err) + } + + got, found, err := NewAuthKeyStore(pool).Get(ctx, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if !found { + t.Fatal("auth key not found after save (重启后丢失)") + } + if got.ID != want.ID || got.Value != want.Value || got.ServerSalt != want.ServerSalt { + t.Fatalf("round trip mismatch: got salt=%#x value[:4]=%x, want salt=%#x value[:4]=%x", + got.ServerSalt, got.Value[:4], want.ServerSalt, want.Value[:4]) + } + + var missing [8]byte + missing[0] = id[0] ^ 0xff + if _, found, err := NewAuthKeyStore(pool).Get(ctx, missing); err != nil || found { + t.Fatalf("missing key: found=%v err=%v, want found=false err=nil", found, err) + } +} diff --git a/internal/store/postgres/authorization.go b/internal/store/postgres/authorization.go new file mode 100644 index 00000000..d6284799 --- /dev/null +++ b/internal/store/postgres/authorization.go @@ -0,0 +1,93 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。 +type AuthorizationStore struct { + q *sqlcgen.Queries +} + +// NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。 +func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore { + return &AuthorizationStore{q: sqlcgen.New(db)} +} + +func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error { + if err := s.q.UpsertAuthorization(ctx, sqlcgen.UpsertAuthorizationParams{ + AuthKeyID: authKeyIDToInt64(a.AuthKeyID), + UserID: a.UserID, + Layer: int32(a.Layer), + DeviceModel: a.DeviceModel, + Platform: a.Platform, + SystemVersion: a.SystemVersion, + ApiID: int32(a.APIID), + AppVersion: a.AppVersion, + Ip: a.IP, + }); err != nil { + return fmt.Errorf("upsert authorization: %w", err) + } + return nil +} + +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 { + 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 +} + +func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) { + rows, err := s.q.ListAuthorizationsByUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list authorizations by user: %w", err) + } + out := make([]domain.Authorization, 0, len(rows)) + for _, row := range rows { + out = append(out, authorizationFromRow(row)) + } + return out, nil +} + +func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error { + if err := s.q.DeleteAuthorization(ctx, authKeyIDToInt64(id)); err != nil { + return fmt.Errorf("delete authorization: %w", err) + } + return nil +} + +func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization { + return domain.Authorization{ + AuthKeyID: authKeyIDFromInt64(row.AuthKeyID), + 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, + } +} diff --git a/internal/store/postgres/business_integration_test.go b/internal/store/postgres/business_integration_test.go new file mode 100644 index 00000000..ef156ee9 --- /dev/null +++ b/internal/store/postgres/business_integration_test.go @@ -0,0 +1,288 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestBusinessStoresRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + var authID [8]byte + var authBody [256]byte + if _, err := rand.Read(authID[:]); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(authBody[:]); err != nil { + t.Fatal(err) + } + if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ + ID: authID, + Value: authBody, + ServerSalt: 42, + }); 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(authID)) + }) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 1, + Phone: "+1555" + suffix + "01", + FirstName: "Owner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + if owner.ID < domain.UserIDSequenceBase { + t.Fatalf("owner id = %d, want >= base %d", owner.ID, domain.UserIDSequenceBase) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 2, + Phone: "+1555" + suffix + "02", + FirstName: "Friend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + username := "owner_" + suffix + owner, err = users.UpdateUsername(ctx, owner.ID, username) + if err != nil { + t.Fatalf("update owner username: %v", err) + } + if owner.Username != username { + t.Fatalf("owner username = %q, want %q", owner.Username, username) + } + byUsername, found, err := users.ByUsername(ctx, strings.ToUpper(username)) + if err != nil || !found || byUsername.ID != owner.ID { + t.Fatalf("by username = user %+v found %v err %v, want owner", byUsername, found, err) + } + if _, err := users.UpdateUsername(ctx, friend.ID, strings.ToUpper(username)); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("duplicate username err = %v, want username occupied", err) + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO contacts (user_id, contact_user_id, mutual) + VALUES ($1, $2, true) + `, owner.ID, friend.ID); err != nil { + t.Fatalf("insert contact: %v", err) + } + contacts, err := NewContactStore(pool).ListByUser(ctx, owner.ID) + if err != nil { + t.Fatalf("list contacts: %v", err) + } + if len(contacts.Contacts) != 1 || contacts.Contacts[0].User.ID != friend.ID || !contacts.Contacts[0].Mutual { + t.Fatalf("contacts = %+v, want friend mutual contact", contacts) + } + if contacts.Hash == 0 { + t.Fatal("contacts hash is zero for non-empty contact list") + } + search, err := users.Search(ctx, owner.ID, "Friend", "", 10) + if err != nil { + t.Fatalf("search users: %v", err) + } + if len(search.MyResults) != 1 || search.MyResults[0].ID != friend.ID || !search.MyResults[0].Contact { + t.Fatalf("search contacts = %+v, want friend in my results", search) + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, unread_count, pinned) + VALUES ($1, 'user', $2, 10, 2, true) + `, owner.ID, friend.ID); err != nil { + t.Fatalf("insert dialog: %v", err) + } + dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10}) + if err != nil { + t.Fatalf("list dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer.ID != friend.ID || !dialogs.Dialogs[0].Pinned { + t.Fatalf("dialogs = %+v, want pinned friend dialog", dialogs) + } + emptyDialogsPage, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{ + PinnedOnly: true, + OffsetID: dialogs.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: dialogs.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("list empty dialogs page: %v", err) + } + if len(emptyDialogsPage.Dialogs) != 0 || emptyDialogsPage.Count != dialogs.Count || emptyDialogsPage.Hash != dialogs.Hash { + t.Fatalf("empty dialogs page = %+v, want empty rows with full count/hash from %+v", emptyDialogsPage, dialogs) + } + peerDialogs, err := NewDialogStore(pool).ListByPeers(ctx, owner.ID, []domain.Peer{ + {Type: domain.PeerTypeUser, ID: friend.ID}, + {Type: domain.PeerTypeUser, ID: owner.ID}, + }) + if err != nil { + t.Fatalf("list peer dialogs: %v", err) + } + if len(peerDialogs.Dialogs) != 2 || peerDialogs.Dialogs[0].Peer.ID != friend.ID || peerDialogs.Dialogs[0].TopMessage != 10 { + t.Fatalf("peer dialogs = %+v, want friend dialog plus owner placeholder", peerDialogs) + } + if peerDialogs.Dialogs[1].Peer.ID != owner.ID || peerDialogs.Dialogs[1].TopMessage != 0 { + t.Fatalf("placeholder dialog = %+v, want owner empty dialog", peerDialogs.Dialogs[1]) + } + if len(peerDialogs.Users) != 2 { + t.Fatalf("peer dialog users = %+v, want both requested users", peerDialogs.Users) + } + + wantState := domain.UpdateState{Pts: 11, Qts: 12, Date: 13, Seq: 14} + states := NewUpdateStateStore(pool) + if err := states.Save(ctx, authID, owner.ID, wantState); err != nil { + t.Fatalf("save update state: %v", err) + } + gotState, found, err := states.Get(ctx, authID, owner.ID) + if err != nil { + t.Fatalf("get update state: %v", err) + } + if !found || gotState != wantState { + t.Fatalf("update state = %+v found=%v, want %+v found=true", gotState, found, wantState) + } + if err := states.Delete(ctx, authID, owner.ID); err != nil { + t.Fatalf("delete update state: %v", err) + } + if _, found, err := states.Get(ctx, authID, owner.ID); err != nil || found { + t.Fatalf("state after delete found=%v err=%v, want not found", found, err) + } + + if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: authID, + PermAuthKeyID: 12345, + Nonce: 67890, + TempSessionID: 24680, + ExpiresAt: 111, + EncryptedMessage: []byte("binding"), + }); err != nil { + t.Fatalf("save temp auth key binding: %v", err) + } + var bindingCount int + var tempSessionID int64 + if err := pool.QueryRow(ctx, "SELECT count(*), coalesce(max(temp_session_id), 0) FROM temp_auth_key_bindings WHERE temp_auth_key_id = $1", authKeyIDToInt64(authID)).Scan(&bindingCount, &tempSessionID); err != nil { + t.Fatalf("count temp auth key binding: %v", err) + } + if bindingCount != 1 || tempSessionID != 24680 { + t.Fatalf("temp auth key binding count/session = %d/%d, want 1/24680", bindingCount, tempSessionID) + } + + passwords := NewPasswordStore(pool) + wantPassword := domain.PasswordSettings{ + Hint: "dev", + SecureRandom: []byte("secure-random"), + } + if err := passwords.Save(ctx, owner.ID, wantPassword); err != nil { + t.Fatalf("save password settings: %v", err) + } + gotPassword, found, err := passwords.GetByUser(ctx, owner.ID) + if err != nil { + t.Fatalf("get password settings: %v", err) + } + if !found || gotPassword.Hint != wantPassword.Hint || string(gotPassword.SecureRandom) != string(wantPassword.SecureRandom) { + t.Fatalf("password settings = %+v found=%v, want %+v found=true", gotPassword, found, wantPassword) + } + + help := NewHelpStore(pool) + client := "tdesktop-test-" + suffix + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM app_configs WHERE client = $1", client) + }) + if err := help.UpsertAppConfig(ctx, domain.AppConfig{Client: client, Hash: 9, JSON: []byte(`{"test":true}`)}); err != nil { + t.Fatalf("upsert app config: %v", err) + } + cfg, found, err := help.GetAppConfig(ctx, client) + if err != nil { + t.Fatalf("get app config: %v", err) + } + if !found || cfg.Hash != 9 || string(cfg.JSON) != `{"test": true}` { + t.Fatalf("app config = %+v found=%v, want hash=9 json", cfg, found) + } + countryISO := "T" + suffix[:1] + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM countries WHERE iso2 = $1", countryISO) + }) + if err := help.UpsertCountries(ctx, []domain.Country{{ + ISO2: countryISO, + DefaultName: "Testland", + CountryCodes: []domain.CountryCode{ + {CountryCode: "999", Prefixes: []string{"999"}, Patterns: []string{"XXX"}}, + }, + }}); err != nil { + t.Fatalf("upsert countries: %v", err) + } + countryList, err := help.ListCountries(ctx, "en") + if err != nil { + t.Fatalf("list countries: %v", err) + } + var foundCountry bool + for _, country := range countryList.Countries { + if country.ISO2 == countryISO && len(country.CountryCodes) == 1 && country.CountryCodes[0].CountryCode == "999" { + foundCountry = true + } + } + if !foundCountry { + t.Fatalf("country %s not found in %+v", countryISO, countryList) + } + + langCode := "test-" + suffix + lang := NewLangPackStore(pool) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM lang_packs WHERE lang_pack = $1 AND lang_code = $2", "tdesktop", langCode) + }) + if err := lang.UpsertPack(ctx, domain.LangPack{ + LangPack: "tdesktop", + LangCode: langCode, + Version: 7, + Strings: []domain.LangPackString{ + {Key: "lng_test", Value: "Test"}, + {Key: "lng_items", Pluralized: true, OneValue: "{count} item", OtherValue: "{count} items"}, + }, + }); err != nil { + t.Fatalf("upsert lang pack: %v", err) + } + pack, err := lang.GetPack(ctx, "tdesktop", langCode, 0) + if err != nil { + t.Fatalf("get lang pack: %v", err) + } + if pack.Version != 7 || len(pack.Strings) != 2 { + t.Fatalf("lang pack = %+v, want version 7 with 2 strings", pack) + } + notModified, err := lang.GetPack(ctx, "tdesktop", langCode, 7) + if err != nil { + t.Fatalf("get lang pack not modified: %v", err) + } + if notModified.Version != 7 || len(notModified.Strings) != 0 { + t.Fatalf("not modified pack = %+v, want version 7 with no strings", notModified) + } + selected, err := lang.GetStrings(ctx, "tdesktop", langCode, []string{"lng_test"}) + if err != nil { + t.Fatalf("get lang pack strings: %v", err) + } + if len(selected.Strings) != 1 || selected.Strings[0].Value != "Test" { + t.Fatalf("selected strings = %+v, want lng_test=Test", selected.Strings) + } +} + +func randomSuffix(t *testing.T) string { + t.Helper() + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatal(err) + } + return fmt.Sprintf("%s", hex.EncodeToString(b[:])) +} diff --git a/internal/store/postgres/channel.go b/internal/store/postgres/channel.go new file mode 100644 index 00000000..8eabd576 --- /dev/null +++ b/internal/store/postgres/channel.go @@ -0,0 +1,10149 @@ +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 + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + 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, channel, err := s.insertParticipantEventTx(ctx, tx, channel, req.UserID, previous, member, req.Date, &reserved) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + 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 + var reserved []reservedChannelPts + defer func() { + if !committed { + _ = tx.Rollback(ctx) + s.recordChannelPtsGaps(ctx, reserved, req.Date) + } + }() + 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, channel, err := s.insertParticipantEventTx(ctx, tx, channel, req.UserID, previous, member, req.Date, &reserved) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + 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 + } + args := []any{filter.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)) + } + if filter.Query != "" { + args = append(args, filter.Query) + where += fmt.Sprintf(" AND body ILIKE '%%' || $%d || '%%'", len(args)) + } + if filter.SenderUserID != 0 { + args = append(args, filter.SenderUserID) + where += fmt.Sprintf(" AND sender_user_id = $%d", len(args)) + } + if filter.MinDate > 0 { + args = append(args, filter.MinDate) + where += fmt.Sprintf(" AND message_date > $%d", len(args)) + } + if filter.MaxDate > 0 { + args = append(args, filter.MaxDate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } else if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + where += fmt.Sprintf(" AND message_date < $%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)) + } + queryLimit := limit + 1 + args = append(args, queryLimit) + 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.ChannelHistory{}, fmt.Errorf("list channel history: %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 + } + if len(out.Messages) > limit { + out.Messages = out.Messages[:limit] + out.Count = limit + 1 + } else { + 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) 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 + } + 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) 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 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 + } + 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) 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 + } + 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 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 (s *ChannelStore) insertParticipantEventTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, actorUserID int64, previous, participant domain.ChannelMember, date int, reserved *[]reservedChannelPts) (domain.ChannelUpdateEvent, domain.Channel, error) { + pts, err := s.pts.NextChannelPts(ctx, channel.ID) + if err != nil { + return domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel participant pts: %w", err) + } + reserveChannelPts(reserved, channel.ID, pts, 1) + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateParticipant, + Pts: pts, + PtsCount: 1, + Date: date, + SenderUserID: actorUserID, + UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), + Previous: previous, + Participant: participant, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.ChannelUpdateEvent{}, channel, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, channel.ID, pts); err != nil { + return domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel participant pts: %w", err) + } + channel.Pts = pts + return event, channel, nil +} + +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) + 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) + SELECT user_id, $1, $2, $3 + 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); 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_integration_test.go b/internal/store/postgres/channel_integration_test.go new file mode 100644 index 00000000..785a628a --- /dev/null +++ b/internal/store/postgres/channel_integration_test.go @@ -0,0 +1,3165 @@ +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 + if _, 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, + }); err != nil { + t.Fatalf("kick member: %v", err) + } + 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 + 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) + } + 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 != promoted.Event.Pts { + t.Fatalf("joined members = %+v, want available_min_pts %d", joined.Members, promoted.Event.Pts) + } + 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 <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join message %+v at or before available_min_pts %d", msg, promoted.Event.Pts) + } + } + for _, event := range diff.OtherUpdates { + if event.Pts <= promoted.Event.Pts { + t.Fatalf("diff leaks pre-join event %+v at or before available_min_pts %d", event, promoted.Event.Pts) + } + } +} + +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/contact.go b/internal/store/postgres/contact.go new file mode 100644 index 00000000..a453b112 --- /dev/null +++ b/internal/store/postgres/contact.go @@ -0,0 +1,393 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// ContactStore 用 PostgreSQL 实现 store.ContactStore。 +type ContactStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewContactStore 基于 pgx 连接池(或事务)创建 ContactStore。 +func NewContactStore(db sqlcgen.DBTX) *ContactStore { + return &ContactStore{db: db, q: sqlcgen.New(db)} +} + +func (s *ContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) { + rows, err := s.q.ListContactsByUser(ctx, userID) + if err != nil { + return domain.ContactList{}, fmt.Errorf("list contacts: %w", err) + } + out := domain.ContactList{Contacts: make([]domain.Contact, 0, len(rows))} + for _, row := range rows { + contact, err := contactFromListRow(row) + if err != nil { + return domain.ContactList{}, err + } + out.Contacts = append(out.Contacts, contact) + } + out.Hash = contactListHash(out.Contacts) + return out, nil +} + +func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) { + row, err := s.q.GetContact(ctx, sqlcgen.GetContactParams{ + UserID: userID, + ContactUserID: contactUserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Contact{}, false, nil + } + return domain.Contact{}, false, fmt.Errorf("get contact: %w", err) + } + contact, err := contactFromGetRow(row) + if err != nil { + return domain.Contact{}, false, err + } + return contact, true, nil +} + +func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { + entities, err := encodeMessageEntities(input.NoteEntities) + if err != nil { + return domain.Contact{}, err + } + row, err := s.q.UpsertContact(ctx, sqlcgen.UpsertContactParams{ + UserID: userID, + ContactUserID: input.ContactUserID, + ContactPhone: input.Phone, + ContactFirstName: input.FirstName, + ContactLastName: input.LastName, + Note: input.Note, + NoteEntities: entities, + }) + if err != nil { + return domain.Contact{}, fmt.Errorf("upsert contact: %w", err) + } + contact, err := contactFromUpsertRow(row) + if err != nil { + return domain.Contact{}, err + } + return contact, nil +} + +const upsertContactsManySQL = ` +WITH input AS ( + SELECT + $1::bigint AS user_id, + i.contact_user_id, + i.contact_phone, + i.contact_first_name, + i.contact_last_name, + i.note, + i.note_entities_json::jsonb AS note_entities, + i.ord + FROM unnest( + $2::bigint[], + $3::text[], + $4::text[], + $5::text[], + $6::text[], + $7::text[] + ) WITH ORDINALITY AS i(contact_user_id, contact_phone, contact_first_name, contact_last_name, note, note_entities_json, ord) +), +reverse AS ( + SELECT + i.contact_user_id, + EXISTS ( + SELECT 1 + FROM contacts c + WHERE c.user_id = i.contact_user_id + AND c.contact_user_id = i.user_id + )::boolean AS mutual + FROM input i +), +upserted AS ( + INSERT INTO contacts ( + user_id, + contact_user_id, + contact_phone, + contact_first_name, + contact_last_name, + note, + note_entities, + mutual + ) + SELECT + i.user_id, + i.contact_user_id, + i.contact_phone, + i.contact_first_name, + i.contact_last_name, + i.note, + i.note_entities, + r.mutual + FROM input i + JOIN reverse r ON r.contact_user_id = i.contact_user_id + ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_phone = EXCLUDED.contact_phone, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + note = EXCLUDED.note, + note_entities = EXCLUDED.note_entities, + mutual = contacts.mutual OR EXCLUDED.mutual, + updated_at = now() + RETURNING * +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = true, + updated_at = now() + FROM upserted u + WHERE c.user_id = u.contact_user_id + AND c.contact_user_id = $1::bigint + AND NOT c.mutual + RETURNING c.user_id +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + 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 +JOIN users u ON u.id = c.contact_user_id +JOIN input i ON i.contact_user_id = c.contact_user_id +ORDER BY i.ord +` + +func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) { + if len(inputs) == 0 { + return nil, nil + } + contactUserIDs := make([]int64, 0, len(inputs)) + phones := make([]string, 0, len(inputs)) + firstNames := make([]string, 0, len(inputs)) + lastNames := make([]string, 0, len(inputs)) + notes := make([]string, 0, len(inputs)) + noteEntities := make([]string, 0, len(inputs)) + for _, input := range inputs { + if input.ContactUserID == 0 { + continue + } + raw, err := encodeMessageEntities(input.NoteEntities) + if err != nil { + return nil, err + } + contactUserIDs = append(contactUserIDs, input.ContactUserID) + phones = append(phones, input.Phone) + firstNames = append(firstNames, input.FirstName) + lastNames = append(lastNames, input.LastName) + notes = append(notes, input.Note) + noteEntities = append(noteEntities, string(raw)) + } + if len(contactUserIDs) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, upsertContactsManySQL, userID, contactUserIDs, phones, firstNames, lastNames, notes, noteEntities) + if err != nil { + return nil, fmt.Errorf("upsert contacts many: %w", err) + } + defer rows.Close() + out := make([]domain.Contact, 0, len(contactUserIDs)) + for rows.Next() { + var ( + 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 + ) + if err := rows.Scan( + &contactUserID, + &mutual, + &contactPhone, + &contactFirstName, + &contactLastName, + ¬e, + ¬eEntitiesJSON, + &id, + &accessHash, + &phone, + &firstName, + &lastName, + &username, + &countryCode, + &verified, + &support, + &lastSeenAt, + &reverseMutualChanged, + ); err != nil { + return nil, fmt.Errorf("scan upsert contacts many: %w", err) + } + _ = reverseMutualChanged + entities, err := decodeMessageEntities(noteEntitiesJSON) + 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)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate upsert contacts many: %w", err) + } + return out, nil +} + +func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) { + raw, err := encodeMessageEntities(entities) + if err != nil { + return domain.Contact{}, false, err + } + row, err := s.q.UpdateContactNote(ctx, sqlcgen.UpdateContactNoteParams{ + UserID: userID, + ContactUserID: contactUserID, + Note: note, + NoteEntities: raw, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Contact{}, false, nil + } + return domain.Contact{}, false, fmt.Errorf("update contact note: %w", err) + } + contact, err := contactFromUpdateNoteRow(row) + if err != nil { + return domain.Contact{}, false, err + } + return contact, true, nil +} + +func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { + if len(contactUserIDs) == 0 { + return 0, nil + } + count, err := s.q.DeleteContacts(ctx, sqlcgen.DeleteContactsParams{ + UserID: userID, + ContactUserIds: contactUserIDs, + }) + if err != nil { + return 0, fmt.Errorf("delete contacts: %w", err) + } + return int(count), nil +} + +func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + 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 +} + +func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + 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 +} + +func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + 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 +} + +func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) { + entities, err := decodeMessageEntities(row.NoteEntitiesJson) + 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 +} + +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 { + 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, + }, + FirstName: contactFirstName, + LastName: contactLastName, + Phone: contactPhone, + Note: note, + NoteEntities: noteEntities, + Mutual: mutual, + } +} + +func contactListHash(contacts []domain.Contact) int64 { + if len(contacts) == 0 { + return 0 + } + h := fnv.New64a() + var buf [16]byte + for _, c := range contacts { + binary.LittleEndian.PutUint64(buf[:8], uint64(c.User.ID)) + if c.Mutual { + buf[8] = 1 + } else { + buf[8] = 0 + } + _, _ = h.Write(buf[:9]) + _, _ = h.Write([]byte(c.FirstName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(c.LastName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(c.Phone)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(c.Note)) + _, _ = h.Write([]byte{0}) + } + return int64(h.Sum64()) +} diff --git a/internal/store/postgres/contact_dialog_integration_test.go b/internal/store/postgres/contact_dialog_integration_test.go new file mode 100644 index 00000000..9cd9838e --- /dev/null +++ b/internal/store/postgres/contact_dialog_integration_test.go @@ -0,0 +1,279 @@ +package postgres + +import ( + "context" + "testing" + + contactsapp "telesrv/internal/app/contacts" + "telesrv/internal/domain" +) + +func TestContactProfilesOwnerScopedRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1900"+suffix+"01", "Owner", "One") + altOwner := createTestUser(t, ctx, users, "+1900"+suffix+"02", "AltOwner", "Two") + friend := createTestUser(t, ctx, users, "+1900"+suffix+"03", "Canonical", "Friend") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, altOwner.ID, friend.ID}) + }) + + contactStore := NewContactStore(pool) + contacts := contactsapp.NewService(contactStore, users) + if _, err := contacts.AddContact(ctx, owner.ID, domain.ContactInput{ + ContactUserID: friend.ID, + Phone: "10001", + FirstName: "OwnerRemark", + LastName: "A", + Note: "first note", + }); err != nil { + t.Fatalf("owner add contact: %v", err) + } + if _, err := contacts.AddContact(ctx, altOwner.ID, domain.ContactInput{ + ContactUserID: friend.ID, + Phone: "20002", + FirstName: "AltRemark", + LastName: "B", + Note: "alt note", + }); err != nil { + t.Fatalf("alt owner add contact: %v", err) + } + + ownerList, _, err := contacts.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("owner contacts: %v", err) + } + altList, _, err := contacts.GetContacts(ctx, altOwner.ID, 0) + if err != nil { + t.Fatalf("alt contacts: %v", err) + } + if got := ownerList.Contacts[0].User.FirstName; got != "OwnerRemark" { + t.Fatalf("owner contact first name = %q, want owner remark", got) + } + if got := altList.Contacts[0].User.FirstName; got != "AltRemark" { + t.Fatalf("alt contact first name = %q, want alt remark", got) + } + if ownerList.Hash == 0 || altList.Hash == 0 || ownerList.Hash == altList.Hash { + t.Fatalf("contact hashes owner=%d alt=%d, want non-zero owner-specific hashes", ownerList.Hash, altList.Hash) + } + + beforeHash := ownerList.Hash + updated, err := contacts.UpdateContactNote(ctx, owner.ID, friend.ID, "fresh note", []domain.MessageEntity{ + {Type: domain.MessageEntityBold, Offset: 0, Length: 5}, + }) + if err != nil { + t.Fatalf("update contact note: %v", err) + } + if updated.Note != "fresh note" || len(updated.NoteEntities) != 1 { + t.Fatalf("updated contact = %+v, want note with entity", updated) + } + ownerList, _, err = contacts.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("owner contacts after note: %v", err) + } + if ownerList.Hash == beforeHash { + t.Fatalf("owner contact hash did not change after note update: %d", ownerList.Hash) + } + altList, _, err = contacts.GetContacts(ctx, altOwner.ID, 0) + if err != nil { + t.Fatalf("alt contacts after owner note: %v", err) + } + if altList.Contacts[0].Note != "alt note" { + t.Fatalf("alt contact note = %q, want isolated alt note", altList.Contacts[0].Note) + } + + if _, err := contacts.AddContact(ctx, friend.ID, domain.ContactInput{ + ContactUserID: owner.ID, + FirstName: "OwnerBack", + }); err != nil { + t.Fatalf("friend reciprocal add: %v", err) + } + ownerContact, found, err := contactStore.Get(ctx, owner.ID, friend.ID) + if err != nil { + t.Fatalf("get owner contact: %v", err) + } + if !found || !ownerContact.Mutual { + t.Fatalf("owner contact = %+v found=%v, want mutual after reciprocal add", ownerContact, found) + } + friendContact, found, err := contactStore.Get(ctx, friend.ID, owner.ID) + if err != nil { + t.Fatalf("get friend contact: %v", err) + } + if !found || !friendContact.Mutual { + t.Fatalf("friend contact = %+v found=%v, want mutual after reciprocal add", friendContact, found) + } + + deleted, err := contacts.DeleteContacts(ctx, owner.ID, []int64{friend.ID}) + if err != nil { + t.Fatalf("delete owner contact: %v", err) + } + if deleted != 1 { + t.Fatalf("deleted = %d, want 1", deleted) + } + if _, found, err := contactStore.Get(ctx, owner.ID, friend.ID); err != nil || found { + t.Fatalf("owner contact after delete found=%v err=%v, want not found", found, err) + } + friendContact, found, err = contactStore.Get(ctx, friend.ID, owner.ID) + if err != nil { + t.Fatalf("get friend contact after delete: %v", err) + } + if !found || friendContact.Mutual { + t.Fatalf("friend contact after delete = %+v found=%v, want reverse mutual cleared", friendContact, found) + } +} + +func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + ownerA := createTestUser(t, ctx, users, "+1910"+suffix+"01", "OwnerA", "") + ownerB := createTestUser(t, ctx, users, "+1910"+suffix+"02", "OwnerB", "") + friend := createTestUser(t, ctx, users, "+1910"+suffix+"03", "Shared", "Friend") + other := createTestUser(t, ctx, users, "+1910"+suffix+"04", "Other", "Peer") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{ownerA.ID, ownerB.ID, friend.ID, other.ID}) + }) + + contacts := contactsapp.NewService(NewContactStore(pool), users) + if _, err := contacts.AddContact(ctx, ownerA.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkA"}); err != nil { + t.Fatalf("owner A add contact: %v", err) + } + if _, err := contacts.AddContact(ctx, ownerB.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "RemarkB"}); err != nil { + t.Fatalf("owner B add contact: %v", err) + } + + messages := NewMessageStore(pool) + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: friend.ID, + RecipientUserID: ownerA.ID, + RandomID: 301, + Message: "to owner A", + Date: 1700000301, + }); err != nil { + t.Fatalf("send to owner A: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: friend.ID, + RecipientUserID: ownerB.ID, + RandomID: 302, + Message: "to owner B", + Date: 1700000302, + }); err != nil { + t.Fatalf("send to owner B: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: other.ID, + RecipientUserID: ownerA.ID, + RandomID: 303, + Message: "second dialog", + Date: 1700000303, + }); err != nil { + t.Fatalf("send second dialog: %v", err) + } + + dialogs := NewDialogStore(pool) + listA, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list owner A dialogs: %v", err) + } + listB, err := dialogs.ListByUser(ctx, ownerB.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list owner B dialogs: %v", err) + } + userA, ok := findDialogUserByID(listA.Users, friend.ID) + if !ok || userA.FirstName != "RemarkA" || !userA.Contact { + t.Fatalf("owner A dialog user = %+v found=%v, want RemarkA contact", userA, ok) + } + userB, ok := findDialogUserByID(listB.Users, friend.ID) + if !ok || userB.FirstName != "RemarkB" || !userB.Contact { + t.Fatalf("owner B dialog user = %+v found=%v, want RemarkB contact", userB, ok) + } + + 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 { + 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 { + 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) + } + 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) + } + + if changed, err := dialogs.SetUnreadMark(ctx, ownerA.ID, friendPeer, true); err != nil || !changed { + t.Fatalf("mark unread changed=%v err=%v, want changed", changed, err) + } + marks, err := dialogs.ListUnreadMarked(ctx, ownerA.ID) + if err != nil { + t.Fatalf("list unread marks: %v", err) + } + if !containsPeer(marks, friendPeer) { + t.Fatalf("unread marks = %+v, want friend peer", marks) + } + if _, err := dialogs.MarkRead(ctx, ownerA.ID, friendPeer, 0); 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 changed, err := dialogs.SetPeerSettingsBarHidden(ctx, ownerA.ID, friendPeer); err != nil || !changed { + t.Fatalf("hide peer settings bar changed=%v err=%v, want changed", changed, err) + } + hidden, err := dialogs.PeerSettingsBarHidden(ctx, ownerA.ID, friendPeer) + if err != nil { + t.Fatalf("peer settings bar hidden: %v", err) + } + if !hidden { + t.Fatal("peer settings bar hidden = false, want true") + } +} + +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{ + AccessHash: int64(len(phone) + len(firstName)*100 + len(lastName)*1000), + Phone: phone, + FirstName: firstName, + LastName: lastName, + }) + if err != nil { + t.Fatalf("create user %s: %v", phone, err) + } + return user +} + +func findDialogUserByID(users []domain.User, id int64) (domain.User, bool) { + for _, user := range users { + if user.ID == id { + return user, true + } + } + return domain.User{}, false +} + +func containsPeer(peers []domain.Peer, want domain.Peer) bool { + for _, peer := range peers { + if peer == want { + return true + } + } + return false +} diff --git a/internal/store/postgres/contiguous_pts_integration_test.go b/internal/store/postgres/contiguous_pts_integration_test.go new file mode 100644 index 00000000..f01a9061 --- /dev/null +++ b/internal/store/postgres/contiguous_pts_integration_test.go @@ -0,0 +1,127 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestMaxContiguousPtsStopsAtHole 用真实 PG 验证 RecentUserPts 窗口查询 + 连续计算: +// 存在在途空洞时只报告最大连续 pts,补洞后回升。 +func TestMaxContiguousPtsStopsAtHole(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 1, + Phone: "+1556" + suffix + "01", + FirstName: "Contig", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + events := NewUpdateEventStore(pool) + appendPts := func(pts int) { + if err := events.Append(ctx, owner.ID, domain.UpdateEvent{ + Type: domain.UpdateEventNoop, + Pts: pts, + PtsCount: 1, + Date: 1700000000 + pts, + }); err != nil { + t.Fatalf("append pts=%d: %v", pts, err) + } + } + + for _, p := range []int{1, 2, 3, 5, 6} { // pts=4 为在途空洞 + 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) + } + 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) + } + + appendPts(4) // 在途事务提交/补洞 + 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) + } +} + +func TestAppendWithDispatchWritesEventAndOutboxAtomically(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 2, + Phone: "+1557" + suffix + "01", + FirstName: "Dispatch", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + event := domain.UpdateEvent{ + UserID: owner.ID, + Type: domain.UpdateEventDialogPinned, + Pts: 1, + PtsCount: 1, + Date: 1700000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + Settings: domain.PeerSettings{ + ShareContact: true, + }, + Bool: true, + } + 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) + } + + got, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10) + if err != nil { + t.Fatalf("ListAfter: %v", err) + } + if len(got) != 1 || got[0].Type != event.Type || got[0].Peer != event.Peer || !got[0].Bool { + t.Fatalf("events = %+v, want dialog pinned event", got) + } + + var outbox struct { + pts int + eventType string + excludeAuthKeyID int64 + excludeSessionID int64 + } + if err := pool.QueryRow(ctx, ` + SELECT pts, event_type, exclude_auth_key_id, exclude_session_id + FROM dispatch_outbox + WHERE target_user_id = $1 + `, owner.ID).Scan(&outbox.pts, &outbox.eventType, &outbox.excludeAuthKeyID, &outbox.excludeSessionID); err != nil { + t.Fatalf("query dispatch outbox: %v", err) + } + if outbox.pts != 1 || outbox.eventType != string(domain.UpdateEventDialogPinned) || outbox.excludeAuthKeyID != authKeyIDToInt64(excludeAuthKeyID) || outbox.excludeSessionID != 77 { + t.Fatalf("outbox = %+v, want event dispatch excluding current session", outbox) + } +} diff --git a/internal/store/postgres/counter_source.go b/internal/store/postgres/counter_source.go new file mode 100644 index 00000000..b8345da1 --- /dev/null +++ b/internal/store/postgres/counter_source.go @@ -0,0 +1,78 @@ +package postgres + +import ( + "context" + "fmt" + + "telesrv/internal/store/postgres/sqlcgen" +) + +// MessageBoxCounterSource 从 message_boxes durable log 恢复某 owner 的当前最大 box_id。 +type MessageBoxCounterSource struct { + q *sqlcgen.Queries +} + +// NewMessageBoxCounterSource 创建 Redis BoxIDAllocator 的 PG 恢复源。 +func NewMessageBoxCounterSource(db sqlcgen.DBTX) *MessageBoxCounterSource { + return &MessageBoxCounterSource{q: sqlcgen.New(db)} +} + +func (s *MessageBoxCounterSource) Current(ctx context.Context, userID int64) (int, error) { + v, err := s.q.MaxMessageBoxID(ctx, userID) + if err != nil { + return 0, fmt.Errorf("max message box id: %w", err) + } + return int(v), nil +} + +// ChannelIDCounterSource 从 channels durable 表恢复全局 channel id。 +type ChannelIDCounterSource struct { + db sqlcgen.DBTX +} + +// NewChannelIDCounterSource 创建 Redis ChannelIDAllocator 的 PG 恢复源。 +func NewChannelIDCounterSource(db sqlcgen.DBTX) *ChannelIDCounterSource { + return &ChannelIDCounterSource{db: db} +} + +func (s *ChannelIDCounterSource) Current(ctx context.Context, _ int64) (int, error) { + var id int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id); err != nil { + return 0, fmt.Errorf("max channel id: %w", err) + } + return id, nil +} + +// ChannelPtsCounterSource 从 channel_update_events 恢复某 channel 的当前最大 pts。 +type ChannelPtsCounterSource struct { + db sqlcgen.DBTX +} + +func NewChannelPtsCounterSource(db sqlcgen.DBTX) *ChannelPtsCounterSource { + return &ChannelPtsCounterSource{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) + } + return pts, nil +} + +// ChannelMessageIDCounterSource 从 channel_messages 恢复某 channel 的当前最大 message id。 +type ChannelMessageIDCounterSource struct { + db sqlcgen.DBTX +} + +func NewChannelMessageIDCounterSource(db sqlcgen.DBTX) *ChannelMessageIDCounterSource { + return &ChannelMessageIDCounterSource{db: db} +} + +func (s *ChannelMessageIDCounterSource) Current(ctx context.Context, channelID int64) (int, error) { + var id int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id); err != nil { + return 0, fmt.Errorf("max channel message id: %w", err) + } + return id, nil +} diff --git a/internal/store/postgres/dialog.go b/internal/store/postgres/dialog.go new file mode 100644 index 00000000..8046040e --- /dev/null +++ b/internal/store/postgres/dialog.go @@ -0,0 +1,722 @@ +package postgres + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// DialogStore 用 PostgreSQL 实现 store.DialogStore。 +type DialogStore struct { + q *sqlcgen.Queries +} + +// NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。 +func NewDialogStore(db sqlcgen.DBTX) *DialogStore { + return &DialogStore{q: sqlcgen.New(db)} +} + +func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + offsetPeerID := int64(0) + if filter.HasOffsetPeer { + offsetPeerID = filter.OffsetPeer.ID + } + folderParams := dialogFolderQueryParams(filter.Folder) + summaryRows, err := s.q.ListDialogSummaryByUser(ctx, sqlcgen.ListDialogSummaryByUserParams{ + UserID: userID, + HasFolderID: filter.HasFolderID, + FolderID: pgInt32NonNegative(filter.FolderID), + FolderExcludeArchived: folderParams.excludeArchived, + FolderExcludeRead: folderParams.excludeRead, + FolderExcludePeerTypes: folderParams.excludeTypes, + FolderExcludePeerIds: folderParams.excludeIDs, + FolderIncludePeerTypes: folderParams.includeTypes, + FolderIncludePeerIds: folderParams.includeIDs, + FolderPinnedPeerTypes: folderParams.pinnedTypes, + FolderPinnedPeerIds: folderParams.pinnedIDs, + FolderContacts: folderParams.contacts, + FolderNonContacts: folderParams.nonContacts, + PinnedOnly: filter.PinnedOnly, + ExcludePinned: filter.ExcludePinned, + }) + if err != nil { + return domain.DialogList{}, fmt.Errorf("list dialog summary: %w", err) + } + summary := make([]domain.Dialog, 0, len(summaryRows)) + for _, row := range summaryRows { + summary = append(summary, domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + FolderID: int(row.FolderID), + TopMessage: int(row.TopMessageID), + TopMessageDate: int(row.TopMessageDate), + ReadInboxMaxID: int(row.ReadInboxMaxID), + ReadOutboxMaxID: int(row.ReadOutboxMaxID), + UnreadCount: int(row.UnreadCount), + UnreadMentions: int(row.UnreadMentionsCount), + UnreadReactions: int(row.UnreadReactionsCount), + Pinned: row.Pinned, + PinnedOrder: int(row.PinnedOrder), + UnreadMark: row.UnreadMark, + PeerSettingsBarHidden: row.HiddenPeerSettingsBar, + }) + } + out := domain.DialogList{ + Dialogs: make([]domain.Dialog, 0, limit), + Count: len(summary), + Hash: dialogListHash(summary), + } + if len(summary) == 0 { + return out, nil + } + rows, err := s.q.ListDialogsByUser(ctx, sqlcgen.ListDialogsByUserParams{ + UserID: userID, + LimitCount: int32(limit), + HasFolderID: filter.HasFolderID, + FolderID: pgInt32NonNegative(filter.FolderID), + FolderExcludeArchived: folderParams.excludeArchived, + FolderExcludeRead: folderParams.excludeRead, + FolderExcludePeerTypes: folderParams.excludeTypes, + FolderExcludePeerIds: folderParams.excludeIDs, + FolderIncludePeerTypes: folderParams.includeTypes, + FolderIncludePeerIds: folderParams.includeIDs, + FolderPinnedPeerTypes: folderParams.pinnedTypes, + FolderPinnedPeerIds: folderParams.pinnedIDs, + FolderContacts: folderParams.contacts, + FolderNonContacts: folderParams.nonContacts, + PinnedOnly: filter.PinnedOnly, + ExcludePinned: filter.ExcludePinned, + OffsetID: pgInt32NonNegative(filter.OffsetID), + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + HasOffsetPeer: filter.HasOffsetPeer, + OffsetPeerID: offsetPeerID, + }) + if err != nil { + return domain.DialogList{}, fmt.Errorf("list dialogs: %w", err) + } + out.Messages = make([]domain.Message, 0, len(rows)) + out.Users = make([]domain.User, 0, len(rows)) + seenUsers := map[int64]struct{}{} + for _, row := range rows { + dialog := domain.Dialog{ + Peer: domain.Peer{ + Type: domain.PeerType(row.PeerType), + ID: row.PeerID, + }, + FolderID: int(row.FolderID), + TopMessage: int(row.TopMessageID), + TopMessageDate: int(row.TopMessageDate), + ReadInboxMaxID: int(row.ReadInboxMaxID), + ReadOutboxMaxID: int(row.ReadOutboxMaxID), + UnreadCount: int(row.UnreadCount), + UnreadMentions: int(row.UnreadMentionsCount), + UnreadReactions: int(row.UnreadReactionsCount), + Pinned: row.Pinned, + PinnedOrder: int(row.PinnedOrder), + UnreadMark: row.UnreadMark, + PeerSettingsBarHidden: row.HiddenPeerSettingsBar, + } + out.Dialogs = append(out.Dialogs, dialog) + if row.PeerUserID != 0 { + 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, + }) + } + } + if row.MessageID != 0 { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message entities: %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, + }) + } + } + return out, nil +} + +func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + if len(peers) == 0 { + return domain.DialogList{}, nil + } + 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 domain.DialogList{}, nil + } + rows, err := s.q.ListDialogsByPeers(ctx, sqlcgen.ListDialogsByPeersParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }) + if err != nil { + return domain.DialogList{}, fmt.Errorf("list dialogs by peers: %w", err) + } + out := domain.DialogList{ + Dialogs: make([]domain.Dialog, 0, len(rows)), + Messages: make([]domain.Message, 0, len(rows)), + Users: make([]domain.User, 0, len(rows)), + } + seenUsers := map[int64]struct{}{} + for _, row := range rows { + dialog := domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + FolderID: int(row.FolderID), + TopMessage: int(row.TopMessageID), + TopMessageDate: int(row.TopMessageDate), + ReadInboxMaxID: int(row.ReadInboxMaxID), + ReadOutboxMaxID: int(row.ReadOutboxMaxID), + UnreadCount: int(row.UnreadCount), + UnreadMentions: int(row.UnreadMentionsCount), + UnreadReactions: int(row.UnreadReactionsCount), + Pinned: row.Pinned, + PinnedOrder: int(row.PinnedOrder), + UnreadMark: row.UnreadMark, + PeerSettingsBarHidden: row.HiddenPeerSettingsBar, + } + out.Dialogs = append(out.Dialogs, dialog) + if row.PeerUserID != 0 { + 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, + }) + } + } + if row.MessageID != 0 { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message entities: %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, + }) + } + } + out.Count = len(out.Dialogs) + out.Hash = dialogListHash(out.Dialogs) + return out, nil +} + +func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error { + if err := s.q.UpsertDialog(ctx, sqlcgen.UpsertDialogParams{ + UserID: userID, + PeerType: string(dialog.Peer.Type), + PeerID: dialog.Peer.ID, + TopMessageID: int32(dialog.TopMessage), + TopMessageDate: int32(dialog.TopMessageDate), + ReadInboxMaxID: int32(dialog.ReadInboxMaxID), + ReadOutboxMaxID: int32(dialog.ReadOutboxMaxID), + UnreadCount: int32(dialog.UnreadCount), + UnreadMentionsCount: int32(dialog.UnreadMentions), + UnreadReactionsCount: int32(dialog.UnreadReactions), + Pinned: dialog.Pinned, + UnreadMark: dialog.UnreadMark, + }); err != nil { + return fmt.Errorf("upsert 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 { + return fmt.Errorf("marshal dialog draft: %w", err) + } + if err := s.q.UpsertDialogDraft(ctx, sqlcgen.UpsertDialogDraftParams{ + UserID: userID, + PeerType: string(draft.Peer.Type), + PeerID: draft.Peer.ID, + TopMessageID: int32(draft.TopMessageID), + Date: int32(draft.Date), + DraftJson: data, + }); err != nil { + return fmt.Errorf("upsert dialog draft: %w", err) + } + return 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, + PeerType: string(peer.Type), + PeerID: peer.ID, + TopMessageID: int32(topMessageID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("delete dialog draft: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + rows, err := s.q.ListDialogDrafts(ctx, sqlcgen.ListDialogDraftsParams{ + UserID: userID, + LimitCount: int32(clampDialogDraftLimit(limit)), + }) + if err != nil { + return nil, fmt.Errorf("list dialog drafts: %w", err) + } + return decodeDialogDrafts(rows) +} + +func (s *DialogStore) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + rows, err := s.q.ClearDialogDrafts(ctx, sqlcgen.ClearDialogDraftsParams{ + UserID: userID, + LimitCount: int32(clampDialogDraftLimit(limit)), + }) + if err != nil { + return nil, fmt.Errorf("clear dialog drafts: %w", err) + } + return decodeDialogDrafts(rows) +} + +func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) { + row, err := s.q.MarkDialogRead(ctx, sqlcgen.MarkDialogReadParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + MaxID: pgInt32NonNegative(maxID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID}, nil + } + return domain.ReadHistoryResult{}, fmt.Errorf("mark dialog read: %w", err) + } + return domain.ReadHistoryResult{ + OwnerUserID: row.UserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + MaxID: int(row.ReadInboxMaxID), + StillUnreadCount: int(row.UnreadCount), + Changed: row.Changed, + }, 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{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + Pinned: pinned, + }) + if err != nil { + return false, fmt.Errorf("set dialog pinned: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + peerTypes, peerIDs := peerArrays(order) + 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) + } + } + if len(peerTypes) == 0 { + return 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) + } + return nil +} + +func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { + changed, err := s.q.SetDialogUnreadMark(ctx, sqlcgen.SetDialogUnreadMarkParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + Unread: unread, + }) + if err != nil { + return false, fmt.Errorf("set dialog unread mark: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) { + rows, err := s.q.ListDialogUnreadMarks(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list dialog unread marks: %w", err) + } + out := make([]domain.Peer, 0, len(rows)) + for _, row := range rows { + out = append(out, domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}) + } + return out, 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, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if err != nil { + return false, fmt.Errorf("set peer settings bar hidden: %w", err) + } + return changed, nil +} + +func (s *DialogStore) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + hidden, err := s.q.GetPeerSettingsBarHidden(ctx, sqlcgen.GetPeerSettingsBarHiddenParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("get peer settings bar hidden: %w", err) + } + return hidden, nil +} + +func (s *DialogStore) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) { + rows, err := s.q.ListDialogFolders(ctx, userID) + if err != nil { + return domain.DialogFolderList{}, fmt.Errorf("list dialog folders: %w", err) + } + tagsEnabled := false + if enabled, err := s.q.GetDialogFolderTags(ctx, userID); err == nil { + tagsEnabled = enabled + } else if !errors.Is(err, pgx.ErrNoRows) { + return domain.DialogFolderList{}, fmt.Errorf("get dialog folder tags: %w", err) + } + out := domain.DialogFolderList{ + TagsEnabled: tagsEnabled, + Folders: make([]domain.DialogFolder, 0, len(rows)), + } + for _, row := range rows { + folder, err := decodeDialogFolder(row.FilterJson) + if err != nil { + return domain.DialogFolderList{}, fmt.Errorf("decode dialog folder %d: %w", row.FilterID, err) + } + folder.ID = int(row.FilterID) + folder.IsChatlist = row.IsChatlist + out.Folders = append(out.Folders, folder) + } + return out, nil +} + +func (s *DialogStore) GetFolder(ctx context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) { + row, err := s.q.GetDialogFolder(ctx, sqlcgen.GetDialogFolderParams{ + UserID: userID, + FilterID: pgInt32NonNegative(folderID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.DialogFolder{}, false, nil + } + return domain.DialogFolder{}, false, fmt.Errorf("get dialog folder: %w", err) + } + folder, err := decodeDialogFolder(row.FilterJson) + if err != nil { + return domain.DialogFolder{}, false, fmt.Errorf("decode dialog folder: %w", err) + } + folder.ID = int(row.FilterID) + folder.IsChatlist = row.IsChatlist + return folder, true, nil +} + +func (s *DialogStore) UpsertFolder(ctx context.Context, userID int64, folder domain.DialogFolder) error { + data, err := json.Marshal(folder) + if err != nil { + return fmt.Errorf("marshal dialog folder: %w", err) + } + if err := s.q.UpsertDialogFolder(ctx, sqlcgen.UpsertDialogFolderParams{ + UserID: userID, + FilterID: pgInt32NonNegative(folder.ID), + IsChatlist: folder.IsChatlist, + FilterJson: data, + }); err != nil { + return fmt.Errorf("upsert dialog folder: %w", err) + } + return nil +} + +func (s *DialogStore) DeleteFolder(ctx context.Context, userID int64, folderID int) error { + if err := s.q.DeleteDialogFolder(ctx, sqlcgen.DeleteDialogFolderParams{ + UserID: userID, + FilterID: pgInt32NonNegative(folderID), + }); err != nil { + return fmt.Errorf("delete dialog folder: %w", err) + } + return nil +} + +func (s *DialogStore) ReorderFolders(ctx context.Context, userID int64, order []int) error { + if err := s.q.ReorderDialogFolders(ctx, sqlcgen.ReorderDialogFoldersParams{ + UserID: userID, + FilterIds: int32s(order), + }); err != nil { + return fmt.Errorf("reorder dialog folders: %w", err) + } + return nil +} + +func (s *DialogStore) SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error { + if err := s.q.SetDialogFolderTags(ctx, sqlcgen.SetDialogFolderTagsParams{ + UserID: userID, + TagsEnabled: enabled, + }); err != nil { + return fmt.Errorf("set dialog folder tags: %w", err) + } + return nil +} + +func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + folderIDs := make([]int32, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, item := range peers { + if item.Peer.Type == "" || item.Peer.ID == 0 { + continue + } + if _, ok := seen[item.Peer]; ok { + continue + } + seen[item.Peer] = struct{}{} + peerTypes = append(peerTypes, string(item.Peer.Type)) + peerIDs = append(peerIDs, item.Peer.ID) + folderIDs = append(folderIDs, pgInt32NonNegative(item.FolderID)) + } + if len(peerTypes) == 0 { + return nil + } + if err := s.q.EditDialogPeerFolders(ctx, sqlcgen.EditDialogPeerFoldersParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + FolderIds: folderIDs, + }); err != nil { + return fmt.Errorf("edit dialog peer folders: %w", err) + } + return nil +} + +type dialogFolderParams struct { + contacts bool + nonContacts bool + excludeArchived bool + excludeRead bool + includeTypes []string + includeIDs []int64 + pinnedTypes []string + pinnedIDs []int64 + excludeTypes []string + excludeIDs []int64 +} + +func dialogFolderQueryParams(folder *domain.DialogFolder) dialogFolderParams { + if folder == nil { + return dialogFolderParams{} + } + includeTypes, includeIDs := folderPeerArrays(folder.IncludePeers) + pinnedTypes, pinnedIDs := folderPeerArrays(folder.PinnedPeers) + excludeTypes, excludeIDs := folderPeerArrays(folder.ExcludePeers) + return dialogFolderParams{ + contacts: folder.Contacts, + nonContacts: folder.NonContacts, + excludeArchived: folder.ExcludeArchived, + excludeRead: folder.ExcludeRead, + includeTypes: includeTypes, + includeIDs: includeIDs, + pinnedTypes: pinnedTypes, + pinnedIDs: pinnedIDs, + excludeTypes: excludeTypes, + excludeIDs: excludeIDs, + } +} + +func folderPeerArrays(peers []domain.DialogFolderPeer) ([]string, []int64) { + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, item := range peers { + peer := item.Peer + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + return peerTypes, peerIDs +} + +func decodeDialogFolder(data string) (domain.DialogFolder, error) { + if data == "" { + return domain.DialogFolder{}, nil + } + var folder domain.DialogFolder + if err := json.Unmarshal([]byte(data), &folder); err != nil { + return domain.DialogFolder{}, err + } + return folder, nil +} + +func decodeDialogDrafts(rows []string) ([]domain.DialogDraft, error) { + out := make([]domain.DialogDraft, 0, len(rows)) + for _, row := range rows { + draft, err := decodeDialogDraft(row) + if err != nil { + return nil, err + } + out = append(out, draft) + } + return out, nil +} + +func decodeDialogDraft(data string) (domain.DialogDraft, error) { + if data == "" { + return domain.DialogDraft{}, nil + } + var draft domain.DialogDraft + if err := json.Unmarshal([]byte(data), &draft); err != nil { + return domain.DialogDraft{}, fmt.Errorf("decode dialog draft: %w", err) + } + if draft.Entities == nil { + draft.Entities = []domain.MessageEntity{} + } + return draft, nil +} + +func clampDialogDraftLimit(limit int) int { + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + return domain.MaxDialogDraftsPerUser + } + return limit +} + +func peerArrays(peers []domain.Peer) ([]string, []int64) { + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + 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{}{} + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + return peerTypes, peerIDs +} + +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()) +} diff --git a/internal/store/postgres/dialog_folders_integration_test.go b/internal/store/postgres/dialog_folders_integration_test.go new file mode 100644 index 00000000..9f405ecd --- /dev/null +++ b/internal/store/postgres/dialog_folders_integration_test.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestDialogFoldersRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "+1666" + suffix + "01", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "+1666" + suffix + "02", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + stranger, err := users.Create(ctx, domain.User{AccessHash: 33, Phone: "+1666" + suffix + "03", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, stranger.ID}) + }) + if _, err := pool.Exec(ctx, ` + INSERT INTO contacts (user_id, contact_user_id, mutual) + VALUES ($1, $2, true) + `, owner.ID, friend.ID); err != nil { + t.Fatalf("insert contact: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO dialogs (user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, unread_count) + VALUES + ($1, 'user', $2, 0, 10, 1000, 0), + ($1, 'user', $3, 1, 9, 900, 1) + `, owner.ID, friend.ID, stranger.ID); err != nil { + t.Fatalf("insert dialogs: %v", err) + } + + dialogs := NewDialogStore(pool) + main, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list main folder: %v", err) + } + if len(main.Dialogs) != 1 || main.Dialogs[0].Peer.ID != friend.ID { + t.Fatalf("main dialogs = %+v, want friend only", main.Dialogs) + } + archive, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list archive folder: %v", err) + } + if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != stranger.ID || archive.Dialogs[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("archive dialogs = %+v, want archived stranger", archive.Dialogs) + } + + folder := domain.DialogFolder{ + ID: 2, + Title: "Work", + Contacts: true, + ExcludeArchived: true, + IncludePeers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, AccessHash: stranger.AccessHash}}, + } + if err := dialogs.UpsertFolder(ctx, owner.ID, folder); err != nil { + t.Fatalf("upsert folder: %v", err) + } + if err := dialogs.SetFolderTagsEnabled(ctx, owner.ID, true); err != nil { + t.Fatalf("set tags: %v", err) + } + folders, err := dialogs.ListFolders(ctx, owner.ID) + if err != nil { + t.Fatalf("list folders: %v", err) + } + if !folders.TagsEnabled || len(folders.Folders) != 1 || folders.Folders[0].Title != "Work" { + t.Fatalf("folders = %+v, want tags plus work folder", folders) + } + custom, err := dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: 2, Folder: &folder, Limit: 10}) + if err != nil { + t.Fatalf("list custom folder: %v", err) + } + if len(custom.Dialogs) != 1 || custom.Dialogs[0].Peer.ID != friend.ID { + t.Fatalf("custom dialogs = %+v, want contact only because archived explicit peer is excluded", custom.Dialogs) + } + if err := dialogs.EditPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: stranger.ID}, FolderID: domain.DialogMainFolderID}}); err != nil { + t.Fatalf("edit peer folders: %v", err) + } + archive, err = dialogs.ListByUser(ctx, owner.ID, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list archive after edit: %v", err) + } + if len(archive.Dialogs) != 0 { + t.Fatalf("archive after edit = %+v, want empty", archive.Dialogs) + } +} diff --git a/internal/store/postgres/dispatch_outbox.go b/internal/store/postgres/dispatch_outbox.go new file mode 100644 index 00000000..7f54baa1 --- /dev/null +++ b/internal/store/postgres/dispatch_outbox.go @@ -0,0 +1,140 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// defaultDispatchLease 是 'dispatching' 行被判定租约过期、可被重新 claim 的默认时长。 +// 与 docs/message-module.md 的 outbox 背压参数对应;生产由 config 注入覆盖。 +const defaultDispatchLease = 30 * time.Second + +// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。 +type DispatchOutboxStore struct { + q *sqlcgen.Queries + leaseSeconds int32 +} + +// DispatchOutboxOption 调整 DispatchOutboxStore 的 claim 行为。 +type DispatchOutboxOption func(*DispatchOutboxStore) + +// WithLeaseTimeout 设置租约超时;<=0 时保持默认。 +func WithLeaseTimeout(d time.Duration) DispatchOutboxOption { + return func(s *DispatchOutboxStore) { + if d > 0 { + s.leaseSeconds = int32(d / time.Second) + if s.leaseSeconds < 1 { + s.leaseSeconds = 1 + } + } + } +} + +// NewDispatchOutboxStore 基于 pgx 连接池(或事务)创建 DispatchOutboxStore。 +func NewDispatchOutboxStore(db sqlcgen.DBTX, opts ...DispatchOutboxOption) *DispatchOutboxStore { + s := &DispatchOutboxStore{ + q: sqlcgen.New(db), + leaseSeconds: int32(defaultDispatchLease / time.Second), + } + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + return s +} + +func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]store.DispatchOutboxItem, error) { + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + rows, err := s.q.ClaimDispatchOutbox(ctx, sqlcgen.ClaimDispatchOutboxParams{ + LeaseSeconds: s.leaseSeconds, + LimitCount: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("claim dispatch outbox: %w", err) + } + out := make([]store.DispatchOutboxItem, 0, len(rows)) + for _, row := range rows { + out = append(out, store.DispatchOutboxItem{ + ID: row.ID, + TargetUserID: row.TargetUserID, + Pts: int(row.Pts), + EventType: domain.UpdateEventType(row.EventType), + ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID), + ExcludeSessionID: row.ExcludeSessionID, + Attempts: int(row.Attempts), + }) + } + return out, nil +} + +// MarkDeliveredBatch 一次性删除一批已投递的 outbox 行(方案 A:投递成功即删),取代逐条 MarkDelivered。 +func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error { + if len(items) == 0 { + return nil + } + targetUserIDs := make([]int64, len(items)) + ids := make([]int64, len(items)) + for i, it := range items { + targetUserIDs[i] = it.TargetUserID + ids[i] = it.ID + } + if err := s.q.MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{ + TargetUserIds: targetUserIDs, + Ids: ids, + }); err != nil { + return fmt.Errorf("mark dispatch delivered batch: %w", err) + } + return nil +} + +func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, targetUserID, id int64) error { + if err := s.q.MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{ + TargetUserID: targetUserID, + ID: id, + }); err != nil { + return fmt.Errorf("mark dispatch delivered: %w", err) + } + return nil +} + +func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, targetUserID, id int64, lastError string) error { + if err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{ + TargetUserID: targetUserID, + ID: id, + LastError: lastError, + }); err != nil { + return fmt.Errorf("mark dispatch failed: %w", err) + } + return nil +} + +func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) { + if olderThan <= 0 { + olderThan = 24 * time.Hour + } + if limit <= 0 { + limit = 10000 + } + if limit > 100000 { + limit = 100000 + } + deleted, err := s.q.DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{ + OlderThanSeconds: int32(olderThan / time.Second), + LimitCount: int32(limit), + }) + if err != nil { + return 0, fmt.Errorf("delete failed dispatch outbox: %w", err) + } + return int(deleted), nil +} diff --git a/internal/store/postgres/help.go b/internal/store/postgres/help.go new file mode 100644 index 00000000..6074676a --- /dev/null +++ b/internal/store/postgres/help.go @@ -0,0 +1,141 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// HelpStore 用 PostgreSQL 实现 store.AppConfigStore 和 store.CountryStore。 +type HelpStore struct { + q *sqlcgen.Queries +} + +// NewHelpStore 基于 pgx 连接池(或事务)创建 HelpStore。 +func NewHelpStore(db sqlcgen.DBTX) *HelpStore { + return &HelpStore{q: sqlcgen.New(db)} +} + +func (s *HelpStore) GetAppConfig(ctx context.Context, client string) (domain.AppConfig, bool, error) { + row, err := s.q.GetAppConfig(ctx, client) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AppConfig{Client: client, JSON: []byte("{}")}, false, nil + } + return domain.AppConfig{}, false, fmt.Errorf("get app config: %w", err) + } + return domain.AppConfig{ + Client: row.Client, + Hash: int(row.Hash), + JSON: []byte(row.ConfigJson), + }, true, nil +} + +func (s *HelpStore) UpsertAppConfig(ctx context.Context, cfg domain.AppConfig) error { + if len(cfg.JSON) == 0 { + cfg.JSON = []byte("{}") + } + if err := s.q.UpsertAppConfig(ctx, sqlcgen.UpsertAppConfigParams{ + Client: cfg.Client, + Hash: int32(cfg.Hash), + ConfigJson: cfg.JSON, + }); err != nil { + return fmt.Errorf("upsert app config: %w", err) + } + return nil +} + +func (s *HelpStore) ListCountries(ctx context.Context, _ string) (domain.CountriesList, error) { + rows, err := s.q.ListCountries(ctx) + if err != nil { + return domain.CountriesList{}, fmt.Errorf("list countries: %w", err) + } + byISO := make(map[string]int) + out := domain.CountriesList{} + for _, row := range rows { + idx, ok := byISO[row.Iso2] + if !ok { + idx = len(out.Countries) + byISO[row.Iso2] = idx + out.Countries = append(out.Countries, domain.Country{ + ISO2: row.Iso2, + DefaultName: row.DefaultName, + Name: row.Name, + Hidden: row.Hidden, + }) + } + out.Countries[idx].CountryCodes = append(out.Countries[idx].CountryCodes, domain.CountryCode{ + CountryCode: row.CountryCode, + Prefixes: append([]string(nil), row.Prefixes...), + Patterns: append([]string(nil), row.Patterns...), + }) + } + out.Hash = countriesHash(out.Countries) + return out, nil +} + +func (s *HelpStore) UpsertCountries(ctx context.Context, countries []domain.Country) error { + for i, country := range countries { + if err := s.q.UpsertCountry(ctx, sqlcgen.UpsertCountryParams{ + Iso2: country.ISO2, + DefaultName: country.DefaultName, + Name: country.Name, + Hidden: country.Hidden, + OrderIndex: int32(i + 1), + }); err != nil { + return fmt.Errorf("upsert country %q: %w", country.ISO2, err) + } + for j, code := range country.CountryCodes { + if err := s.q.UpsertCountryCode(ctx, sqlcgen.UpsertCountryCodeParams{ + Iso2: country.ISO2, + CountryCode: code.CountryCode, + Prefixes: code.Prefixes, + Patterns: code.Patterns, + OrderIndex: int32(j + 1), + }); err != nil { + return fmt.Errorf("upsert country code %q/%q: %w", country.ISO2, code.CountryCode, err) + } + } + } + return nil +} + +func countriesHash(countries []domain.Country) int { + if len(countries) == 0 { + return 0 + } + h := fnv.New32a() + var buf [4]byte + for _, country := range countries { + _, _ = h.Write([]byte(country.ISO2)) + _, _ = h.Write([]byte(country.DefaultName)) + _, _ = h.Write([]byte(country.Name)) + if country.Hidden { + buf[0] = 1 + } else { + buf[0] = 0 + } + _, _ = h.Write(buf[:1]) + for _, code := range country.CountryCodes { + _, _ = h.Write([]byte(code.CountryCode)) + binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Prefixes))) + _, _ = h.Write(buf[:]) + for _, prefix := range code.Prefixes { + _, _ = h.Write([]byte(prefix)) + } + binary.LittleEndian.PutUint32(buf[:], uint32(len(code.Patterns))) + _, _ = h.Write(buf[:]) + for _, pattern := range code.Patterns { + _, _ = h.Write([]byte(pattern)) + } + } + } + return int(h.Sum32()) +} diff --git a/internal/store/postgres/langpack.go b/internal/store/postgres/langpack.go new file mode 100644 index 00000000..d74c22a7 --- /dev/null +++ b/internal/store/postgres/langpack.go @@ -0,0 +1,169 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// LangPackStore 用 PostgreSQL 实现 store.LangPackStore。 +type LangPackStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewLangPackStore 基于 pgx 连接池(或事务)创建 LangPackStore。 +func NewLangPackStore(db sqlcgen.DBTX) *LangPackStore { + return &LangPackStore{db: db, q: sqlcgen.New(db)} +} + +func (s *LangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + meta, found, err := s.meta(ctx, langPack, langCode) + if err != nil || !found { + return meta, err + } + meta.FromVersion = fromVersion + if meta.Version <= fromVersion { + return meta, nil + } + rows, err := s.q.ListLangPackStrings(ctx, sqlcgen.ListLangPackStringsParams{ + LangPack: langPack, + LangCode: langCode, + }) + if err != nil { + return domain.LangPack{}, fmt.Errorf("list lang pack strings: %w", err) + } + meta.Strings = make([]domain.LangPackString, 0, len(rows)) + for _, row := range rows { + meta.Strings = append(meta.Strings, langPackStringFromListRow(row)) + } + return meta, nil +} + +func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + meta, found, err := s.meta(ctx, langPack, langCode) + if err != nil || !found { + return meta, err + } + if len(keys) == 0 { + return s.GetPack(ctx, langPack, langCode, 0) + } + rows, err := s.q.GetLangPackStringsByKeys(ctx, sqlcgen.GetLangPackStringsByKeysParams{ + LangPack: langPack, + LangCode: langCode, + Keys: keys, + }) + if err != nil { + return domain.LangPack{}, fmt.Errorf("get lang pack strings: %w", err) + } + meta.Strings = make([]domain.LangPackString, 0, len(rows)) + for _, row := range rows { + meta.Strings = append(meta.Strings, langPackStringFromKeysRow(row)) + } + return meta, nil +} + +func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error { + if txer, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }); ok { + tx, err := txer.Begin(ctx) + if err != nil { + return fmt.Errorf("begin lang pack upsert: %w", err) + } + q := s.q.WithTx(tx) + if err := upsertPackWith(ctx, q, pack); err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit lang pack upsert: %w", err) + } + return nil + } + return upsertPackWith(ctx, s.q, pack) +} + +func upsertPackWith(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error { + if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{ + LangPack: pack.LangPack, + LangCode: pack.LangCode, + Version: int32(pack.Version), + StringsCount: int32(len(pack.Strings)), + }); err != nil { + return fmt.Errorf("upsert lang pack meta: %w", err) + } + for _, item := range pack.Strings { + if err := q.UpsertLangPackString(ctx, sqlcgen.UpsertLangPackStringParams{ + LangPack: pack.LangPack, + LangCode: pack.LangCode, + Key: item.Key, + Version: int32(pack.Version), + Pluralized: item.Pluralized, + Value: item.Value, + ZeroValue: item.ZeroValue, + OneValue: item.OneValue, + TwoValue: item.TwoValue, + FewValue: item.FewValue, + ManyValue: item.ManyValue, + OtherValue: item.OtherValue, + Deleted: item.Deleted, + }); err != nil { + return fmt.Errorf("upsert lang pack string %q: %w", item.Key, err) + } + } + return nil +} + +func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) { + row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{ + LangPack: langPack, + LangCode: langCode, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.LangPack{LangPack: langPack, LangCode: langCode}, false, nil + } + return domain.LangPack{}, false, fmt.Errorf("get lang pack meta: %w", err) + } + return domain.LangPack{ + LangPack: row.LangPack, + LangCode: row.LangCode, + Version: int(row.Version), + }, true, nil +} + +func langPackStringFromListRow(row sqlcgen.ListLangPackStringsRow) domain.LangPackString { + return domain.LangPackString{ + Key: row.Key, + Value: row.Value, + Pluralized: row.Pluralized, + ZeroValue: row.ZeroValue, + OneValue: row.OneValue, + TwoValue: row.TwoValue, + FewValue: row.FewValue, + ManyValue: row.ManyValue, + OtherValue: row.OtherValue, + Deleted: row.Deleted, + } +} + +func langPackStringFromKeysRow(row sqlcgen.GetLangPackStringsByKeysRow) domain.LangPackString { + return domain.LangPackString{ + Key: row.Key, + Value: row.Value, + Pluralized: row.Pluralized, + ZeroValue: row.ZeroValue, + OneValue: row.OneValue, + TwoValue: row.TwoValue, + FewValue: row.FewValue, + ManyValue: row.ManyValue, + OtherValue: row.OtherValue, + Deleted: row.Deleted, + } +} diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go new file mode 100644 index 00000000..5fc4a936 --- /dev/null +++ b/internal/store/postgres/media.go @@ -0,0 +1,509 @@ +package postgres + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// MediaStore 用 PostgreSQL 实现 store.MediaStore(媒体元数据 + blob 索引)。 +type MediaStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewMediaStore 基于 pgx 连接池(或事务)创建 MediaStore。 +func NewMediaStore(db sqlcgen.DBTX) *MediaStore { + return &MediaStore{db: db, q: sqlcgen.New(db)} +} + +// bytesOrEmpty 把 nil []byte 归一为空切片,避免落入 NOT NULL bytea 列时被当作 NULL。 +func bytesOrEmpty(b []byte) []byte { + if b == nil { + return []byte{} + } + return b +} + +var _ store.MediaStore = (*MediaStore)(nil) + +// ---- 上传分片 ---- + +func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error { + 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, + }) +} + +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 { + return nil, err + } + out := make([]domain.UploadPart, 0, len(rows)) + for _, r := range rows { + out = append(out, domain.UploadPart{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: int(r.Part), + TotalParts: int(r.TotalParts), + Big: r.IsBig, + Bytes: r.Bytes, + }) + } + return out, nil +} + +func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error { + return s.q.DeleteUploadParts(ctx, sqlcgen.DeleteUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) +} + +// ---- blob 索引 ---- + +func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error { + backend := string(blob.Backend) + if backend == "" { + backend = string(domain.MediaBackendLocalFS) + } + sha := blob.SHA256 + if sha == nil { + sha = []byte{} // 列为 NOT NULL;nil []byte 会被 pgx 当作 NULL。 + } + return s.q.PutFileBlob(ctx, sqlcgen.PutFileBlobParams{ + LocationKey: blob.LocationKey, + Backend: backend, + ObjectKey: blob.ObjectKey, + Size: blob.Size, + Sha256: sha, + MimeType: blob.MimeType, + }) +} + +func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) { + row, err := s.q.GetFileBlob(ctx, locationKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.FileBlob{}, false, nil + } + return domain.FileBlob{}, false, err + } + return domain.FileBlob{ + LocationKey: row.LocationKey, + Backend: domain.MediaBackend(row.Backend), + ObjectKey: row.ObjectKey, + Size: row.Size, + SHA256: row.Sha256, + MimeType: row.MimeType, + }, true, nil +} + +// ---- 文档 ---- + +func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error { + attrs, err := jsonArrayOrEmpty(doc.Attributes) + if err != nil { + return err + } + thumbs, err := jsonArrayOrEmpty(doc.Thumbs) + if err != nil { + return err + } + return s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{ + ID: doc.ID, + AccessHash: doc.AccessHash, + FileReference: bytesOrEmpty(doc.FileReference), + Date: int32(doc.Date), + MimeType: doc.MimeType, + Size: doc.Size, + DcID: int32(doc.DCID), + AttributesJson: attrs, + ThumbsJson: thumbs, + }) +} + +func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { + row, err := s.q.GetDocument(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Document{}, false, nil + } + return domain.Document{}, false, err + } + doc, err := documentFromRow(row) + if err != nil { + return domain.Document{}, false, err + } + return doc, true, nil +} + +func (s *MediaStore) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := s.q.GetDocuments(ctx, ids) + if err != nil { + return nil, err + } + out := make([]domain.Document, 0, len(rows)) + for _, r := range rows { + doc, err := documentFromRow(sqlcgen.GetDocumentRow(r)) + if err != nil { + return nil, err + } + out = append(out, doc) + } + return out, nil +} + +func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) { + attrs, err := decodeDocumentAttributes(row.AttributesJson) + if err != nil { + return domain.Document{}, err + } + thumbs, err := decodePhotoSizes(row.ThumbsJson) + if err != nil { + return domain.Document{}, err + } + return domain.Document{ + ID: row.ID, + AccessHash: row.AccessHash, + FileReference: row.FileReference, + Date: int(row.Date), + MimeType: row.MimeType, + Size: row.Size, + DCID: int(row.DcID), + Attributes: attrs, + Thumbs: thumbs, + }, nil +} + +// ---- 照片 ---- + +func (s *MediaStore) PutPhoto(ctx context.Context, photo domain.Photo) error { + sizes, err := jsonArrayOrEmpty(photo.Sizes) + if err != nil { + return err + } + return s.q.PutPhoto(ctx, sqlcgen.PutPhotoParams{ + ID: photo.ID, + AccessHash: photo.AccessHash, + FileReference: bytesOrEmpty(photo.FileReference), + Date: int32(photo.Date), + DcID: int32(photo.DCID), + HasStickers: photo.HasStickers, + SizesJson: sizes, + }) +} + +func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) { + row, err := s.q.GetPhoto(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Photo{}, false, nil + } + return domain.Photo{}, false, err + } + sizes, err := decodePhotoSizes(row.SizesJson) + if err != nil { + return domain.Photo{}, false, err + } + return domain.Photo{ + ID: row.ID, + AccessHash: row.AccessHash, + FileReference: row.FileReference, + Date: int(row.Date), + DCID: int(row.DcID), + HasStickers: row.HasStickers, + Sizes: sizes, + }, true, nil +} + +// ---- 贴纸集 ---- + +func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) error { + thumbs, err := jsonArrayOrEmpty(set.Thumbs) + if err != nil { + return err + } + docIDs, err := jsonArrayOrEmpty(set.DocumentIDs) + if err != nil { + return err + } + packs, err := jsonArrayOrEmpty(set.Packs) + if err != nil { + return err + } + kind := string(set.Kind) + if kind == "" { + kind = string(domain.StickerSetKindStickers) + } + return s.q.PutStickerSet(ctx, sqlcgen.PutStickerSetParams{ + ID: set.ID, + AccessHash: set.AccessHash, + ShortName: set.ShortName, + Title: set.Title, + Count: int32(set.Count), + Hash: int32(set.Hash), + SetKind: kind, + Official: set.Official, + Animated: set.Animated, + Videos: set.Videos, + Emojis: set.Emojis, + Masks: set.Masks, + Installed: set.Installed, + Archived: set.Archived, + InstalledDate: int32(set.InstalledDate), + ThumbDocumentID: set.ThumbDocumentID, + ThumbsJson: thumbs, + ThumbDcID: int32(set.ThumbDCID), + ThumbVersion: int32(set.ThumbVersion), + DocumentIdsJson: docIDs, + PacksJson: packs, + SortOrder: int32(set.SortOrder), + SystemKey: set.SystemKey, + }) +} + +func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { + row, err := s.q.GetStickerSetByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return stickerSetFromRow(row) +} + +func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) { + row, err := s.q.GetStickerSetByShortName(ctx, shortName) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) +} + +func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) { + row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) +} + +func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + rows, err := s.q.ListStickerSetsByKind(ctx, string(kind)) + if err != nil { + return nil, err + } + out := make([]domain.StickerSet, 0, len(rows)) + for _, r := range rows { + set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r)) + if err != nil { + return nil, err + } + out = append(out, set) + } + return out, nil +} + +func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) { + n, err := s.q.CountStickerSets(ctx) + return int(n), err +} + +func stickerSetFromRow(row sqlcgen.GetStickerSetByIDRow) (domain.StickerSet, bool, error) { + thumbs, err := decodePhotoSizes(row.ThumbsJson) + if err != nil { + return domain.StickerSet{}, false, err + } + docIDs, err := decodeInt64Slice(row.DocumentIdsJson) + if err != nil { + return domain.StickerSet{}, false, err + } + packs, err := decodeStickerPacks(row.PacksJson) + if err != nil { + return domain.StickerSet{}, false, err + } + return domain.StickerSet{ + ID: row.ID, + AccessHash: row.AccessHash, + ShortName: row.ShortName, + Title: row.Title, + Count: int(row.Count), + Hash: int(row.Hash), + Kind: domain.StickerSetKind(row.SetKind), + Official: row.Official, + Animated: row.Animated, + Videos: row.Videos, + Emojis: row.Emojis, + Masks: row.Masks, + Installed: row.Installed, + Archived: row.Archived, + InstalledDate: int(row.InstalledDate), + ThumbDocumentID: row.ThumbDocumentID, + Thumbs: thumbs, + ThumbDCID: int(row.ThumbDcID), + ThumbVersion: int(row.ThumbVersion), + DocumentIDs: docIDs, + Packs: packs, + SortOrder: int(row.SortOrder), + SystemKey: row.SystemKey, + }, true, nil +} + +// ---- 可用 reaction ---- + +func (s *MediaStore) PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error { + return s.q.PutAvailableReaction(ctx, sqlcgen.PutAvailableReactionParams{ + Reaction: r.Reaction, + Title: r.Title, + Inactive: r.Inactive, + Premium: r.Premium, + StaticIconID: r.StaticIconID, + AppearAnimationID: r.AppearAnimationID, + SelectAnimationID: r.SelectAnimationID, + ActivateAnimationID: r.ActivateAnimationID, + EffectAnimationID: r.EffectAnimationID, + AroundAnimationID: r.AroundAnimationID, + CenterIconID: r.CenterIconID, + SortOrder: int32(r.Order), + }) +} + +func (s *MediaStore) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) { + rows, err := s.q.ListAvailableReactions(ctx) + if err != nil { + return nil, err + } + out := make([]domain.AvailableReaction, 0, len(rows)) + for _, r := range rows { + out = append(out, domain.AvailableReaction{ + Reaction: r.Reaction, + Title: r.Title, + Inactive: r.Inactive, + Premium: r.Premium, + StaticIconID: r.StaticIconID, + AppearAnimationID: r.AppearAnimationID, + SelectAnimationID: r.SelectAnimationID, + ActivateAnimationID: r.ActivateAnimationID, + EffectAnimationID: r.EffectAnimationID, + AroundAnimationID: r.AroundAnimationID, + CenterIconID: r.CenterIconID, + Order: int(r.SortOrder), + }) + } + return out, nil +} + +func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) { + n, err := s.q.CountAvailableReactions(ctx) + return int(n), err +} + +// ---- 头像历史 ---- + +func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error { + next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + }) + if err != nil { + return err + } + return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + PhotoID: photoID, + Date: int32(date), + SortOrder: next + 1, + }) +} + +func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) { + id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, false, nil + } + return 0, false, err + } + return id, true, nil +} + +func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + if len(ownerIDs) == 0 { + return map[int64]domain.ProfilePhotoRef{}, nil + } + rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{ + OwnerPeerType: string(ownerType), + OwnerIds: ownerIDs, + }) + if err != nil { + return nil, err + } + out := make(map[int64]domain.ProfilePhotoRef, len(rows)) + for _, r := range rows { + sizes, err := decodePhotoSizes(r.SizesJson) + if err != nil { + return nil, err + } + out[r.OwnerPeerID] = domain.ProfilePhotoRef{ + PhotoID: r.PhotoID, + DCID: int(r.DcID), + Stripped: domain.StrippedFromSizes(sizes), + } + } + return out, nil +} + +func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) { + ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + MaxID: maxID, + OffsetCount: int32(offset), + LimitCount: int32(limit), + }) + if err != nil { + return nil, 0, err + } + total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + }) + if err != nil { + return nil, 0, err + } + return ids, int(total), nil +} + +func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) { + if len(photoIDs) == 0 { + return nil, nil + } + return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{ + OwnerPeerType: string(ownerType), + OwnerPeerID: ownerID, + PhotoIds: photoIDs, + }) +} diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go new file mode 100644 index 00000000..28353c0c --- /dev/null +++ b/internal/store/postgres/media_codec.go @@ -0,0 +1,88 @@ +package postgres + +import ( + "encoding/json" + + "telesrv/internal/domain" +) + +// 媒体相关 JSON 编解码:domain 值对象 ↔ JSONB 列。domain.* 带 json tag,可直接 marshal。 + +// jsonArrayOrEmpty 把切片序列化为 JSONB;nil 序列化为 "[]"(列为 NOT NULL DEFAULT '[]')。 +func jsonArrayOrEmpty(v any) ([]byte, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + if string(b) == "null" { + return []byte("[]"), nil + } + return b, nil +} + +// encodeMessageMedia 把消息媒体快照序列化为 JSONB;无媒体序列化为 "{}"。 +func encodeMessageMedia(m *domain.MessageMedia) ([]byte, error) { + if m.IsZero() { + return []byte("{}"), nil + } + return json.Marshal(m) +} + +// decodeMessageMedia 把消息行的 media JSONB 文本还原为 *MessageMedia;空载荷返回 nil。 +func decodeMessageMedia(s string) (*domain.MessageMedia, error) { + if s == "" || s == "{}" || s == "null" { + return nil, nil + } + var m domain.MessageMedia + 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 + } + var out []domain.PhotoSize + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func decodeDocumentAttributes(s string) ([]domain.DocumentAttribute, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []domain.DocumentAttribute + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func decodeInt64Slice(s string) ([]int64, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []int64 + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} + +func decodeStickerPacks(s string) ([]domain.StickerPack, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []domain.StickerPack + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/store/postgres/media_integration_test.go b/internal/store/postgres/media_integration_test.go new file mode 100644 index 00000000..3331062d --- /dev/null +++ b/internal/store/postgres/media_integration_test.go @@ -0,0 +1,210 @@ +package postgres + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" +) + +// TestMediaStoreRoundTrip 验证 MediaStore 各表的写读往返(含 nil bytea 归一、JSONB attributes/sizes、 +// 头像历史 current/list/delete、上传分片)。直接证明媒体元数据落 PG 后可原样读回。 +func TestMediaStoreRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + s := NewMediaStore(pool) + + const docID = int64(9100000000000000001) + const photoID = int64(9100000000000000002) + const setID = int64(9100000000000000003) + const ownerID = int64(9100000000000000099) + const reactionEmoji = "\U0001f9ea" + + cleanupMediaStoreRoundTripRows(t, ctx, pool) + t.Cleanup(func() { + cleanupMediaStoreRoundTripRows(t, context.Background(), pool) + }) + + // ---- file blob(nil sha256 应被归一为空,不报 NOT NULL)---- + if err := s.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: "doc:9100000000000000001", + ObjectKey: "ab/cd/abcdef", + Size: 1234, + MimeType: "application/x-tgsticker", + }); err != nil { + t.Fatalf("put file blob (nil sha256): %v", err) + } + blob, ok, err := s.GetFileBlob(ctx, "doc:9100000000000000001") + if err != nil || !ok { + t.Fatalf("get file blob: ok=%v err=%v", ok, err) + } + if blob.ObjectKey != "ab/cd/abcdef" || blob.Size != 1234 || blob.Backend != domain.MediaBackendLocalFS { + t.Fatalf("file blob mismatch: %+v", blob) + } + + // ---- document(含 sticker 属性 + thumbs JSONB;nil file_reference 路径)---- + doc := domain.Document{ + ID: docID, + AccessHash: 77, + DCID: 2, + MimeType: "application/x-tgsticker", + Size: 2048, + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrImageSize, W: 512, H: 512}, + {Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: setID, StickerSetAccessHash: 5}, + }, + Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindPath, Type: "j", Bytes: []byte{1, 2, 3}}}, + } + if err := s.PutDocument(ctx, doc); err != nil { + t.Fatalf("put document: %v", err) + } + got, ok, err := s.GetDocument(ctx, docID) + if err != nil || !ok { + t.Fatalf("get document: ok=%v err=%v", ok, err) + } + if got.DCID != 2 || len(got.Attributes) != 2 || len(got.Thumbs) != 1 { + t.Fatalf("document mismatch: %+v", got) + } + if id, hash, ok := got.StickerSetRef(); !ok || id != setID || hash != 5 { + t.Fatalf("document sticker set ref = (%d,%d,%v)", id, hash, ok) + } + docs, err := s.GetDocuments(ctx, []int64{docID}) + if err != nil || len(docs) != 1 { + t.Fatalf("get documents: n=%d err=%v", len(docs), err) + } + + // ---- photo(sizes JSONB)---- + photo := domain.Photo{ + ID: photoID, + AccessHash: 88, + DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600, Size: 4096}}, + } + if err := s.PutPhoto(ctx, photo); err != nil { + t.Fatalf("put photo: %v", err) + } + gotPhoto, ok, err := s.GetPhoto(ctx, photoID) + if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" { + t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto) + } + + // ---- sticker set ---- + set := domain.StickerSet{ + ID: setID, + AccessHash: 5, + ShortName: "telesrv_test_set_9100000000000000003", + Title: "Test Set", + Count: 1, + Kind: domain.StickerSetKindStickers, + Animated: true, + Installed: true, + DocumentIDs: []int64{docID}, + Packs: []domain.StickerPack{{Emoticon: "\U0001f600", DocumentIDs: []int64{docID}}}, + SystemKey: "test_system_9100000000000000003", + } + if err := s.PutStickerSet(ctx, set); err != nil { + t.Fatalf("put sticker set: %v", err) + } + byID, ok, err := s.GetStickerSetByID(ctx, setID) + if err != nil || !ok || len(byID.DocumentIDs) != 1 || len(byID.Packs) != 1 { + t.Fatalf("get sticker set by id: ok=%v err=%v set=%+v", ok, err, byID) + } + if byShort, ok, _ := s.GetStickerSetByShortName(ctx, set.ShortName); !ok || byShort.ID != setID { + t.Fatalf("get sticker set by short name failed: ok=%v", ok) + } + if bySys, ok, _ := s.GetStickerSetBySystemKey(ctx, set.SystemKey); !ok || bySys.ID != setID { + t.Fatalf("get sticker set by system key failed: ok=%v", ok) + } + + // ---- available reaction ---- + if err := s.PutAvailableReaction(ctx, domain.AvailableReaction{ + Reaction: reactionEmoji, Title: "Test", StaticIconID: docID, SelectAnimationID: docID, Order: 9999, + }); err != nil { + t.Fatalf("put available reaction: %v", err) + } + reactions, err := s.ListAvailableReactions(ctx) + if err != nil { + t.Fatalf("list available reactions: %v", err) + } + foundReaction := false + for _, r := range reactions { + if r.Reaction == reactionEmoji { + foundReaction = true + if r.StaticIconID != docID { + t.Fatalf("reaction static icon id = %d", r.StaticIconID) + } + } + } + if !foundReaction { + t.Fatal("inserted reaction not found in list") + } + + // ---- profile photo 历史 ---- + if err := s.AddProfilePhoto(ctx, domain.PeerTypeUser, ownerID, photoID, 1700000000); err != nil { + t.Fatalf("add profile photo: %v", err) + } + cur, ok, err := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID) + if err != nil || !ok || cur != photoID { + t.Fatalf("current profile photo = (%d,%v,%v)", cur, ok, err) + } + refs, err := s.CurrentProfilePhotos(ctx, domain.PeerTypeUser, []int64{ownerID}) + 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) + if err != nil || total < 1 || len(ids) < 1 { + t.Fatalf("list profile photos: ids=%v total=%d err=%v", ids, total, err) + } + deleted, err := s.DeleteProfilePhotos(ctx, domain.PeerTypeUser, ownerID, []int64{photoID}) + 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 { + 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 { + t.Fatalf("save file part: %v", err) + } + parts, err := s.LoadFileParts(ctx, ownerID, 555) + if err != nil || len(parts) != 1 || string(parts[0].Bytes) != "hello" { + t.Fatalf("load file parts: parts=%+v err=%v", parts, err) + } + if err := s.DeleteFileParts(ctx, ownerID, 555); err != nil { + t.Fatalf("delete file parts: %v", err) + } + if parts, _ := s.LoadFileParts(ctx, ownerID, 555); len(parts) != 0 { + t.Fatal("file parts not cleared") + } +} + +func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + + const docID = int64(9100000000000000001) + const photoID = int64(9100000000000000002) + const setID = int64(9100000000000000003) + const ownerID = int64(9100000000000000099) + const reactionEmoji = "\U0001f9ea" + + statements := []struct { + sql string + args []any + }{ + {sql: "DELETE FROM upload_parts WHERE owner_user_id = $1 AND file_id = 555", 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}}, + {sql: "DELETE FROM file_blobs WHERE location_key = 'doc:9100000000000000001'"}, + {sql: "DELETE FROM documents WHERE id = $1", args: []any{docID}}, + {sql: "DELETE FROM photos WHERE id = $1", args: []any{photoID}}, + } + for _, stmt := range statements { + if _, err := pool.Exec(ctx, stmt.sql, stmt.args...); err != nil { + t.Fatalf("cleanup media store round trip rows: %v", err) + } + } +} diff --git a/internal/store/postgres/message.go b/internal/store/postgres/message.go new file mode 100644 index 00000000..ce65e426 --- /dev/null +++ b/internal/store/postgres/message.go @@ -0,0 +1,2561 @@ +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 + if !selfMessage { + 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, + } + 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 := sender + if !selfMessage { + 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, + } + 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 := sender + if recipientUserID != senderUserID { + recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: recipientUserID, + PrivateMessageID: pm.ID, + }) + if err != 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, + }) + 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, + }) + 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 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 + } + rows, err := s.db.Query(ctx, ` +SELECT box_id +FROM message_boxes +WHERE owner_user_id = $1 + AND box_id = ANY($2::int[]) + AND NOT deleted +ORDER BY box_id`, req.OwnerUserID, ids) + if err != nil { + return res, fmt.Errorf("read message contents: %w", err) + } + defer rows.Close() + for rows.Next() { + var id int32 + if err := rows.Scan(&id); err != nil { + return res, fmt.Errorf("scan read message contents: %w", err) + } + res.MessageIDs = append(res.MessageIDs, int(id)) + } + if err := rows.Err(); err != nil { + return res, fmt.Errorf("read message contents rows: %w", err) + } + 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) + } + } + + 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), + } +} + +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), + } +} + +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), + }, 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), + }, 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), + }, 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), + }, 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_concurrent_integration_test.go b/internal/store/postgres/message_concurrent_integration_test.go new file mode 100644 index 00000000..26862339 --- /dev/null +++ b/internal/store/postgres/message_concurrent_integration_test.go @@ -0,0 +1,114 @@ +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateTextConcurrentNoPtsGap 是「Redis 分配移出事务」的正确性核心证明: +// N 条消息并发 sender→recipient 发送,全部成功提交后,接收方账号事件 pts 必须严格连续 1..N +// (无空洞、无重复、无丢失),且 MaxContiguousPts == N。 +// 用 perUserCounterAllocator(mutex 单调自增,忠实复刻 Redis INCR 原子性)驱动分配器, +// 验证事务外分配 + 连续 pts 兜底在高并发下不丢消息,不引入 Redis 依赖。 +func TestSendPrivateTextConcurrentNoPtsGap(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: "+1999" + suffix + "01", + FirstName: "ConcSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1999" + suffix + "02", + FirstName: "ConcRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + // 按 FK 依赖序清理子表(message_boxes.from_user_id 为 RESTRICT)再删用户。 + _, _ = 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{}, &perUserCounterAllocator{})) + + const n = 200 + base := time.Now().UnixNano() + date := int(time.Now().Unix()) + errs := make([]error, n) + recipPts := make([]int, n) + sem := make(chan struct{}, 32) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + sem <- struct{}{} + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: base + int64(i), + Message: "concurrent body", + Date: date, + }) + errs[i] = err + recipPts[i] = res.RecipientMessage.Pts + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("send %d: %v", i, err) + } + } + + // 进程内检测 allocator 是否给接收方分配了重复 pts。 + seen := map[int]int{} + for i := 0; i < n; i++ { + if prev, ok := seen[recipPts[i]]; ok { + t.Errorf("recipient pts %d 被发送 #%d 和 #%d 同时分配(allocator 并发重复)", recipPts[i], prev, i) + } + seen[recipPts[i]] = i + } + + // 接收方应有 n 条事件,pts 连续 1..n(无空洞无重复无丢失)。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, n+10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + if len(got) != n { + t.Fatalf("recipient events = %d, want %d(无丢失无重复)", len(got), n) + } + for i, ev := range got { + if ev.Pts != i+1 { + t.Fatalf("recipient event[%d].Pts = %d, want %d(pts 必须连续无洞)", i, ev.Pts, i+1) + } + } + contig, err := events.MaxContiguousPts(ctx, recipient.ID) + if err != nil { + t.Fatalf("MaxContiguousPts: %v", err) + } + if contig != n { + t.Fatalf("MaxContiguousPts(recipient) = %d, want %d", contig, n) + } +} diff --git a/internal/store/postgres/message_deadlock_integration_test.go b/internal/store/postgres/message_deadlock_integration_test.go new file mode 100644 index 00000000..7ea30176 --- /dev/null +++ b/internal/store/postgres/message_deadlock_integration_test.go @@ -0,0 +1,119 @@ +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestMessageStoreBidirectionalConcurrencyNoDeadlock 验证 watermark/dialog 死锁修复(advisory lock)。 +// +// 背景:send/read/edit/delete 在一个事务内会按业务顺序锁住收发双方的 user_update_watermarks 与 +// channel/private dialog 行。A→B 与 B→A 反向并发时,两个事务以相反顺序竞争同一对用户的这些行 +// (watermark[A]→watermark[B] vs watermark[B]→watermark[A],dialog(A,B)→dialog(B,A) vs 反向), +// 形成 AB-BA 死锁——PostgreSQL 会检测并 abort 其中一个事务(SQLSTATE 40P01),表现为操作返回错误。 +// +// 修复:每个写事务在任何行锁之前,用事务级 advisory lock 按 user_id 升序锁住涉及的用户 +// (lockUsersForUpdate),把同一对用户的并发写事务串行化。advisory 与行锁处于独立锁空间且升序获取, +// 既不与行锁交叉成新死锁,也消除了 watermark 与 dialog 的 AB-BA。本测试在高并发反向负载下应 +// 全部成功、零错误;若死锁回归,会以 40P01 错误形式被捕获。 +func TestMessageStoreBidirectionalConcurrencyNoDeadlock(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + a, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1997" + suffix + "01", FirstName: "BidiA"}) + if err != nil { + t.Fatalf("create a: %v", err) + } + b, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1997" + suffix + "02", FirstName: "BidiB"}) + if err != nil { + t.Fatalf("create b: %v", err) + } + ids := []int64{a.ID, b.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 user_update_watermarks 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{}, &perUserCounterAllocator{})) + date := int(time.Now().Unix()) + + var ridMu sync.Mutex + rid := time.Now().UnixNano() + nextRID := func() int64 { + ridMu.Lock() + defer ridMu.Unlock() + rid++ + return rid + } + + // 预热:双向各发若干条,建立双向 dialog 与未读历史,使后续 read 真正推进 watermark(命中行锁)。 + for i := 0; i < 4; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "warmup a->b", Date: date}); err != nil { + t.Fatalf("warmup a->b: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "warmup b->a", Date: date}); err != nil { + t.Fatalf("warmup b->a: %v", err) + } + } + + // 反向并发:每轮同时发起 A→B send、B→A send、A 读 B、B 读 A,goroutine 一起抢同一对用户的行锁。 + const rounds = 80 + var wg sync.WaitGroup + errCh := make(chan error, rounds*4) + 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 r := 0; r < rounds; r++ { + submit(func() error { + _, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: nextRID(), Message: "a->b", Date: date}) + return e + }) + submit(func() error { + _, e := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: nextRID(), Message: "b->a", Date: date}) + return e + }) + submit(func() error { + _, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: a.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}, MaxID: domain.MaxMessageBoxID, Date: date}) + return e + }) + submit(func() error { + _, e := messages.ReadHistory(ctx, domain.ReadHistoryRequest{OwnerUserID: b.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: a.ID}, MaxID: domain.MaxMessageBoxID, Date: date}) + return e + }) + } + wg.Wait() + close(errCh) + + failed := 0 + for e := range errCh { + failed++ + if failed <= 5 { + t.Errorf("反向并发操作失败(疑似死锁回归): %v", e) + } + } + if failed > 0 { + t.Fatalf("%d/%d 反向并发操作失败", failed, rounds*4) + } +} diff --git a/internal/store/postgres/message_integration_test.go b/internal/store/postgres/message_integration_test.go new file mode 100644 index 00000000..42391258 --- /dev/null +++ b/internal/store/postgres/message_integration_test.go @@ -0,0 +1,813 @@ +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_media_integration_test.go b/internal/store/postgres/message_media_integration_test.go new file mode 100644 index 00000000..e377c9ff --- /dev/null +++ b/internal/store/postgres/message_media_integration_test.go @@ -0,0 +1,181 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateMediaSurvivesUpdateEvent 验证带 media 的私聊消息: +// - 发送后 message_boxes 持久化 media 快照; +// - 接收方经 UpdateEventStore.ListAfter(在线 outbox / 离线 getDifference 共用的重建路径) +// 能拿回 media(曾因 update event 查询漏选 m.media 导致收件人/离线丢媒体,本测试守护该修复); +// - history(ListByUser)读取也带 media。 +func TestSendPrivateMediaSurvivesUpdateEvent(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: "+1998" + suffix + "01", FirstName: "MediaSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1998" + suffix + "02", FirstName: "MediaRecipient"}) + 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{}, &perUserCounterAllocator{})) + + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ + ID: 9200000000000000001, + AccessHash: 9, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "\U0001f600", StickerSetID: 5, StickerSetAccessHash: 7}}, + }, + } + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "", // 仅媒体(无 caption) + Media: media, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send private media: %v", err) + } + + // 发送结果双端均带 media。 + for name, msg := range map[string]domain.Message{"sender": res.SenderMessage, "recipient": res.RecipientMessage} { + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindDocument || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID { + t.Fatalf("%s message media lost: %+v", name, msg.Media) + } + } + + // 关键:接收方经更新事件重建(在线推送 / 离线 difference 共用路径)仍带 media。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + var found bool + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 { + found = true + if ev.Message.Media == nil || ev.Message.Media.Document == nil || ev.Message.Media.Document.ID != media.Document.ID { + t.Fatalf("recipient update-event message lost media: %+v", ev.Message.Media) + } + } + } + if !found { + t.Fatal("no new_message event for recipient") + } + + // history 读取也带 media。 + 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 || list.Messages[0].Media == nil || list.Messages[0].Media.Document == nil { + t.Fatalf("history message lost media: %+v", list.Messages) + } +} + +func TestSendChannelMediaSurvivesDifference(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 63, Phone: "+1998" + suffix + "03", FirstName: "MediaChannelOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 64, Phone: "+1998" + suffix + "04", FirstName: "MediaChannelMember"}) + 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: "Media Difference " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ + ID: 9200000000000000002, + AccessHash: 10, + DCID: 2, + MimeType: "application/octet-stream", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "telesrv-media.bin"}}, + }, + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: time.Now().UnixNano(), + Message: "", + Media: media, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send channel media: %v", err) + } + if sent.Message.Media == nil || sent.Message.Media.Document == nil || sent.Message.Media.Document.ID != media.Document.ID { + t.Fatalf("send result lost media: %+v", sent.Message.Media) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: member.ID, + ChannelID: channelID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + var found bool + for _, msg := range diff.NewMessages { + if msg.ID == sent.Message.ID { + found = true + if msg.Media == nil || msg.Media.Document == nil || msg.Media.Document.ID != media.Document.ID { + t.Fatalf("channel difference message lost media: %+v", msg.Media) + } + } + } + if !found { + t.Fatalf("sent media message %d not found in channel difference: %+v", sent.Message.ID, diff.NewMessages) + } +} diff --git a/internal/store/postgres/message_plan_integration_test.go b/internal/store/postgres/message_plan_integration_test.go new file mode 100644 index 00000000..1363bfd6 --- /dev/null +++ b/internal/store/postgres/message_plan_integration_test.go @@ -0,0 +1,160 @@ +package postgres + +import ( + "context" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func TestMessagePartitionSeekPlansUseIndexes(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: "+1999" + suffix + "01", + FirstName: "PlanSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1999" + suffix + "02", + FirstName: "PlanRecipient", + }) + 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) + for i := 0; i < 3; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: int64(9000 + i), + Message: "plan check", + Date: 1700000300 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + if _, err := pool.Exec(ctx, ` + UPDATE dispatch_outbox + SET status = 'dispatching', + updated_at = now() - interval '1 minute' + WHERE target_user_id = $1 + `, recipient.ID); err != nil { + t.Fatalf("mark dispatch stale: %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) + } + + historyPlan := explainText(t, ctx, tx, ` +SELECT box_id +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = 'user' + AND peer_id = $2 + AND NOT deleted + AND box_id < $3 +ORDER BY box_id DESC +LIMIT 20 +`, recipient.ID, sender.ID, 100000) + requirePlanUsesPartitionIndex(t, historyPlan, "message_boxes") + requirePlanNotContains(t, historyPlan, "Append") + + updatesPlan := explainText(t, ctx, tx, ` +SELECT pts +FROM user_update_events +WHERE user_id = $1 + AND pts > $2 +ORDER BY pts ASC +LIMIT 100 +`, recipient.ID, 0) + requirePlanUsesPartitionIndex(t, updatesPlan, "user_update_events") + requirePlanNotContains(t, updatesPlan, "Append") + + dispatchPlan := explainText(t, ctx, tx, ` +WITH picked AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE ( + status = 'pending' + AND next_attempt_at <= now() + ) + OR ( + status = 'dispatching' + AND updated_at < now() - interval '30 seconds' + ) + ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + LIMIT 100 + FOR UPDATE SKIP LOCKED +) +SELECT target_user_id, id +FROM picked +`) + requirePlanContains(t, dispatchPlan, "dispatch_outbox_p") + requirePlanContains(t, dispatchPlan, "Index") + requirePlanNotContains(t, dispatchPlan, "Seq Scan") +} + +func explainText(t *testing.T, ctx context.Context, tx pgx.Tx, query string, args ...any) string { + t.Helper() + rows, err := tx.Query(ctx, "EXPLAIN (COSTS OFF) "+query, args...) + if err != nil { + t.Fatalf("explain query: %v", err) + } + defer rows.Close() + + var b strings.Builder + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatalf("scan explain row: %v", err) + } + b.WriteString(line) + b.WriteByte('\n') + } + if err := rows.Err(); err != nil { + t.Fatalf("read explain rows: %v", err) + } + return b.String() +} + +func requirePlanUsesPartitionIndex(t *testing.T, plan, table string) { + t.Helper() + requirePlanContains(t, plan, table+"_p") + requirePlanContains(t, plan, "Index") + requirePlanNotContains(t, plan, "Seq Scan") +} + +func requirePlanContains(t *testing.T, plan string, needle string) { + t.Helper() + if !strings.Contains(plan, needle) { + t.Fatalf("plan missing %q:\n%s", needle, plan) + } +} + +func requirePlanNotContains(t *testing.T, plan string, needle string) { + t.Helper() + if strings.Contains(plan, needle) { + t.Fatalf("plan contains %q:\n%s", needle, plan) + } +} diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go new file mode 100644 index 00000000..f258889d --- /dev/null +++ b/internal/store/postgres/postgres.go @@ -0,0 +1,128 @@ +// Package postgres 用 PostgreSQL 实现持久化存储接口(第一阶段:AuthKeyStore)。 +// +// 查询代码由 sqlc 生成于 ./sqlcgen(见 telesrv/sqlc.yaml);本包在其上实现 store 接口。 +package postgres + +import ( + "context" + "errors" + "fmt" + "strings" + + "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/pgxpool" + + "telesrv/deploy" +) + +const defaultMinConns = 16 + +// PoolOption 调整 pgxpool 连接池配置。 +type PoolOption func(*pgxpool.Config) + +// WithMaxConns 设置连接池最大连接数;<=0 时保持 pgx 默认。 +// 同时把 MinConns 预热到 min(maxConns, 16),降低 TDesktop 启动风暴下的冷连接尾延迟突刺。 +func WithMaxConns(n int) PoolOption { + return func(cfg *pgxpool.Config) { + if n <= 0 { + return + } + cfg.MaxConns = int32(n) + minConns := int32(defaultMinConns) + if int32(n) < minConns { + minConns = int32(n) + } + cfg.MinConns = minConns + } +} + +// WithMinConns 设置启动时预热的最小连接数;<=0 保持既有配置。 +func WithMinConns(n int) PoolOption { + return func(cfg *pgxpool.Config) { + if n <= 0 { + return + } + minConns := int32(n) + if cfg.MaxConns > 0 && minConns > cfg.MaxConns { + minConns = cfg.MaxConns + } + cfg.MinConns = minConns + } +} + +// Open 建立 pgxpool 连接池并 ping 验证。 +func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("pgxpool parse config: %w", err) + } + for _, opt := range opts { + if opt != nil { + opt(cfg) + } + } + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("pgxpool new: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pg ping: %w", err) + } + if err := warmMinConns(ctx, pool); err != nil { + pool.Close() + return nil, err + } + return pool, nil +} + +func warmMinConns(ctx context.Context, pool *pgxpool.Pool) error { + target := pool.Config().MinConns + if target <= 0 { + return nil + } + conns := make([]*pgxpool.Conn, 0, target) + defer func() { + for _, conn := range conns { + conn.Release() + } + }() + for int32(len(conns)) < target { + conn, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("prewarm pg connection %d/%d: %w", len(conns)+1, target, err) + } + conns = append(conns, conn) + } + return nil +} + +// Migrate 用嵌入的迁移脚本将数据库迁移到最新版本。幂等:已最新时返回 nil。 +func Migrate(dsn string) error { + src, err := iofs.New(deploy.Migrations, "migrations") + if err != nil { + return fmt.Errorf("iofs source: %w", err) + } + m, err := migrate.NewWithSourceInstance("iofs", src, toPgx5DSN(dsn)) + if err != nil { + return 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 nil +} + +// toPgx5DSN 把 pgxpool 用的 postgres:// DSN 转成 golang-migrate pgx5 driver 所需的 pgx5:// scheme。 +func toPgx5DSN(dsn string) string { + if s, ok := strings.CutPrefix(dsn, "postgres://"); ok { + return "pgx5://" + s + } + if s, ok := strings.CutPrefix(dsn, "postgresql://"); ok { + return "pgx5://" + s + } + return dsn +} diff --git a/internal/store/postgres/queries/account.sql b/internal/store/postgres/queries/account.sql new file mode 100644 index 00000000..6cb71f26 --- /dev/null +++ b/internal/store/postgres/queries/account.sql @@ -0,0 +1,22 @@ +-- name: GetPasswordByUser :one +SELECT + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +FROM account_passwords +WHERE user_id = $1; + +-- name: UpsertPassword :exec +INSERT INTO account_passwords ( + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (user_id) DO UPDATE SET + has_recovery = EXCLUDED.has_recovery, + has_secure_values = EXCLUDED.has_secure_values, + has_password = EXCLUDED.has_password, + hint = EXCLUDED.hint, + email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern, + login_email_pattern = EXCLUDED.login_email_pattern, + secure_random = EXCLUDED.secure_random, + updated_at = now(); diff --git a/internal/store/postgres/queries/authkey.sql b/internal/store/postgres/queries/authkey.sql new file mode 100644 index 00000000..2d8dbe4a --- /dev/null +++ b/internal/store/postgres/queries/authkey.sql @@ -0,0 +1,10 @@ +-- name: GetAuthKey :one +SELECT auth_key_id, body, server_salt, created_at +FROM auth_keys +WHERE auth_key_id = $1; + +-- name: UpsertAuthKey :exec +INSERT INTO auth_keys (auth_key_id, body, server_salt) +VALUES ($1, $2, $3) +ON CONFLICT (auth_key_id) DO UPDATE +SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt; diff --git a/internal/store/postgres/queries/authorization.sql b/internal/store/postgres/queries/authorization.sql new file mode 100644 index 00000000..1539b810 --- /dev/null +++ b/internal/store/postgres/queries/authorization.sql @@ -0,0 +1,24 @@ +-- name: UpsertAuthorization :exec +INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (auth_key_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + layer = EXCLUDED.layer, + device_model = EXCLUDED.device_model, + platform = EXCLUDED.platform, + system_version = EXCLUDED.system_version, + api_id = EXCLUDED.api_id, + app_version = EXCLUDED.app_version, + ip = EXCLUDED.ip, + active_at = now(); + +-- name: GetAuthorizationByAuthKey :one +SELECT * FROM authorizations WHERE auth_key_id = $1; + +-- name: ListAuthorizationsByUser :many +SELECT * FROM authorizations +WHERE user_id = $1 +ORDER BY active_at DESC, auth_key_id DESC; + +-- name: DeleteAuthorization :exec +DELETE FROM authorizations WHERE auth_key_id = $1; diff --git a/internal/store/postgres/queries/contact.sql b/internal/store/postgres/queries/contact.sql new file mode 100644 index 00000000..137b4a51 --- /dev/null +++ b/internal/store/postgres/queries/contact.sql @@ -0,0 +1,168 @@ +-- name: ListContactsByUser :many +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 +ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id; + +-- name: GetContact :one +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 + AND c.contact_user_id = $2; + +-- name: UpsertContact :one +WITH reverse AS ( + SELECT EXISTS ( + SELECT 1 + FROM contacts + WHERE user_id = sqlc.arg(contact_user_id)::bigint + AND contact_user_id = sqlc.arg(user_id)::bigint + )::boolean AS mutual +), +upserted AS ( + INSERT INTO contacts ( + user_id, + contact_user_id, + contact_phone, + contact_first_name, + contact_last_name, + note, + note_entities, + mutual + ) + SELECT + sqlc.arg(user_id)::bigint, + sqlc.arg(contact_user_id)::bigint, + sqlc.arg(contact_phone)::text, + sqlc.arg(contact_first_name)::text, + sqlc.arg(contact_last_name)::text, + sqlc.arg(note)::text, + sqlc.arg(note_entities)::jsonb, + reverse.mutual + FROM reverse + ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_phone = EXCLUDED.contact_phone, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + note = EXCLUDED.note, + note_entities = EXCLUDED.note_entities, + mutual = contacts.mutual OR EXCLUDED.mutual, + updated_at = now() + RETURNING * +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = true, + updated_at = now() + WHERE c.user_id = sqlc.arg(contact_user_id)::bigint + AND c.contact_user_id = sqlc.arg(user_id)::bigint + AND NOT c.mutual + RETURNING c.user_id +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed +FROM upserted c +JOIN users u ON u.id = c.contact_user_id; + +-- name: UpdateContactNote :one +WITH updated AS ( + UPDATE contacts c + SET note = sqlc.arg(note)::text, + note_entities = sqlc.arg(note_entities)::jsonb, + updated_at = now() + WHERE c.user_id = sqlc.arg(user_id)::bigint + AND c.contact_user_id = sqlc.arg(contact_user_id)::bigint + RETURNING * +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM updated c +JOIN users u ON u.id = c.contact_user_id; + +-- name: DeleteContacts :one +WITH deleted AS ( + DELETE FROM contacts + WHERE user_id = sqlc.arg(user_id)::bigint + AND contact_user_id = ANY(sqlc.arg(contact_user_ids)::bigint[]) + RETURNING contact_user_id +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = false, + updated_at = now() + FROM deleted d + WHERE c.user_id = d.contact_user_id + AND c.contact_user_id = sqlc.arg(user_id)::bigint + RETURNING c.user_id +) +SELECT COUNT(*)::int AS deleted_count +FROM deleted; diff --git a/internal/store/postgres/queries/dialog.sql b/internal/store/postgres/queries/dialog.sql new file mode 100644 index 00000000..ba7cf24e --- /dev/null +++ b/internal/store/postgres/queries/dialog.sql @@ -0,0 +1,773 @@ +-- name: ListDialogsByUser :many +WITH base AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + 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.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.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 + 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 + OR ( + sqlc.arg(folder_id)::int < 2 + AND d.folder_id = sqlc.arg(folder_id)::int + ) + OR ( + sqlc.arg(folder_id)::int >= 2 + AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1) + AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL) + OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned) + AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned) +), +paged AS ( + SELECT * + FROM base + WHERE ( + (sqlc.arg(offset_date)::int <= 0 AND sqlc.arg(offset_id)::int <= 0) + OR ( + sqlc.arg(offset_date)::int > 0 + AND ( + top_message_date < sqlc.arg(offset_date)::int + OR ( + top_message_date = sqlc.arg(offset_date)::int + AND ( + sqlc.arg(offset_id)::int <= 0 + OR top_message_id < sqlc.arg(offset_id)::int + OR ( + top_message_id = sqlc.arg(offset_id)::int + AND sqlc.arg(has_offset_peer)::boolean + AND peer_id < sqlc.arg(offset_peer_id)::bigint + ) + ) + ) + ) + ) + OR ( + sqlc.arg(offset_date)::int <= 0 + AND sqlc.arg(offset_id)::int > 0 + AND ( + top_message_id < sqlc.arg(offset_id)::int + OR ( + top_message_id = sqlc.arg(offset_id)::int + AND sqlc.arg(has_offset_peer)::boolean + AND peer_id < sqlc.arg(offset_peer_id)::bigint + ) + ) + ) + ) +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS 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 +FROM paged +ORDER BY + pinned DESC, + CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + top_message_date DESC, + top_message_id DESC, + peer_id DESC +LIMIT sqlc.arg(limit_count); + +-- name: ListDialogSummaryByUser :many +SELECT + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar +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 + OR ( + sqlc.arg(folder_id)::int < 2 + AND d.folder_id = sqlc.arg(folder_id)::int + ) + OR ( + sqlc.arg(folder_id)::int >= 2 + AND NOT (sqlc.arg(folder_exclude_archived)::boolean AND d.folder_id = 1) + AND NOT (sqlc.arg(folder_exclude_read)::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_exclude_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_exclude_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_include_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_include_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest(sqlc.arg(folder_pinned_peer_types)::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest(sqlc.arg(folder_pinned_peer_ids)::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR (sqlc.arg(folder_contacts)::boolean AND c.contact_user_id IS NOT NULL) + OR (sqlc.arg(folder_non_contacts)::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT sqlc.arg(pinned_only)::boolean OR d.pinned) + 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, + d.top_message_date DESC, + d.top_message_id DESC, + d.peer_id DESC; + +-- name: ListDialogsByPeers :many +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id, + i::int AS ord + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + ord + FROM requested + ORDER BY peer_type, peer_id, ord +), +base AS ( + SELECT + sqlc.arg(user_id)::bigint AS user_id, + r.peer_type, + r.peer_id, + COALESCE(d.folder_id, 0)::int AS folder_id, + COALESCE(d.top_message_id, 0)::int AS top_message_id, + COALESCE(d.top_message_date, 0)::int AS top_message_date, + COALESCE(d.read_inbox_max_id, 0)::int AS read_inbox_max_id, + COALESCE(d.read_outbox_max_id, 0)::int AS read_outbox_max_id, + 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.pinned, false)::boolean AS pinned, + COALESCE(d.pinned_order, 0)::int AS pinned_order, + COALESCE(d.unread_mark, false)::boolean AS unread_mark, + COALESCE(d.hidden_peer_settings_bar, false)::boolean AS hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + 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.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.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, + r.ord + FROM deduped r + LEFT JOIN dialogs d + ON d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = r.peer_type + AND d.peer_id = r.peer_id + LEFT JOIN users u ON r.peer_type = 'user' AND u.id = r.peer_id + LEFT JOIN contacts c ON r.peer_type = 'user' AND c.user_id = sqlc.arg(user_id)::bigint AND c.contact_user_id = r.peer_id + LEFT JOIN message_boxes m ON m.owner_user_id = sqlc.arg(user_id)::bigint AND m.box_id = d.top_message_id AND NOT m.deleted +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS 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 +FROM base +ORDER BY ord; + +-- name: UpsertDialog :exec +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, + unread_mark +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 +) +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, + read_inbox_max_id = EXCLUDED.read_inbox_max_id, + read_outbox_max_id = EXCLUDED.read_outbox_max_id, + unread_count = EXCLUDED.unread_count, + unread_mentions_count = EXCLUDED.unread_mentions_count, + unread_reactions_count = EXCLUDED.unread_reactions_count, + pinned = EXCLUDED.pinned, + unread_mark = EXCLUDED.unread_mark, + updated_at = now(); + +-- name: UpsertOutboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $1, $2, $3, $4, $5, 0 +) +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, + updated_at = now(); + +-- name: UpsertInboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $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, + updated_at = now(); + +-- name: MarkDialogRead :one +WITH target AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.top_message_id, + d.read_inbox_max_id, + d.unread_count + FROM dialogs d + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 +), +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 + ), + 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 + AND d.peer_type = target.peer_type + AND d.peer_id = target.peer_id +RETURNING + d.user_id, + d.peer_type, + d.peer_id, + d.read_inbox_max_id, + 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 + )::boolean AS changed +) +SELECT + user_id, + peer_type, + peer_id, + read_inbox_max_id, + unread_count, + changed +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 +), +updated AS ( + UPDATE dialogs d + SET pinned = sqlc.arg(pinned)::boolean, + pinned_order = CASE + WHEN sqlc.arg(pinned)::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 = 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 +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; + +-- name: SetDialogUnreadMark :one +WITH updated AS ( + UPDATE dialogs d + SET unread_mark = sqlc.arg(unread)::boolean, + 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 + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; + +-- name: ListDialogUnreadMarks :many +SELECT + peer_type, + peer_id +FROM dialogs +WHERE user_id = $1 + AND unread_mark +ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC; + +-- name: SetPeerSettingsBarHidden :one +WITH updated AS ( + UPDATE dialogs d + SET hidden_peer_settings_bar = true, + 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 + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; + +-- name: GetPeerSettingsBarHidden :one +SELECT hidden_peer_settings_bar +FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3; + +-- name: ReorderPinnedDialogs :exec +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id, + i::int AS pos + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + pos::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 = sqlc.arg(user_id)::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id; + +-- name: EditDialogPeerFolders :exec +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id, + (sqlc.arg(folder_ids)::int[])[i] AS folder_id + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) + AND i <= cardinality(sqlc.arg(folder_ids)::int[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + folder_id + FROM requested + WHERE folder_id IN (0, 1) + ORDER BY peer_type, peer_id +) +UPDATE dialogs d +SET folder_id = deduped.folder_id, + 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: ClearPinnedDialogsNotInOrder :exec +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[]) +) +UPDATE dialogs d +SET pinned = false, + pinned_order = 0, + updated_at = now() +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.pinned + AND NOT EXISTS ( + SELECT 1 + FROM requested r + WHERE r.peer_type = d.peer_type + AND r.peer_id = d.peer_id + ); + +-- name: RefreshDialogAfterMessageDelete :exec +UPDATE dialogs d +SET + top_message_id = sqlc.arg(top_message_id)::int, + top_message_date = sqlc.arg(top_message_date)::int, + 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 > d.read_inbox_max_id + ), + 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 + AND d.peer_id = sqlc.arg(peer_id)::bigint; + +-- name: ClearDialogAfterHistoryDelete :exec +UPDATE dialogs d +SET + top_message_id = 0, + top_message_date = 0, + read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id), + read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id), + unread_count = 0, + 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 + 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; + +-- name: ListDialogFolders :many +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 +ORDER BY order_value ASC, filter_id ASC; + +-- name: GetDialogFolder :one +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2; + +-- name: UpsertDialogFolder :exec +INSERT INTO dialog_filters ( + user_id, + filter_id, + is_chatlist, + filter, + order_value +) VALUES ( + $1, + $2, + $3, + sqlc.arg(filter_json)::jsonb, + COALESCE( + (SELECT order_value FROM dialog_filters WHERE user_id = $1 AND filter_id = $2), + (SELECT COALESCE(MAX(order_value), 0) + 1 FROM dialog_filters WHERE user_id = $1) + ) +) +ON CONFLICT (user_id, filter_id) DO UPDATE SET + is_chatlist = EXCLUDED.is_chatlist, + filter = EXCLUDED.filter, + updated_at = now(); + +-- name: DeleteDialogFolder :exec +DELETE FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2; + +-- name: ReorderDialogFolders :exec +WITH requested AS ( + SELECT filter_id, ord::int AS order_value + FROM unnest(sqlc.arg(filter_ids)::int[]) WITH ORDINALITY AS t(filter_id, ord) +), +deduped AS ( + SELECT DISTINCT ON (filter_id) + filter_id, + order_value + FROM requested + WHERE filter_id >= 2 + ORDER BY filter_id, order_value +) +UPDATE dialog_filters f +SET order_value = deduped.order_value, + updated_at = now() +FROM deduped +WHERE f.user_id = sqlc.arg(user_id)::bigint + AND f.filter_id = deduped.filter_id; + +-- name: GetDialogFolderTags :one +SELECT tags_enabled +FROM dialog_filter_settings +WHERE user_id = $1; + +-- name: SetDialogFolderTags :exec +INSERT INTO dialog_filter_settings ( + user_id, + tags_enabled +) VALUES ( + $1, + $2 +) +ON CONFLICT (user_id) DO UPDATE SET + tags_enabled = EXCLUDED.tags_enabled, + updated_at = now(); + +-- name: UpsertDialogDraft :exec +INSERT INTO dialog_drafts ( + user_id, + peer_type, + peer_id, + top_message_id, + date, + draft +) VALUES ( + $1, + $2, + $3, + $4, + $5, + sqlc.arg(draft_json)::jsonb +) +ON CONFLICT (user_id, peer_type, peer_id, top_message_id) DO UPDATE SET + date = EXCLUDED.date, + draft = EXCLUDED.draft, + updated_at = now(); + +-- name: DeleteDialogDraft :one +WITH deleted AS ( + DELETE FROM dialog_drafts + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND top_message_id = $4 + RETURNING user_id +) +SELECT EXISTS (SELECT 1 FROM deleted)::boolean AS changed; + +-- name: ListDialogDrafts :many +SELECT draft::text AS draft_json +FROM dialog_drafts +WHERE user_id = $1 +ORDER BY date DESC, peer_type ASC, peer_id DESC, top_message_id DESC +LIMIT sqlc.arg(limit_count); + +-- name: ClearDialogDrafts :many +WITH doomed AS ( + SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id + FROM dialog_drafts d + WHERE d.user_id = $1 + ORDER BY d.date DESC, d.peer_type ASC, d.peer_id DESC, d.top_message_id DESC + LIMIT sqlc.arg(limit_count) +), +deleted AS ( + DELETE FROM dialog_drafts d + USING doomed + WHERE d.user_id = doomed.user_id + AND d.peer_type = doomed.peer_type + AND d.peer_id = doomed.peer_id + AND d.top_message_id = doomed.top_message_id + RETURNING d.draft::text AS draft_json +) +SELECT draft_json +FROM deleted; diff --git a/internal/store/postgres/queries/help.sql b/internal/store/postgres/queries/help.sql new file mode 100644 index 00000000..c6207001 --- /dev/null +++ b/internal/store/postgres/queries/help.sql @@ -0,0 +1,43 @@ +-- name: GetAppConfig :one +SELECT client, hash, config_json::text AS config_json +FROM app_configs +WHERE client = $1; + +-- name: UpsertAppConfig :exec +INSERT INTO app_configs (client, hash, config_json) +VALUES ($1, $2, sqlc.arg(config_json)::jsonb) +ON CONFLICT (client) DO UPDATE SET + hash = EXCLUDED.hash, + config_json = EXCLUDED.config_json, + updated_at = now(); + +-- name: ListCountries :many +SELECT + c.iso2, + c.default_name, + c.name, + c.hidden, + cc.country_code, + cc.prefixes, + cc.patterns +FROM countries c +JOIN country_codes cc ON cc.iso2 = c.iso2 +ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code; + +-- name: UpsertCountry :exec +INSERT INTO countries (iso2, default_name, name, hidden, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2) DO UPDATE SET + default_name = EXCLUDED.default_name, + name = EXCLUDED.name, + hidden = EXCLUDED.hidden, + order_index = EXCLUDED.order_index, + updated_at = now(); + +-- name: UpsertCountryCode :exec +INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2, country_code) DO UPDATE SET + prefixes = EXCLUDED.prefixes, + patterns = EXCLUDED.patterns, + order_index = EXCLUDED.order_index; diff --git a/internal/store/postgres/queries/langpack.sql b/internal/store/postgres/queries/langpack.sql new file mode 100644 index 00000000..4c4bdf56 --- /dev/null +++ b/internal/store/postgres/queries/langpack.sql @@ -0,0 +1,47 @@ +-- name: GetLangPackMeta :one +SELECT lang_pack, lang_code, version, strings_count +FROM lang_packs +WHERE lang_pack = $1 AND lang_code = $2; + +-- name: UpsertLangPackMeta :exec +INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count) +VALUES ($1, $2, $3, $4) +ON CONFLICT (lang_pack, lang_code) DO UPDATE SET + version = EXCLUDED.version, + strings_count = EXCLUDED.strings_count, + updated_at = now(); + +-- name: UpsertLangPackString :exec +INSERT INTO lang_pack_strings ( + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET + version = EXCLUDED.version, + pluralized = EXCLUDED.pluralized, + value = EXCLUDED.value, + zero_value = EXCLUDED.zero_value, + one_value = EXCLUDED.one_value, + two_value = EXCLUDED.two_value, + few_value = EXCLUDED.few_value, + many_value = EXCLUDED.many_value, + other_value = EXCLUDED.other_value, + deleted = EXCLUDED.deleted, + updated_at = now(); + +-- name: ListLangPackStrings :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted +ORDER BY key; + +-- name: GetLangPackStringsByKeys :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY(sqlc.arg(keys)::text[]) AND NOT deleted +ORDER BY key; diff --git a/internal/store/postgres/queries/media.sql b/internal/store/postgres/queries/media.sql new file mode 100644 index 00000000..85053f8a --- /dev/null +++ b/internal/store/postgres/queries/media.sql @@ -0,0 +1,331 @@ +-- upload_parts ---------------------------------------------------------------- + +-- name: SaveUploadPart :exec +INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes) +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 +) +ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET + total_parts = EXCLUDED.total_parts, + is_big = EXCLUDED.is_big, + bytes = EXCLUDED.bytes; + +-- name: ListUploadParts :many +SELECT part, total_parts, is_big, bytes +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 +DELETE FROM upload_parts +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND file_id = sqlc.arg(file_id)::bigint; + +-- file_blobs ------------------------------------------------------------------ + +-- name: PutFileBlob :exec +INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type) +VALUES ( + sqlc.arg(location_key)::text, + sqlc.arg(backend)::text, + sqlc.arg(object_key)::text, + sqlc.arg(size)::bigint, + sqlc.arg(sha256)::bytea, + sqlc.arg(mime_type)::text +) +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; + +-- name: GetFileBlob :one +SELECT location_key, backend, object_key, size, sha256, mime_type +FROM file_blobs +WHERE location_key = sqlc.arg(location_key)::text; + +-- documents ------------------------------------------------------------------- + +-- name: PutDocument :exec +INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs) +VALUES ( + sqlc.arg(id)::bigint, + sqlc.arg(access_hash)::bigint, + sqlc.arg(file_reference)::bytea, + sqlc.arg(date)::int, + sqlc.arg(mime_type)::text, + sqlc.arg(size)::bigint, + sqlc.arg(dc_id)::int, + sqlc.arg(attributes_json)::jsonb, + sqlc.arg(thumbs_json)::jsonb +) +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; + +-- name: GetDocument :one +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = sqlc.arg(id)::bigint; + +-- name: GetDocuments :many +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = ANY(sqlc.arg(ids)::bigint[]); + +-- photos ---------------------------------------------------------------------- + +-- name: PutPhoto :exec +INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes) +VALUES ( + sqlc.arg(id)::bigint, + sqlc.arg(access_hash)::bigint, + sqlc.arg(file_reference)::bytea, + sqlc.arg(date)::int, + sqlc.arg(dc_id)::int, + sqlc.arg(has_stickers)::boolean, + sqlc.arg(sizes_json)::jsonb +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + dc_id = EXCLUDED.dc_id, + has_stickers = EXCLUDED.has_stickers, + sizes = EXCLUDED.sizes; + +-- name: GetPhoto :one +SELECT id, access_hash, file_reference, date, dc_id, has_stickers, + sizes::text AS sizes_json +FROM photos +WHERE id = sqlc.arg(id)::bigint; + +-- sticker_sets ---------------------------------------------------------------- + +-- name: PutStickerSet :exec +INSERT INTO sticker_sets ( + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs, thumb_dc_id, thumb_version, document_ids, packs, sort_order, system_key +) VALUES ( + sqlc.arg(id)::bigint, + sqlc.arg(access_hash)::bigint, + sqlc.arg(short_name)::text, + sqlc.arg(title)::text, + sqlc.arg(count)::int, + sqlc.arg(hash)::int, + sqlc.arg(set_kind)::text, + sqlc.arg(official)::boolean, + sqlc.arg(animated)::boolean, + sqlc.arg(videos)::boolean, + sqlc.arg(emojis)::boolean, + sqlc.arg(masks)::boolean, + sqlc.arg(installed)::boolean, + sqlc.arg(archived)::boolean, + sqlc.arg(installed_date)::int, + sqlc.arg(thumb_document_id)::bigint, + sqlc.arg(thumbs_json)::jsonb, + sqlc.arg(thumb_dc_id)::int, + sqlc.arg(thumb_version)::int, + sqlc.arg(document_ids_json)::jsonb, + sqlc.arg(packs_json)::jsonb, + sqlc.arg(sort_order)::int, + sqlc.arg(system_key)::text +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + short_name = EXCLUDED.short_name, + title = EXCLUDED.title, + count = EXCLUDED.count, + hash = EXCLUDED.hash, + set_kind = EXCLUDED.set_kind, + official = EXCLUDED.official, + animated = EXCLUDED.animated, + videos = EXCLUDED.videos, + emojis = EXCLUDED.emojis, + masks = EXCLUDED.masks, + installed = EXCLUDED.installed, + archived = EXCLUDED.archived, + installed_date = EXCLUDED.installed_date, + thumb_document_id = EXCLUDED.thumb_document_id, + thumbs = EXCLUDED.thumbs, + thumb_dc_id = EXCLUDED.thumb_dc_id, + thumb_version = EXCLUDED.thumb_version, + document_ids = EXCLUDED.document_ids, + packs = EXCLUDED.packs, + sort_order = EXCLUDED.sort_order, + system_key = EXCLUDED.system_key; + +-- name: GetStickerSetByID :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE id = sqlc.arg(id)::bigint; + +-- name: GetStickerSetByShortName :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE short_name = sqlc.arg(short_name)::text; + +-- name: GetStickerSetBySystemKey :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE system_key = sqlc.arg(system_key)::text; + +-- name: ListStickerSetsByKind :many +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE set_kind = sqlc.arg(set_kind)::text +ORDER BY sort_order ASC, id ASC; + +-- name: CountStickerSets :one +SELECT count(*)::int AS total FROM sticker_sets; + +-- available_reactions --------------------------------------------------------- + +-- name: PutAvailableReaction :exec +INSERT INTO available_reactions ( + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +) VALUES ( + sqlc.arg(reaction)::text, + sqlc.arg(title)::text, + sqlc.arg(inactive)::boolean, + sqlc.arg(premium)::boolean, + sqlc.arg(static_icon_id)::bigint, + sqlc.arg(appear_animation_id)::bigint, + sqlc.arg(select_animation_id)::bigint, + sqlc.arg(activate_animation_id)::bigint, + sqlc.arg(effect_animation_id)::bigint, + sqlc.arg(around_animation_id)::bigint, + sqlc.arg(center_icon_id)::bigint, + sqlc.arg(sort_order)::int +) +ON CONFLICT (reaction) DO UPDATE SET + title = EXCLUDED.title, + inactive = EXCLUDED.inactive, + premium = EXCLUDED.premium, + static_icon_id = EXCLUDED.static_icon_id, + appear_animation_id = EXCLUDED.appear_animation_id, + select_animation_id = EXCLUDED.select_animation_id, + activate_animation_id = EXCLUDED.activate_animation_id, + effect_animation_id = EXCLUDED.effect_animation_id, + around_animation_id = EXCLUDED.around_animation_id, + center_icon_id = EXCLUDED.center_icon_id, + sort_order = EXCLUDED.sort_order; + +-- name: ListAvailableReactions :many +SELECT + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +FROM available_reactions +ORDER BY sort_order ASC, reaction ASC; + +-- name: CountAvailableReactions :one +SELECT count(*)::int AS total FROM available_reactions; + +-- profile_photos -------------------------------------------------------------- + +-- name: AddProfilePhoto :exec +INSERT INTO profile_photos (owner_peer_type, owner_peer_id, photo_id, date, active, sort_order) +VALUES ( + sqlc.arg(owner_peer_type)::text, + sqlc.arg(owner_peer_id)::bigint, + sqlc.arg(photo_id)::bigint, + sqlc.arg(date)::int, + true, + sqlc.arg(sort_order)::bigint +) +ON CONFLICT (owner_peer_type, owner_peer_id, photo_id) DO UPDATE SET + date = EXCLUDED.date, + active = true, + sort_order = EXCLUDED.sort_order; + +-- name: NextProfilePhotoOrder :one +SELECT COALESCE(MAX(sort_order), 0)::bigint AS max_order +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint; + +-- name: CurrentProfilePhoto :one +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND active +ORDER BY sort_order DESC +LIMIT 1; + +-- name: CurrentProfilePhotosForOwners :many +SELECT DISTINCT ON (pp.owner_peer_id) + pp.owner_peer_id, + pp.photo_id, + ph.dc_id, + ph.sizes::text AS sizes_json +FROM profile_photos pp +JOIN photos ph ON ph.id = pp.photo_id +WHERE pp.owner_peer_type = sqlc.arg(owner_peer_type)::text + AND pp.owner_peer_id = ANY(sqlc.arg(owner_ids)::bigint[]) + AND pp.active +ORDER BY pp.owner_peer_id, pp.sort_order DESC; + +-- name: ListProfilePhotos :many +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND active + AND (sqlc.arg(max_id)::bigint <= 0 OR photo_id < sqlc.arg(max_id)::bigint) +ORDER BY sort_order DESC +OFFSET sqlc.arg(offset_count)::int +LIMIT sqlc.arg(limit_count)::int; + +-- name: CountProfilePhotos :one +SELECT count(*)::int AS total +FROM profile_photos +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND active; + +-- name: DeactivateProfilePhotos :many +UPDATE profile_photos +SET active = false +WHERE owner_peer_type = sqlc.arg(owner_peer_type)::text + AND owner_peer_id = sqlc.arg(owner_peer_id)::bigint + AND photo_id = ANY(sqlc.arg(photo_ids)::bigint[]) + AND active +RETURNING photo_id; diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql new file mode 100644 index 00000000..fe605787 --- /dev/null +++ b/internal/store/postgres/queries/message.sql @@ -0,0 +1,932 @@ +-- name: CreateMessage :one +WITH pm AS ( + INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities + ) VALUES ( + sqlc.arg(from_user_id), + sqlc.arg(owner_user_id), + 0, + sqlc.arg(message_date), + sqlc.arg(body), + sqlc.arg(entities_json)::jsonb + ) + RETURNING id, sender_user_id +), +box AS ( + 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 + sqlc.arg(owner_user_id), + sqlc.arg(box_id), + pm.id, + pm.sender_user_id, + sqlc.arg(peer_type), + sqlc.arg(peer_id), + sqlc.arg(from_user_id), + sqlc.arg(message_date), + sqlc.arg(outgoing), + sqlc.arg(body), + sqlc.arg(entities_json)::jsonb, + sqlc.arg(pts) + FROM pm + RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + pts +) +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities_json, + pts +FROM box; + +-- name: CreatePrivateMessage :one +INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities, + 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, + media +) VALUES ( + $1, $2, $3, $4, $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(quote_text)::text, + sqlc.arg(quote_entities_json)::jsonb, + sqlc.arg(quote_offset)::int, + sqlc.arg(fwd_from_peer_type)::text, + sqlc.arg(fwd_from_peer_id)::bigint, + sqlc.arg(fwd_from_name)::text, + sqlc.arg(fwd_date)::int, + sqlc.arg(media_json)::jsonb +) +ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING +RETURNING + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json; + +-- name: GetPrivateMessageByRandomID :one +SELECT + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json +FROM private_messages +WHERE sender_user_id = $1 + AND random_id = $2 + AND random_id <> 0; + +-- name: CreateMessageBox :one +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, + 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, + pts, + media +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $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(quote_text)::text, + sqlc.arg(quote_entities_json)::jsonb, + sqlc.arg(quote_offset)::int, + sqlc.arg(fwd_from_peer_type)::text, + sqlc.arg(fwd_from_peer_id)::bigint, + sqlc.arg(fwd_from_name)::text, + sqlc.arg(fwd_date)::int, + sqlc.arg(pts)::int, + sqlc.arg(media_json)::jsonb +) +RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json; + +-- name: GetMessageBoxByPrivateMessage :one +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1 + AND private_message_id = $2 + AND NOT deleted; + +-- name: GetMessageBoxForReply :one +SELECT + box_id, + private_message_id, + message_sender_id +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; + +-- name: GetMessageBoxesForForward :many +WITH requested AS ( + SELECT + id::int AS box_id, + ord::int AS ord + FROM unnest(sqlc.arg(box_ids)::int[]) WITH ORDINALITY AS r(id, ord) +) +SELECT + r.ord, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.message_sender_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + 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.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.pts, + m.media::text AS media_json +FROM requested r +JOIN message_boxes m + ON 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.box_id = r.box_id + AND NOT m.deleted +ORDER BY r.ord ASC; + +-- name: MaxMessageBoxID :one +SELECT COALESCE(MAX(box_id), 0)::int AS max_box_id +FROM message_boxes +WHERE owner_user_id = $1; + +-- name: ListMessagesByUser :many +WITH load_params AS ( + SELECT + sqlc.arg(offset_id)::int AS offset_id, + sqlc.arg(offset_date)::int AS offset_date, + sqlc.arg(add_offset)::int AS add_offset, + sqlc.arg(limit_count)::int AS limit_count, + CASE + WHEN sqlc.arg(add_offset)::int >= 0 THEN 'backward' + WHEN sqlc.arg(add_offset)::int + sqlc.arg(limit_count)::int > 0 THEN 'around' + ELSE 'forward' + END::text AS load_type +), +base AS NOT MATERIALIZED ( + 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.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.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.pts, + m.media::text AS media_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.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.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 + 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) +), +total AS ( + SELECT count(*)::int AS total_count + FROM base + WHERE sqlc.arg(need_total_count)::boolean +), +backward AS ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'backward' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id)) + ) + ORDER BY b.box_id DESC + OFFSET GREATEST((SELECT add_offset FROM load_params), 0) + LIMIT (SELECT limit_count FROM load_params) +), +around_forward AS ( + SELECT f.* + FROM ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params)) + ) f +), +around_backward AS ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id)) + ) + ORDER BY b.box_id DESC + LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0) +), +forward AS ( + SELECT f.* + FROM ( + SELECT b.* + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'forward' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT (SELECT limit_count FROM load_params) + ) f +), +paged AS ( + SELECT * FROM backward + UNION ALL + SELECT * FROM around_forward + UNION ALL + SELECT * FROM around_backward + UNION ALL + SELECT * FROM forward +) +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, + 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, + COALESCE(total.total_count, 0)::int AS total_count +FROM paged +CROSS JOIN total +ORDER BY box_id DESC; + +-- name: GetMessageBoxesByIDs :many +SELECT + wanted.box_id AS requested_box_id, + 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.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.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.pts, + m.media::text AS media_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.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.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 + ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = wanted.box_id + AND NOT m.deleted +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 +ORDER BY wanted.ord ASC; + +-- name: GetMessageBoxForEdit :one +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND NOT deleted +LIMIT 1 +FOR UPDATE; + +-- name: ListVisibleMessageBoxesByPrivateMessage :many +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE 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 +FOR UPDATE; + +-- name: UpdatePrivateMessageEdit :exec +UPDATE private_messages +SET body = sqlc.arg(body)::text, + entities = sqlc.arg(entities_json)::jsonb, + edit_date = sqlc.arg(edit_date)::int +WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint + AND id = sqlc.arg(private_message_id)::bigint; + +-- name: UpdateMessageBoxEdit :one +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 +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND NOT deleted +RETURNING + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json; + +-- name: GetDialogReadStateForUpdate :one +SELECT + user_id, + peer_type, + peer_id, + top_message_id, + read_inbox_max_id, + unread_count +FROM dialogs +WHERE user_id = sqlc.arg(user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint +FOR UPDATE; + +-- name: LatestIncomingReadReceiptCandidate :one +SELECT + m.message_sender_id, + m.private_message_id, + sender_box.owner_user_id AS sender_owner_user_id, + sender_box.box_id AS sender_box_id +FROM message_boxes m +JOIN message_boxes sender_box + ON sender_box.message_sender_id = m.message_sender_id + AND sender_box.private_message_id = m.private_message_id + AND sender_box.owner_user_id = m.message_sender_id + AND sender_box.outgoing + AND NOT sender_box.deleted +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 NOT m.outgoing + AND NOT m.deleted + AND m.box_id > sqlc.arg(old_read_inbox_max_id)::int + AND m.box_id <= sqlc.arg(new_read_inbox_max_id)::int +ORDER BY m.box_id DESC +LIMIT 1; + +-- name: UpdateDialogReadInbox :one +UPDATE dialogs d +SET + read_inbox_max_id = GREATEST(d.read_inbox_max_id, sqlc.arg(read_inbox_max_id)::int), + 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, sqlc.arg(read_inbox_max_id)::int) + ), + 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 + AND d.peer_id = sqlc.arg(peer_id)::bigint +RETURNING + d.read_inbox_max_id, + d.unread_count; + +-- name: UpdateDialogReadOutbox :one +UPDATE dialogs +SET + read_outbox_max_id = GREATEST(read_outbox_max_id, sqlc.arg(read_outbox_max_id)::int), + updated_at = now() +WHERE user_id = sqlc.arg(user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND read_outbox_max_id < sqlc.arg(read_outbox_max_id)::int +RETURNING read_outbox_max_id; + +-- name: GetOutboxMessageForReadDate :one +SELECT box_id +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 outgoing + AND NOT deleted +LIMIT 1; + +-- name: GetOutboxReadDate :one +SELECT COALESCE(MIN(date), 0)::int AS read_date +FROM user_update_events +WHERE user_id = sqlc.arg(user_id)::bigint + AND event_type = 'read_history_outbox' + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND max_id >= sqlc.arg(message_id)::int; + +-- name: DeleteMessageBoxesByIDs :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = ANY(sqlc.arg(box_ids)::int[]) + AND NOT m.deleted + 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: DeleteMessageBoxesByPeer :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + 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 (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND NOT m.deleted + 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: DeleteMessageBoxesByPeerBatch :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 = 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 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: HasDeletableMessageBoxByPeer :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 (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND NOT m.deleted + LIMIT 1 +)::boolean AS more; + +-- name: DeleteMessageBoxesByPrivateMessages :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[]) +), +deduped AS ( + SELECT DISTINCT message_sender_id, private_message_id + FROM requested +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM deduped d + WHERE 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, + 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 owner_user_id ASC, box_id ASC; + +-- name: TopVisibleMessageBoxByPeer :one +SELECT + box_id, + message_date +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted +ORDER BY box_id DESC +LIMIT 1; diff --git a/internal/store/postgres/queries/temp_auth_key.sql b/internal/store/postgres/queries/temp_auth_key.sql new file mode 100644 index 00000000..c4e17928 --- /dev/null +++ b/internal/store/postgres/queries/temp_auth_key.sql @@ -0,0 +1,23 @@ +-- name: UpsertTempAuthKeyBinding :exec +INSERT INTO temp_auth_key_bindings ( + temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message +) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (temp_auth_key_id) DO UPDATE SET + perm_auth_key_id = EXCLUDED.perm_auth_key_id, + nonce = EXCLUDED.nonce, + temp_session_id = EXCLUDED.temp_session_id, + expires_at = EXCLUDED.expires_at, + encrypted_message = EXCLUDED.encrypted_message, + created_at = now(); + +-- name: GetTempAuthKeyBinding :one +SELECT + temp_auth_key_id, + perm_auth_key_id, + nonce, + temp_session_id, + expires_at, + encrypted_message +FROM temp_auth_key_bindings +WHERE temp_auth_key_id = $1; diff --git a/internal/store/postgres/queries/update_state.sql b/internal/store/postgres/queries/update_state.sql new file mode 100644 index 00000000..3ed6b22e --- /dev/null +++ b/internal/store/postgres/queries/update_state.sql @@ -0,0 +1,24 @@ +-- name: GetUpdateState :one +SELECT auth_key_id, user_id, pts, qts, date, seq +FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2; + +-- 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, + updated_at = now(); + +-- name: DeleteUpdateState :exec +DELETE FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2; + +-- name: DeleteUpdateStatesByAuthKey :exec +DELETE FROM update_states +WHERE auth_key_id = $1; diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql new file mode 100644 index 00000000..c7f68c41 --- /dev/null +++ b/internal/store/postgres/queries/user.sql @@ -0,0 +1,104 @@ +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1; + +-- name: GetUsersByIDs :many +SELECT * +FROM users +WHERE id = ANY(sqlc.arg(ids)::bigint[]) +ORDER BY id; + +-- name: GetUserByPhone :one +SELECT * FROM users WHERE phone = $1; + +-- name: GetUsersByPhones :many +SELECT * +FROM users +WHERE phone = ANY(sqlc.arg(phones)::text[]) +ORDER BY id; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE lower(username) = lower($1) AND username <> ''; + +-- name: SearchUsers :many +WITH matched AS ( + SELECT + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.about, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS contact, + COALESCE(c.mutual, false)::boolean AS mutual, + CASE + WHEN sqlc.arg(phone_query)::text <> '' AND u.phone = sqlc.arg(phone_query)::text THEN 0 + WHEN lower(u.username) = sqlc.arg(query_lower)::text THEN 1 + WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = sqlc.arg(query_lower)::text THEN 2 + WHEN lower(u.first_name) = sqlc.arg(query_lower)::text THEN 3 + WHEN c.contact_user_id IS NOT NULL THEN 4 + ELSE 5 + END AS rank + FROM users u + LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id + WHERE u.id <> sqlc.arg(current_user_id)::bigint + AND sqlc.arg(query_lower)::text <> '' + AND ( + (sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%') + OR lower(u.username) LIKE sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(u.first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(u.last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(c.contact_first_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(c.contact_last_name) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || sqlc.arg(query_like)::text || '%' ESCAPE '\' + ) +) +SELECT + id, + access_hash, + phone, + first_name, + last_name, + about, + username, + country_code, + verified, + support, + last_seen_at, + contact, + mutual +FROM matched +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) +RETURNING *; + +-- name: UpdateUserUsername :one +UPDATE users +SET username = $2, + updated_at = now() +WHERE id = $1 +RETURNING *; + +-- name: UpdateUserLastSeen :exec +UPDATE users +SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint), + updated_at = now() +WHERE id = sqlc.arg(id)::bigint; + +-- name: UpdateUserProfile :one +UPDATE users +SET first_name = $2, + last_name = $3, + about = $4, + updated_at = now() +WHERE id = $1 +RETURNING *; diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql new file mode 100644 index 00000000..824c4a3b --- /dev/null +++ b/internal/store/postgres/queries/user_update_event.sql @@ -0,0 +1,464 @@ +-- name: AppendUserUpdateEvent :exec +INSERT INTO user_update_events ( + user_id, + pts, + pts_count, + date, + event_type, + event_bool, + event_peers, + peer_settings, + message_ids, + dialog_filter, + filter_order, + folder_peers, + message_box_id, + peer_type, + peer_id, + filter_id, + max_id, + still_unread_count, + tags_enabled +) VALUES ( + $1, + $2, + $3, + $4, + $5, + sqlc.arg(event_bool)::boolean, + sqlc.arg(event_peers)::jsonb, + sqlc.arg(peer_settings)::jsonb, + sqlc.arg(message_ids)::jsonb, + sqlc.arg(dialog_filter)::jsonb, + sqlc.arg(filter_order)::jsonb, + sqlc.arg(folder_peers)::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; + +-- name: ListUserUpdateEventsAfter :many +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + 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.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.tags_enabled, + 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, + COALESCE(m.peer_type, '')::text AS peer_type, + 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.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_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(m.reply_to_top_id, 0)::int AS reply_to_top_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, + 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.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_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(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(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, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + 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(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, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + 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 +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 +LIMIT sqlc.arg(limit_count); + +-- name: MaxUserPts :one +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, + pts, + event_type, + exclude_auth_key_id, + exclude_session_id +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT DO NOTHING; + +-- name: ClaimDispatchOutbox :many +WITH picked AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE ( + status = 'pending' + AND next_attempt_at <= now() + ) + OR ( + status = 'dispatching' + AND updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int) + ) + ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + LIMIT sqlc.arg(limit_count) + FOR UPDATE SKIP LOCKED +) +UPDATE dispatch_outbox d +SET + status = 'dispatching', + attempts = d.attempts + 1, + updated_at = now() +FROM picked p +WHERE d.target_user_id = p.target_user_id + AND d.id = p.id +RETURNING + d.id, + d.target_user_id, + d.pts, + d.event_type, + d.exclude_auth_key_id, + d.exclude_session_id, + d.attempts; + +-- name: MarkDispatchDelivered :exec +-- 方案 A:投递成功即删除。outbox 是任务队列,delivered 行无保留价值 +-- (消息在 message_boxes、离线补偿在 user_update_events),删除让表维持「未完成任务」小稳态。 +DELETE FROM dispatch_outbox +WHERE target_user_id = $1 + AND id = $2; + +-- name: MarkDispatchFailed :exec +UPDATE dispatch_outbox +SET + status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END, + next_attempt_at = CASE + WHEN attempts >= 5 THEN next_attempt_at + ELSE now() + make_interval(secs => LEAST(60, attempts * attempts)) + END, + last_error = $3, + updated_at = now() +WHERE target_user_id = $1 + AND id = $2; + +-- name: BatchListDispatchEvents :many +-- 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情, +-- 取代逐条 ListUserUpdateEventsAfter。列与 ListUserUpdateEventsAfter 完全一致以复用转换逻辑。 +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + 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.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.tags_enabled, + 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, + COALESCE(m.peer_type, '')::text AS peer_type, + 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.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_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(m.reply_to_top_id, 0)::int AS reply_to_top_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, + 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.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_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(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(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, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + 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(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, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + 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 +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 +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; + +-- name: MarkDispatchDeliveredBatch :exec +-- 批量删除一批已投递的 (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) +WHERE d.target_user_id = tu.target_user_id + AND d.id = di.id; + +-- name: DeleteFailedDispatchOutbox :one +WITH doomed AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE status = 'failed' + AND updated_at < now() - make_interval(secs => sqlc.arg(older_than_seconds)::int) + ORDER BY updated_at ASC, target_user_id ASC, id ASC + LIMIT sqlc.arg(limit_count) +), +deleted AS ( + DELETE FROM dispatch_outbox d + USING doomed x + WHERE d.target_user_id = x.target_user_id + AND d.id = x.id + RETURNING d.id +) +SELECT count(*)::int AS deleted_count +FROM deleted; diff --git a/internal/store/postgres/sqlcgen/account.sql.go b/internal/store/postgres/sqlcgen/account.sql.go new file mode 100644 index 00000000..71a4610d --- /dev/null +++ b/internal/store/postgres/sqlcgen/account.sql.go @@ -0,0 +1,87 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: account.sql + +package sqlcgen + +import ( + "context" +) + +const getPasswordByUser = `-- name: GetPasswordByUser :one +SELECT + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +FROM account_passwords +WHERE user_id = $1 +` + +type GetPasswordByUserRow struct { + UserID int64 + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte +} + +func (q *Queries) GetPasswordByUser(ctx context.Context, userID int64) (GetPasswordByUserRow, error) { + row := q.db.QueryRow(ctx, getPasswordByUser, userID) + var i GetPasswordByUserRow + err := row.Scan( + &i.UserID, + &i.HasRecovery, + &i.HasSecureValues, + &i.HasPassword, + &i.Hint, + &i.EmailUnconfirmedPattern, + &i.LoginEmailPattern, + &i.SecureRandom, + ) + return i, err +} + +const upsertPassword = `-- name: UpsertPassword :exec +INSERT INTO account_passwords ( + user_id, has_recovery, has_secure_values, has_password, hint, + email_unconfirmed_pattern, login_email_pattern, secure_random +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (user_id) DO UPDATE SET + has_recovery = EXCLUDED.has_recovery, + has_secure_values = EXCLUDED.has_secure_values, + has_password = EXCLUDED.has_password, + hint = EXCLUDED.hint, + email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern, + login_email_pattern = EXCLUDED.login_email_pattern, + secure_random = EXCLUDED.secure_random, + updated_at = now() +` + +type UpsertPasswordParams struct { + UserID int64 + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte +} + +func (q *Queries) UpsertPassword(ctx context.Context, arg UpsertPasswordParams) error { + _, err := q.db.Exec(ctx, upsertPassword, + arg.UserID, + arg.HasRecovery, + arg.HasSecureValues, + arg.HasPassword, + arg.Hint, + arg.EmailUnconfirmedPattern, + arg.LoginEmailPattern, + arg.SecureRandom, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/authkey.sql.go b/internal/store/postgres/sqlcgen/authkey.sql.go new file mode 100644 index 00000000..b3ea11d4 --- /dev/null +++ b/internal/store/postgres/sqlcgen/authkey.sql.go @@ -0,0 +1,46 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: authkey.sql + +package sqlcgen + +import ( + "context" +) + +const getAuthKey = `-- name: GetAuthKey :one +SELECT auth_key_id, body, server_salt, created_at +FROM auth_keys +WHERE auth_key_id = $1 +` + +func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (AuthKey, error) { + row := q.db.QueryRow(ctx, getAuthKey, authKeyID) + var i AuthKey + err := row.Scan( + &i.AuthKeyID, + &i.Body, + &i.ServerSalt, + &i.CreatedAt, + ) + return i, err +} + +const upsertAuthKey = `-- name: UpsertAuthKey :exec +INSERT INTO auth_keys (auth_key_id, body, server_salt) +VALUES ($1, $2, $3) +ON CONFLICT (auth_key_id) DO UPDATE +SET body = EXCLUDED.body, server_salt = EXCLUDED.server_salt +` + +type UpsertAuthKeyParams struct { + AuthKeyID int64 + Body []byte + ServerSalt int64 +} + +func (q *Queries) UpsertAuthKey(ctx context.Context, arg UpsertAuthKeyParams) error { + _, err := q.db.Exec(ctx, upsertAuthKey, arg.AuthKeyID, arg.Body, arg.ServerSalt) + return err +} diff --git a/internal/store/postgres/sqlcgen/authorization.sql.go b/internal/store/postgres/sqlcgen/authorization.sql.go new file mode 100644 index 00000000..9739ea5f --- /dev/null +++ b/internal/store/postgres/sqlcgen/authorization.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: authorization.sql + +package sqlcgen + +import ( + "context" +) + +const deleteAuthorization = `-- name: DeleteAuthorization :exec +DELETE FROM authorizations WHERE auth_key_id = $1 +` + +func (q *Queries) DeleteAuthorization(ctx context.Context, authKeyID int64) error { + _, err := q.db.Exec(ctx, deleteAuthorization, authKeyID) + return err +} + +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 +` + +func (q *Queries) GetAuthorizationByAuthKey(ctx context.Context, authKeyID int64) (Authorization, error) { + row := q.db.QueryRow(ctx, getAuthorizationByAuthKey, authKeyID) + var i Authorization + err := row.Scan( + &i.AuthKeyID, + &i.UserID, + &i.Hash, + &i.Layer, + &i.DeviceModel, + &i.Platform, + &i.SystemVersion, + &i.ApiID, + &i.AppVersion, + &i.Ip, + &i.CreatedAt, + &i.ActiveAt, + ) + 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 +WHERE user_id = $1 +ORDER BY active_at DESC, auth_key_id DESC +` + +func (q *Queries) ListAuthorizationsByUser(ctx context.Context, userID int64) ([]Authorization, error) { + rows, err := q.db.Query(ctx, listAuthorizationsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Authorization + for rows.Next() { + var i Authorization + if err := rows.Scan( + &i.AuthKeyID, + &i.UserID, + &i.Hash, + &i.Layer, + &i.DeviceModel, + &i.Platform, + &i.SystemVersion, + &i.ApiID, + &i.AppVersion, + &i.Ip, + &i.CreatedAt, + &i.ActiveAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAuthorization = `-- name: UpsertAuthorization :exec +INSERT INTO authorizations (auth_key_id, user_id, layer, device_model, platform, system_version, api_id, app_version, ip) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (auth_key_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + layer = EXCLUDED.layer, + device_model = EXCLUDED.device_model, + platform = EXCLUDED.platform, + system_version = EXCLUDED.system_version, + api_id = EXCLUDED.api_id, + app_version = EXCLUDED.app_version, + ip = EXCLUDED.ip, + active_at = now() +` + +type UpsertAuthorizationParams struct { + AuthKeyID int64 + UserID int64 + Layer int32 + DeviceModel string + Platform string + SystemVersion string + ApiID int32 + AppVersion string + Ip string +} + +func (q *Queries) UpsertAuthorization(ctx context.Context, arg UpsertAuthorizationParams) error { + _, err := q.db.Exec(ctx, upsertAuthorization, + arg.AuthKeyID, + arg.UserID, + arg.Layer, + arg.DeviceModel, + arg.Platform, + arg.SystemVersion, + arg.ApiID, + arg.AppVersion, + arg.Ip, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/contact.sql.go b/internal/store/postgres/sqlcgen/contact.sql.go new file mode 100644 index 00000000..ca02eadc --- /dev/null +++ b/internal/store/postgres/sqlcgen/contact.sql.go @@ -0,0 +1,426 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: contact.sql + +package sqlcgen + +import ( + "context" +) + +const deleteContacts = `-- name: DeleteContacts :one +WITH deleted AS ( + DELETE FROM contacts + WHERE user_id = $1::bigint + AND contact_user_id = ANY($2::bigint[]) + RETURNING contact_user_id +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = false, + updated_at = now() + FROM deleted d + WHERE c.user_id = d.contact_user_id + AND c.contact_user_id = $1::bigint + RETURNING c.user_id +) +SELECT COUNT(*)::int AS deleted_count +FROM deleted +` + +type DeleteContactsParams struct { + UserID int64 + ContactUserIds []int64 +} + +func (q *Queries) DeleteContacts(ctx context.Context, arg DeleteContactsParams) (int32, error) { + row := q.db.QueryRow(ctx, deleteContacts, arg.UserID, arg.ContactUserIds) + var deleted_count int32 + err := row.Scan(&deleted_count) + return deleted_count, err +} + +const getContact = `-- name: GetContact :one +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 + AND c.contact_user_id = $2 +` + +type GetContactParams struct { + UserID int64 + ContactUserID int64 +} + +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 +} + +func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) { + row := q.db.QueryRow(ctx, getContact, arg.UserID, arg.ContactUserID) + var i GetContactRow + err := row.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + ) + return i, err +} + +const listContactsByUser = `-- name: ListContactsByUser :many +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM contacts c +JOIN users u ON u.id = c.contact_user_id +WHERE c.user_id = $1 +ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u.id +` + +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 +} + +func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) { + rows, err := q.db.Query(ctx, listContactsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListContactsByUserRow + for rows.Next() { + var i ListContactsByUserRow + if err := rows.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateContactNote = `-- name: UpdateContactNote :one +WITH updated AS ( + UPDATE contacts c + SET note = $1::text, + note_entities = $2::jsonb, + 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 +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at +FROM updated c +JOIN users u ON u.id = c.contact_user_id +` + +type UpdateContactNoteParams struct { + Note string + NoteEntities []byte + UserID int64 + ContactUserID int64 +} + +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 +} + +func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) { + row := q.db.QueryRow(ctx, updateContactNote, + arg.Note, + arg.NoteEntities, + arg.UserID, + arg.ContactUserID, + ) + var i UpdateContactNoteRow + err := row.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + ) + return i, err +} + +const upsertContact = `-- name: UpsertContact :one +WITH reverse AS ( + SELECT EXISTS ( + SELECT 1 + FROM contacts + WHERE user_id = $1::bigint + AND contact_user_id = $2::bigint + )::boolean AS mutual +), +upserted AS ( + INSERT INTO contacts ( + user_id, + contact_user_id, + contact_phone, + contact_first_name, + contact_last_name, + note, + note_entities, + mutual + ) + SELECT + $2::bigint, + $1::bigint, + $3::text, + $4::text, + $5::text, + $6::text, + $7::jsonb, + reverse.mutual + FROM reverse + ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_phone = EXCLUDED.contact_phone, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + note = EXCLUDED.note, + 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 +), +reverse_updated AS ( + UPDATE contacts c + SET mutual = true, + updated_at = now() + WHERE c.user_id = $1::bigint + AND c.contact_user_id = $2::bigint + AND NOT c.mutual + RETURNING c.user_id +) +SELECT + c.contact_user_id, + c.mutual, + c.contact_phone, + c.contact_first_name, + c.contact_last_name, + c.note, + COALESCE(c.note_entities::text, '[]')::text AS note_entities_json, + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed +FROM upserted c +JOIN users u ON u.id = c.contact_user_id +` + +type UpsertContactParams struct { + ContactUserID int64 + UserID int64 + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntities []byte +} + +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 +} + +func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) { + row := q.db.QueryRow(ctx, upsertContact, + arg.ContactUserID, + arg.UserID, + arg.ContactPhone, + arg.ContactFirstName, + arg.ContactLastName, + arg.Note, + arg.NoteEntities, + ) + var i UpsertContactRow + err := row.Scan( + &i.ContactUserID, + &i.Mutual, + &i.ContactPhone, + &i.ContactFirstName, + &i.ContactLastName, + &i.Note, + &i.NoteEntitiesJson, + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + &i.ReverseMutualChanged, + ) + return i, err +} diff --git a/internal/store/postgres/sqlcgen/db.go b/internal/store/postgres/sqlcgen/db.go new file mode 100644 index 00000000..38811fcc --- /dev/null +++ b/internal/store/postgres/sqlcgen/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/postgres/sqlcgen/dialog.sql.go b/internal/store/postgres/sqlcgen/dialog.sql.go new file mode 100644 index 00000000..a557f0c2 --- /dev/null +++ b/internal/store/postgres/sqlcgen/dialog.sql.go @@ -0,0 +1,1592 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dialog.sql + +package sqlcgen + +import ( + "context" +) + +const clearDialogAfterHistoryDelete = `-- name: ClearDialogAfterHistoryDelete :exec +UPDATE dialogs d +SET + top_message_id = 0, + top_message_date = 0, + read_inbox_max_id = GREATEST(d.read_inbox_max_id, d.top_message_id), + read_outbox_max_id = GREATEST(d.read_outbox_max_id, d.top_message_id), + unread_count = 0, + unread_mark = false, + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = $1::bigint + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint +` + +type ClearDialogAfterHistoryDeleteParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) ClearDialogAfterHistoryDelete(ctx context.Context, arg ClearDialogAfterHistoryDeleteParams) error { + _, err := q.db.Exec(ctx, clearDialogAfterHistoryDelete, arg.UserID, arg.PeerType, arg.PeerID) + return err +} + +const clearDialogDrafts = `-- name: ClearDialogDrafts :many +WITH doomed AS ( + SELECT d.user_id, d.peer_type, d.peer_id, d.top_message_id + FROM dialog_drafts d + WHERE d.user_id = $1 + ORDER BY d.date DESC, d.peer_type ASC, d.peer_id DESC, d.top_message_id DESC + LIMIT $2 +), +deleted AS ( + DELETE FROM dialog_drafts d + USING doomed + WHERE d.user_id = doomed.user_id + AND d.peer_type = doomed.peer_type + AND d.peer_id = doomed.peer_id + AND d.top_message_id = doomed.top_message_id + RETURNING d.draft::text AS draft_json +) +SELECT draft_json +FROM deleted +` + +type ClearDialogDraftsParams struct { + UserID int64 + LimitCount int32 +} + +func (q *Queries) ClearDialogDrafts(ctx context.Context, arg ClearDialogDraftsParams) ([]string, error) { + rows, err := q.db.Query(ctx, clearDialogDrafts, arg.UserID, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var draft_json string + if err := rows.Scan(&draft_json); err != nil { + return nil, err + } + items = append(items, draft_json) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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[]) +) +UPDATE dialogs d +SET pinned = false, + pinned_order = 0, + updated_at = now() +WHERE d.user_id = $1::bigint + AND d.pinned + AND NOT EXISTS ( + SELECT 1 + FROM requested r + WHERE r.peer_type = d.peer_type + AND r.peer_id = d.peer_id + ) +` + +type ClearPinnedDialogsNotInOrderParams struct { + UserID int64 + 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) + return err +} + +const deleteDialogByPeer = `-- name: DeleteDialogByPeer :exec +DELETE FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 +` + +type DeleteDialogByPeerParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteDialogByPeer(ctx context.Context, arg DeleteDialogByPeerParams) error { + _, err := q.db.Exec(ctx, deleteDialogByPeer, arg.UserID, arg.PeerType, arg.PeerID) + return err +} + +const deleteDialogDraft = `-- name: DeleteDialogDraft :one +WITH deleted AS ( + DELETE FROM dialog_drafts + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND top_message_id = $4 + RETURNING user_id +) +SELECT EXISTS (SELECT 1 FROM deleted)::boolean AS changed +` + +type DeleteDialogDraftParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 +} + +func (q *Queries) DeleteDialogDraft(ctx context.Context, arg DeleteDialogDraftParams) (bool, error) { + row := q.db.QueryRow(ctx, deleteDialogDraft, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + ) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const deleteDialogFolder = `-- name: DeleteDialogFolder :exec +DELETE FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2 +` + +type DeleteDialogFolderParams struct { + UserID int64 + FilterID int32 +} + +func (q *Queries) DeleteDialogFolder(ctx context.Context, arg DeleteDialogFolderParams) error { + _, err := q.db.Exec(ctx, deleteDialogFolder, arg.UserID, arg.FilterID) + return err +} + +const editDialogPeerFolders = `-- name: EditDialogPeerFolders :exec +WITH requested AS ( + SELECT + ($2::text[])[i] AS peer_type, + ($3::bigint[])[i] AS peer_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 (peer_type, peer_id) + peer_type, + peer_id, + folder_id + FROM requested + WHERE folder_id IN (0, 1) + ORDER BY peer_type, peer_id +) +UPDATE dialogs d +SET folder_id = deduped.folder_id, + updated_at = now() +FROM deduped +WHERE d.user_id = $1::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id +` + +type EditDialogPeerFoldersParams struct { + UserID int64 + PeerTypes []string + PeerIds []int64 + FolderIds []int32 +} + +func (q *Queries) EditDialogPeerFolders(ctx context.Context, arg EditDialogPeerFoldersParams) error { + _, err := q.db.Exec(ctx, editDialogPeerFolders, + arg.UserID, + arg.PeerTypes, + arg.PeerIds, + arg.FolderIds, + ) + return err +} + +const getDialogFolder = `-- name: GetDialogFolder :one +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 + AND filter_id = $2 +` + +type GetDialogFolderParams struct { + UserID int64 + FilterID int32 +} + +type GetDialogFolderRow struct { + FilterID int32 + IsChatlist bool + FilterJson string +} + +func (q *Queries) GetDialogFolder(ctx context.Context, arg GetDialogFolderParams) (GetDialogFolderRow, error) { + row := q.db.QueryRow(ctx, getDialogFolder, arg.UserID, arg.FilterID) + var i GetDialogFolderRow + err := row.Scan(&i.FilterID, &i.IsChatlist, &i.FilterJson) + return i, err +} + +const getDialogFolderTags = `-- name: GetDialogFolderTags :one +SELECT tags_enabled +FROM dialog_filter_settings +WHERE user_id = $1 +` + +func (q *Queries) GetDialogFolderTags(ctx context.Context, userID int64) (bool, error) { + row := q.db.QueryRow(ctx, getDialogFolderTags, userID) + var tags_enabled bool + err := row.Scan(&tags_enabled) + return tags_enabled, err +} + +const getPeerSettingsBarHidden = `-- name: GetPeerSettingsBarHidden :one +SELECT hidden_peer_settings_bar +FROM dialogs +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 +` + +type GetPeerSettingsBarHiddenParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) GetPeerSettingsBarHidden(ctx context.Context, arg GetPeerSettingsBarHiddenParams) (bool, error) { + row := q.db.QueryRow(ctx, getPeerSettingsBarHidden, arg.UserID, arg.PeerType, arg.PeerID) + var hidden_peer_settings_bar bool + err := row.Scan(&hidden_peer_settings_bar) + return hidden_peer_settings_bar, err +} + +const listDialogDrafts = `-- name: ListDialogDrafts :many +SELECT draft::text AS draft_json +FROM dialog_drafts +WHERE user_id = $1 +ORDER BY date DESC, peer_type ASC, peer_id DESC, top_message_id DESC +LIMIT $2 +` + +type ListDialogDraftsParams struct { + UserID int64 + LimitCount int32 +} + +func (q *Queries) ListDialogDrafts(ctx context.Context, arg ListDialogDraftsParams) ([]string, error) { + rows, err := q.db.Query(ctx, listDialogDrafts, arg.UserID, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var draft_json string + if err := rows.Scan(&draft_json); err != nil { + return nil, err + } + items = append(items, draft_json) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogFolders = `-- name: ListDialogFolders :many +SELECT + filter_id, + is_chatlist, + filter::text AS filter_json +FROM dialog_filters +WHERE user_id = $1 +ORDER BY order_value ASC, filter_id ASC +` + +type ListDialogFoldersRow struct { + FilterID int32 + IsChatlist bool + FilterJson string +} + +func (q *Queries) ListDialogFolders(ctx context.Context, userID int64) ([]ListDialogFoldersRow, error) { + rows, err := q.db.Query(ctx, listDialogFolders, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogFoldersRow + for rows.Next() { + var i ListDialogFoldersRow + if err := rows.Scan(&i.FilterID, &i.IsChatlist, &i.FilterJson); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogSummaryByUser = `-- name: ListDialogSummaryByUser :many +SELECT + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar +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 + OR ( + $3::int < 2 + AND d.folder_id = $3::int + ) + OR ( + $3::int >= 2 + AND NOT ($4::boolean AND d.folder_id = 1) + AND NOT ($5::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($6::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($7::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($8::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($9::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($10::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($11::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR ($12::boolean AND c.contact_user_id IS NOT NULL) + OR ($13::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT $14::boolean OR d.pinned) + 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, + d.top_message_date DESC, + d.top_message_id DESC, + d.peer_id DESC +` + +type ListDialogSummaryByUserParams struct { + UserID int64 + HasFolderID bool + FolderID int32 + FolderExcludeArchived bool + FolderExcludeRead bool + FolderExcludePeerTypes []string + FolderExcludePeerIds []int64 + FolderIncludePeerTypes []string + FolderIncludePeerIds []int64 + FolderPinnedPeerTypes []string + FolderPinnedPeerIds []int64 + FolderContacts bool + FolderNonContacts bool + PinnedOnly bool + ExcludePinned bool +} + +type ListDialogSummaryByUserRow struct { + 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 +} + +func (q *Queries) ListDialogSummaryByUser(ctx context.Context, arg ListDialogSummaryByUserParams) ([]ListDialogSummaryByUserRow, error) { + rows, err := q.db.Query(ctx, listDialogSummaryByUser, + arg.UserID, + arg.HasFolderID, + arg.FolderID, + arg.FolderExcludeArchived, + arg.FolderExcludeRead, + arg.FolderExcludePeerTypes, + arg.FolderExcludePeerIds, + arg.FolderIncludePeerTypes, + arg.FolderIncludePeerIds, + arg.FolderPinnedPeerTypes, + arg.FolderPinnedPeerIds, + arg.FolderContacts, + arg.FolderNonContacts, + arg.PinnedOnly, + arg.ExcludePinned, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogSummaryByUserRow + for rows.Next() { + var i ListDialogSummaryByUserRow + if err := rows.Scan( + &i.PeerType, + &i.PeerID, + &i.FolderID, + &i.TopMessageID, + &i.TopMessageDate, + &i.ReadInboxMaxID, + &i.ReadOutboxMaxID, + &i.UnreadCount, + &i.UnreadMentionsCount, + &i.UnreadReactionsCount, + &i.Pinned, + &i.PinnedOrder, + &i.UnreadMark, + &i.HiddenPeerSettingsBar, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogUnreadMarks = `-- name: ListDialogUnreadMarks :many +SELECT + peer_type, + peer_id +FROM dialogs +WHERE user_id = $1 + AND unread_mark +ORDER BY top_message_date DESC, top_message_id DESC, peer_id DESC +` + +type ListDialogUnreadMarksRow struct { + PeerType string + PeerID int64 +} + +func (q *Queries) ListDialogUnreadMarks(ctx context.Context, userID int64) ([]ListDialogUnreadMarksRow, error) { + rows, err := q.db.Query(ctx, listDialogUnreadMarks, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogUnreadMarksRow + for rows.Next() { + var i ListDialogUnreadMarksRow + if err := rows.Scan(&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 listDialogsByPeers = `-- name: ListDialogsByPeers :many +WITH requested AS ( + SELECT + ($1::text[])[i] AS peer_type, + ($2::bigint[])[i] AS peer_id, + i::int AS ord + FROM generate_subscripts($2::bigint[], 1) AS g(i) + WHERE i <= cardinality($1::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + ord + FROM requested + ORDER BY peer_type, peer_id, ord +), +base AS ( + SELECT + $3::bigint AS user_id, + r.peer_type, + r.peer_id, + COALESCE(d.folder_id, 0)::int AS folder_id, + COALESCE(d.top_message_id, 0)::int AS top_message_id, + COALESCE(d.top_message_date, 0)::int AS top_message_date, + COALESCE(d.read_inbox_max_id, 0)::int AS read_inbox_max_id, + COALESCE(d.read_outbox_max_id, 0)::int AS read_outbox_max_id, + 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.pinned, false)::boolean AS pinned, + COALESCE(d.pinned_order, 0)::int AS pinned_order, + COALESCE(d.unread_mark, false)::boolean AS unread_mark, + COALESCE(d.hidden_peer_settings_bar, false)::boolean AS hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + 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.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.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, + r.ord + FROM deduped r + LEFT JOIN dialogs d + ON d.user_id = $3::bigint + AND d.peer_type = r.peer_type + AND d.peer_id = r.peer_id + LEFT JOIN users u ON r.peer_type = 'user' AND u.id = r.peer_id + LEFT JOIN contacts c ON r.peer_type = 'user' AND c.user_id = $3::bigint AND c.contact_user_id = r.peer_id + LEFT JOIN message_boxes m ON m.owner_user_id = $3::bigint AND m.box_id = d.top_message_id AND NOT m.deleted +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS 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 +FROM base +ORDER BY ord +` + +type ListDialogsByPeersParams struct { + PeerTypes []string + PeerIds []int64 + UserID int64 +} + +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 +} + +func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeersParams) ([]ListDialogsByPeersRow, error) { + rows, err := q.db.Query(ctx, listDialogsByPeers, arg.PeerTypes, arg.PeerIds, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogsByPeersRow + for rows.Next() { + var i ListDialogsByPeersRow + if err := rows.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.FolderID, + &i.TopMessageID, + &i.TopMessageDate, + &i.ReadInboxMaxID, + &i.ReadOutboxMaxID, + &i.UnreadCount, + &i.UnreadMentionsCount, + &i.UnreadReactionsCount, + &i.Pinned, + &i.PinnedOrder, + &i.UnreadMark, + &i.HiddenPeerSettingsBar, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.PeerContact, + &i.PeerMutual, + &i.MessageID, + &i.MessageFromUserID, + &i.MessageDate, + &i.MessageOutgoing, + &i.MessageBody, + &i.MessageEntitiesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDialogsByUser = `-- name: ListDialogsByUser :many +WITH base AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.folder_id, + d.top_message_id, + d.top_message_date, + d.read_inbox_max_id, + d.read_outbox_max_id, + d.unread_count, + d.unread_mentions_count, + d.unread_reactions_count, + d.pinned, + d.pinned_order, + d.unread_mark, + d.hidden_peer_settings_bar, + COALESCE(u.id, 0)::bigint AS peer_user_id, + COALESCE(u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone, '')::text AS peer_phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name, '')::text AS peer_first_name, + COALESCE(c.contact_last_name, u.last_name, '')::text AS peer_last_name, + COALESCE(u.username, '')::text AS peer_username, + 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.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.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 + 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 + OR ( + $4::int < 2 + AND d.folder_id = $4::int + ) + OR ( + $4::int >= 2 + AND NOT ($5::boolean AND d.folder_id = 1) + AND NOT ($6::boolean AND d.unread_count = 0 AND NOT d.unread_mark) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($7::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($8::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + AND ( + EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($9::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($10::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR EXISTS ( + SELECT 1 + FROM ( + SELECT fpt.peer_type, fpi.peer_id + FROM unnest($11::text[]) WITH ORDINALITY AS fpt(peer_type, ord) + JOIN unnest($12::bigint[]) WITH ORDINALITY AS fpi(peer_id, ord) USING (ord) + ) fp + WHERE fp.peer_type = d.peer_type AND fp.peer_id = d.peer_id + ) + OR ($13::boolean AND c.contact_user_id IS NOT NULL) + OR ($14::boolean AND c.contact_user_id IS NULL) + ) + ) + ) + AND (NOT $15::boolean OR d.pinned) + 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 + FROM base + WHERE ( + ($17::int <= 0 AND $18::int <= 0) + OR ( + $17::int > 0 + AND ( + top_message_date < $17::int + OR ( + top_message_date = $17::int + AND ( + $18::int <= 0 + OR top_message_id < $18::int + OR ( + top_message_id = $18::int + AND $19::boolean + AND peer_id < $20::bigint + ) + ) + ) + ) + ) + OR ( + $17::int <= 0 + AND $18::int > 0 + AND ( + top_message_id < $18::int + OR ( + top_message_id = $18::int + AND $19::boolean + AND peer_id < $20::bigint + ) + ) + ) + ) +) +SELECT + user_id, + peer_type::text AS peer_type, + peer_id::bigint AS 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 +FROM paged +ORDER BY + pinned DESC, + CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + top_message_date DESC, + top_message_id DESC, + peer_id DESC +LIMIT $2 +` + +type ListDialogsByUserParams struct { + UserID int64 + LimitCount int32 + HasFolderID bool + FolderID int32 + FolderExcludeArchived bool + FolderExcludeRead bool + FolderExcludePeerTypes []string + FolderExcludePeerIds []int64 + FolderIncludePeerTypes []string + FolderIncludePeerIds []int64 + FolderPinnedPeerTypes []string + FolderPinnedPeerIds []int64 + FolderContacts bool + FolderNonContacts bool + PinnedOnly bool + ExcludePinned bool + OffsetDate int32 + OffsetID int32 + HasOffsetPeer bool + OffsetPeerID int64 +} + +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 +} + +func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserParams) ([]ListDialogsByUserRow, error) { + rows, err := q.db.Query(ctx, listDialogsByUser, + arg.UserID, + arg.LimitCount, + arg.HasFolderID, + arg.FolderID, + arg.FolderExcludeArchived, + arg.FolderExcludeRead, + arg.FolderExcludePeerTypes, + arg.FolderExcludePeerIds, + arg.FolderIncludePeerTypes, + arg.FolderIncludePeerIds, + arg.FolderPinnedPeerTypes, + arg.FolderPinnedPeerIds, + arg.FolderContacts, + arg.FolderNonContacts, + arg.PinnedOnly, + arg.ExcludePinned, + arg.OffsetDate, + arg.OffsetID, + arg.HasOffsetPeer, + arg.OffsetPeerID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDialogsByUserRow + for rows.Next() { + var i ListDialogsByUserRow + if err := rows.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.FolderID, + &i.TopMessageID, + &i.TopMessageDate, + &i.ReadInboxMaxID, + &i.ReadOutboxMaxID, + &i.UnreadCount, + &i.UnreadMentionsCount, + &i.UnreadReactionsCount, + &i.Pinned, + &i.PinnedOrder, + &i.UnreadMark, + &i.HiddenPeerSettingsBar, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.PeerContact, + &i.PeerMutual, + &i.MessageID, + &i.MessageFromUserID, + &i.MessageDate, + &i.MessageOutgoing, + &i.MessageBody, + &i.MessageEntitiesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markDialogRead = `-- name: MarkDialogRead :one +WITH target AS ( + SELECT + d.user_id, + d.peer_type, + d.peer_id, + d.top_message_id, + d.read_inbox_max_id, + d.unread_count + FROM dialogs d + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 +), +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 + ), + 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 + AND d.peer_type = target.peer_type + AND d.peer_id = target.peer_id +RETURNING + d.user_id, + d.peer_type, + d.peer_id, + d.read_inbox_max_id, + 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 + )::boolean AS changed +) +SELECT + user_id, + peer_type, + peer_id, + read_inbox_max_id, + unread_count, + changed +FROM updated +` + +type MarkDialogReadParams struct { + UserID int64 + PeerType string + PeerID int64 + MaxID int32 +} + +type MarkDialogReadRow struct { + UserID int64 + PeerType string + PeerID int64 + ReadInboxMaxID int32 + UnreadCount int32 + Changed bool +} + +func (q *Queries) MarkDialogRead(ctx context.Context, arg MarkDialogReadParams) (MarkDialogReadRow, error) { + row := q.db.QueryRow(ctx, markDialogRead, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + ) + var i MarkDialogReadRow + err := row.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.ReadInboxMaxID, + &i.UnreadCount, + &i.Changed, + ) + return i, err +} + +const refreshDialogAfterMessageDelete = `-- name: RefreshDialogAfterMessageDelete :exec +UPDATE dialogs d +SET + top_message_id = $1::int, + top_message_date = $2::int, + 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 > d.read_inbox_max_id + ), + unread_mentions_count = 0, + unread_reactions_count = 0, + updated_at = now() +WHERE d.user_id = $3::bigint + AND d.peer_type = $4::text + AND d.peer_id = $5::bigint +` + +type RefreshDialogAfterMessageDeleteParams struct { + TopMessageID int32 + TopMessageDate int32 + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) RefreshDialogAfterMessageDelete(ctx context.Context, arg RefreshDialogAfterMessageDeleteParams) error { + _, err := q.db.Exec(ctx, refreshDialogAfterMessageDelete, + arg.TopMessageID, + arg.TopMessageDate, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + return err +} + +const reorderDialogFolders = `-- name: ReorderDialogFolders :exec +WITH requested AS ( + SELECT filter_id, ord::int AS order_value + FROM unnest($2::int[]) WITH ORDINALITY AS t(filter_id, ord) +), +deduped AS ( + SELECT DISTINCT ON (filter_id) + filter_id, + order_value + FROM requested + WHERE filter_id >= 2 + ORDER BY filter_id, order_value +) +UPDATE dialog_filters f +SET order_value = deduped.order_value, + updated_at = now() +FROM deduped +WHERE f.user_id = $1::bigint + AND f.filter_id = deduped.filter_id +` + +type ReorderDialogFoldersParams struct { + UserID int64 + FilterIds []int32 +} + +func (q *Queries) ReorderDialogFolders(ctx context.Context, arg ReorderDialogFoldersParams) error { + _, err := q.db.Exec(ctx, reorderDialogFolders, arg.UserID, arg.FilterIds) + return err +} + +const reorderPinnedDialogs = `-- name: ReorderPinnedDialogs :exec +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_type, peer_id) + peer_type, + peer_id, + pos::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::bigint + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id +` + +type ReorderPinnedDialogsParams struct { + UserID int64 + 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) + return err +} + +const setDialogFolderTags = `-- name: SetDialogFolderTags :exec +INSERT INTO dialog_filter_settings ( + user_id, + tags_enabled +) VALUES ( + $1, + $2 +) +ON CONFLICT (user_id) DO UPDATE SET + tags_enabled = EXCLUDED.tags_enabled, + updated_at = now() +` + +type SetDialogFolderTagsParams struct { + UserID int64 + TagsEnabled bool +} + +func (q *Queries) SetDialogFolderTags(ctx context.Context, arg SetDialogFolderTagsParams) error { + _, err := q.db.Exec(ctx, setDialogFolderTags, arg.UserID, arg.TagsEnabled) + return err +} + +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 +), +updated AS ( + UPDATE dialogs d + SET pinned = $2::boolean, + pinned_order = CASE + WHEN $2::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 +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +` + +type SetDialogPinnedParams struct { + UserID int64 + Pinned bool + PeerType string + PeerID int64 +} + +func (q *Queries) SetDialogPinned(ctx context.Context, arg SetDialogPinnedParams) (bool, error) { + row := q.db.QueryRow(ctx, setDialogPinned, + arg.UserID, + arg.Pinned, + arg.PeerType, + arg.PeerID, + ) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const setDialogUnreadMark = `-- name: SetDialogUnreadMark :one +WITH updated AS ( + UPDATE dialogs d + SET unread_mark = $1::boolean, + updated_at = now() + WHERE d.user_id = $2::bigint + AND d.peer_type = $3::text + AND d.peer_id = $4::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +` + +type SetDialogUnreadMarkParams struct { + Unread bool + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) SetDialogUnreadMark(ctx context.Context, arg SetDialogUnreadMarkParams) (bool, error) { + row := q.db.QueryRow(ctx, setDialogUnreadMark, + arg.Unread, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const setPeerSettingsBarHidden = `-- name: SetPeerSettingsBarHidden :one +WITH updated AS ( + UPDATE dialogs d + SET hidden_peer_settings_bar = true, + updated_at = now() + WHERE d.user_id = $1::bigint + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +` + +type SetPeerSettingsBarHiddenParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) SetPeerSettingsBarHidden(ctx context.Context, arg SetPeerSettingsBarHiddenParams) (bool, error) { + row := q.db.QueryRow(ctx, setPeerSettingsBarHidden, arg.UserID, arg.PeerType, arg.PeerID) + var changed bool + err := row.Scan(&changed) + return changed, err +} + +const upsertDialog = `-- name: UpsertDialog :exec +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, + unread_mark +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 +) +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, + read_inbox_max_id = EXCLUDED.read_inbox_max_id, + read_outbox_max_id = EXCLUDED.read_outbox_max_id, + unread_count = EXCLUDED.unread_count, + unread_mentions_count = EXCLUDED.unread_mentions_count, + unread_reactions_count = EXCLUDED.unread_reactions_count, + pinned = EXCLUDED.pinned, + unread_mark = EXCLUDED.unread_mark, + updated_at = now() +` + +type UpsertDialogParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + UnreadMark bool +} + +func (q *Queries) UpsertDialog(ctx context.Context, arg UpsertDialogParams) error { + _, err := q.db.Exec(ctx, upsertDialog, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.TopMessageDate, + arg.ReadInboxMaxID, + arg.ReadOutboxMaxID, + arg.UnreadCount, + arg.UnreadMentionsCount, + arg.UnreadReactionsCount, + arg.Pinned, + arg.UnreadMark, + ) + return err +} + +const upsertDialogDraft = `-- name: UpsertDialogDraft :exec +INSERT INTO dialog_drafts ( + user_id, + peer_type, + peer_id, + top_message_id, + date, + draft +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6::jsonb +) +ON CONFLICT (user_id, peer_type, peer_id, top_message_id) DO UPDATE SET + date = EXCLUDED.date, + draft = EXCLUDED.draft, + updated_at = now() +` + +type UpsertDialogDraftParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + Date int32 + DraftJson []byte +} + +func (q *Queries) UpsertDialogDraft(ctx context.Context, arg UpsertDialogDraftParams) error { + _, err := q.db.Exec(ctx, upsertDialogDraft, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.Date, + arg.DraftJson, + ) + return err +} + +const upsertDialogFolder = `-- name: UpsertDialogFolder :exec +INSERT INTO dialog_filters ( + user_id, + filter_id, + is_chatlist, + filter, + order_value +) VALUES ( + $1, + $2, + $3, + $4::jsonb, + COALESCE( + (SELECT order_value FROM dialog_filters WHERE user_id = $1 AND filter_id = $2), + (SELECT COALESCE(MAX(order_value), 0) + 1 FROM dialog_filters WHERE user_id = $1) + ) +) +ON CONFLICT (user_id, filter_id) DO UPDATE SET + is_chatlist = EXCLUDED.is_chatlist, + filter = EXCLUDED.filter, + updated_at = now() +` + +type UpsertDialogFolderParams struct { + UserID int64 + FilterID int32 + IsChatlist bool + FilterJson []byte +} + +func (q *Queries) UpsertDialogFolder(ctx context.Context, arg UpsertDialogFolderParams) error { + _, err := q.db.Exec(ctx, upsertDialogFolder, + arg.UserID, + arg.FilterID, + arg.IsChatlist, + arg.FilterJson, + ) + return err +} + +const upsertInboxDialog = `-- name: UpsertInboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $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, + updated_at = now() +` + +type UpsertInboxDialogParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 +} + +func (q *Queries) UpsertInboxDialog(ctx context.Context, arg UpsertInboxDialogParams) error { + _, err := q.db.Exec(ctx, upsertInboxDialog, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.TopMessageDate, + ) + return err +} + +const upsertOutboxDialog = `-- name: UpsertOutboxDialog :exec +INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + unread_count +) VALUES ( + $1, $2, $3, $4, $5, 0 +) +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, + updated_at = now() +` + +type UpsertOutboxDialogParams struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 +} + +func (q *Queries) UpsertOutboxDialog(ctx context.Context, arg UpsertOutboxDialogParams) error { + _, err := q.db.Exec(ctx, upsertOutboxDialog, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.TopMessageID, + arg.TopMessageDate, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/help.sql.go b/internal/store/postgres/sqlcgen/help.sql.go new file mode 100644 index 00000000..09def2fc --- /dev/null +++ b/internal/store/postgres/sqlcgen/help.sql.go @@ -0,0 +1,159 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: help.sql + +package sqlcgen + +import ( + "context" +) + +const getAppConfig = `-- name: GetAppConfig :one +SELECT client, hash, config_json::text AS config_json +FROM app_configs +WHERE client = $1 +` + +type GetAppConfigRow struct { + Client string + Hash int32 + ConfigJson string +} + +func (q *Queries) GetAppConfig(ctx context.Context, client string) (GetAppConfigRow, error) { + row := q.db.QueryRow(ctx, getAppConfig, client) + var i GetAppConfigRow + err := row.Scan(&i.Client, &i.Hash, &i.ConfigJson) + return i, err +} + +const listCountries = `-- name: ListCountries :many +SELECT + c.iso2, + c.default_name, + c.name, + c.hidden, + cc.country_code, + cc.prefixes, + cc.patterns +FROM countries c +JOIN country_codes cc ON cc.iso2 = c.iso2 +ORDER BY c.order_index, c.iso2, cc.order_index, cc.country_code +` + +type ListCountriesRow struct { + Iso2 string + DefaultName string + Name string + Hidden bool + CountryCode string + Prefixes []string + Patterns []string +} + +func (q *Queries) ListCountries(ctx context.Context) ([]ListCountriesRow, error) { + rows, err := q.db.Query(ctx, listCountries) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListCountriesRow + for rows.Next() { + var i ListCountriesRow + if err := rows.Scan( + &i.Iso2, + &i.DefaultName, + &i.Name, + &i.Hidden, + &i.CountryCode, + &i.Prefixes, + &i.Patterns, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertAppConfig = `-- name: UpsertAppConfig :exec +INSERT INTO app_configs (client, hash, config_json) +VALUES ($1, $2, $3::jsonb) +ON CONFLICT (client) DO UPDATE SET + hash = EXCLUDED.hash, + config_json = EXCLUDED.config_json, + updated_at = now() +` + +type UpsertAppConfigParams struct { + Client string + Hash int32 + ConfigJson []byte +} + +func (q *Queries) UpsertAppConfig(ctx context.Context, arg UpsertAppConfigParams) error { + _, err := q.db.Exec(ctx, upsertAppConfig, arg.Client, arg.Hash, arg.ConfigJson) + return err +} + +const upsertCountry = `-- name: UpsertCountry :exec +INSERT INTO countries (iso2, default_name, name, hidden, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2) DO UPDATE SET + default_name = EXCLUDED.default_name, + name = EXCLUDED.name, + hidden = EXCLUDED.hidden, + order_index = EXCLUDED.order_index, + updated_at = now() +` + +type UpsertCountryParams struct { + Iso2 string + DefaultName string + Name string + Hidden bool + OrderIndex int32 +} + +func (q *Queries) UpsertCountry(ctx context.Context, arg UpsertCountryParams) error { + _, err := q.db.Exec(ctx, upsertCountry, + arg.Iso2, + arg.DefaultName, + arg.Name, + arg.Hidden, + arg.OrderIndex, + ) + return err +} + +const upsertCountryCode = `-- name: UpsertCountryCode :exec +INSERT INTO country_codes (iso2, country_code, prefixes, patterns, order_index) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (iso2, country_code) DO UPDATE SET + prefixes = EXCLUDED.prefixes, + patterns = EXCLUDED.patterns, + order_index = EXCLUDED.order_index +` + +type UpsertCountryCodeParams struct { + Iso2 string + CountryCode string + Prefixes []string + Patterns []string + OrderIndex int32 +} + +func (q *Queries) UpsertCountryCode(ctx context.Context, arg UpsertCountryCodeParams) error { + _, err := q.db.Exec(ctx, upsertCountryCode, + arg.Iso2, + arg.CountryCode, + arg.Prefixes, + arg.Patterns, + arg.OrderIndex, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/langpack.sql.go b/internal/store/postgres/sqlcgen/langpack.sql.go new file mode 100644 index 00000000..5839429a --- /dev/null +++ b/internal/store/postgres/sqlcgen/langpack.sql.go @@ -0,0 +1,250 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: langpack.sql + +package sqlcgen + +import ( + "context" +) + +const getLangPackMeta = `-- name: GetLangPackMeta :one +SELECT lang_pack, lang_code, version, strings_count +FROM lang_packs +WHERE lang_pack = $1 AND lang_code = $2 +` + +type GetLangPackMetaParams struct { + LangPack string + LangCode string +} + +type GetLangPackMetaRow struct { + LangPack string + LangCode string + Version int32 + StringsCount int32 +} + +func (q *Queries) GetLangPackMeta(ctx context.Context, arg GetLangPackMetaParams) (GetLangPackMetaRow, error) { + row := q.db.QueryRow(ctx, getLangPackMeta, arg.LangPack, arg.LangCode) + var i GetLangPackMetaRow + err := row.Scan( + &i.LangPack, + &i.LangCode, + &i.Version, + &i.StringsCount, + ) + return i, err +} + +const getLangPackStringsByKeys = `-- name: GetLangPackStringsByKeys :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND key = ANY($3::text[]) AND NOT deleted +ORDER BY key +` + +type GetLangPackStringsByKeysParams struct { + LangPack string + LangCode string + Keys []string +} + +type GetLangPackStringsByKeysRow struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} + +func (q *Queries) GetLangPackStringsByKeys(ctx context.Context, arg GetLangPackStringsByKeysParams) ([]GetLangPackStringsByKeysRow, error) { + rows, err := q.db.Query(ctx, getLangPackStringsByKeys, arg.LangPack, arg.LangCode, arg.Keys) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLangPackStringsByKeysRow + for rows.Next() { + var i GetLangPackStringsByKeysRow + if err := rows.Scan( + &i.LangPack, + &i.LangCode, + &i.Key, + &i.Version, + &i.Pluralized, + &i.Value, + &i.ZeroValue, + &i.OneValue, + &i.TwoValue, + &i.FewValue, + &i.ManyValue, + &i.OtherValue, + &i.Deleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLangPackStrings = `-- name: ListLangPackStrings :many +SELECT + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +FROM lang_pack_strings +WHERE lang_pack = $1 AND lang_code = $2 AND NOT deleted +ORDER BY key +` + +type ListLangPackStringsParams struct { + LangPack string + LangCode string +} + +type ListLangPackStringsRow struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} + +func (q *Queries) ListLangPackStrings(ctx context.Context, arg ListLangPackStringsParams) ([]ListLangPackStringsRow, error) { + rows, err := q.db.Query(ctx, listLangPackStrings, arg.LangPack, arg.LangCode) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLangPackStringsRow + for rows.Next() { + var i ListLangPackStringsRow + if err := rows.Scan( + &i.LangPack, + &i.LangCode, + &i.Key, + &i.Version, + &i.Pluralized, + &i.Value, + &i.ZeroValue, + &i.OneValue, + &i.TwoValue, + &i.FewValue, + &i.ManyValue, + &i.OtherValue, + &i.Deleted, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertLangPackMeta = `-- name: UpsertLangPackMeta :exec +INSERT INTO lang_packs (lang_pack, lang_code, version, strings_count) +VALUES ($1, $2, $3, $4) +ON CONFLICT (lang_pack, lang_code) DO UPDATE SET + version = EXCLUDED.version, + strings_count = EXCLUDED.strings_count, + updated_at = now() +` + +type UpsertLangPackMetaParams struct { + LangPack string + LangCode string + Version int32 + StringsCount int32 +} + +func (q *Queries) UpsertLangPackMeta(ctx context.Context, arg UpsertLangPackMetaParams) error { + _, err := q.db.Exec(ctx, upsertLangPackMeta, + arg.LangPack, + arg.LangCode, + arg.Version, + arg.StringsCount, + ) + return err +} + +const upsertLangPackString = `-- name: UpsertLangPackString :exec +INSERT INTO lang_pack_strings ( + lang_pack, lang_code, key, version, pluralized, value, + zero_value, one_value, two_value, few_value, many_value, other_value, deleted +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +ON CONFLICT (lang_pack, lang_code, key) DO UPDATE SET + version = EXCLUDED.version, + pluralized = EXCLUDED.pluralized, + value = EXCLUDED.value, + zero_value = EXCLUDED.zero_value, + one_value = EXCLUDED.one_value, + two_value = EXCLUDED.two_value, + few_value = EXCLUDED.few_value, + many_value = EXCLUDED.many_value, + other_value = EXCLUDED.other_value, + deleted = EXCLUDED.deleted, + updated_at = now() +` + +type UpsertLangPackStringParams struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool +} + +func (q *Queries) UpsertLangPackString(ctx context.Context, arg UpsertLangPackStringParams) error { + _, err := q.db.Exec(ctx, upsertLangPackString, + arg.LangPack, + arg.LangCode, + arg.Key, + arg.Version, + arg.Pluralized, + arg.Value, + arg.ZeroValue, + arg.OneValue, + arg.TwoValue, + arg.FewValue, + arg.ManyValue, + arg.OtherValue, + arg.Deleted, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/media.sql.go b/internal/store/postgres/sqlcgen/media.sql.go new file mode 100644 index 00000000..26393db0 --- /dev/null +++ b/internal/store/postgres/sqlcgen/media.sql.go @@ -0,0 +1,1154 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: media.sql + +package sqlcgen + +import ( + "context" +) + +const addProfilePhoto = `-- name: AddProfilePhoto :exec + +INSERT INTO profile_photos (owner_peer_type, owner_peer_id, photo_id, date, active, sort_order) +VALUES ( + $1::text, + $2::bigint, + $3::bigint, + $4::int, + true, + $5::bigint +) +ON CONFLICT (owner_peer_type, owner_peer_id, photo_id) DO UPDATE SET + date = EXCLUDED.date, + active = true, + sort_order = EXCLUDED.sort_order +` + +type AddProfilePhotoParams struct { + OwnerPeerType string + OwnerPeerID int64 + PhotoID int64 + Date int32 + SortOrder int64 +} + +// profile_photos -------------------------------------------------------------- +func (q *Queries) AddProfilePhoto(ctx context.Context, arg AddProfilePhotoParams) error { + _, err := q.db.Exec(ctx, addProfilePhoto, + arg.OwnerPeerType, + arg.OwnerPeerID, + arg.PhotoID, + arg.Date, + arg.SortOrder, + ) + return err +} + +const countAvailableReactions = `-- name: CountAvailableReactions :one +SELECT count(*)::int AS total FROM available_reactions +` + +func (q *Queries) CountAvailableReactions(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countAvailableReactions) + var total int32 + err := row.Scan(&total) + return total, err +} + +const countProfilePhotos = `-- name: CountProfilePhotos :one +SELECT count(*)::int AS total +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND active +` + +type CountProfilePhotosParams struct { + OwnerPeerType string + OwnerPeerID int64 +} + +func (q *Queries) CountProfilePhotos(ctx context.Context, arg CountProfilePhotosParams) (int32, error) { + row := q.db.QueryRow(ctx, countProfilePhotos, arg.OwnerPeerType, arg.OwnerPeerID) + var total int32 + err := row.Scan(&total) + return total, err +} + +const countStickerSets = `-- name: CountStickerSets :one +SELECT count(*)::int AS total FROM sticker_sets +` + +func (q *Queries) CountStickerSets(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countStickerSets) + var total int32 + err := row.Scan(&total) + return total, err +} + +const currentProfilePhoto = `-- name: CurrentProfilePhoto :one +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND active +ORDER BY sort_order DESC +LIMIT 1 +` + +type CurrentProfilePhotoParams struct { + OwnerPeerType string + OwnerPeerID int64 +} + +func (q *Queries) CurrentProfilePhoto(ctx context.Context, arg CurrentProfilePhotoParams) (int64, error) { + row := q.db.QueryRow(ctx, currentProfilePhoto, arg.OwnerPeerType, arg.OwnerPeerID) + var photo_id int64 + err := row.Scan(&photo_id) + return photo_id, err +} + +const currentProfilePhotosForOwners = `-- name: CurrentProfilePhotosForOwners :many +SELECT DISTINCT ON (pp.owner_peer_id) + pp.owner_peer_id, + pp.photo_id, + ph.dc_id, + 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::text + AND pp.owner_peer_id = ANY($2::bigint[]) + AND pp.active +ORDER BY pp.owner_peer_id, pp.sort_order DESC +` + +type CurrentProfilePhotosForOwnersParams struct { + OwnerPeerType string + OwnerIds []int64 +} + +type CurrentProfilePhotosForOwnersRow struct { + OwnerPeerID int64 + PhotoID int64 + DcID int32 + SizesJson string +} + +func (q *Queries) CurrentProfilePhotosForOwners(ctx context.Context, arg CurrentProfilePhotosForOwnersParams) ([]CurrentProfilePhotosForOwnersRow, error) { + rows, err := q.db.Query(ctx, currentProfilePhotosForOwners, arg.OwnerPeerType, arg.OwnerIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CurrentProfilePhotosForOwnersRow + for rows.Next() { + var i CurrentProfilePhotosForOwnersRow + if err := rows.Scan( + &i.OwnerPeerID, + &i.PhotoID, + &i.DcID, + &i.SizesJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deactivateProfilePhotos = `-- name: DeactivateProfilePhotos :many +UPDATE profile_photos +SET active = false +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND photo_id = ANY($3::bigint[]) + AND active +RETURNING photo_id +` + +type DeactivateProfilePhotosParams struct { + OwnerPeerType string + OwnerPeerID int64 + PhotoIds []int64 +} + +func (q *Queries) DeactivateProfilePhotos(ctx context.Context, arg DeactivateProfilePhotosParams) ([]int64, error) { + rows, err := q.db.Query(ctx, deactivateProfilePhotos, arg.OwnerPeerType, arg.OwnerPeerID, arg.PhotoIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var photo_id int64 + if err := rows.Scan(&photo_id); err != nil { + return nil, err + } + items = append(items, photo_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteUploadParts = `-- name: DeleteUploadParts :exec +DELETE FROM upload_parts +WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint +` + +type DeleteUploadPartsParams struct { + OwnerUserID int64 + FileID int64 +} + +func (q *Queries) DeleteUploadParts(ctx context.Context, arg DeleteUploadPartsParams) error { + _, err := q.db.Exec(ctx, deleteUploadParts, arg.OwnerUserID, arg.FileID) + return err +} + +const getDocument = `-- name: GetDocument :one +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = $1::bigint +` + +type GetDocumentRow struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + AttributesJson string + ThumbsJson string +} + +func (q *Queries) GetDocument(ctx context.Context, id int64) (GetDocumentRow, error) { + row := q.db.QueryRow(ctx, getDocument, id) + var i GetDocumentRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.FileReference, + &i.Date, + &i.MimeType, + &i.Size, + &i.DcID, + &i.AttributesJson, + &i.ThumbsJson, + ) + return i, err +} + +const getDocuments = `-- name: GetDocuments :many +SELECT id, access_hash, file_reference, date, mime_type, size, dc_id, + attributes::text AS attributes_json, + thumbs::text AS thumbs_json +FROM documents +WHERE id = ANY($1::bigint[]) +` + +type GetDocumentsRow struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + AttributesJson string + ThumbsJson string +} + +func (q *Queries) GetDocuments(ctx context.Context, ids []int64) ([]GetDocumentsRow, error) { + rows, err := q.db.Query(ctx, getDocuments, ids) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetDocumentsRow + for rows.Next() { + var i GetDocumentsRow + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.FileReference, + &i.Date, + &i.MimeType, + &i.Size, + &i.DcID, + &i.AttributesJson, + &i.ThumbsJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFileBlob = `-- name: GetFileBlob :one +SELECT location_key, backend, object_key, size, sha256, mime_type +FROM file_blobs +WHERE location_key = $1::text +` + +type GetFileBlobRow struct { + LocationKey string + Backend string + ObjectKey string + Size int64 + Sha256 []byte + MimeType string +} + +func (q *Queries) GetFileBlob(ctx context.Context, locationKey string) (GetFileBlobRow, error) { + row := q.db.QueryRow(ctx, getFileBlob, locationKey) + var i GetFileBlobRow + err := row.Scan( + &i.LocationKey, + &i.Backend, + &i.ObjectKey, + &i.Size, + &i.Sha256, + &i.MimeType, + ) + return i, err +} + +const getPhoto = `-- name: GetPhoto :one +SELECT id, access_hash, file_reference, date, dc_id, has_stickers, + sizes::text AS sizes_json +FROM photos +WHERE id = $1::bigint +` + +type GetPhotoRow struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + DcID int32 + HasStickers bool + SizesJson string +} + +func (q *Queries) GetPhoto(ctx context.Context, id int64) (GetPhotoRow, error) { + row := q.db.QueryRow(ctx, getPhoto, id) + var i GetPhotoRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.FileReference, + &i.Date, + &i.DcID, + &i.HasStickers, + &i.SizesJson, + ) + return i, err +} + +const getStickerSetByID = `-- name: GetStickerSetByID :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE id = $1::bigint +` + +type GetStickerSetByIDRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) GetStickerSetByID(ctx context.Context, id int64) (GetStickerSetByIDRow, error) { + row := q.db.QueryRow(ctx, getStickerSetByID, id) + var i GetStickerSetByIDRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ) + return i, err +} + +const getStickerSetByShortName = `-- name: GetStickerSetByShortName :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE short_name = $1::text +` + +type GetStickerSetByShortNameRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) GetStickerSetByShortName(ctx context.Context, shortName string) (GetStickerSetByShortNameRow, error) { + row := q.db.QueryRow(ctx, getStickerSetByShortName, shortName) + var i GetStickerSetByShortNameRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ) + return i, err +} + +const getStickerSetBySystemKey = `-- name: GetStickerSetBySystemKey :one +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE system_key = $1::text +` + +type GetStickerSetBySystemKeyRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (GetStickerSetBySystemKeyRow, error) { + row := q.db.QueryRow(ctx, getStickerSetBySystemKey, systemKey) + var i GetStickerSetBySystemKeyRow + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ) + return i, err +} + +const listAvailableReactions = `-- name: ListAvailableReactions :many +SELECT + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +FROM available_reactions +ORDER BY sort_order ASC, reaction ASC +` + +func (q *Queries) ListAvailableReactions(ctx context.Context) ([]AvailableReaction, error) { + rows, err := q.db.Query(ctx, listAvailableReactions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AvailableReaction + for rows.Next() { + var i AvailableReaction + if err := rows.Scan( + &i.Reaction, + &i.Title, + &i.Inactive, + &i.Premium, + &i.StaticIconID, + &i.AppearAnimationID, + &i.SelectAnimationID, + &i.ActivateAnimationID, + &i.EffectAnimationID, + &i.AroundAnimationID, + &i.CenterIconID, + &i.SortOrder, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProfilePhotos = `-- name: ListProfilePhotos :many +SELECT photo_id +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint + AND active + AND ($3::bigint <= 0 OR photo_id < $3::bigint) +ORDER BY sort_order DESC +OFFSET $4::int +LIMIT $5::int +` + +type ListProfilePhotosParams struct { + OwnerPeerType string + OwnerPeerID int64 + MaxID int64 + OffsetCount int32 + LimitCount int32 +} + +func (q *Queries) ListProfilePhotos(ctx context.Context, arg ListProfilePhotosParams) ([]int64, error) { + rows, err := q.db.Query(ctx, listProfilePhotos, + arg.OwnerPeerType, + arg.OwnerPeerID, + arg.MaxID, + arg.OffsetCount, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int64 + for rows.Next() { + var photo_id int64 + if err := rows.Scan(&photo_id); err != nil { + return nil, err + } + items = append(items, photo_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listStickerSetsByKind = `-- name: ListStickerSetsByKind :many +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key +FROM sticker_sets +WHERE set_kind = $1::text +ORDER BY sort_order ASC, id ASC +` + +type ListStickerSetsByKindRow struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson string + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson string + PacksJson string + SortOrder int32 + SystemKey string +} + +func (q *Queries) ListStickerSetsByKind(ctx context.Context, setKind string) ([]ListStickerSetsByKindRow, error) { + rows, err := q.db.Query(ctx, listStickerSetsByKind, setKind) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListStickerSetsByKindRow + for rows.Next() { + var i ListStickerSetsByKindRow + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.ShortName, + &i.Title, + &i.Count, + &i.Hash, + &i.SetKind, + &i.Official, + &i.Animated, + &i.Videos, + &i.Emojis, + &i.Masks, + &i.Installed, + &i.Archived, + &i.InstalledDate, + &i.ThumbDocumentID, + &i.ThumbsJson, + &i.ThumbDcID, + &i.ThumbVersion, + &i.DocumentIdsJson, + &i.PacksJson, + &i.SortOrder, + &i.SystemKey, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUploadParts = `-- name: ListUploadParts :many +SELECT part, total_parts, is_big, bytes +FROM upload_parts +WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint +ORDER BY part ASC +` + +type ListUploadPartsParams struct { + OwnerUserID int64 + FileID int64 +} + +type ListUploadPartsRow struct { + Part int32 + TotalParts int32 + IsBig bool + Bytes []byte +} + +func (q *Queries) ListUploadParts(ctx context.Context, arg ListUploadPartsParams) ([]ListUploadPartsRow, error) { + rows, err := q.db.Query(ctx, listUploadParts, arg.OwnerUserID, arg.FileID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUploadPartsRow + for rows.Next() { + var i ListUploadPartsRow + if err := rows.Scan( + &i.Part, + &i.TotalParts, + &i.IsBig, + &i.Bytes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const nextProfilePhotoOrder = `-- name: NextProfilePhotoOrder :one +SELECT COALESCE(MAX(sort_order), 0)::bigint AS max_order +FROM profile_photos +WHERE owner_peer_type = $1::text + AND owner_peer_id = $2::bigint +` + +type NextProfilePhotoOrderParams struct { + OwnerPeerType string + OwnerPeerID int64 +} + +func (q *Queries) NextProfilePhotoOrder(ctx context.Context, arg NextProfilePhotoOrderParams) (int64, error) { + row := q.db.QueryRow(ctx, nextProfilePhotoOrder, arg.OwnerPeerType, arg.OwnerPeerID) + var max_order int64 + err := row.Scan(&max_order) + return max_order, err +} + +const putAvailableReaction = `-- name: PutAvailableReaction :exec + +INSERT INTO available_reactions ( + reaction, title, inactive, premium, + static_icon_id, appear_animation_id, select_animation_id, + activate_animation_id, effect_animation_id, around_animation_id, center_icon_id, sort_order +) VALUES ( + $1::text, + $2::text, + $3::boolean, + $4::boolean, + $5::bigint, + $6::bigint, + $7::bigint, + $8::bigint, + $9::bigint, + $10::bigint, + $11::bigint, + $12::int +) +ON CONFLICT (reaction) DO UPDATE SET + title = EXCLUDED.title, + inactive = EXCLUDED.inactive, + premium = EXCLUDED.premium, + static_icon_id = EXCLUDED.static_icon_id, + appear_animation_id = EXCLUDED.appear_animation_id, + select_animation_id = EXCLUDED.select_animation_id, + activate_animation_id = EXCLUDED.activate_animation_id, + effect_animation_id = EXCLUDED.effect_animation_id, + around_animation_id = EXCLUDED.around_animation_id, + center_icon_id = EXCLUDED.center_icon_id, + sort_order = EXCLUDED.sort_order +` + +type PutAvailableReactionParams struct { + Reaction string + Title string + Inactive bool + Premium bool + StaticIconID int64 + AppearAnimationID int64 + SelectAnimationID int64 + ActivateAnimationID int64 + EffectAnimationID int64 + AroundAnimationID int64 + CenterIconID int64 + SortOrder int32 +} + +// available_reactions --------------------------------------------------------- +func (q *Queries) PutAvailableReaction(ctx context.Context, arg PutAvailableReactionParams) error { + _, err := q.db.Exec(ctx, putAvailableReaction, + arg.Reaction, + arg.Title, + arg.Inactive, + arg.Premium, + arg.StaticIconID, + arg.AppearAnimationID, + arg.SelectAnimationID, + arg.ActivateAnimationID, + arg.EffectAnimationID, + arg.AroundAnimationID, + arg.CenterIconID, + arg.SortOrder, + ) + return err +} + +const putDocument = `-- name: PutDocument :exec + +INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs) +VALUES ( + $1::bigint, + $2::bigint, + $3::bytea, + $4::int, + $5::text, + $6::bigint, + $7::int, + $8::jsonb, + $9::jsonb +) +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 +` + +type PutDocumentParams struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + AttributesJson []byte + ThumbsJson []byte +} + +// documents ------------------------------------------------------------------- +func (q *Queries) PutDocument(ctx context.Context, arg PutDocumentParams) error { + _, err := q.db.Exec(ctx, putDocument, + arg.ID, + arg.AccessHash, + arg.FileReference, + arg.Date, + arg.MimeType, + arg.Size, + arg.DcID, + arg.AttributesJson, + arg.ThumbsJson, + ) + return err +} + +const putFileBlob = `-- name: PutFileBlob :exec + +INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type) +VALUES ( + $1::text, + $2::text, + $3::text, + $4::bigint, + $5::bytea, + $6::text +) +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 +` + +type PutFileBlobParams struct { + LocationKey string + Backend string + ObjectKey string + Size int64 + Sha256 []byte + MimeType string +} + +// file_blobs ------------------------------------------------------------------ +func (q *Queries) PutFileBlob(ctx context.Context, arg PutFileBlobParams) error { + _, err := q.db.Exec(ctx, putFileBlob, + arg.LocationKey, + arg.Backend, + arg.ObjectKey, + arg.Size, + arg.Sha256, + arg.MimeType, + ) + return err +} + +const putPhoto = `-- name: PutPhoto :exec + +INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes) +VALUES ( + $1::bigint, + $2::bigint, + $3::bytea, + $4::int, + $5::int, + $6::boolean, + $7::jsonb +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + file_reference = EXCLUDED.file_reference, + date = EXCLUDED.date, + dc_id = EXCLUDED.dc_id, + has_stickers = EXCLUDED.has_stickers, + sizes = EXCLUDED.sizes +` + +type PutPhotoParams struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + DcID int32 + HasStickers bool + SizesJson []byte +} + +// photos ---------------------------------------------------------------------- +func (q *Queries) PutPhoto(ctx context.Context, arg PutPhotoParams) error { + _, err := q.db.Exec(ctx, putPhoto, + arg.ID, + arg.AccessHash, + arg.FileReference, + arg.Date, + arg.DcID, + arg.HasStickers, + arg.SizesJson, + ) + return err +} + +const putStickerSet = `-- name: PutStickerSet :exec + +INSERT INTO sticker_sets ( + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs, thumb_dc_id, thumb_version, document_ids, packs, sort_order, system_key +) VALUES ( + $1::bigint, + $2::bigint, + $3::text, + $4::text, + $5::int, + $6::int, + $7::text, + $8::boolean, + $9::boolean, + $10::boolean, + $11::boolean, + $12::boolean, + $13::boolean, + $14::boolean, + $15::int, + $16::bigint, + $17::jsonb, + $18::int, + $19::int, + $20::jsonb, + $21::jsonb, + $22::int, + $23::text +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + short_name = EXCLUDED.short_name, + title = EXCLUDED.title, + count = EXCLUDED.count, + hash = EXCLUDED.hash, + set_kind = EXCLUDED.set_kind, + official = EXCLUDED.official, + animated = EXCLUDED.animated, + videos = EXCLUDED.videos, + emojis = EXCLUDED.emojis, + masks = EXCLUDED.masks, + installed = EXCLUDED.installed, + archived = EXCLUDED.archived, + installed_date = EXCLUDED.installed_date, + thumb_document_id = EXCLUDED.thumb_document_id, + thumbs = EXCLUDED.thumbs, + thumb_dc_id = EXCLUDED.thumb_dc_id, + thumb_version = EXCLUDED.thumb_version, + document_ids = EXCLUDED.document_ids, + packs = EXCLUDED.packs, + sort_order = EXCLUDED.sort_order, + system_key = EXCLUDED.system_key +` + +type PutStickerSetParams struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + ThumbsJson []byte + ThumbDcID int32 + ThumbVersion int32 + DocumentIdsJson []byte + PacksJson []byte + SortOrder int32 + SystemKey string +} + +// sticker_sets ---------------------------------------------------------------- +func (q *Queries) PutStickerSet(ctx context.Context, arg PutStickerSetParams) error { + _, err := q.db.Exec(ctx, putStickerSet, + arg.ID, + arg.AccessHash, + arg.ShortName, + arg.Title, + arg.Count, + arg.Hash, + arg.SetKind, + arg.Official, + arg.Animated, + arg.Videos, + arg.Emojis, + arg.Masks, + arg.Installed, + arg.Archived, + arg.InstalledDate, + arg.ThumbDocumentID, + arg.ThumbsJson, + arg.ThumbDcID, + arg.ThumbVersion, + arg.DocumentIdsJson, + arg.PacksJson, + arg.SortOrder, + arg.SystemKey, + ) + return err +} + +const saveUploadPart = `-- name: SaveUploadPart :exec + +INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes) +VALUES ( + $1::bigint, + $2::bigint, + $3::int, + $4::int, + $5::boolean, + $6::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 +` + +type SaveUploadPartParams struct { + OwnerUserID int64 + FileID int64 + Part int32 + TotalParts int32 + IsBig bool + Bytes []byte +} + +// upload_parts ---------------------------------------------------------------- +func (q *Queries) SaveUploadPart(ctx context.Context, arg SaveUploadPartParams) error { + _, err := q.db.Exec(ctx, saveUploadPart, + arg.OwnerUserID, + arg.FileID, + arg.Part, + arg.TotalParts, + arg.IsBig, + arg.Bytes, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go new file mode 100644 index 00000000..2d68720d --- /dev/null +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -0,0 +1,2331 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: message.sql + +package sqlcgen + +import ( + "context" +) + +const createMessage = `-- name: CreateMessage :one +WITH pm AS ( + INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities + ) VALUES ( + $1, + $2, + 0, + $3, + $4, + $5::jsonb + ) + RETURNING id, sender_user_id +), +box AS ( + 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 + $2, + $6, + pm.id, + pm.sender_user_id, + $7, + $8, + $1, + $3, + $9, + $4, + $5::jsonb, + $10 + FROM pm + RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities::text AS entities_json, + pts +) +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + edit_date, + outgoing, + body, + entities_json, + pts +FROM box +` + +type CreateMessageParams struct { + FromUserID int64 + OwnerUserID int64 + MessageDate int32 + Body string + EntitiesJson []byte + BoxID int32 + PeerType string + PeerID int64 + Outgoing bool + Pts int32 +} + +type CreateMessageRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Pts int32 +} + +func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (CreateMessageRow, error) { + row := q.db.QueryRow(ctx, createMessage, + arg.FromUserID, + arg.OwnerUserID, + arg.MessageDate, + arg.Body, + arg.EntitiesJson, + arg.BoxID, + arg.PeerType, + arg.PeerID, + arg.Outgoing, + arg.Pts, + ) + var i CreateMessageRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Pts, + ) + return i, err +} + +const createMessageBox = `-- name: CreateMessageBox :one +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, + 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, + pts, + media +) 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, + $20::int, + $21::text, + $22::bigint, + $23::text, + $24::int, + $25::int, + $26::jsonb +) +RETURNING + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_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 +} + +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 +} + +func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxParams) (CreateMessageBoxRow, error) { + row := q.db.QueryRow(ctx, createMessageBox, + arg.OwnerUserID, + arg.BoxID, + arg.PrivateMessageID, + arg.MessageSenderID, + arg.PeerType, + arg.PeerID, + arg.FromUserID, + arg.MessageDate, + arg.Outgoing, + arg.Body, + arg.EntitiesJson, + arg.Silent, + arg.Noforwards, + arg.ReplyToMsgID, + arg.ReplyToPeerType, + arg.ReplyToPeerID, + arg.ReplyToTopID, + arg.QuoteText, + arg.QuoteEntitiesJson, + arg.QuoteOffset, + arg.FwdFromPeerType, + arg.FwdFromPeerID, + arg.FwdFromName, + arg.FwdDate, + arg.Pts, + arg.MediaJson, + ) + var i CreateMessageBoxRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const createPrivateMessage = `-- name: CreatePrivateMessage :one +INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities, + 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, + media +) VALUES ( + $1, $2, $3, $4, $5, $6::jsonb, + $7::boolean, + $8::boolean, + $9::int, + $10::text, + $11::bigint, + $12::int, + $13::text, + $14::jsonb, + $15::int, + $16::text, + $17::bigint, + $18::text, + $19::int, + $20::jsonb +) +ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING +RETURNING + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json +` + +type CreatePrivateMessageParams struct { + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + 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 + MediaJson []byte +} + +type CreatePrivateMessageRow struct { + ID int64 + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + EditDate int32 + Body string + EntitiesJson string +} + +func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMessageParams) (CreatePrivateMessageRow, error) { + row := q.db.QueryRow(ctx, createPrivateMessage, + arg.SenderUserID, + arg.RecipientUserID, + arg.RandomID, + arg.MessageDate, + arg.Body, + arg.EntitiesJson, + arg.Silent, + arg.Noforwards, + arg.ReplyToMsgID, + arg.ReplyToPeerType, + arg.ReplyToPeerID, + arg.ReplyToTopID, + arg.QuoteText, + arg.QuoteEntitiesJson, + arg.QuoteOffset, + arg.FwdFromPeerType, + arg.FwdFromPeerID, + arg.FwdFromName, + arg.FwdDate, + arg.MediaJson, + ) + var i CreatePrivateMessageRow + err := row.Scan( + &i.ID, + &i.SenderUserID, + &i.RecipientUserID, + &i.RandomID, + &i.MessageDate, + &i.EditDate, + &i.Body, + &i.EntitiesJson, + ) + return i, err +} + +const deleteMessageBoxesByIDs = `-- name: DeleteMessageBoxesByIDs :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = $1::bigint + AND m.box_id = ANY($2::int[]) + AND NOT m.deleted + 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 DeleteMessageBoxesByIDsParams struct { + OwnerUserID int64 + BoxIds []int32 +} + +type DeleteMessageBoxesByIDsRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByIDs(ctx context.Context, arg DeleteMessageBoxesByIDsParams) ([]DeleteMessageBoxesByIDsRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByIDs, arg.OwnerUserID, arg.BoxIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByIDsRow + for rows.Next() { + var i DeleteMessageBoxesByIDsRow + 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 deleteMessageBoxesByPeer = `-- name: DeleteMessageBoxesByPeer :many +WITH updated AS ( + UPDATE message_boxes m + SET deleted = true + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND ($4::int <= 0 OR m.box_id <= $4::int) + AND NOT m.deleted + 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 DeleteMessageBoxesByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + MaxID int32 +} + +type DeleteMessageBoxesByPeerRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByPeer(ctx context.Context, arg DeleteMessageBoxesByPeerParams) ([]DeleteMessageBoxesByPeerRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByPeer, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByPeerRow + for rows.Next() { + var i DeleteMessageBoxesByPeerRow + 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 deleteMessageBoxesByPeerBatch = `-- name: DeleteMessageBoxesByPeerBatch :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 ($4::int <= 0 OR m.box_id <= $4::int) + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT $5::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 DeleteMessageBoxesByPeerBatchParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + MaxID int32 + LimitCount int32 +} + +type DeleteMessageBoxesByPeerBatchRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByPeerBatch(ctx context.Context, arg DeleteMessageBoxesByPeerBatchParams) ([]DeleteMessageBoxesByPeerBatchRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByPeerBatch, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByPeerBatchRow + for rows.Next() { + var i DeleteMessageBoxesByPeerBatchRow + 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 deleteMessageBoxesByPrivateMessages = `-- name: DeleteMessageBoxesByPrivateMessages :many +WITH requested AS ( + SELECT + ($1::bigint[])[i] AS message_sender_id, + ($2::bigint[])[i] AS private_message_id + FROM generate_subscripts($2::bigint[], 1) AS g(i) + WHERE i <= cardinality($1::bigint[]) +), +deduped AS ( + SELECT DISTINCT message_sender_id, private_message_id + FROM requested +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM deduped d + WHERE 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, + 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 owner_user_id ASC, box_id ASC +` + +type DeleteMessageBoxesByPrivateMessagesParams struct { + MessageSenderIds []int64 + PrivateMessageIds []int64 +} + +type DeleteMessageBoxesByPrivateMessagesRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesByPrivateMessages(ctx context.Context, arg DeleteMessageBoxesByPrivateMessagesParams) ([]DeleteMessageBoxesByPrivateMessagesRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesByPrivateMessages, arg.MessageSenderIds, arg.PrivateMessageIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesByPrivateMessagesRow + for rows.Next() { + var i DeleteMessageBoxesByPrivateMessagesRow + 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 getDialogReadStateForUpdate = `-- name: GetDialogReadStateForUpdate :one +SELECT + user_id, + peer_type, + peer_id, + top_message_id, + read_inbox_max_id, + unread_count +FROM dialogs +WHERE user_id = $1::bigint + AND peer_type = $2::text + AND peer_id = $3::bigint +FOR UPDATE +` + +type GetDialogReadStateForUpdateParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +type GetDialogReadStateForUpdateRow struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + ReadInboxMaxID int32 + UnreadCount int32 +} + +func (q *Queries) GetDialogReadStateForUpdate(ctx context.Context, arg GetDialogReadStateForUpdateParams) (GetDialogReadStateForUpdateRow, error) { + row := q.db.QueryRow(ctx, getDialogReadStateForUpdate, arg.UserID, arg.PeerType, arg.PeerID) + var i GetDialogReadStateForUpdateRow + err := row.Scan( + &i.UserID, + &i.PeerType, + &i.PeerID, + &i.TopMessageID, + &i.ReadInboxMaxID, + &i.UnreadCount, + ) + return i, err +} + +const getMessageBoxByPrivateMessage = `-- name: GetMessageBoxByPrivateMessage :one +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1 + AND private_message_id = $2 + AND NOT deleted +` + +type GetMessageBoxByPrivateMessageParams struct { + OwnerUserID int64 + PrivateMessageID int64 +} + +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 +} + +func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMessageBoxByPrivateMessageParams) (GetMessageBoxByPrivateMessageRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxByPrivateMessage, arg.OwnerUserID, arg.PrivateMessageID) + var i GetMessageBoxByPrivateMessageRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const getMessageBoxForEdit = `-- name: GetMessageBoxForEdit :one +SELECT + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1::bigint + AND box_id = $2::int + AND peer_type = $3::text + AND peer_id = $4::bigint + AND NOT deleted +LIMIT 1 +FOR UPDATE +` + +type GetMessageBoxForEditParams struct { + OwnerUserID int64 + BoxID int32 + PeerType string + PeerID int64 +} + +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 +} + +func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxForEditParams) (GetMessageBoxForEditRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxForEdit, + arg.OwnerUserID, + arg.BoxID, + arg.PeerType, + arg.PeerID, + ) + var i GetMessageBoxForEditRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +const getMessageBoxForReply = `-- name: GetMessageBoxForReply :one +SELECT + box_id, + private_message_id, + message_sender_id +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 +` + +type GetMessageBoxForReplyParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxID int32 +} + +type GetMessageBoxForReplyRow struct { + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 +} + +func (q *Queries) GetMessageBoxForReply(ctx context.Context, arg GetMessageBoxForReplyParams) (GetMessageBoxForReplyRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxForReply, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxID, + ) + var i GetMessageBoxForReplyRow + err := row.Scan(&i.BoxID, &i.PrivateMessageID, &i.MessageSenderID) + return i, err +} + +const getMessageBoxesByIDs = `-- name: GetMessageBoxesByIDs :many +SELECT + wanted.box_id AS requested_box_id, + 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.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.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.pts, + m.media::text AS media_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.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.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 + ON m.owner_user_id = $2::bigint + AND m.box_id = wanted.box_id + AND NOT m.deleted +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 +ORDER BY wanted.ord ASC +` + +type GetMessageBoxesByIDsParams struct { + BoxIds []int32 + OwnerUserID int64 +} + +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 + 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 +} + +func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesByIDsParams) ([]GetMessageBoxesByIDsRow, error) { + rows, err := q.db.Query(ctx, getMessageBoxesByIDs, arg.BoxIds, arg.OwnerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMessageBoxesByIDsRow + for rows.Next() { + var i GetMessageBoxesByIDsRow + if err := rows.Scan( + &i.RequestedBoxID, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FromUserLastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMessageBoxesForForward = `-- name: GetMessageBoxesForForward :many +WITH requested AS ( + SELECT + id::int AS box_id, + ord::int AS ord + FROM unnest($4::int[]) WITH ORDINALITY AS r(id, ord) +) +SELECT + r.ord, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.message_sender_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + 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.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.pts, + m.media::text AS media_json +FROM requested r +JOIN message_boxes m + ON m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND m.box_id = r.box_id + AND NOT m.deleted +ORDER BY r.ord ASC +` + +type GetMessageBoxesForForwardParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxIds []int32 +} + +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 +} + +func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageBoxesForForwardParams) ([]GetMessageBoxesForForwardRow, error) { + rows, err := q.db.Query(ctx, getMessageBoxesForForward, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxIds, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMessageBoxesForForwardRow + for rows.Next() { + var i GetMessageBoxesForForwardRow + if err := rows.Scan( + &i.Ord, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getOutboxMessageForReadDate = `-- name: GetOutboxMessageForReadDate :one +SELECT box_id +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 outgoing + AND NOT deleted +LIMIT 1 +` + +type GetOutboxMessageForReadDateParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxID int32 +} + +func (q *Queries) GetOutboxMessageForReadDate(ctx context.Context, arg GetOutboxMessageForReadDateParams) (int32, error) { + row := q.db.QueryRow(ctx, getOutboxMessageForReadDate, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxID, + ) + var box_id int32 + err := row.Scan(&box_id) + return box_id, err +} + +const getOutboxReadDate = `-- name: GetOutboxReadDate :one +SELECT COALESCE(MIN(date), 0)::int AS read_date +FROM user_update_events +WHERE user_id = $1::bigint + AND event_type = 'read_history_outbox' + AND peer_type = $2::text + AND peer_id = $3::bigint + AND max_id >= $4::int +` + +type GetOutboxReadDateParams struct { + UserID int64 + PeerType string + PeerID int64 + MessageID int32 +} + +func (q *Queries) GetOutboxReadDate(ctx context.Context, arg GetOutboxReadDateParams) (int32, error) { + row := q.db.QueryRow(ctx, getOutboxReadDate, + arg.UserID, + arg.PeerType, + arg.PeerID, + arg.MessageID, + ) + var read_date int32 + err := row.Scan(&read_date) + return read_date, err +} + +const getPrivateMessageByRandomID = `-- name: GetPrivateMessageByRandomID :one +SELECT + id, + sender_user_id, + recipient_user_id, + random_id, + message_date, + edit_date, + body, + entities::text AS entities_json +FROM private_messages +WHERE sender_user_id = $1 + AND random_id = $2 + AND random_id <> 0 +` + +type GetPrivateMessageByRandomIDParams struct { + SenderUserID int64 + RandomID int64 +} + +type GetPrivateMessageByRandomIDRow struct { + ID int64 + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + EditDate int32 + Body string + EntitiesJson string +} + +func (q *Queries) GetPrivateMessageByRandomID(ctx context.Context, arg GetPrivateMessageByRandomIDParams) (GetPrivateMessageByRandomIDRow, error) { + row := q.db.QueryRow(ctx, getPrivateMessageByRandomID, arg.SenderUserID, arg.RandomID) + var i GetPrivateMessageByRandomIDRow + err := row.Scan( + &i.ID, + &i.SenderUserID, + &i.RecipientUserID, + &i.RandomID, + &i.MessageDate, + &i.EditDate, + &i.Body, + &i.EntitiesJson, + ) + return i, err +} + +const hasDeletableMessageBoxByPeer = `-- name: HasDeletableMessageBoxByPeer :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 ($4::int <= 0 OR m.box_id <= $4::int) + AND NOT m.deleted + LIMIT 1 +)::boolean AS more +` + +type HasDeletableMessageBoxByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + MaxID int32 +} + +func (q *Queries) HasDeletableMessageBoxByPeer(ctx context.Context, arg HasDeletableMessageBoxByPeerParams) (bool, error) { + row := q.db.QueryRow(ctx, hasDeletableMessageBoxByPeer, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.MaxID, + ) + var more bool + err := row.Scan(&more) + return more, err +} + +const latestIncomingReadReceiptCandidate = `-- name: LatestIncomingReadReceiptCandidate :one +SELECT + m.message_sender_id, + m.private_message_id, + sender_box.owner_user_id AS sender_owner_user_id, + sender_box.box_id AS sender_box_id +FROM message_boxes m +JOIN message_boxes sender_box + ON sender_box.message_sender_id = m.message_sender_id + AND sender_box.private_message_id = m.private_message_id + AND sender_box.owner_user_id = m.message_sender_id + AND sender_box.outgoing + AND NOT sender_box.deleted +WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND NOT m.outgoing + AND NOT m.deleted + AND m.box_id > $4::int + AND m.box_id <= $5::int +ORDER BY m.box_id DESC +LIMIT 1 +` + +type LatestIncomingReadReceiptCandidateParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + OldReadInboxMaxID int32 + NewReadInboxMaxID int32 +} + +type LatestIncomingReadReceiptCandidateRow struct { + MessageSenderID int64 + PrivateMessageID int64 + SenderOwnerUserID int64 + SenderBoxID int32 +} + +func (q *Queries) LatestIncomingReadReceiptCandidate(ctx context.Context, arg LatestIncomingReadReceiptCandidateParams) (LatestIncomingReadReceiptCandidateRow, error) { + row := q.db.QueryRow(ctx, latestIncomingReadReceiptCandidate, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.OldReadInboxMaxID, + arg.NewReadInboxMaxID, + ) + var i LatestIncomingReadReceiptCandidateRow + err := row.Scan( + &i.MessageSenderID, + &i.PrivateMessageID, + &i.SenderOwnerUserID, + &i.SenderBoxID, + ) + return i, err +} + +const listMessagesByUser = `-- name: ListMessagesByUser :many +WITH load_params AS ( + SELECT + $2::int AS offset_id, + $3::int AS offset_date, + $4::int AS add_offset, + $5::int AS limit_count, + CASE + WHEN $4::int >= 0 THEN 'backward' + WHEN $4::int + $5::int > 0 THEN 'around' + ELSE 'forward' + END::text AS load_type +), +base AS NOT MATERIALIZED ( + 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.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.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.pts, + m.media::text AS media_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.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.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 + AND NOT m.deleted + AND ( + NOT $6::boolean + OR (m.peer_type = $7::text AND m.peer_id = $8::bigint) + ) + AND ( + $9::text = '' + OR m.body ILIKE ('%' || $9::text || '%') + ) + AND ($10::int <= 0 OR m.box_id < $10::int) + AND ($11::int <= 0 OR m.box_id > $11::int) +), +total AS ( + SELECT count(*)::int AS total_count + FROM base + WHERE $12::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.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 + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'backward' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id)) + ) + ORDER BY b.box_id DESC + OFFSET GREATEST((SELECT add_offset FROM load_params), 0) + 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.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 + 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.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 + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params)) + ) 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.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 + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'around' + AND ( + (p.offset_date > 0 AND b.message_date < p.offset_date) + OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id)) + ) + ORDER BY b.box_id DESC + 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.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 + 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.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 + FROM base b + CROSS JOIN load_params p + WHERE p.load_type = 'forward' + AND ( + (p.offset_date > 0 AND b.message_date >= p.offset_date) + OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id) + ) + ORDER BY b.box_id ASC + LIMIT (SELECT limit_count FROM load_params) + ) 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, 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 + 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, 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 + 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, 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 + 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, 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, + 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, + 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, + COALESCE(total.total_count, 0)::int AS total_count +FROM paged +CROSS JOIN total +ORDER BY box_id DESC +` + +type ListMessagesByUserParams struct { + OwnerUserID int64 + OffsetID int32 + OffsetDate int32 + AddOffset int32 + LimitCount int32 + HasPeer bool + PeerType string + PeerID int64 + Query string + MaxID int32 + MinID int32 + 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 + 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 +} + +func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUserParams) ([]ListMessagesByUserRow, error) { + rows, err := q.db.Query(ctx, listMessagesByUser, + arg.OwnerUserID, + arg.OffsetID, + arg.OffsetDate, + arg.AddOffset, + arg.LimitCount, + arg.HasPeer, + arg.PeerType, + arg.PeerID, + arg.Query, + arg.MaxID, + arg.MinID, + arg.NeedTotalCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMessagesByUserRow + for rows.Next() { + var i ListMessagesByUserRow + if err := rows.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerLastSeenAt, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FromUserLastSeenAt, + &i.TotalCount, + ); 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, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +FROM message_boxes +WHERE message_sender_id = $1::bigint + AND private_message_id = $2::bigint + AND NOT deleted +ORDER BY owner_user_id ASC, box_id ASC +FOR UPDATE +` + +type ListVisibleMessageBoxesByPrivateMessageParams struct { + 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 +} + +func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, arg ListVisibleMessageBoxesByPrivateMessageParams) ([]ListVisibleMessageBoxesByPrivateMessageRow, error) { + rows, err := q.db.Query(ctx, listVisibleMessageBoxesByPrivateMessage, arg.MessageSenderID, arg.PrivateMessageID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVisibleMessageBoxesByPrivateMessageRow + for rows.Next() { + var i ListVisibleMessageBoxesByPrivateMessageRow + if err := rows.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const maxMessageBoxID = `-- name: MaxMessageBoxID :one +SELECT COALESCE(MAX(box_id), 0)::int AS max_box_id +FROM message_boxes +WHERE owner_user_id = $1 +` + +func (q *Queries) MaxMessageBoxID(ctx context.Context, ownerUserID int64) (int32, error) { + row := q.db.QueryRow(ctx, maxMessageBoxID, ownerUserID) + var max_box_id int32 + err := row.Scan(&max_box_id) + return max_box_id, err +} + +const topVisibleMessageBoxByPeer = `-- name: TopVisibleMessageBoxByPeer :one +SELECT + box_id, + message_date +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted +ORDER BY box_id DESC +LIMIT 1 +` + +type TopVisibleMessageBoxByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 +} + +type TopVisibleMessageBoxByPeerRow struct { + BoxID int32 + MessageDate int32 +} + +func (q *Queries) TopVisibleMessageBoxByPeer(ctx context.Context, arg TopVisibleMessageBoxByPeerParams) (TopVisibleMessageBoxByPeerRow, error) { + row := q.db.QueryRow(ctx, topVisibleMessageBoxByPeer, arg.OwnerUserID, arg.PeerType, arg.PeerID) + var i TopVisibleMessageBoxByPeerRow + err := row.Scan(&i.BoxID, &i.MessageDate) + return i, err +} + +const updateDialogReadInbox = `-- name: UpdateDialogReadInbox :one +UPDATE dialogs d +SET + read_inbox_max_id = GREATEST(d.read_inbox_max_id, $1::int), + 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, $1::int) + ), + 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 + AND d.peer_id = $4::bigint +RETURNING + d.read_inbox_max_id, + d.unread_count +` + +type UpdateDialogReadInboxParams struct { + ReadInboxMaxID int32 + UserID int64 + PeerType string + PeerID int64 +} + +type UpdateDialogReadInboxRow struct { + ReadInboxMaxID int32 + UnreadCount int32 +} + +func (q *Queries) UpdateDialogReadInbox(ctx context.Context, arg UpdateDialogReadInboxParams) (UpdateDialogReadInboxRow, error) { + row := q.db.QueryRow(ctx, updateDialogReadInbox, + arg.ReadInboxMaxID, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + var i UpdateDialogReadInboxRow + err := row.Scan(&i.ReadInboxMaxID, &i.UnreadCount) + return i, err +} + +const updateDialogReadOutbox = `-- name: UpdateDialogReadOutbox :one +UPDATE dialogs +SET + read_outbox_max_id = GREATEST(read_outbox_max_id, $1::int), + updated_at = now() +WHERE user_id = $2::bigint + AND peer_type = $3::text + AND peer_id = $4::bigint + AND read_outbox_max_id < $1::int +RETURNING read_outbox_max_id +` + +type UpdateDialogReadOutboxParams struct { + ReadOutboxMaxID int32 + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) UpdateDialogReadOutbox(ctx context.Context, arg UpdateDialogReadOutboxParams) (int32, error) { + row := q.db.QueryRow(ctx, updateDialogReadOutbox, + arg.ReadOutboxMaxID, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + var read_outbox_max_id int32 + err := row.Scan(&read_outbox_max_id) + return read_outbox_max_id, err +} + +const updateMessageBoxEdit = `-- name: UpdateMessageBoxEdit :one +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 + AND NOT deleted +RETURNING + box_id, + private_message_id, + owner_user_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + 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, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + pts, + media::text AS media_json +` + +type UpdateMessageBoxEditParams struct { + Body string + EntitiesJson []byte + EditDate int32 + Pts int32 + 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 +} + +func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBoxEditParams) (UpdateMessageBoxEditRow, error) { + row := q.db.QueryRow(ctx, updateMessageBoxEdit, + arg.Body, + arg.EntitiesJson, + arg.EditDate, + arg.Pts, + arg.OwnerUserID, + arg.BoxID, + ) + var i UpdateMessageBoxEditRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.Pts, + &i.MediaJson, + ) + return i, err +} + +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 +` + +type UpdatePrivateMessageEditParams struct { + Body string + EntitiesJson []byte + EditDate int32 + SenderUserID int64 + PrivateMessageID int64 +} + +func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivateMessageEditParams) error { + _, err := q.db.Exec(ctx, updatePrivateMessageEdit, + arg.Body, + arg.EntitiesJson, + arg.EditDate, + arg.SenderUserID, + arg.PrivateMessageID, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go new file mode 100644 index 00000000..36655ae2 --- /dev/null +++ b/internal/store/postgres/sqlcgen/models.go @@ -0,0 +1,681 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlcgen + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type AccountPassword struct { + UserID int64 + HasRecovery bool + HasSecureValues bool + HasPassword bool + Hint string + EmailUnconfirmedPattern string + LoginEmailPattern string + SecureRandom []byte + UpdatedAt pgtype.Timestamptz +} + +type AppConfig struct { + Client string + Hash int32 + ConfigJson []byte + UpdatedAt pgtype.Timestamptz +} + +type AuthKey struct { + AuthKeyID int64 + Body []byte + ServerSalt int64 + CreatedAt pgtype.Timestamptz +} + +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 +} + +type AvailableReaction struct { + Reaction string + Title string + Inactive bool + Premium bool + StaticIconID int64 + AppearAnimationID int64 + SelectAnimationID int64 + ActivateAnimationID int64 + EffectAnimationID int64 + AroundAnimationID int64 + CenterIconID int64 + SortOrder int32 +} + +type Channel struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Title string + About string + Username *string + Broadcast bool + Megagroup bool + Forum bool + ForumTabs bool + Noforwards bool + JoinToSend bool + JoinRequest bool + Signatures bool + PreHistoryHidden bool + ParticipantsHidden bool + Antispam bool + LinkedChatID int64 + SlowmodeSeconds int32 + DefaultBannedRights []byte + AvailableReactions []byte + ColorSet bool + Color int32 + ColorBackgroundEmojiID int64 + ProfileColorSet bool + ProfileColor int32 + ProfileColorBackgroundEmojiID int64 + EmojiStatusDocumentID int64 + EmojiStatusUntil int32 + ParticipantsCount int32 + AdminsCount int32 + KickedCount int32 + BannedCount int32 + TopMessageID int32 + Pts int32 + AdminLogSeq int64 + TtlPeriod int32 + Date int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + PinnedMessageID int32 + Autotranslation bool + RestrictedSponsored bool + BroadcastMessagesAllowed bool + SendPaidMessagesStars int64 + PhotoID int64 + PhotoDcID int32 + PhotoStripped []byte +} + +type ChannelAdminLogEvent struct { + ChannelID int64 + ID int64 + ActorUserID int64 + EventDate int32 + EventType string + PrevString string + NewString string + PrevBool bool + NewBool bool + PrevInt int32 + NewInt int32 + PrevParticipant []byte + NewParticipant []byte + Participant []byte + Message []byte + PrevMessage []byte + NewMessage []byte + Query string + CreatedAt pgtype.Timestamptz +} + +type ChannelDialog struct { + UserID int64 + ChannelID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + PinnedOrder int32 + UnreadMark bool + ViewForumAsMessages bool + NotifySettings []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DefaultSendAsPeerType *string + DefaultSendAsPeerID *int64 +} + +type ChannelForumTopic struct { + ChannelID int64 + TopicID int32 + CreatorUserID int64 + Title string + IconColor int32 + IconEmojiID int64 + TitleMissing bool + Closed bool + Hidden bool + Pinned bool + PinnedOrder int32 + Date int32 + TopMessageID int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + UnreadPollVotesCount int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelInvite struct { + ChannelID int64 + InviteID int64 + Hash string + AdminUserID int64 + Title string + Permanent bool + Revoked bool + RequestNeeded bool + ExpireDate *int32 + UsageLimit *int32 + UsageCount int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + RequestedCount int32 +} + +type ChannelInviteHash struct { + Hash string + ChannelID int64 + InviteID int64 + UpdatedAt pgtype.Timestamptz +} + +type ChannelInviteImporter struct { + ChannelID int64 + InviteID int64 + UserID int64 + Date int32 + Requested bool + ApprovedBy int64 + ViaChatlist bool + About string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelMember struct { + ChannelID int64 + UserID int64 + InviterUserID int64 + Role string + Status string + JoinedAt int32 + LeftAt int32 + AdminRights []byte + BannedRights []byte + Rank string + AvailableMinID int32 + AvailableMinPts int32 + ReadInboxMaxID int32 + ReadInboxDate int32 + ReadOutboxMaxID int32 + UnreadMark bool + SlowmodeLastSendDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelMessage struct { + ChannelID int64 + ID int32 + RandomID int64 + SenderUserID int64 + FromPeerType string + FromPeerID int64 + SendAsPeerType *string + SendAsPeerID *int64 + MessageDate int32 + EditDate int32 + Post bool + Silent bool + Noforwards bool + Body string + Entities []byte + ReplyTo []byte + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + FwdFrom []byte + DiscussionChannelID int64 + DiscussionMessageID int32 + Action []byte + Pts int32 + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ViewsCount int32 + Media []byte +} + +type ChannelMessageReaction struct { + ChannelID int64 + MessageID int32 + ReactedUserID int64 + SenderUserID int64 + ReactionType string + ReactionValue string + Big bool + Unread bool + ChosenOrder int32 + ReactionDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChannelMessageViewer struct { + ChannelID int64 + MessageID int32 + ViewerUserID int64 + ViewedAt int32 + CreatedAt pgtype.Timestamptz +} + +type ChannelUnreadMention struct { + UserID int64 + ChannelID int64 + MessageID int32 + TopMessageID int32 + CreatedAt pgtype.Timestamptz +} + +type ChannelUpdateEvent struct { + ChannelID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + MessageID int32 + MessageIds []byte + SenderUserID int64 + UserIds []byte + Payload []byte + CreatedAt pgtype.Timestamptz +} + +type ChannelUsername struct { + UsernameLower string + ChannelID int64 + UpdatedAt pgtype.Timestamptz +} + +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 +} + +type Country struct { + Iso2 string + DefaultName string + Name string + Hidden bool + OrderIndex int32 + UpdatedAt pgtype.Timestamptz +} + +type CountryCode struct { + ID int64 + Iso2 string + CountryCode string + Prefixes []string + Patterns []string + OrderIndex int32 +} + +type Dialog struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + Pinned bool + UpdatedAt pgtype.Timestamptz + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool + FolderID int32 +} + +type DialogDraft struct { + UserID int64 + PeerType string + PeerID int64 + TopMessageID int32 + Date int32 + Draft []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type DialogFilter struct { + UserID int64 + FilterID int32 + IsChatlist bool + Filter []byte + OrderValue int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type DialogFilterSetting struct { + UserID int64 + TagsEnabled bool + UpdatedAt pgtype.Timestamptz +} + +type DispatchOutbox struct { + ID int64 + TargetUserID int64 + Pts int32 + EventType string + ExcludeSessionID int64 + Status string + Attempts int32 + NextAttemptAt pgtype.Timestamptz + LastError string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ExcludeAuthKeyID int64 +} + +type Document struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + MimeType string + Size int64 + DcID int32 + Attributes []byte + Thumbs []byte + CreatedAt pgtype.Timestamptz +} + +type FileBlob struct { + LocationKey string + Backend string + ObjectKey string + Size int64 + Sha256 []byte + MimeType string + CreatedAt pgtype.Timestamptz +} + +type LangPack struct { + LangPack string + LangCode string + Version int32 + StringsCount int32 + UpdatedAt pgtype.Timestamptz +} + +type LangPackString struct { + LangPack string + LangCode string + Key string + Version int32 + Pluralized bool + Value string + ZeroValue string + OneValue string + TwoValue string + FewValue string + ManyValue string + OtherValue string + Deleted bool + UpdatedAt pgtype.Timestamptz +} + +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 +} + +type Photo struct { + ID int64 + AccessHash int64 + FileReference []byte + Date int32 + DcID int32 + HasStickers bool + Sizes []byte + CreatedAt pgtype.Timestamptz +} + +type PrivateMessage struct { + ID int64 + SenderUserID int64 + RecipientUserID int64 + RandomID int64 + MessageDate int32 + Body string + Entities []byte + 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 +} + +type ProfilePhoto struct { + OwnerPeerType string + OwnerPeerID int64 + PhotoID int64 + Date int32 + Active bool + SortOrder int64 + CreatedAt pgtype.Timestamptz +} + +type StickerSet struct { + ID int64 + AccessHash int64 + ShortName string + Title string + Count int32 + Hash int32 + SetKind string + Official bool + Animated bool + Videos bool + Emojis bool + Masks bool + Installed bool + Archived bool + InstalledDate int32 + ThumbDocumentID int64 + Thumbs []byte + ThumbDcID int32 + ThumbVersion int32 + DocumentIds []byte + Packs []byte + SortOrder int32 + SystemKey string + CreatedAt pgtype.Timestamptz +} + +type TempAuthKeyBinding struct { + TempAuthKeyID int64 + PermAuthKeyID int64 + Nonce int64 + ExpiresAt int32 + EncryptedMessage []byte + CreatedAt pgtype.Timestamptz + TempSessionID int64 +} + +type UpdateState struct { + AuthKeyID int64 + Pts int32 + Qts int32 + Date int32 + Seq int32 + UpdatedAt pgtype.Timestamptz + UserID int64 +} + +type UploadPart struct { + OwnerUserID int64 + FileID int64 + Part int32 + TotalParts int32 + IsBig bool + Bytes []byte + CreatedAt pgtype.Timestamptz +} + +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 +} + +type UserRecentReaction struct { + UserID int64 + ReactionType string + ReactionValue string + ReactionDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type UserSavedReactionTag struct { + UserID int64 + ReactionType string + ReactionValue string + Title string + ReactionCount int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type UserTopReaction struct { + UserID int64 + ReactionType string + ReactionValue string + ReactionCount int32 + ReactionDate int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +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 +} + +type UserUpdateWatermark struct { + UserID int64 + ContiguousPts int32 + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/store/postgres/sqlcgen/temp_auth_key.sql.go b/internal/store/postgres/sqlcgen/temp_auth_key.sql.go new file mode 100644 index 00000000..4821e763 --- /dev/null +++ b/internal/store/postgres/sqlcgen/temp_auth_key.sql.go @@ -0,0 +1,80 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: temp_auth_key.sql + +package sqlcgen + +import ( + "context" +) + +const getTempAuthKeyBinding = `-- name: GetTempAuthKeyBinding :one +SELECT + temp_auth_key_id, + perm_auth_key_id, + nonce, + temp_session_id, + expires_at, + encrypted_message +FROM temp_auth_key_bindings +WHERE temp_auth_key_id = $1 +` + +type GetTempAuthKeyBindingRow struct { + TempAuthKeyID int64 + PermAuthKeyID int64 + Nonce int64 + TempSessionID int64 + ExpiresAt int32 + EncryptedMessage []byte +} + +func (q *Queries) GetTempAuthKeyBinding(ctx context.Context, tempAuthKeyID int64) (GetTempAuthKeyBindingRow, error) { + row := q.db.QueryRow(ctx, getTempAuthKeyBinding, tempAuthKeyID) + var i GetTempAuthKeyBindingRow + err := row.Scan( + &i.TempAuthKeyID, + &i.PermAuthKeyID, + &i.Nonce, + &i.TempSessionID, + &i.ExpiresAt, + &i.EncryptedMessage, + ) + return i, err +} + +const upsertTempAuthKeyBinding = `-- name: UpsertTempAuthKeyBinding :exec +INSERT INTO temp_auth_key_bindings ( + temp_auth_key_id, perm_auth_key_id, nonce, temp_session_id, expires_at, encrypted_message +) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (temp_auth_key_id) DO UPDATE SET + perm_auth_key_id = EXCLUDED.perm_auth_key_id, + nonce = EXCLUDED.nonce, + temp_session_id = EXCLUDED.temp_session_id, + expires_at = EXCLUDED.expires_at, + encrypted_message = EXCLUDED.encrypted_message, + created_at = now() +` + +type UpsertTempAuthKeyBindingParams struct { + TempAuthKeyID int64 + PermAuthKeyID int64 + Nonce int64 + TempSessionID int64 + ExpiresAt int32 + EncryptedMessage []byte +} + +func (q *Queries) UpsertTempAuthKeyBinding(ctx context.Context, arg UpsertTempAuthKeyBindingParams) error { + _, err := q.db.Exec(ctx, upsertTempAuthKeyBinding, + arg.TempAuthKeyID, + arg.PermAuthKeyID, + arg.Nonce, + arg.TempSessionID, + arg.ExpiresAt, + arg.EncryptedMessage, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/update_state.sql.go b/internal/store/postgres/sqlcgen/update_state.sql.go new file mode 100644 index 00000000..d2740b7f --- /dev/null +++ b/internal/store/postgres/sqlcgen/update_state.sql.go @@ -0,0 +1,103 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: update_state.sql + +package sqlcgen + +import ( + "context" +) + +const deleteUpdateState = `-- name: DeleteUpdateState :exec +DELETE FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2 +` + +type DeleteUpdateStateParams struct { + AuthKeyID int64 + UserID int64 +} + +func (q *Queries) DeleteUpdateState(ctx context.Context, arg DeleteUpdateStateParams) error { + _, err := q.db.Exec(ctx, deleteUpdateState, arg.AuthKeyID, arg.UserID) + return err +} + +const deleteUpdateStatesByAuthKey = `-- name: DeleteUpdateStatesByAuthKey :exec +DELETE FROM update_states +WHERE auth_key_id = $1 +` + +func (q *Queries) DeleteUpdateStatesByAuthKey(ctx context.Context, authKeyID int64) error { + _, err := q.db.Exec(ctx, deleteUpdateStatesByAuthKey, authKeyID) + return err +} + +const getUpdateState = `-- name: GetUpdateState :one +SELECT auth_key_id, user_id, pts, qts, date, seq +FROM update_states +WHERE auth_key_id = $1 + AND user_id = $2 +` + +type GetUpdateStateParams struct { + AuthKeyID int64 + UserID int64 +} + +type GetUpdateStateRow struct { + AuthKeyID int64 + UserID int64 + Pts int32 + Qts int32 + Date int32 + Seq int32 +} + +func (q *Queries) GetUpdateState(ctx context.Context, arg GetUpdateStateParams) (GetUpdateStateRow, error) { + row := q.db.QueryRow(ctx, getUpdateState, arg.AuthKeyID, arg.UserID) + var i GetUpdateStateRow + err := row.Scan( + &i.AuthKeyID, + &i.UserID, + &i.Pts, + &i.Qts, + &i.Date, + &i.Seq, + ) + return i, err +} + +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, + updated_at = now() +` + +type UpsertUpdateStateParams struct { + AuthKeyID int64 + UserID int64 + Pts int32 + Qts int32 + Date int32 + Seq int32 +} + +func (q *Queries) UpsertUpdateState(ctx context.Context, arg UpsertUpdateStateParams) error { + _, err := q.db.Exec(ctx, upsertUpdateState, + arg.AuthKeyID, + arg.UserID, + arg.Pts, + arg.Qts, + arg.Date, + arg.Seq, + ) + return err +} diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go new file mode 100644 index 00000000..2d377141 --- /dev/null +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -0,0 +1,426 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: user.sql + +package sqlcgen + +import ( + "context" +) + +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 +` + +type CreateUserParams struct { + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.AccessHash, + arg.Phone, + arg.FirstName, + arg.LastName, + arg.Username, + arg.CountryCode, + ) + 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, + ) + 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 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, 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, + ) + 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 +` + +func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { + row := q.db.QueryRow(ctx, getUserByPhone, phone) + 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, + ) + 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 <> '' +` + +func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, lower) + 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, + ) + 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 +FROM users +WHERE id = ANY($1::bigint[]) +ORDER BY id +` + +func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error) { + rows, err := q.db.Query(ctx, getUsersByIDs, ids) + 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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +FROM users +WHERE phone = ANY($1::text[]) +ORDER BY id +` + +func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User, error) { + rows, err := q.db.Query(ctx, getUsersByPhones, phones) + 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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchUsers = `-- name: SearchUsers :many +WITH matched AS ( + SELECT + u.id, + u.access_hash, + COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone, + COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name, + COALESCE(c.contact_last_name, u.last_name)::text AS last_name, + u.about, + u.username, + u.country_code, + u.verified, + u.support, + u.last_seen_at, + (c.contact_user_id IS NOT NULL)::boolean AS contact, + COALESCE(c.mutual, false)::boolean AS mutual, + CASE + WHEN $2::text <> '' AND u.phone = $2::text THEN 0 + WHEN lower(u.username) = $3::text THEN 1 + WHEN lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) = $3::text THEN 2 + WHEN lower(u.first_name) = $3::text THEN 3 + WHEN c.contact_user_id IS NOT NULL THEN 4 + ELSE 5 + END AS rank + FROM users u + LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id + WHERE u.id <> $4::bigint + AND $3::text <> '' + AND ( + ($2::text <> '' AND u.phone LIKE $2::text || '%') + OR lower(u.username) LIKE $5::text || '%' ESCAPE '\' + OR lower(u.first_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(u.last_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(trim(u.first_name || ' ' || u.last_name)) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(c.contact_first_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(c.contact_last_name) LIKE '%' || $5::text || '%' ESCAPE '\' + OR lower(trim(c.contact_first_name || ' ' || c.contact_last_name)) LIKE '%' || $5::text || '%' ESCAPE '\' + ) +) +SELECT + id, + access_hash, + phone, + first_name, + last_name, + about, + username, + country_code, + verified, + support, + last_seen_at, + contact, + mutual +FROM matched +ORDER BY contact DESC, rank, id +LIMIT $1 +` + +type SearchUsersParams struct { + LimitCount int32 + PhoneQuery string + QueryLower string + CurrentUserID int64 + QueryLike string +} + +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 +} + +func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) { + rows, err := q.db.Query(ctx, searchUsers, + arg.LimitCount, + arg.PhoneQuery, + arg.QueryLower, + arg.CurrentUserID, + arg.QueryLike, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUsersRow + for rows.Next() { + var i SearchUsersRow + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.About, + &i.Username, + &i.CountryCode, + &i.Verified, + &i.Support, + &i.LastSeenAt, + &i.Contact, + &i.Mutual, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec +UPDATE users +SET last_seen_at = GREATEST(last_seen_at, $1::bigint), + updated_at = now() +WHERE id = $2::bigint +` + +type UpdateUserLastSeenParams struct { + LastSeenAt int64 + ID int64 +} + +func (q *Queries) UpdateUserLastSeen(ctx context.Context, arg UpdateUserLastSeenParams) error { + _, err := q.db.Exec(ctx, updateUserLastSeen, arg.LastSeenAt, arg.ID) + return err +} + +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE users +SET first_name = $2, + last_name = $3, + 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 +` + +type UpdateUserProfileParams struct { + ID int64 + FirstName string + LastName string + About string +} + +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserProfile, + arg.ID, + arg.FirstName, + arg.LastName, + arg.About, + ) + 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, + ) + return i, err +} + +const updateUserUsername = `-- name: UpdateUserUsername :one +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 +` + +type UpdateUserUsernameParams struct { + ID int64 + Username string +} + +func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsernameParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserUsername, arg.ID, 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, + ) + 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 new file mode 100644 index 00000000..d2dac885 --- /dev/null +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -0,0 +1,1307 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: user_update_event.sql + +package sqlcgen + +import ( + "context" +) + +const appendUserUpdateEvent = `-- name: AppendUserUpdateEvent :exec +INSERT INTO user_update_events ( + user_id, + pts, + pts_count, + date, + event_type, + event_bool, + event_peers, + peer_settings, + message_ids, + dialog_filter, + filter_order, + folder_peers, + message_box_id, + peer_type, + peer_id, + filter_id, + max_id, + still_unread_count, + tags_enabled +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6::boolean, + $7::jsonb, + $8::jsonb, + $9::jsonb, + $10::jsonb, + $11::jsonb, + $12::jsonb, + $13, + $14::text, + $15::bigint, + $16::int, + $17::int, + $18::int, + $19::boolean +) +ON CONFLICT (user_id, pts) DO NOTHING +` + +type AppendUserUpdateEventParams struct { + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + MessageBoxID *int32 + PeerType *string + PeerID *int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + TagsEnabled bool +} + +func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error { + _, err := q.db.Exec(ctx, appendUserUpdateEvent, + arg.UserID, + arg.Pts, + arg.PtsCount, + arg.Date, + arg.EventType, + arg.EventBool, + arg.EventPeers, + arg.PeerSettings, + arg.MessageIds, + arg.DialogFilter, + arg.FilterOrder, + arg.FolderPeers, + arg.MessageBoxID, + arg.PeerType, + arg.PeerID, + arg.FilterID, + arg.MaxID, + arg.StillUnreadCount, + arg.TagsEnabled, + ) + return err +} + +const batchListDispatchEvents = `-- name: BatchListDispatchEvents :many +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + 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.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.tags_enabled, + 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, + COALESCE(m.peer_type, '')::text AS peer_type, + 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.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_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(m.reply_to_top_id, 0)::int AS reply_to_top_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, + 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.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_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(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(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, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + 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(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, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + 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 +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 +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 { + UserIds []int64 + PtsList []int32 +} + +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 + 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 +} + +// 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情, +// 取代逐条 ListUserUpdateEventsAfter。列与 ListUserUpdateEventsAfter 完全一致以复用转换逻辑。 +func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDispatchEventsParams) ([]BatchListDispatchEventsRow, error) { + rows, err := q.db.Query(ctx, batchListDispatchEvents, arg.UserIds, arg.PtsList) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BatchListDispatchEventsRow + for rows.Next() { + var i BatchListDispatchEventsRow + if err := rows.Scan( + &i.UserID, + &i.Pts, + &i.PtsCount, + &i.Date, + &i.EventType, + &i.EventBool, + &i.EventPeersJson, + &i.PeerSettingsJson, + &i.MessageIdsJson, + &i.DialogFilterJson, + &i.FilterOrderJson, + &i.FolderPeersJson, + &i.EventPeerType, + &i.EventPeerID, + &i.FilterID, + &i.MaxID, + &i.StillUnreadCount, + &i.TagsEnabled, + &i.MessageID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.MessageEntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FwdUserID, + &i.FwdUserAccessHash, + &i.FwdUserPhone, + &i.FwdUserFirstName, + &i.FwdUserLastName, + &i.FwdUserUsername, + &i.FwdUserCountryCode, + &i.FwdUserVerified, + &i.FwdUserSupport, + &i.ReplyUserID, + &i.ReplyUserAccessHash, + &i.ReplyUserPhone, + &i.ReplyUserFirstName, + &i.ReplyUserLastName, + &i.ReplyUserUsername, + &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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const claimDispatchOutbox = `-- name: ClaimDispatchOutbox :many +WITH picked AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE ( + status = 'pending' + AND next_attempt_at <= now() + ) + OR ( + status = 'dispatching' + AND updated_at < now() - make_interval(secs => $1::int) + ) + ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED +) +UPDATE dispatch_outbox d +SET + status = 'dispatching', + attempts = d.attempts + 1, + updated_at = now() +FROM picked p +WHERE d.target_user_id = p.target_user_id + AND d.id = p.id +RETURNING + d.id, + d.target_user_id, + d.pts, + d.event_type, + d.exclude_auth_key_id, + d.exclude_session_id, + d.attempts +` + +type ClaimDispatchOutboxParams struct { + LeaseSeconds int32 + LimitCount int32 +} + +type ClaimDispatchOutboxRow struct { + ID int64 + TargetUserID int64 + Pts int32 + EventType string + ExcludeAuthKeyID int64 + ExcludeSessionID int64 + Attempts int32 +} + +func (q *Queries) ClaimDispatchOutbox(ctx context.Context, arg ClaimDispatchOutboxParams) ([]ClaimDispatchOutboxRow, error) { + rows, err := q.db.Query(ctx, claimDispatchOutbox, arg.LeaseSeconds, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ClaimDispatchOutboxRow + for rows.Next() { + var i ClaimDispatchOutboxRow + if err := rows.Scan( + &i.ID, + &i.TargetUserID, + &i.Pts, + &i.EventType, + &i.ExcludeAuthKeyID, + &i.ExcludeSessionID, + &i.Attempts, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteFailedDispatchOutbox = `-- name: DeleteFailedDispatchOutbox :one +WITH doomed AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE status = 'failed' + AND updated_at < now() - make_interval(secs => $1::int) + ORDER BY updated_at ASC, target_user_id ASC, id ASC + LIMIT $2 +), +deleted AS ( + DELETE FROM dispatch_outbox d + USING doomed x + WHERE d.target_user_id = x.target_user_id + AND d.id = x.id + RETURNING d.id +) +SELECT count(*)::int AS deleted_count +FROM deleted +` + +type DeleteFailedDispatchOutboxParams struct { + OlderThanSeconds int32 + LimitCount int32 +} + +func (q *Queries) DeleteFailedDispatchOutbox(ctx context.Context, arg DeleteFailedDispatchOutboxParams) (int32, error) { + row := q.db.QueryRow(ctx, deleteFailedDispatchOutbox, arg.OlderThanSeconds, arg.LimitCount) + var deleted_count int32 + err := row.Scan(&deleted_count) + return deleted_count, err +} + +const enqueueDispatch = `-- name: EnqueueDispatch :exec +INSERT INTO dispatch_outbox ( + target_user_id, + pts, + event_type, + exclude_auth_key_id, + exclude_session_id +) VALUES ( + $1, $2, $3, $4, $5 +) +ON CONFLICT DO NOTHING +` + +type EnqueueDispatchParams struct { + TargetUserID int64 + Pts int32 + EventType string + ExcludeAuthKeyID int64 + ExcludeSessionID int64 +} + +func (q *Queries) EnqueueDispatch(ctx context.Context, arg EnqueueDispatchParams) error { + _, err := q.db.Exec(ctx, enqueueDispatch, + arg.TargetUserID, + arg.Pts, + arg.EventType, + arg.ExcludeAuthKeyID, + arg.ExcludeSessionID, + ) + 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 +WHERE user_id = $1 +` + +func (q *Queries) GetUserUpdateWatermark(ctx context.Context, userID int64) (int32, error) { + row := q.db.QueryRow(ctx, getUserUpdateWatermark, userID) + var contiguous_pts int32 + err := row.Scan(&contiguous_pts) + return contiguous_pts, err +} + +const listUserUpdateEventsAfter = `-- name: ListUserUpdateEventsAfter :many +SELECT + e.user_id, + e.pts, + e.pts_count, + e.date, + e.event_type, + e.event_bool, + COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, + COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, + COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, + 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.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.tags_enabled, + 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, + COALESCE(m.peer_type, '')::text AS peer_type, + 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.edit_date, 0)::int AS edit_date, + COALESCE(m.outgoing, false)::boolean AS outgoing, + COALESCE(m.body, '')::text AS body, + COALESCE(m.entities::text, '[]')::text AS message_entities_json, + COALESCE(m.silent, false)::boolean AS silent, + COALESCE(m.noforwards, false)::boolean AS noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS reply_to_msg_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(m.reply_to_top_id, 0)::int AS reply_to_top_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, + 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.fwd_from_name, '')::text AS fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.media::text, '{}')::text AS media_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(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(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, + COALESCE(fwd_u.first_name, '')::text AS fwd_user_first_name, + COALESCE(fwd_u.last_name, '')::text AS fwd_user_last_name, + COALESCE(fwd_u.username, '')::text AS fwd_user_username, + 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(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, + COALESCE(reply_u.first_name, '')::text AS reply_user_first_name, + COALESCE(reply_u.last_name, '')::text AS reply_user_last_name, + COALESCE(reply_u.username, '')::text AS reply_user_username, + 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 +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 +LIMIT $3 +` + +type ListUserUpdateEventsAfterParams struct { + UserID int64 + Pts int32 + LimitCount int32 +} + +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 + 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 +} + +func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpdateEventsAfterParams) ([]ListUserUpdateEventsAfterRow, error) { + rows, err := q.db.Query(ctx, listUserUpdateEventsAfter, arg.UserID, arg.Pts, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUserUpdateEventsAfterRow + for rows.Next() { + var i ListUserUpdateEventsAfterRow + if err := rows.Scan( + &i.UserID, + &i.Pts, + &i.PtsCount, + &i.Date, + &i.EventType, + &i.EventBool, + &i.EventPeersJson, + &i.PeerSettingsJson, + &i.MessageIdsJson, + &i.DialogFilterJson, + &i.FilterOrderJson, + &i.FolderPeersJson, + &i.EventPeerType, + &i.EventPeerID, + &i.FilterID, + &i.MaxID, + &i.StillUnreadCount, + &i.TagsEnabled, + &i.MessageID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.MessageEntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.MediaJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FwdUserID, + &i.FwdUserAccessHash, + &i.FwdUserPhone, + &i.FwdUserFirstName, + &i.FwdUserLastName, + &i.FwdUserUsername, + &i.FwdUserCountryCode, + &i.FwdUserVerified, + &i.FwdUserSupport, + &i.ReplyUserID, + &i.ReplyUserAccessHash, + &i.ReplyUserPhone, + &i.ReplyUserFirstName, + &i.ReplyUserLastName, + &i.ReplyUserUsername, + &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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + 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 + AND id = $2 +` + +type MarkDispatchDeliveredParams struct { + TargetUserID int64 + ID int64 +} + +// 方案 A:投递成功即删除。outbox 是任务队列,delivered 行无保留价值 +// (消息在 message_boxes、离线补偿在 user_update_events),删除让表维持「未完成任务」小稳态。 +func (q *Queries) MarkDispatchDelivered(ctx context.Context, arg MarkDispatchDeliveredParams) error { + _, err := q.db.Exec(ctx, markDispatchDelivered, arg.TargetUserID, arg.ID) + return err +} + +const markDispatchDeliveredBatch = `-- name: MarkDispatchDeliveredBatch :exec +DELETE FROM dispatch_outbox d +USING unnest($1::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord) +JOIN unnest($2::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord) +WHERE d.target_user_id = tu.target_user_id + AND d.id = di.id +` + +type MarkDispatchDeliveredBatchParams struct { + TargetUserIds []int64 + Ids []int64 +} + +// 批量删除一批已投递的 (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 +} + +const markDispatchFailed = `-- name: MarkDispatchFailed :exec +UPDATE dispatch_outbox +SET + status = CASE WHEN attempts >= 5 THEN 'failed' ELSE 'pending' END, + next_attempt_at = CASE + WHEN attempts >= 5 THEN next_attempt_at + ELSE now() + make_interval(secs => LEAST(60, attempts * attempts)) + END, + last_error = $3, + updated_at = now() +WHERE target_user_id = $1 + AND id = $2 +` + +type MarkDispatchFailedParams struct { + TargetUserID int64 + ID int64 + LastError string +} + +func (q *Queries) MarkDispatchFailed(ctx context.Context, arg MarkDispatchFailedParams) error { + _, err := q.db.Exec(ctx, markDispatchFailed, arg.TargetUserID, arg.ID, arg.LastError) + return err +} + +const maxUserPts = `-- name: MaxUserPts :one +SELECT COALESCE(MAX(pts), 0)::int AS max_pts +FROM user_update_events +WHERE user_id = $1 +` + +func (q *Queries) MaxUserPts(ctx context.Context, userID int64) (int32, error) { + row := q.db.QueryRow(ctx, maxUserPts, userID) + var max_pts int32 + 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/temp_auth_key.go b/internal/store/postgres/temp_auth_key.go new file mode 100644 index 00000000..88a3e5f1 --- /dev/null +++ b/internal/store/postgres/temp_auth_key.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// TempAuthKeyBindingStore 用 PostgreSQL 实现 store.TempAuthKeyBindingStore。 +type TempAuthKeyBindingStore struct { + q *sqlcgen.Queries +} + +// NewTempAuthKeyBindingStore 基于 pgx 连接池(或事务)创建 TempAuthKeyBindingStore。 +func NewTempAuthKeyBindingStore(db sqlcgen.DBTX) *TempAuthKeyBindingStore { + return &TempAuthKeyBindingStore{q: sqlcgen.New(db)} +} + +func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKeyBinding) error { + if err := s.q.UpsertTempAuthKeyBinding(ctx, sqlcgen.UpsertTempAuthKeyBindingParams{ + TempAuthKeyID: authKeyIDToInt64(b.TempAuthKeyID), + PermAuthKeyID: b.PermAuthKeyID, + Nonce: b.Nonce, + TempSessionID: b.TempSessionID, + ExpiresAt: int32(b.ExpiresAt), + EncryptedMessage: b.EncryptedMessage, + }); err != nil { + return fmt.Errorf("upsert temp auth key binding: %w", err) + } + return 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 { + if errors.Is(err, pgx.ErrNoRows) { + return domain.TempAuthKeyBinding{}, false, nil + } + return domain.TempAuthKeyBinding{}, false, fmt.Errorf("get temp auth key binding: %w", err) + } + return domain.TempAuthKeyBinding{ + TempAuthKeyID: authKeyIDFromInt64(row.TempAuthKeyID), + PermAuthKeyID: row.PermAuthKeyID, + Nonce: row.Nonce, + TempSessionID: row.TempSessionID, + ExpiresAt: int(row.ExpiresAt), + EncryptedMessage: append([]byte(nil), row.EncryptedMessage...), + }, true, nil +} diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go new file mode 100644 index 00000000..89fb24cc --- /dev/null +++ b/internal/store/postgres/update_event.go @@ -0,0 +1,925 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// UpdateEventStore 用 PostgreSQL 实现 store.UpdateEventStore。 +type UpdateEventStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewUpdateEventStore 基于 pgx 连接池(或事务)创建 UpdateEventStore。 +func NewUpdateEventStore(db sqlcgen.DBTX) *UpdateEventStore { + return &UpdateEventStore{db: db, q: sqlcgen.New(db)} +} + +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 +} + +// AppendWithDispatch 将账号级 update 事件与在线投递 outbox 放入同一个 PG 事务。 +// 设置类 RPC 不像消息发送那样已有业务大事务;这里至少保证“事件已持久化”与 +// “可靠在线投递任务已入队”同生共死,避免进程在手动 push 前退出造成在线通知漏投。 +func (s *UpdateEventStore) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error { + beginner, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }) + if !ok { + if err := s.Append(ctx, userID, event); err != nil { + return 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 + } + tx, err := beginner.Begin(ctx) + if err != nil { + return fmt.Errorf("begin append update dispatch: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + 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) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit append update dispatch: %w", err) + } + committed = true + return nil +} + +func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error { + var messageID *int32 + if event.Message.ID != 0 { + id := int32(event.Message.ID) + messageID = &id + } + var peerType *string + var peerID *int64 + peer := event.Peer + if peer.ID == 0 { + peer = event.Message.Peer + } + if peer.ID != 0 { + t := string(peer.Type) + id := peer.ID + peerType = &t + peerID = &id + } + peers, err := encodeEventPeers(event.Peers) + if err != nil { + return err + } + settings, err := encodePeerSettings(event.Settings) + if err != nil { + return err + } + messageIDs, err := encodeEventMessageIDs(event.MessageIDs) + if err != nil { + return err + } + dialogFilter, err := encodeEventDialogFilter(event.DialogFilter) + if err != nil { + return err + } + filterOrder, err := encodeEventFilterOrder(event.FilterOrder) + if err != nil { + return err + } + folderPeers, err := encodeEventFolderPeers(event.FolderPeers) + if err != nil { + return err + } + if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: userID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(event.Type), + EventBool: event.Bool, + EventPeers: peers, + PeerSettings: settings, + MessageIds: messageIDs, + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + MaxID: pgInt32NonNegative(event.MaxID), + StillUnreadCount: int32(event.StillUnreadCount), + FilterID: pgInt32NonNegative(event.FilterID), + TagsEnabled: event.TagsEnabled, + 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) + } + return nil +} + +func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.q.ListUserUpdateEventsAfter(ctx, sqlcgen.ListUserUpdateEventsAfterParams{ + UserID: userID, + Pts: int32(pts), + LimitCount: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("list update events: %w", err) + } + out := make([]domain.UpdateEvent, 0, len(rows)) + for _, row := range rows { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return nil, 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 nil, fmt.Errorf("decode message metadata: %w", err) + } + peers, err := decodeEventPeers(row.EventPeersJson) + if err != nil { + return nil, fmt.Errorf("decode event peers: %w", err) + } + settings, err := decodePeerSettings(row.PeerSettingsJson) + if err != nil { + return nil, fmt.Errorf("decode peer settings: %w", err) + } + messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson) + if err != nil { + return nil, fmt.Errorf("decode message ids: %w", err) + } + dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson) + if err != nil { + return nil, fmt.Errorf("decode dialog filter: %w", err) + } + filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson) + if err != nil { + return nil, fmt.Errorf("decode filter order: %w", err) + } + folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson) + if err != nil { + return nil, fmt.Errorf("decode folder peers: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode message media: %w", err) + } + out = append(out, 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}, + Peers: peers, + Bool: row.EventBool, + Settings: settings, + MessageIDs: messageIDs, + MaxID: int(row.MaxID), + StillUnreadCount: int(row.StillUnreadCount), + FilterID: int(row.FilterID), + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + TagsEnabled: row.TagsEnabled, + Message: domain.Message{ + ID: int(row.MessageID), + 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, + Media: media, + }, + Users: usersFromUpdateEventRow(row), + Channels: channelsFromUpdateEventRow(row), + }) + } + 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 事件计算并补写。 +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) { + 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 +} + +// BatchByCursor 按 (user_id, pts) 一次性批量取多条账号事件,供 outbox worker 取代逐条 ListAfter。 +// 返回顺序不保证与 cursors 一致,调用方按 (UserID,Pts) 自行索引。 +func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.EventCursor) ([]domain.UpdateEvent, error) { + if len(cursors) == 0 { + return nil, nil + } + userIDs := make([]int64, len(cursors)) + ptsList := make([]int32, len(cursors)) + for i, c := range cursors { + userIDs[i] = c.UserID + ptsList[i] = int32(c.Pts) + } + rows, err := s.q.BatchListDispatchEvents(ctx, sqlcgen.BatchListDispatchEventsParams{ + UserIds: userIDs, + PtsList: ptsList, + }) + if err != nil { + return nil, fmt.Errorf("batch list dispatch events: %w", err) + } + out := make([]domain.UpdateEvent, 0, len(rows)) + for _, row := range rows { + entities, err := decodeMessageEntities(row.MessageEntitiesJson) + if err != nil { + return nil, 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 nil, fmt.Errorf("decode message metadata: %w", err) + } + peers, err := decodeEventPeers(row.EventPeersJson) + if err != nil { + return nil, fmt.Errorf("decode event peers: %w", err) + } + settings, err := decodePeerSettings(row.PeerSettingsJson) + if err != nil { + return nil, fmt.Errorf("decode peer settings: %w", err) + } + messageIDs, err := decodeEventMessageIDs(row.MessageIdsJson) + if err != nil { + return nil, fmt.Errorf("decode message ids: %w", err) + } + dialogFilter, err := decodeEventDialogFilter(row.DialogFilterJson) + if err != nil { + return nil, fmt.Errorf("decode dialog filter: %w", err) + } + filterOrder, err := decodeEventFilterOrder(row.FilterOrderJson) + if err != nil { + return nil, fmt.Errorf("decode filter order: %w", err) + } + folderPeers, err := decodeEventFolderPeers(row.FolderPeersJson) + if err != nil { + return nil, fmt.Errorf("decode folder peers: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return nil, fmt.Errorf("decode message media: %w", err) + } + out = append(out, 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}, + Peers: peers, + Bool: row.EventBool, + Settings: settings, + MessageIDs: messageIDs, + MaxID: int(row.MaxID), + StillUnreadCount: int(row.StillUnreadCount), + FilterID: int(row.FilterID), + DialogFilter: dialogFilter, + FilterOrder: filterOrder, + FolderPeers: folderPeers, + TagsEnabled: row.TagsEnabled, + Message: domain.Message{ + ID: int(row.MessageID), + 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, + Media: media, + }, + Users: usersFromBatchDispatchRow(row), + Channels: channelsFromBatchDispatchRow(row), + }) + } + return out, nil +} + +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, + }, + 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, + }, + 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, + }, + 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, + }, + ) +} + +// usersFromBatchDispatchRow 与 usersFromUpdateEventRow 等价,只是行类型为 BatchListDispatchEventsRow +// (两条查询列完全一致;改一处列时务必同步另一处)。 +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, + }, + 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, + }, + 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, + }, + 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, + }, + ) +} + +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)) + add := func(u domain.User) { + if u.ID == 0 { + return + } + for _, existing := range users { + if existing.ID == u.ID { + return + } + } + users = append(users, u) + } + for _, item := range items { + add(item) + } + 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"` +} + +func encodeEventPeers(peers []domain.Peer) ([]byte, error) { + if len(peers) == 0 { + return []byte("[]"), nil + } + wire := make([]eventPeerJSON, 0, len(peers)) + for _, peer := range peers { + if peer.ID == 0 { + continue + } + wire = append(wire, eventPeerJSON{Type: string(peer.Type), ID: peer.ID}) + } + raw, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("marshal event peers: %w", err) + } + return raw, nil +} + +func decodeEventPeers(raw string) ([]domain.Peer, error) { + if raw == "" { + return nil, nil + } + var wire []eventPeerJSON + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, err + } + out := make([]domain.Peer, 0, len(wire)) + for _, peer := range wire { + if peer.ID == 0 { + continue + } + out = append(out, domain.Peer{Type: domain.PeerType(peer.Type), ID: peer.ID}) + } + return out, nil +} + +func encodeEventMessageIDs(ids []int) ([]byte, error) { + if len(ids) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(ids) + if err != nil { + return nil, fmt.Errorf("marshal event message ids: %w", err) + } + return raw, nil +} + +func decodeEventMessageIDs(raw string) ([]int, error) { + if raw == "" { + return nil, nil + } + var ids []int + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return nil, err + } + return ids, nil +} + +func encodeEventDialogFilter(folder *domain.DialogFolder) ([]byte, error) { + if folder == nil { + return []byte("{}"), nil + } + raw, err := json.Marshal(folder) + if err != nil { + return nil, fmt.Errorf("marshal event dialog filter: %w", err) + } + return raw, nil +} + +func decodeEventDialogFilter(raw string) (*domain.DialogFolder, error) { + if raw == "" || raw == "{}" { + return nil, nil + } + var folder domain.DialogFolder + if err := json.Unmarshal([]byte(raw), &folder); err != nil { + return nil, err + } + return &folder, nil +} + +func encodeEventFilterOrder(order []int) ([]byte, error) { + if len(order) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(order) + if err != nil { + return nil, fmt.Errorf("marshal event filter order: %w", err) + } + return raw, nil +} + +func decodeEventFilterOrder(raw string) ([]int, error) { + if raw == "" { + return nil, nil + } + var order []int + if err := json.Unmarshal([]byte(raw), &order); err != nil { + return nil, err + } + return order, nil +} + +func encodeEventFolderPeers(peers []domain.FolderPeerUpdate) ([]byte, error) { + if len(peers) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(peers) + if err != nil { + return nil, fmt.Errorf("marshal event folder peers: %w", err) + } + return raw, nil +} + +func decodeEventFolderPeers(raw string) ([]domain.FolderPeerUpdate, error) { + if raw == "" { + return nil, nil + } + var peers []domain.FolderPeerUpdate + if err := json.Unmarshal([]byte(raw), &peers); err != nil { + return nil, err + } + return peers, nil +} + +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"` +} + +func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) { + raw, err := json.Marshal(peerSettingsJSON{ + AddContact: settings.AddContact, + BlockContact: settings.BlockContact, + ShareContact: settings.ShareContact, + NeedContactsException: settings.NeedContactsException, + HiddenPeerSettingsBar: settings.HiddenPeerSettingsBar, + }) + if err != nil { + return nil, fmt.Errorf("marshal peer settings: %w", err) + } + return raw, nil +} + +func decodePeerSettings(raw string) (domain.PeerSettings, error) { + if raw == "" { + return domain.PeerSettings{}, nil + } + var wire peerSettingsJSON + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return domain.PeerSettings{}, err + } + return domain.PeerSettings{ + AddContact: wire.AddContact, + BlockContact: wire.BlockContact, + ShareContact: wire.ShareContact, + NeedContactsException: wire.NeedContactsException, + HiddenPeerSettingsBar: wire.HiddenPeerSettingsBar, + }, nil +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/store/postgres/updatestate.go b/internal/store/postgres/updatestate.go new file mode 100644 index 00000000..e8213fb3 --- /dev/null +++ b/internal/store/postgres/updatestate.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// UpdateStateStore 用 PostgreSQL 实现 store.UpdateStateStore。 +type UpdateStateStore struct { + q *sqlcgen.Queries +} + +// NewUpdateStateStore 基于 pgx 连接池(或事务)创建 UpdateStateStore。 +func NewUpdateStateStore(db sqlcgen.DBTX) *UpdateStateStore { + return &UpdateStateStore{q: sqlcgen.New(db)} +} + +func (s *UpdateStateStore) Get(ctx context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) { + row, err := s.q.GetUpdateState(ctx, sqlcgen.GetUpdateStateParams{ + AuthKeyID: authKeyIDToInt64(id), + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.UpdateState{}, false, nil + } + return domain.UpdateState{}, false, fmt.Errorf("get update state: %w", err) + } + return domain.UpdateState{ + Pts: int(row.Pts), + Qts: int(row.Qts), + Date: int(row.Date), + Seq: int(row.Seq), + }, true, nil +} + +func (s *UpdateStateStore) Save(ctx context.Context, id [8]byte, userID int64, st domain.UpdateState) error { + if err := s.q.UpsertUpdateState(ctx, sqlcgen.UpsertUpdateStateParams{ + AuthKeyID: authKeyIDToInt64(id), + UserID: userID, + Pts: int32(st.Pts), + Qts: int32(st.Qts), + Date: int32(st.Date), + Seq: int32(st.Seq), + }); err != nil { + return fmt.Errorf("upsert update state: %w", err) + } + return nil +} + +func (s *UpdateStateStore) Delete(ctx context.Context, id [8]byte, userID int64) error { + if err := s.q.DeleteUpdateState(ctx, sqlcgen.DeleteUpdateStateParams{ + AuthKeyID: authKeyIDToInt64(id), + UserID: userID, + }); err != nil { + return fmt.Errorf("delete update state: %w", err) + } + return nil +} + +func (s *UpdateStateStore) DeleteAuthKey(ctx context.Context, id [8]byte) error { + if err := s.q.DeleteUpdateStatesByAuthKey(ctx, authKeyIDToInt64(id)); err != nil { + return fmt.Errorf("delete update states by auth key: %w", err) + } + return nil +} diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go new file mode 100644 index 00000000..e445bc47 --- /dev/null +++ b/internal/store/postgres/user.go @@ -0,0 +1,234 @@ +package postgres + +import ( + "context" + "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" +) + +// UserStore 用 PostgreSQL 实现 store.UserStore。 +type UserStore struct { + q *sqlcgen.Queries +} + +// NewUserStore 基于 pgx 连接池(或事务)创建 UserStore。 +func NewUserStore(db sqlcgen.DBTX) *UserStore { + return &UserStore{q: sqlcgen.New(db)} +} + +func (s *UserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { + row, err := s.q.GetUserByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by id: %w", err) + } + return userFromModel(row), true, nil +} + +func (s *UserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := s.q.GetUsersByIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("get users by ids: %w", err) + } + out := make([]domain.User, 0, len(rows)) + for _, row := range rows { + out = append(out, userFromModel(row)) + } + return out, nil +} + +func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) { + row, err := s.q.GetUserByPhone(ctx, phone) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by phone: %w", err) + } + return userFromModel(row), true, nil +} + +func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) { + if len(phones) == 0 { + return nil, nil + } + rows, err := s.q.GetUsersByPhones(ctx, phones) + if err != nil { + return nil, fmt.Errorf("get users by phones: %w", err) + } + out := make([]domain.User, 0, len(rows)) + for _, row := range rows { + out = append(out, userFromModel(row)) + } + return out, nil +} + +func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + if username == "" { + return domain.User{}, false, nil + } + row, err := s.q.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, false, nil + } + return domain.User{}, false, fmt.Errorf("get user by username: %w", err) + } + return userFromModel(row), true, nil +} + +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 == "" { + return domain.UserSearchResult{}, nil + } + if limit <= 0 || limit > 50 { + limit = 50 + } + rows, err := s.q.SearchUsers(ctx, sqlcgen.SearchUsersParams{ + CurrentUserID: currentUserID, + QueryLower: query, + QueryLike: escapeLike(query), + PhoneQuery: phoneQuery, + LimitCount: int32(limit), + }) + if err != nil { + return domain.UserSearchResult{}, fmt.Errorf("search users: %w", err) + } + out := domain.UserSearchResult{ + MyResults: make([]domain.User, 0, len(rows)), + Results: make([]domain.User, 0, len(rows)), + } + 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, + } + if row.Contact { + out.MyResults = append(out.MyResults, u) + } else { + out.Results = append(out.Results, u) + } + } + return out, nil +} + +func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) { + row, err := s.q.UpdateUserProfile(ctx, sqlcgen.UpdateUserProfileParams{ + ID: userID, + FirstName: firstName, + LastName: lastName, + About: about, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrFirstNameInvalid + } + return domain.User{}, fmt.Errorf("update user profile: %w", err) + } + return userFromModel(row), nil +} + +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{ + 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" { + return domain.User{}, domain.ErrUsernameOccupied + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUsernameNotOccupied + } + return domain.User{}, fmt.Errorf("update user username: %w", err) + } + return userFromModel(row), nil +} + +func (s *UserStore) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error { + if lastSeenAt <= 0 { + return nil + } + if err := s.q.UpdateUserLastSeen(ctx, sqlcgen.UpdateUserLastSeenParams{ + ID: userID, + LastSeenAt: int64(lastSeenAt), + }); err != nil { + return fmt.Errorf("update user last seen: %w", err) + } + return nil +} + +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, + }) + 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.ErrUsernameOccupied + } + return domain.User{}, fmt.Errorf("create user: %w", err) + } + return userFromModel(row), nil +} + +func escapeLike(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '%' || r == '_' || r == '\\' { + b.WriteRune('\\') + } + b.WriteRune(r) + } + return b.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), + } +} diff --git a/internal/store/ratelimit.go b/internal/store/ratelimit.go new file mode 100644 index 00000000..069facfb --- /dev/null +++ b/internal/store/ratelimit.go @@ -0,0 +1,11 @@ +package store + +import ( + "context" + "time" +) + +// 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) +} diff --git a/internal/store/redisstore/allocator.go b/internal/store/redisstore/allocator.go new file mode 100644 index 00000000..ab051b01 --- /dev/null +++ b/internal/store/redisstore/allocator.go @@ -0,0 +1,303 @@ +package redisstore + +import ( + "context" + "errors" + "fmt" + + "github.com/redis/go-redis/v9" + + "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 +} + +// ChannelMessageIDAllocator 用 Redis INCR 分配 channel 维度 message id。 +type ChannelMessageIDAllocator struct { + counter counterAllocator +} + +type counterAllocator struct { + c *redis.Client + source store.CounterSource + key func(int64) string + name string +} + +const missingCounterSentinel int64 = -1 + +var ( + counterNextScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +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(` +local current = redis.call("GET", KEYS[1]) +if current then + return tonumber(current) +end +redis.call("SET", KEYS[1], ARGV[1]) +return tonumber(ARGV[1]) +`) + + counterRecoverNextScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then + redis.call("SET", KEYS[1], ARGV[1]) +end +return redis.call("INCR", KEYS[1]) +`) + + counterRecoverNextByScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then + redis.call("SET", KEYS[1], ARGV[1]) +end +return redis.call("INCRBY", KEYS[1], ARGV[2]) +`) +) + +// 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{ + c: c, + source: source, + key: boxIDKey, + name: "box_id", + }} +} + +// 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{ + c: c, + source: source, + key: channelIDKey, + name: "channel_id", + }} +} + +// NewChannelMessageIDAllocator 创建 Redis-backed channel message id allocator。 +func NewChannelMessageIDAllocator(c *redis.Client, source store.CounterSource) *ChannelMessageIDAllocator { + return &ChannelMessageIDAllocator{counter: counterAllocator{ + c: c, + source: source, + key: channelMessageIDKey, + name: "channel_msg_id", + }} +} + +func ptsKey(userID int64) string { + return fmt.Sprintf("counter:pts:{%d}", userID) +} + +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 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 +} + +func (a *BoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) { + v, err := a.counter.current(ctx, userID) + 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 +} + +func (a *ChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { + return a.counter.next(ctx, 1) +} + +func (a *ChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { + return a.counter.current(ctx, 1) +} + +func (a *ChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { + v, err := a.counter.next(ctx, channelID) + return int(v), err +} + +func (a *ChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { + v, err := a.counter.current(ctx, channelID) + return int(v), err +} + +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() + if err != nil { + return 0, fmt.Errorf("redis next %s counter: %w", a.name, err) + } + if v != missingCounterSentinel { + return v, nil + } + recovered, err := a.recovered(ctx, userID) + 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() + if err != nil { + return 0, fmt.Errorf("redis recover-next %s counter: %w", a.name, err) + } + return v, nil +} + +func (a counterAllocator) current(ctx context.Context, userID int64) (int64, error) { + key, err := a.validatedKey(userID) + if err != nil { + return 0, err + } + v, err := a.c.Get(ctx, key).Int64() + if err == nil { + return v, nil + } + if !errors.Is(err, redis.Nil) { + return 0, fmt.Errorf("redis get %s counter: %w", a.name, err) + } + recovered, err := a.recovered(ctx, userID) + if err != nil { + return 0, err + } + v, err = counterRecoverCurrentScript.Run(ctx, a.c, []string{key}, recovered).Int64() + if err != nil { + return 0, fmt.Errorf("redis recover-current %s counter: %w", a.name, err) + } + return v, nil +} + +func (a counterAllocator) validatedKey(userID int64) (string, error) { + if userID == 0 { + return "", fmt.Errorf("redis %s counter: missing user id", a.name) + } + if a.c == nil { + return "", fmt.Errorf("redis %s counter: nil client", a.name) + } + return a.key(userID), nil +} + +func (a counterAllocator) recovered(ctx context.Context, userID int64) (int, error) { + recovered := 0 + var err error + if a.source != nil { + recovered, err = a.source.Current(ctx, userID) + if err != nil { + return 0, fmt.Errorf("recover %s counter: %w", a.name, err) + } + } + return recovered, nil +} diff --git a/internal/store/redisstore/allocator_integration_test.go b/internal/store/redisstore/allocator_integration_test.go new file mode 100644 index 00000000..bdcb5956 --- /dev/null +++ b/internal/store/redisstore/allocator_integration_test.go @@ -0,0 +1,160 @@ +package redisstore + +import ( + "context" + "os" + "sync" + "testing" + "time" +) + +type staticCounterSource struct { + value int +} + +func (s staticCounterSource) Current(context.Context, int64) (int, error) { + return s.value, nil +} + +func TestRedisAllocatorsRecoverFromCounterSource(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() }) + + 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) + } + + boxes := NewBoxIDAllocator(c, staticCounterSource{value: 100}) + currentBox, err := boxes.CurrentBoxID(ctx, userID) + if err != nil { + t.Fatalf("CurrentBoxID: %v", err) + } + if currentBox != 100 { + t.Fatalf("current box = %d, want recovered 100", currentBox) + } + nextBox, err := boxes.NextBoxID(ctx, userID) + if err != nil { + t.Fatalf("NextBoxID: %v", err) + } + if nextBox != 101 { + t.Fatalf("next box = %d, want 101", nextBox) + } +} + +func TestRedisAllocatorConcurrentFirstUse(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() }) + + userID := time.Now().UnixNano() + t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID)).Err() }) + + const workers = 32 + pts := NewPtsAllocator(c, staticCounterSource{value: 1000}) + values := make(chan int, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + v, err := pts.NextPts(ctx, userID) + if err != nil { + errs <- err + return + } + values <- v + }() + } + wg.Wait() + close(values) + close(errs) + + for err := range errs { + t.Fatalf("NextPts: %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) + } + if seen[v] { + t.Fatalf("duplicate pts %d", v) + } + seen[v] = true + } + for want := 1001; want <= 1000+workers; want++ { + if !seen[want] { + t.Fatalf("missing pts %d", want) + } + } + current, err := pts.CurrentPts(ctx, userID) + if err != nil { + t.Fatalf("CurrentPts: %v", err) + } + if current != 1000+workers { + t.Fatalf("current pts = %d, want %d", current, 1000+workers) + } +} + +func TestRedisRateLimiterWindow(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() }) + + key := "test:" + time.Now().Format("150405.000000000") + t.Cleanup(func() { _ = c.Del(ctx, rateLimitKey(key)).Err() }) + + limiter := NewRateLimiter(c) + allowed, retry, err := limiter.Allow(ctx, key, 1, time.Minute) + if err != nil { + t.Fatalf("Allow first: %v", err) + } + if !allowed || retry != 0 { + t.Fatalf("first allowed=%v retry=%d, want allowed", allowed, retry) + } + allowed, retry, err = limiter.Allow(ctx, key, 1, time.Minute) + if err != nil { + t.Fatalf("Allow second: %v", err) + } + if allowed || retry <= 0 { + t.Fatalf("second allowed=%v retry=%d, want limited with retry", allowed, retry) + } +} diff --git a/internal/store/redisstore/code.go b/internal/store/redisstore/code.go new file mode 100644 index 00000000..c2d83808 --- /dev/null +++ b/internal/store/redisstore/code.go @@ -0,0 +1,55 @@ +package redisstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/store" +) + +// CodeStore 用 Redis 实现 store.CodeStore(验证码带 TTL 自动过期)。 +type CodeStore struct { + c *redis.Client +} + +// NewCodeStore 创建 Redis CodeStore。 +func NewCodeStore(c *redis.Client) *CodeStore { + return &CodeStore{c: c} +} + +func codeKey(hash string) string { return "phonecode:" + hash } + +func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { + v, err := json.Marshal(code) + if err != nil { + return fmt.Errorf("marshal phone code: %w", err) + } + if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil { + return fmt.Errorf("redis set phone code: %w", err) + } + return nil +} + +func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool, error) { + raw, err := s.c.Get(ctx, codeKey(hash)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return store.PhoneCode{}, false, nil + } + return store.PhoneCode{}, false, fmt.Errorf("redis get phone code: %w", err) + } + var code store.PhoneCode + if err := json.Unmarshal(raw, &code); err != nil { + return store.PhoneCode{}, false, fmt.Errorf("unmarshal phone code: %w", err) + } + return code, true, nil +} + +func (s *CodeStore) Del(ctx context.Context, hash string) error { + return s.c.Del(ctx, codeKey(hash)).Err() +} diff --git a/internal/store/redisstore/ratelimit.go b/internal/store/redisstore/ratelimit.go new file mode 100644 index 00000000..1b5a948d --- /dev/null +++ b/internal/store/redisstore/ratelimit.go @@ -0,0 +1,57 @@ +package redisstore + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/redis/go-redis/v9" +) + +// RateLimiter 用 Redis INCR + TTL 实现固定窗口限流。 +type RateLimiter struct { + c *redis.Client +} + +// NewRateLimiter 创建 Redis-backed RateLimiter。 +func NewRateLimiter(c *redis.Client) *RateLimiter { + return &RateLimiter{c: c} +} + +func rateLimitKey(key string) string { + return "ratelimit:" + key +} + +func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) { + if limit <= 0 { + return true, 0, nil + } + if window <= 0 { + window = time.Second + } + if l == nil || l.c == nil { + return false, 0, fmt.Errorf("redis rate limiter: nil client") + } + redisKey := rateLimitKey(key) + count, err := l.c.Incr(ctx, redisKey).Result() + if err != nil { + return false, 0, fmt.Errorf("redis incr rate limit: %w", err) + } + if count == 1 { + if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil { + return false, 0, fmt.Errorf("redis expire rate limit: %w", err) + } + } + if count <= int64(limit) { + return true, 0, nil + } + ttl, err := l.c.TTL(ctx, redisKey).Result() + if err != nil { + return false, 0, fmt.Errorf("redis ttl rate limit: %w", err) + } + if ttl <= 0 { + ttl = window + } + return false, int(math.Ceil(ttl.Seconds())), nil +} diff --git a/internal/store/redisstore/redis.go b/internal/store/redisstore/redis.go new file mode 100644 index 00000000..f7491907 --- /dev/null +++ b/internal/store/redisstore/redis.go @@ -0,0 +1,25 @@ +// Package redisstore 用 Redis 实现高频易失态的存储接口(第一阶段:SessionStore)。 +// +// 职责边界见 docs/persistence-layer.md §1:Redis 存「态与计数」,丢失可由 PG/协议恢复。 +package redisstore + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// Open 按地址建立 Redis 连接并 ping 验证。 +func Open(ctx context.Context, addr, password string, db int) (*redis.Client, error) { + c := redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + }) + if err := c.Ping(ctx).Err(); err != nil { + _ = c.Close() + return nil, fmt.Errorf("redis ping %s: %w", addr, err) + } + return c, nil +} diff --git a/internal/store/redisstore/session.go b/internal/store/redisstore/session.go new file mode 100644 index 00000000..626c9533 --- /dev/null +++ b/internal/store/redisstore/session.go @@ -0,0 +1,75 @@ +package redisstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/store" +) + +// DefaultSessionTTL 是 session 记录的默认过期时间。 +// session 是连接态:过期或丢失后,客户端重连会触发 new_session_created / bad_server_salt 重建, +// 因此 TTL 不必很长。 +const DefaultSessionTTL = 30 * 24 * time.Hour + +// SessionStore 用 Redis 实现 store.SessionStore。 +type SessionStore struct { + c *redis.Client + ttl time.Duration +} + +// NewSessionStore 创建 Redis SessionStore。ttl<=0 表示永不过期。 +func NewSessionStore(c *redis.Client, ttl time.Duration) *SessionStore { + return &SessionStore{c: c, ttl: ttl} +} + +func sessionKey(id int64) string { + return fmt.Sprintf("session:%d", id) +} + +// sessionValue 是 SessionData 在 Redis 中的序列化形态(不含 ID,ID 即 key)。 +type sessionValue struct { + AuthKeyID [8]byte `json:"auth_key_id"` + Salt int64 `json:"salt"` + LastSeen int64 `json:"last_seen"` +} + +// Save 实现 store.SessionStore。 +func (s *SessionStore) Save(ctx context.Context, d store.SessionData) error { + v, err := json.Marshal(sessionValue{AuthKeyID: d.AuthKeyID, Salt: d.Salt, LastSeen: d.LastSeen}) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + if err := s.c.Set(ctx, sessionKey(d.ID), v, s.ttl).Err(); err != nil { + return fmt.Errorf("redis set session: %w", err) + } + return nil +} + +// Get 实现 store.SessionStore。不存在时 found=false。 +func (s *SessionStore) Get(ctx context.Context, id int64) (store.SessionData, bool, error) { + raw, err := s.c.Get(ctx, sessionKey(id)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return store.SessionData{}, false, nil + } + return store.SessionData{}, false, fmt.Errorf("redis get session: %w", err) + } + var v sessionValue + if err := json.Unmarshal(raw, &v); err != nil { + return store.SessionData{}, false, fmt.Errorf("unmarshal session: %w", err) + } + return store.SessionData{ID: id, AuthKeyID: v.AuthKeyID, Salt: v.Salt, LastSeen: v.LastSeen}, true, nil +} + +func (s *SessionStore) Delete(ctx context.Context, id int64) error { + if err := s.c.Del(ctx, sessionKey(id)).Err(); err != nil { + return fmt.Errorf("redis delete session: %w", err) + } + return nil +} diff --git a/internal/store/redisstore/session_integration_test.go b/internal/store/redisstore/session_integration_test.go new file mode 100644 index 00000000..aff049e5 --- /dev/null +++ b/internal/store/redisstore/session_integration_test.go @@ -0,0 +1,52 @@ +package redisstore + +import ( + "context" + "os" + "testing" + "time" + + "telesrv/internal/store" +) + +// TestSessionStoreRoundTrip 验证 session 落 Redis 后能用全新 store 实例原样读回。 +// 未设 TELESRV_TEST_REDIS_ADDR 则跳过。 +func TestSessionStoreRoundTrip(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() }) + + want := store.SessionData{ + ID: 0x1234beef, + AuthKeyID: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Salt: 42, + LastSeen: 1000, + } + t.Cleanup(func() { _ = c.Del(ctx, sessionKey(want.ID)).Err() }) + + if err := NewSessionStore(c, time.Minute).Save(ctx, want); err != nil { + t.Fatalf("save: %v", err) + } + + got, found, err := NewSessionStore(c, time.Minute).Get(ctx, want.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !found { + t.Fatal("session not found after save") + } + if got != want { + t.Fatalf("round trip mismatch: got %+v want %+v", got, want) + } + + if _, found, _ := NewSessionStore(c, time.Minute).Get(ctx, 999999); found { + t.Fatal("unexpected found for missing session") + } +} diff --git a/internal/store/redisstore/user_counter_allocator.go b/internal/store/redisstore/user_counter_allocator.go new file mode 100644 index 00000000..b5f77c18 --- /dev/null +++ b/internal/store/redisstore/user_counter_allocator.go @@ -0,0 +1,112 @@ +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/session.go b/internal/store/session.go new file mode 100644 index 00000000..e42401b9 --- /dev/null +++ b/internal/store/session.go @@ -0,0 +1,23 @@ +package store + +import "context" + +// SessionData 是一条 MTProto session 记录(client 生成的 session_id)。 +// +// 后续里程碑会扩展 device / layer 等字段。 +type SessionData struct { + ID int64 // session_id(客户端生成) + AuthKeyID [8]byte // 绑定的 auth key + Salt int64 // 当前 server salt + LastSeen int64 // unix 秒 +} + +// SessionStore 记录在线 MTProto session。实现见 store/memory(测试替身)、store/redisstore。 +type SessionStore interface { + // Save 保存或更新一条 session 记录。 + Save(ctx context.Context, s SessionData) error + // Get 按 session_id 查询;不存在时 found=false。 + Get(ctx context.Context, id int64) (data SessionData, found bool, err error) + // Delete 删除一条 session 记录;不存在时不报错。 + Delete(ctx context.Context, id int64) error +} diff --git a/internal/store/temp_auth_key.go b/internal/store/temp_auth_key.go new file mode 100644 index 00000000..ae195866 --- /dev/null +++ b/internal/store/temp_auth_key.go @@ -0,0 +1,13 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// TempAuthKeyBindingStore 持久化 auth.bindTempAuthKey 的 temp→perm 绑定。 +type TempAuthKeyBindingStore interface { + Save(ctx context.Context, binding domain.TempAuthKeyBinding) error + GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) +} diff --git a/internal/store/update_event.go b/internal/store/update_event.go new file mode 100644 index 00000000..502584ad --- /dev/null +++ b/internal/store/update_event.go @@ -0,0 +1,26 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// UpdateEventStore 持久化 user 维度的增量事件。 +type UpdateEventStore interface { + Append(ctx context.Context, userID int64, event 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(ctx context.Context, userID int64) (int, error) + // AdvanceContiguousPts 推进并返回账号级连续 pts 水位。实现应在写事件的同一事务内调用。 + AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) +} + +// EventCursor 精确定位单条账号事件,用于 outbox worker 批量加载已 claim 事件。 +type EventCursor struct { + UserID int64 + Pts int +} diff --git a/internal/store/updatestate.go b/internal/store/updatestate.go new file mode 100644 index 00000000..7ed28d3e --- /dev/null +++ b/internal/store/updatestate.go @@ -0,0 +1,15 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// UpdateStateStore 持久化 auth_key + user 维度的 pts/qts/seq 状态,避免同一设备换号串状态。 +type UpdateStateStore interface { + Get(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error) + Save(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error + Delete(ctx context.Context, authKeyID [8]byte, userID int64) error + DeleteAuthKey(ctx context.Context, authKeyID [8]byte) error +} diff --git a/internal/store/user.go b/internal/store/user.go new file mode 100644 index 00000000..fff88664 --- /dev/null +++ b/internal/store/user.go @@ -0,0 +1,22 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// UserStore 持久化用户。实现见 store/memory(测试替身)、store/postgres。 +type UserStore interface { + ByID(ctx context.Context, id int64) (domain.User, bool, error) + ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) + ByPhone(ctx context.Context, phone string) (domain.User, bool, error) + ByPhones(ctx context.Context, phones []string) ([]domain.User, error) + ByUsername(ctx context.Context, username string) (domain.User, bool, error) + Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) + UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error) + UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) + UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error + // Create 创建用户并返回分配了 ID 的副本。 + Create(ctx context.Context, u domain.User) (domain.User, error) +} diff --git a/internal/testkit/doc.go b/internal/testkit/doc.go new file mode 100644 index 00000000..abe31e86 --- /dev/null +++ b/internal/testkit/doc.go @@ -0,0 +1,3 @@ +// Package testkit 提供协议与业务测试工具:协议 spike 的集成测试辅助、 +// 驱动客户端调用首批 RPC 的脚手架等。 +package testkit diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 00000000..afbc215d --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "postgresql" + # schema 只取 *.up.sql,避免 golang-migrate 的 down 脚本(DROP)被 sqlc 当成 schema 的一部分。 + # 与 golang-migrate 共用同一份迁移文件,单一事实源。 + schema: "deploy/migrations/*.up.sql" + queries: "internal/store/postgres/queries" + gen: + go: + package: "sqlcgen" + out: "internal/store/postgres/sqlcgen" + sql_package: "pgx/v5" + emit_pointers_for_null_types: true