Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

22
.gitignore vendored Normal file
View file

@ -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/

195
README.md Normal file
View file

@ -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)

182
README.zh-CN.md Normal file
View file

@ -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 layer225
- 本地 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)

View file

@ -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)
}

212
cmd/telesrv/main.go Normal file
View file

@ -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)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

10
deploy/deploy.go Normal file
View file

@ -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

48
deploy/docker-compose.yml Normal file
View file

@ -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:

View file

@ -0,0 +1,4 @@
-- 0001_init 回滚:按外键依赖逆序删表。
DROP TABLE IF EXISTS authorizations;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS auth_keys;

View file

@ -0,0 +1,48 @@
-- 0001_init: telesrv 第一阶段持久化 schemaauth_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.ClientInfodevice/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);

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

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

View file

@ -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);

View file

@ -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;

View file

@ -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'));

View file

@ -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
);

View file

@ -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
);

View file

@ -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);

View file

@ -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;

View file

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS dispatch_outbox_dispatching_stale_idx;
DROP INDEX IF EXISTS message_boxes_dialog_date_seek_idx;

View file

@ -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';

View file

@ -0,0 +1,6 @@
-- 0011 down (no-op): 本迁移清除的是已被取代、运行时零引用的死表
-- update_events / messages_legacy / dialogs_legacy
--
-- 全新项目明确不保留回退路径,故不在此重建这些表;历史结构可查 0002 / 0005 / 0006 / 0007 迁移脚本(保留未删)。
-- golang-migrate 执行本文件即把版本回退到 0010不恢复任何死表或其数据。
SELECT 1;

View file

@ -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 由旧 messages0005重命名保留的迁移残骸数据已迁入 private_messages + message_boxes。
-- - dialogs_legacy : 0009 由旧 dialogs0002重命名保留的迁移残骸数据已迁入新 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;

View file

@ -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'));

View file

@ -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'));

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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'
)
);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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'
)
);

View file

@ -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;

View file

@ -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 <> '';

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS users_username_lower_unique_idx;

View file

@ -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 <> '';

View file

@ -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;

View file

@ -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'
)
);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 $$;

View file

@ -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;

View file

@ -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);

View file

@ -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.

View file

@ -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 $$;

View file

@ -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.

View file

@ -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;

View file

@ -0,0 +1 @@
-- No-op: read receipt dates and TDesktop app config keys are forward-compatible.

View file

@ -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';

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS channel_messages_body_trgm_idx;

View file

@ -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 <> '';

View file

@ -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.

View file

@ -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;

View file

@ -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.

View file

@ -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;

View file

@ -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'));

View file

@ -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'));

View file

@ -0,0 +1,2 @@
ALTER TABLE channel_members
DROP COLUMN IF EXISTS available_min_pts;

View file

@ -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;

View file

@ -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'
)
);

View file

@ -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'
)
);

View file

@ -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';

View file

@ -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';

View file

@ -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;

View file

@ -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 $$;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS channels_linked_chat_idx;
ALTER TABLE channels
DROP COLUMN IF EXISTS linked_chat_id;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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')
);

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS channel_message_viewers CASCADE;
ALTER TABLE channel_messages
DROP COLUMN IF EXISTS views_count;

View file

@ -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 $$;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS channel_unread_mentions CASCADE;

View file

@ -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 $$;

View file

@ -0,0 +1,2 @@
ALTER TABLE channels
DROP COLUMN IF EXISTS participants_hidden;

View file

@ -0,0 +1,2 @@
ALTER TABLE channels
ADD COLUMN IF NOT EXISTS participants_hidden BOOLEAN NOT NULL DEFAULT false;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,2 @@
ALTER TABLE channel_dialogs
DROP COLUMN IF EXISTS view_forum_as_messages;

View file

@ -0,0 +1,2 @@
ALTER TABLE channel_dialogs
ADD COLUMN IF NOT EXISTS view_forum_as_messages BOOLEAN NOT NULL DEFAULT false;

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -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'
)
);

View file

@ -0,0 +1,3 @@
-- 0046_channel_forum_topics rollback.
DROP TABLE IF EXISTS channel_forum_topics CASCADE;

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