feat: sync chatlist sharing support
This commit is contained in:
parent
ec6e8fd13d
commit
2a9c12263f
62 changed files with 3408 additions and 212 deletions
|
|
@ -6,6 +6,10 @@ TELESRV_ADVERTISE_IP=127.0.0.1
|
||||||
TELESRV_DC=2
|
TELESRV_DC=2
|
||||||
TELESRV_DEV_AUTH_CODE=12345
|
TELESRV_DEV_AUTH_CODE=12345
|
||||||
|
|
||||||
|
# Client-visible telesrv links. Production uses https://telesrv.net.
|
||||||
|
# For local link/deeplink smoke tests use http://127.0.0.1:2401.
|
||||||
|
TELESRV_PUBLIC_BASE_URL=https://telesrv.net
|
||||||
|
|
||||||
# Admin API / Admin UI 配置
|
# Admin API / Admin UI 配置
|
||||||
#
|
#
|
||||||
# TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token,
|
# TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token,
|
||||||
|
|
@ -40,6 +44,10 @@ TELESRV_LANGPACK_SEED_DIR=data/langpack
|
||||||
TELESRV_BLOB_DIR=data/blobs
|
TELESRV_BLOB_DIR=data/blobs
|
||||||
TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
TELESRV_STICKER_SEED_DIR=data/sticker-seed
|
||||||
|
|
||||||
|
# Optional sticker/custom emoji/chatlist landing endpoint.
|
||||||
|
# Public canonical URLs use TELESRV_PUBLIC_BASE_URL.
|
||||||
|
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
|
||||||
|
|
||||||
# AI compose for TDesktop/Android input box rewrite/polish.
|
# AI compose for TDesktop/Android input box rewrite/polish.
|
||||||
# The local provider is deterministic and does not call external services.
|
# The local provider is deterministic and does not call external services.
|
||||||
TELESRV_AI_ENABLED=true
|
TELESRV_AI_ENABLED=true
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ codebase.
|
||||||
| ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. |
|
| ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. |
|
||||||
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
|
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
|
||||||
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
|
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
|
||||||
|
| ✅ | Chatlists and public links | Chat folder sharing, exported chatlist invite links, join/import flows, revoked invite handling, and shared public link landing pages. |
|
||||||
| ✅ | Private chats | Send, history, read receipts, edit, delete, forward, reply, rich entities, grouped/media messages, reactions, scheduled/TTL-oriented paths. |
|
| ✅ | Private chats | Send, history, read receipts, edit, delete, forward, reply, rich entities, grouped/media messages, reactions, scheduled/TTL-oriented paths. |
|
||||||
| ✅ | Rich messages | Telegram Desktop rich text messages, rich content conversion, send/edit/scheduled flows, dialog/history projections, and memory/PostgreSQL persistence. |
|
| ✅ | Rich messages | Telegram Desktop rich text messages, rich content conversion, send/edit/scheduled flows, dialog/history projections, and memory/PostgreSQL persistence. |
|
||||||
| ✅ | AI compose and ChatBot | Input-box rewrite/polish, default and custom tones, addstyle previews, local and external provider chains, streamed `@ChatBot` draft replies, and Business AI reply hooks. |
|
| ✅ | AI compose and ChatBot | Input-box rewrite/polish, default and custom tones, addstyle previews, local and external provider chains, streamed `@ChatBot` draft replies, and Business AI reply hooks. |
|
||||||
|
|
@ -94,11 +95,13 @@ Useful local environment variables:
|
||||||
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP advertised to compatible clients |
|
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP advertised to compatible clients |
|
||||||
| `TELESRV_DC` | `2` | self-hosted DC id |
|
| `TELESRV_DC` | `2` | self-hosted DC id |
|
||||||
| `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development |
|
| `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development |
|
||||||
|
| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | canonical base URL for public sticker/chatlist links |
|
||||||
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string |
|
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string |
|
||||||
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address |
|
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address |
|
||||||
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory |
|
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory |
|
||||||
| `TELESRV_BLOB_DIR` | `data/blobs` | local media blob directory |
|
| `TELESRV_BLOB_DIR` | `data/blobs` | local media blob directory |
|
||||||
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | optional sticker/reaction seed directory |
|
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | optional sticker/reaction seed directory |
|
||||||
|
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | empty | optional public link landing endpoint for sticker and chatlist links |
|
||||||
| `TELESRV_AI_ENABLED` | `true` | enable AI compose entry points |
|
| `TELESRV_AI_ENABLED` | `true` | enable AI compose entry points |
|
||||||
| `TELESRV_AI_PROVIDERS` | `local` | ordered AI provider chain, such as `local` or `kimi,local` |
|
| `TELESRV_AI_PROVIDERS` | `local` | ordered AI provider chain, such as `local` or `kimi,local` |
|
||||||
| `TELESRV_AI_TIMEOUT` | `15s` | per AI provider call timeout |
|
| `TELESRV_AI_TIMEOUT` | `15s` | per AI provider call timeout |
|
||||||
|
|
@ -138,7 +141,7 @@ to the features you enable.
|
||||||
| 12400 | UDP | TURN/STUN server | P2P/call relay |
|
| 12400 | UDP | TURN/STUN server | P2P/call relay |
|
||||||
| 12500-12999 | UDP | TURN relay port range | TURN relay |
|
| 12500-12999 | UDP | TURN relay port range | TURN relay |
|
||||||
| configurable | TCP | Bot API | When `TELESRV_BOT_API_ADDR` is set |
|
| configurable | TCP | Bot API | When `TELESRV_BOT_API_ADDR` is set |
|
||||||
| configurable | TCP | Sticker Web deep-link landing | When `TELESRV_STICKER_WEB_ADDR` is set |
|
| configurable | TCP | Public link deep-link landing | When `TELESRV_PUBLIC_LINK_WEB_ADDR` is set |
|
||||||
|
|
||||||
### Internal/debug ports (do not expose publicly)
|
### Internal/debug ports (do not expose publicly)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
|
||||||
| ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 |
|
| ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 |
|
||||||
| ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 |
|
| ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 |
|
||||||
| ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 |
|
| ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 |
|
||||||
|
| ✅ | Chatlists 与公开链接 | 聊天文件夹分享、chatlist invite links、加入/导入流程、撤销邀请处理,以及统一公开链接落地页。 |
|
||||||
| ✅ | 私聊消息 | send、history、read receipts、edit、delete、forward、reply、富文本实体、媒体/相册消息、reactions、scheduled/TTL 相关路径。 |
|
| ✅ | 私聊消息 | send、history、read receipts、edit、delete、forward、reply、富文本实体、媒体/相册消息、reactions、scheduled/TTL 相关路径。 |
|
||||||
| ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 |
|
| ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 |
|
||||||
| ✅ | AI 输入框与 ChatBot | 输入框改写/润色、默认和自定义 tone、addstyle 预览、本地与外部 provider 链、流式 `@ChatBot` 草稿回复、Business AI 回复钩子。 |
|
| ✅ | AI 输入框与 ChatBot | 输入框改写/润色、默认和自定义 tone、addstyle 预览、本地与外部 provider 链、流式 `@ChatBot` 草稿回复、Business AI 回复钩子。 |
|
||||||
|
|
@ -86,11 +87,13 @@ go build -o bin/gramsrv.exe ./cmd/telesrv
|
||||||
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 下发给兼容客户端的连接 IP |
|
| `TELESRV_ADVERTISE_IP` | `127.0.0.1` | 下发给兼容客户端的连接 IP |
|
||||||
| `TELESRV_DC` | `2` | 自建 DC id |
|
| `TELESRV_DC` | `2` | 自建 DC id |
|
||||||
| `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 |
|
| `TELESRV_DEV_AUTH_CODE` | `12345` | 本地开发固定登录验证码 |
|
||||||
|
| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | sticker/chatlist 公开链接的 canonical base URL |
|
||||||
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 |
|
| `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL 连接串 |
|
||||||
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 |
|
| `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 |
|
||||||
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 |
|
| `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 |
|
||||||
| `TELESRV_BLOB_DIR` | `data/blobs` | 本地媒体 blob 目录 |
|
| `TELESRV_BLOB_DIR` | `data/blobs` | 本地媒体 blob 目录 |
|
||||||
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | 可选 sticker/reaction 种子目录 |
|
| `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | 可选 sticker/reaction 种子目录 |
|
||||||
|
| `TELESRV_PUBLIC_LINK_WEB_ADDR` | 空 | 可选的 sticker/chatlist 公开链接落地页监听地址 |
|
||||||
| `TELESRV_AI_ENABLED` | `true` | 启用 AI compose 入口 |
|
| `TELESRV_AI_ENABLED` | `true` | 启用 AI compose 入口 |
|
||||||
| `TELESRV_AI_PROVIDERS` | `local` | AI provider 调用链,例如 `local` 或 `kimi,local` |
|
| `TELESRV_AI_PROVIDERS` | `local` | AI provider 调用链,例如 `local` 或 `kimi,local` |
|
||||||
| `TELESRV_AI_TIMEOUT` | `15s` | 单次 AI provider 调用超时 |
|
| `TELESRV_AI_TIMEOUT` | `15s` | 单次 AI provider 调用超时 |
|
||||||
|
|
@ -128,7 +131,7 @@ go build -o bin/gramsrv.exe ./cmd/telesrv
|
||||||
| 12400 | UDP | TURN/STUN 服务器 | 启用 P2P/通话 relay |
|
| 12400 | UDP | TURN/STUN 服务器 | 启用 P2P/通话 relay |
|
||||||
| 12500-12999 | UDP | TURN relay 端口段 | 启用 TURN relay |
|
| 12500-12999 | UDP | TURN relay 端口段 | 启用 TURN relay |
|
||||||
| 可配置 | TCP | Bot API | 设置 `TELESRV_BOT_API_ADDR` 时 |
|
| 可配置 | TCP | Bot API | 设置 `TELESRV_BOT_API_ADDR` 时 |
|
||||||
| 可配置 | TCP | Sticker Web 深链落地页 | 设置 `TELESRV_STICKER_WEB_ADDR` 时 |
|
| 可配置 | TCP | 公开链接深链落地页 | 设置 `TELESRV_PUBLIC_LINK_WEB_ADDR` 时 |
|
||||||
|
|
||||||
### 内部/调试端口(不要暴露到公网)
|
### 内部/调试端口(不要暴露到公网)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import (
|
||||||
"telesrv/internal/app/auth"
|
"telesrv/internal/app/auth"
|
||||||
botsapp "telesrv/internal/app/bots"
|
botsapp "telesrv/internal/app/bots"
|
||||||
channelapp "telesrv/internal/app/channels"
|
channelapp "telesrv/internal/app/channels"
|
||||||
|
chatlistsapp "telesrv/internal/app/chatlists"
|
||||||
"telesrv/internal/app/contacts"
|
"telesrv/internal/app/contacts"
|
||||||
"telesrv/internal/app/dialogs"
|
"telesrv/internal/app/dialogs"
|
||||||
filesapp "telesrv/internal/app/files"
|
filesapp "telesrv/internal/app/files"
|
||||||
|
|
@ -322,6 +323,7 @@ func run(logger *zap.Logger) error {
|
||||||
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
|
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
|
||||||
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
|
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
|
||||||
dialogStore := postgres.NewDialogStore(pool)
|
dialogStore := postgres.NewDialogStore(pool)
|
||||||
|
chatlistStore := postgres.NewChatlistStore(pool)
|
||||||
messageStore := postgres.NewMessageStore(pool,
|
messageStore := postgres.NewMessageStore(pool,
|
||||||
postgres.WithMessageAllocators(boxIDAllocator),
|
postgres.WithMessageAllocators(boxIDAllocator),
|
||||||
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
|
||||||
|
|
@ -470,16 +472,18 @@ func run(logger *zap.Logger) error {
|
||||||
account.WithUserStickerSets(passwordStore),
|
account.WithUserStickerSets(passwordStore),
|
||||||
account.WithSavedMusic(passwordStore),
|
account.WithSavedMusic(passwordStore),
|
||||||
account.WithBusinessAutomation(passwordStore),
|
account.WithBusinessAutomation(passwordStore),
|
||||||
account.WithUsers(userStore))
|
account.WithUsers(userStore),
|
||||||
|
account.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||||
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||||||
botsapp.WithLogger(logger.Named("bots")),
|
botsapp.WithLogger(logger.Named("bots")),
|
||||||
botsapp.WithBlockChecker(contactStore),
|
botsapp.WithBlockChecker(contactStore),
|
||||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||||
botsapp.WithUserCache(userCache),
|
botsapp.WithUserCache(userCache),
|
||||||
botsapp.WithStickerSetCreator(filesService),
|
botsapp.WithStickerSetCreator(filesService),
|
||||||
botsapp.WithUserStickerSets(accountService))
|
botsapp.WithUserStickerSets(accountService),
|
||||||
|
botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||||
groupCallStore := postgres.NewGroupCallStore(pool)
|
groupCallStore := postgres.NewGroupCallStore(pool)
|
||||||
groupCallsService := groupcallsapp.NewService(groupCallStore)
|
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||||
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
||||||
// 回报给信令侧保活水位(sweeper 双过期判据的实现);未启用则退化为纯信令(M0)。
|
// 回报给信令侧保活水位(sweeper 双过期判据的实现);未启用则退化为纯信令(M0)。
|
||||||
sfuService := sfu.Service(sfu.Disabled())
|
sfuService := sfu.Service(sfu.Disabled())
|
||||||
|
|
@ -591,6 +595,12 @@ func run(logger *zap.Logger) error {
|
||||||
channelapp.WithReadModelVersions(readModelVersionStore),
|
channelapp.WithReadModelVersions(readModelVersionStore),
|
||||||
channelapp.WithSendPermissionChecker(adminService),
|
channelapp.WithSendPermissionChecker(adminService),
|
||||||
)
|
)
|
||||||
|
chatlistsService := chatlistsapp.NewService(
|
||||||
|
chatlistStore,
|
||||||
|
dialogStore,
|
||||||
|
chatlistsapp.WithChannels(channelsService),
|
||||||
|
chatlistsapp.WithPremiumChecker(usersService.PremiumActive),
|
||||||
|
)
|
||||||
businessAutomationOptions := newBusinessAutomationOptions(cfg, activeSessions, aiComposeService, logger)
|
businessAutomationOptions := newBusinessAutomationOptions(cfg, activeSessions, aiComposeService, logger)
|
||||||
messagesService := messageapp.NewService(messageStore, dialogStore,
|
messagesService := messageapp.NewService(messageStore, dialogStore,
|
||||||
messageapp.WithContactStore(contactStore),
|
messageapp.WithContactStore(contactStore),
|
||||||
|
|
@ -617,6 +627,7 @@ func run(logger *zap.Logger) error {
|
||||||
CallForceRelay: cfg.CallForceRelay,
|
CallForceRelay: cfg.CallForceRelay,
|
||||||
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
|
||||||
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
RtmpIngestURL: cfg.LiveStreamRtmpURL,
|
||||||
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
// PFS temp→perm 解析缓存 5s:削减每帧 ResolveAuthKey 的 PG 查询。显式撤销会清缓存并
|
// PFS temp→perm 解析缓存 5s:削减每帧 ResolveAuthKey 的 PG 查询。显式撤销会清缓存并
|
||||||
// 断开连接;re-bind 即时失效(onAuthBindTempAuthKey)。
|
// 断开连接;re-bind 即时失效(onAuthBindTempAuthKey)。
|
||||||
TempKeyResolveCacheTTL: 5 * time.Second,
|
TempKeyResolveCacheTTL: 5 * time.Second,
|
||||||
|
|
@ -632,6 +643,7 @@ func run(logger *zap.Logger) error {
|
||||||
BootstrapUpdates: bootstrapUpdateStore,
|
BootstrapUpdates: bootstrapUpdateStore,
|
||||||
Contacts: contactsService,
|
Contacts: contactsService,
|
||||||
Dialogs: dialogsService,
|
Dialogs: dialogsService,
|
||||||
|
Chatlists: chatlistsService,
|
||||||
Messages: messagesService,
|
Messages: messagesService,
|
||||||
Channels: channelsService,
|
Channels: channelsService,
|
||||||
Files: filesService,
|
Files: filesService,
|
||||||
|
|
@ -710,8 +722,8 @@ func run(logger *zap.Logger) error {
|
||||||
return fmt.Errorf("start admin api: %w", err)
|
return fmt.Errorf("start admin api: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := stickerlinks.Start(ctx, stickerlinks.Config{
|
if _, err := stickerlinks.Start(ctx, stickerlinks.Config{
|
||||||
Addr: cfg.StickerWebAddr,
|
Addr: cfg.PublicLinkWebAddr,
|
||||||
PublicBaseURL: cfg.StickerWebPublicURL,
|
PublicBaseURL: cfg.PublicBaseURL,
|
||||||
}, filesService, logger.Named("stickerlinks")); err != nil {
|
}, filesService, logger.Named("stickerlinks")); err != nil {
|
||||||
return fmt.Errorf("start sticker links: %w", err)
|
return fmt.Errorf("start sticker links: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
deploy/migrations/0056_chatlists.down.sql
Normal file
2
deploy/migrations/0056_chatlists.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP TABLE IF EXISTS public.chatlist_memberships;
|
||||||
|
DROP TABLE IF EXISTS public.chatlist_invites;
|
||||||
38
deploy/migrations/0056_chatlists.up.sql
Normal file
38
deploy/migrations/0056_chatlists.up.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
CREATE TABLE public.chatlist_invites (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
owner_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
filter_id integer NOT NULL CHECK (filter_id >= 2),
|
||||||
|
slug text NOT NULL UNIQUE CHECK (slug <> ''),
|
||||||
|
title text NOT NULL DEFAULT '',
|
||||||
|
peers jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
revoked boolean NOT NULL DEFAULT false,
|
||||||
|
deleted boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_chatlist_invites_owner_filter_slug
|
||||||
|
ON public.chatlist_invites(owner_user_id, filter_id, slug);
|
||||||
|
|
||||||
|
CREATE INDEX idx_chatlist_invites_owner_filter_live
|
||||||
|
ON public.chatlist_invites(owner_user_id, filter_id, created_at, slug)
|
||||||
|
WHERE NOT deleted;
|
||||||
|
|
||||||
|
CREATE TABLE public.chatlist_memberships (
|
||||||
|
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
local_filter_id integer NOT NULL CHECK (local_filter_id >= 2),
|
||||||
|
owner_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||||
|
owner_filter_id integer NOT NULL CHECK (owner_filter_id >= 2),
|
||||||
|
slug text NOT NULL REFERENCES public.chatlist_invites(slug),
|
||||||
|
hidden_updates boolean NOT NULL DEFAULT false,
|
||||||
|
joined_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, local_filter_id),
|
||||||
|
UNIQUE (user_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_chatlist_memberships_user_slug
|
||||||
|
ON public.chatlist_memberships(user_id, slug);
|
||||||
|
|
||||||
|
CREATE INDEX idx_chatlist_memberships_slug
|
||||||
|
ON public.chatlist_memberships(slug);
|
||||||
2
deploy/migrations/0057_chatlist_invites_revoked.down.sql
Normal file
2
deploy/migrations/0057_chatlist_invites_revoked.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.chatlist_invites
|
||||||
|
DROP COLUMN IF EXISTS revoked;
|
||||||
2
deploy/migrations/0057_chatlist_invites_revoked.up.sql
Normal file
2
deploy/migrations/0057_chatlist_invites_revoked.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE public.chatlist_invites
|
||||||
|
ADD COLUMN IF NOT EXISTS revoked boolean NOT NULL DEFAULT false;
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
Fragment collectibles
|
|
||||||
Telegram users can make it easy for others to contact them or find their public groups and channels via usernames: clients can also assign multiple Fragment » collectible usernames to accounts, supergroups and channels they own; Fragment » also allows purchasing phone number collectibles that can be used to register Telegram accounts.
|
|
||||||
|
|
||||||
Collectible usernames
|
|
||||||
Schema:
|
|
||||||
|
|
||||||
username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username;
|
|
||||||
|
|
||||||
updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector<Username> = Update;
|
|
||||||
|
|
||||||
user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true bot_guestchat:flags2.19?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector<RestrictionReason> bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector<Username> stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User;
|
|
||||||
|
|
||||||
channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector<RestrictionReason> admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector<Username> stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat;
|
|
||||||
|
|
||||||
---functions---
|
|
||||||
|
|
||||||
account.toggleUsername#58d6b376 username:string active:Bool = Bool;
|
|
||||||
account.reorderUsernames#ef500eab order:Vector<string> = Bool;
|
|
||||||
|
|
||||||
bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector<string> = Bool;
|
|
||||||
bots.toggleUsername#053ca973 bot:InputUser username:string active:Bool = Bool;
|
|
||||||
|
|
||||||
channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool;
|
|
||||||
channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector<string> = Bool;
|
|
||||||
channels.deactivateAllUsernames#0a245dd3 channel:InputChannel = Bool;
|
|
||||||
Collectible usernames work just like basic @usernames, they appear in Global Search results and have deep links », just like basic usernames.
|
|
||||||
|
|
||||||
The ownership of collectible usernames is secured by TON, a fast and scalable blockchain network. They can be bought and sold through the Fragment platform », giving a simple and secure way to acquire and exchange valuable Telegram domains.
|
|
||||||
|
|
||||||
On the Fragment platform, clients may associate or dissociate purchased collectible usernames either to their Telegram account, or to a bot/channel/supergroup they own.
|
|
||||||
|
|
||||||
If any collectible username is associated to an account, channel or supergroup, user.username and channel.username won't be set, and user.usernames and channel.usernames will be set, instead: these fields contain an array of username constructors, which indicate whether a certain username is a basic username (editable is set) or a collectible username (editable is not set); and whether a collectible username is active or not.
|
|
||||||
The first username in the usernames vector must be shown in-UI as the main username.
|
|
||||||
|
|
||||||
Immediately after association, a username is marked as not active (i.e. only the owner can see it in the usernames list): use account.toggleUsername, bots.toggleUsername or channels.toggleUsername to mark it as active.
|
|
||||||
Note that you can't mark as inactive the basic (non-collectible) username, if any.
|
|
||||||
|
|
||||||
channels.deactivateAllUsernames may also be used to mark as inactive all collectible usernames associated to a certain supergroup or channel: useful for example when making a group or channel private, by first invoking channels.updateUsername with an empty username to remove the editable username (if present), and then invoking this method to remove all associated collectible usernames.
|
|
||||||
|
|
||||||
Use account.reorderUsernames/bots.reorderUsernames/channels.reorderUsernames to change the order of the usernames associated to an account, channel or supergroup: all currently active usernames must be specified.
|
|
||||||
|
|
||||||
Collectible phone numbers
|
|
||||||
Fragment collectible phone numbers can only be used to create Telegram accounts, using the usual sign up/login flow ».
|
|
||||||
|
|
||||||
Fetching info about Fragment collectibles
|
|
||||||
Schema:
|
|
||||||
|
|
||||||
inputCollectibleUsername#e39460a9 username:string = InputCollectible;
|
|
||||||
inputCollectiblePhone#a2e214a4 phone:string = InputCollectible;
|
|
||||||
|
|
||||||
fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo;
|
|
||||||
|
|
||||||
---functions---
|
|
||||||
|
|
||||||
fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo;
|
|
||||||
fragment.getCollectibleInfo may be used to fetch info about Fragment collectible owned by us or other users (i.e. the purchase date & price).
|
|
||||||
|
|
||||||
The collectible must be visible to the current user, for example it can be a Fragment user/phone number collectible we own, or a Fragment username that another user has enabled on their account, or another user's Fragment phone number that we can see thanks to the owner's privacy settings.
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -113,7 +114,7 @@ func (s *Service) CreateBusinessChatLink(ctx context.Context, userID int64, inpu
|
||||||
link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{
|
link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Link: businessChatLinkURL(slug),
|
Link: s.businessChatLinkURL(slug),
|
||||||
Message: normalized.Message,
|
Message: normalized.Message,
|
||||||
Entities: normalized.Entities,
|
Entities: normalized.Entities,
|
||||||
Title: normalized.Title,
|
Title: normalized.Title,
|
||||||
|
|
@ -501,6 +502,10 @@ func randomBusinessChatLinkSlug() (string, error) {
|
||||||
return fmt.Sprintf("%x", value), nil
|
return fmt.Sprintf("%x", value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func businessChatLinkURL(slug string) string {
|
func (s *Service) businessChatLinkURL(slug string) string {
|
||||||
return "https://telesrv.net/m/" + slug
|
baseURL := links.DefaultPublicBaseURL
|
||||||
|
if s != nil && s.publicBaseURL != "" {
|
||||||
|
baseURL = s.publicBaseURL
|
||||||
|
}
|
||||||
|
return links.Build(baseURL, "m/"+slug, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,7 +30,8 @@ type Service struct {
|
||||||
savedMusic store.SavedMusicStore
|
savedMusic store.SavedMusicStore
|
||||||
business store.BusinessAutomationStore
|
business store.BusinessAutomationStore
|
||||||
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
||||||
users store.UserStore
|
users store.UserStore
|
||||||
|
publicBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceOption 调整 account 服务依赖。
|
// ServiceOption 调整 account 服务依赖。
|
||||||
|
|
@ -91,9 +93,15 @@ func WithUsers(users store.UserStore) ServiceOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithPublicBaseURL(baseURL string) ServiceOption {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService 创建 account 服务。
|
// NewService 创建 account 服务。
|
||||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||||
s := &Service{passwords: passwords}
|
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(s)
|
opt(s)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,7 @@ func (s *Service) handleNewBotUsername(ctx context.Context, state domain.BotChat
|
||||||
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil {
|
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil {
|
||||||
s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err))
|
||||||
}
|
}
|
||||||
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at telesrv.net/%s.\n\nUse this token to access the HTTP API:\n", u.Username)
|
head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at %s.\n\nUse this token to access the HTTP API:\n", s.publicURL(u.Username))
|
||||||
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,6 +89,7 @@ type Service struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
chatBotStreamThrottle time.Duration
|
chatBotStreamThrottle time.Duration
|
||||||
|
publicBaseURL string
|
||||||
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
// replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。
|
||||||
replySeq atomic.Int64
|
replySeq atomic.Int64
|
||||||
replyLocks [replyLockStripes]sync.Mutex
|
replyLocks [replyLockStripes]sync.Mutex
|
||||||
|
|
@ -182,6 +184,12 @@ func WithAIChatStreamThrottle(d time.Duration) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithPublicBaseURL(baseURL string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
||||||
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
||||||
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
||||||
|
|
@ -252,6 +260,7 @@ func NewService(users store.UserStore, bots store.BotStore, messages store.Messa
|
||||||
log: zap.NewNop(),
|
log: zap.NewNop(),
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
chatBotStreamThrottle: defaultChatBotStreamThrottle,
|
chatBotStreamThrottle: defaultChatBotStreamThrottle,
|
||||||
|
publicBaseURL: links.DefaultPublicBaseURL,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(s)
|
opt(s)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -13,6 +14,7 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -409,7 +411,7 @@ func (s *Service) handleStickersAddEmoji(ctx context.Context, state domain.BotCh
|
||||||
if s.hooks != nil {
|
if s.hooks != nil {
|
||||||
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
|
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
|
||||||
}
|
}
|
||||||
return botReply{Text: fmt.Sprintf("Done. Added to %s.\n\n%s", stickersBotSetTitle(set), stickersBotPublicURL(set))}
|
return botReply{Text: fmt.Sprintf("Done. Added to %s.\n\n%s", stickersBotSetTitle(set), s.stickersBotPublicURL(set))}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
|
func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
|
||||||
|
|
@ -443,7 +445,7 @@ func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain
|
||||||
if s.hooks != nil {
|
if s.hooks != nil {
|
||||||
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
|
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
|
||||||
}
|
}
|
||||||
return botReply{Text: fmt.Sprintf("Done. Removed from %s.\n\n%s", stickersBotSetTitle(set), stickersBotPublicURL(set))}
|
return botReply{Text: fmt.Sprintf("Done. Removed from %s.\n\n%s", stickersBotSetTitle(set), s.stickersBotPublicURL(set))}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotChatState, raw string) botReply {
|
func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotChatState, raw string) botReply {
|
||||||
|
|
@ -484,7 +486,7 @@ func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotC
|
||||||
if s.hooks != nil {
|
if s.hooks != nil {
|
||||||
s.hooks.PushStickerSetsChanged(ctx, state.UserID, installKind)
|
s.hooks.PushStickerSetsChanged(ctx, state.UserID, installKind)
|
||||||
}
|
}
|
||||||
return botReply{Text: "Done. Your pack is published and installed.\n\n" + stickersBotPublicURL(set)}
|
return botReply{Text: "Done. Your pack is published and installed.\n\n" + s.stickersBotPublicURL(set)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) stickersBotCreateError(userID int64, err error) botReply {
|
func (s *Service) stickersBotCreateError(userID int64, err error) botReply {
|
||||||
|
|
@ -542,7 +544,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep
|
||||||
lines := make([]string, 0, len(sets)+1)
|
lines := make([]string, 0, len(sets)+1)
|
||||||
lines = append(lines, "Your packs:")
|
lines = append(lines, "Your packs:")
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
lines = append(lines, fmt.Sprintf("%s - %s", set.Title, stickersBotPublicURL(set)))
|
lines = append(lines, fmt.Sprintf("%s - %s", set.Title, s.stickersBotPublicURL(set)))
|
||||||
}
|
}
|
||||||
if total > len(sets) {
|
if total > len(sets) {
|
||||||
lines = append(lines, fmt.Sprintf("Showing %d of %d.", len(sets), total))
|
lines = append(lines, fmt.Sprintf("Showing %d of %d.", len(sets), total))
|
||||||
|
|
@ -746,12 +748,21 @@ func cloneStickersBotState(state domain.BotChatState) domain.BotChatState {
|
||||||
|
|
||||||
func normalizeStickersBotShortName(raw string) string {
|
func normalizeStickersBotShortName(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
raw = strings.TrimPrefix(raw, "https://telesrv.net/addstickers/")
|
|
||||||
raw = strings.TrimPrefix(raw, "https://telesrv.net/addemoji/")
|
|
||||||
raw = strings.TrimPrefix(raw, "telesrv://addstickers?set=")
|
raw = strings.TrimPrefix(raw, "telesrv://addstickers?set=")
|
||||||
raw = strings.TrimPrefix(raw, "telesrv://addemoji?set=")
|
raw = strings.TrimPrefix(raw, "telesrv://addemoji?set=")
|
||||||
raw = strings.TrimPrefix(raw, "tg://addstickers?set=")
|
raw = strings.TrimPrefix(raw, "tg://addstickers?set=")
|
||||||
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
|
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
|
||||||
|
if strings.Contains(raw, "://") {
|
||||||
|
if parsed, err := url.Parse(raw); err == nil {
|
||||||
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) {
|
||||||
|
raw = parts[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return strings.ToLower(strings.Trim(raw, " /"))
|
return strings.ToLower(strings.Trim(raw, " /"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -784,10 +795,18 @@ func validStickersBotEmoji(raw string) bool {
|
||||||
return hasEmoji
|
return hasEmoji
|
||||||
}
|
}
|
||||||
|
|
||||||
func stickersBotPublicURL(set domain.StickerSet) string {
|
func (s *Service) stickersBotPublicURL(set domain.StickerSet) string {
|
||||||
part := "addstickers"
|
part := "addstickers"
|
||||||
if stickersBotSetKind(set) == domain.StickerSetKindEmoji {
|
if stickersBotSetKind(set) == domain.StickerSetKindEmoji {
|
||||||
part = "addemoji"
|
part = "addemoji"
|
||||||
}
|
}
|
||||||
return "https://telesrv.net/" + part + "/" + set.ShortName
|
return s.publicURL(part + "/" + set.ShortName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) publicURL(path string) string {
|
||||||
|
baseURL := links.DefaultPublicBaseURL
|
||||||
|
if s != nil && s.publicBaseURL != "" {
|
||||||
|
baseURL = s.publicBaseURL
|
||||||
|
}
|
||||||
|
return links.Build(baseURL, path, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
834
internal/app/chatlists/service.go
Normal file
834
internal/app/chatlists/service.go
Normal file
|
|
@ -0,0 +1,834 @@
|
||||||
|
package chatlists
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
chatlists store.ChatlistStore
|
||||||
|
dialogs store.DialogStore
|
||||||
|
channels ChannelService
|
||||||
|
premium PremiumChecker
|
||||||
|
newSlug func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// ChannelService is the domain-only channel dependency used for shared-folder
|
||||||
|
// peer membership side effects.
|
||||||
|
type ChannelService interface {
|
||||||
|
GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||||
|
InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error)
|
||||||
|
JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error)
|
||||||
|
LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PremiumChecker func(ctx context.Context, userID int64) bool
|
||||||
|
|
||||||
|
func WithChannels(channels ChannelService) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.channels = channels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPremiumChecker(fn PremiumChecker) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.premium = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSlugGenerator(fn func() (string, error)) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if fn != nil {
|
||||||
|
s.newSlug = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(chatlists store.ChatlistStore, dialogs store.DialogStore, opts ...Option) *Service {
|
||||||
|
s := &Service{
|
||||||
|
chatlists: chatlists,
|
||||||
|
dialogs: dialogs,
|
||||||
|
newSlug: randomChatlistSlug,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExportInvite(ctx context.Context, userID int64, filterID int, title string, peers []domain.DialogFolderPeer, date int) (domain.DialogFolder, domain.ChatlistInvite, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
folder, err := s.ownerFolder(ctx, userID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if !folderShareable(folder) {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistNotShareable
|
||||||
|
}
|
||||||
|
selected, err := selectChatlistPeers(folder, peers, true)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if err := s.validateShareableChannelPeers(ctx, userID, selected); err != nil {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
count, err := s.chatlists.CountActiveInvites(ctx, userID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if count >= s.invitesLimit(ctx, userID) {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvitesTooMuch
|
||||||
|
}
|
||||||
|
folder = exportedChatlistFolder(folder, true)
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
slug, err := s.newSlug()
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
invite := domain.ChatlistInvite{
|
||||||
|
OwnerUserID: userID,
|
||||||
|
FilterID: filterID,
|
||||||
|
Slug: slug,
|
||||||
|
Title: title,
|
||||||
|
Peers: selected,
|
||||||
|
Date: date,
|
||||||
|
}
|
||||||
|
saved, err := s.chatlists.SaveInvite(ctx, invite)
|
||||||
|
if err == nil {
|
||||||
|
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||||
|
rollbackErr := s.deleteInviteIfSaved(ctx, userID, filterID, saved.Slug)
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, errors.Join(fmt.Errorf("save exported chatlist folder: %w", err), rollbackErr)
|
||||||
|
}
|
||||||
|
return folder, saved, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, domain.ErrChatlistSlugOccupied) {
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = domain.ErrChatlistSlugOccupied
|
||||||
|
}
|
||||||
|
return domain.DialogFolder{}, domain.ChatlistInvite{}, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListInvites(ctx context.Context, userID int64, filterID int) ([]domain.ChatlistInvite, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.ownerFolder(ctx, userID, filterID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.chatlists.ListInvites(ctx, userID, filterID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EditInvite(ctx context.Context, userID int64, filterID int, slug string, title *string, peers *[]domain.DialogFolderPeer, revoke bool) (domain.ChatlistInvite, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||||
|
return domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
slug = CleanSlug(slug)
|
||||||
|
if !links.ValidChatlistSlug(slug) {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistInviteInvalid
|
||||||
|
}
|
||||||
|
folder, err := s.ownerFolder(ctx, userID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
existing, found, err := s.chatlists.GetInvite(ctx, userID, filterID, slug)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistInviteExpired
|
||||||
|
}
|
||||||
|
if title != nil {
|
||||||
|
if utf8.RuneCountInString(*title) > domain.MaxDialogFolderTitleRunes {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
existing.Title = *title
|
||||||
|
}
|
||||||
|
if peers != nil {
|
||||||
|
selected, err := selectChatlistPeers(folder, *peers, true)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if err := s.validateShareableChannelPeers(ctx, userID, selected); err != nil {
|
||||||
|
return domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
existing.Peers = selected
|
||||||
|
}
|
||||||
|
if revoke {
|
||||||
|
existing.Revoked = true
|
||||||
|
}
|
||||||
|
return s.chatlists.SaveInvite(ctx, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteInvite(ctx context.Context, userID int64, filterID int, slug string) (domain.DialogFolder, bool, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, filterID); err != nil {
|
||||||
|
return domain.DialogFolder{}, false, err
|
||||||
|
}
|
||||||
|
slug = CleanSlug(slug)
|
||||||
|
if !links.ValidChatlistSlug(slug) {
|
||||||
|
return domain.DialogFolder{}, false, domain.ErrChatlistInviteInvalid
|
||||||
|
}
|
||||||
|
folder, err := s.ownerFolder(ctx, userID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, false, err
|
||||||
|
}
|
||||||
|
deleted, err := s.chatlists.DeleteInvite(ctx, userID, filterID, slug)
|
||||||
|
if err != nil || !deleted {
|
||||||
|
return domain.DialogFolder{}, false, err
|
||||||
|
}
|
||||||
|
count, err := s.chatlists.CountInvites(ctx, userID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, false, err
|
||||||
|
}
|
||||||
|
if count > 0 || !folder.HasMyInvites {
|
||||||
|
return domain.DialogFolder{}, false, nil
|
||||||
|
}
|
||||||
|
folder = exportedChatlistFolder(folder, false)
|
||||||
|
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||||
|
return domain.DialogFolder{}, false, fmt.Errorf("clear exported chatlist folder flag: %w", err)
|
||||||
|
}
|
||||||
|
return folder, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CheckInvite(ctx context.Context, userID int64, slug string) (domain.ChatlistInvitePreview, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
return domain.ChatlistInvitePreview{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
slug = CleanSlug(slug)
|
||||||
|
if !links.ValidChatlistSlug(slug) {
|
||||||
|
return domain.ChatlistInvitePreview{}, domain.ErrChatlistInviteInvalid
|
||||||
|
}
|
||||||
|
invite, folder, err := s.inviteWithFolder(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvitePreview{}, err
|
||||||
|
}
|
||||||
|
out := domain.ChatlistInvitePreview{Invite: invite, OwnerFolder: folder}
|
||||||
|
if userID == invite.OwnerUserID {
|
||||||
|
ownerFolder := exportedChatlistFolder(folder, true)
|
||||||
|
out.LocalFolder = &ownerFolder
|
||||||
|
out.Already = peersIntersection(invite.Peers, folderPeers(ownerFolder))
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
membership, found, err := s.chatlists.GetMembershipBySlug(ctx, userID, slug)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvitePreview{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
out.Missing = cloneFolderPeers(invite.Peers)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
local, found, err := s.dialogs.GetFolder(ctx, userID, membership.LocalFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvitePreview{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistInvitePreview{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
out.Membership = &membership
|
||||||
|
out.LocalFolder = &local
|
||||||
|
out.Already = peersIntersection(invite.Peers, folderPeers(local))
|
||||||
|
out.Missing = peersDifference(invite.Peers, folderPeers(local))
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) JoinInvite(ctx context.Context, userID int64, slug string, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
return domain.ChatlistJoinResult{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
slug = CleanSlug(slug)
|
||||||
|
if !links.ValidChatlistSlug(slug) {
|
||||||
|
return domain.ChatlistJoinResult{}, domain.ErrChatlistInviteInvalid
|
||||||
|
}
|
||||||
|
invite, ownerFolder, err := s.inviteWithFolder(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
if userID == invite.OwnerUserID {
|
||||||
|
return domain.ChatlistJoinResult{Folder: exportedChatlistFolder(ownerFolder, true), Date: date}, nil
|
||||||
|
}
|
||||||
|
if existing, found, err := s.chatlists.GetMembershipBySlug(ctx, userID, slug); err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
} else if found {
|
||||||
|
folder, found, err := s.dialogs.GetFolder(ctx, userID, existing.LocalFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistJoinResult{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
return domain.ChatlistJoinResult{Folder: folder, Membership: existing, Date: date}, nil
|
||||||
|
}
|
||||||
|
selected, err := selectInvitePeers(invite, peers, true)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
count, err := s.chatlists.CountMemberships(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
if count >= s.joinedLimit(ctx, userID) {
|
||||||
|
return domain.ChatlistJoinResult{}, domain.ErrChatlistsTooMuch
|
||||||
|
}
|
||||||
|
channelResults, err := s.joinChannelPeers(ctx, invite.OwnerUserID, userID, selected, date)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
filterID, err := s.nextLocalFilterID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
folder := importedChatlistFolder(ownerFolder, filterID, selected)
|
||||||
|
membership := domain.ChatlistMembership{
|
||||||
|
UserID: userID,
|
||||||
|
LocalFilterID: filterID,
|
||||||
|
OwnerUserID: invite.OwnerUserID,
|
||||||
|
OwnerFilterID: invite.FilterID,
|
||||||
|
Slug: invite.Slug,
|
||||||
|
Date: date,
|
||||||
|
}
|
||||||
|
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||||
|
if rollbackErr := s.leaveJoinedChannelResults(ctx, userID, channelResults, date); rollbackErr != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, errors.Join(fmt.Errorf("save joined chatlist folder: %w", err), rollbackErr)
|
||||||
|
}
|
||||||
|
return domain.ChatlistJoinResult{}, fmt.Errorf("save joined chatlist folder: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.chatlists.SaveMembership(ctx, membership); err != nil {
|
||||||
|
rollbackErr := errors.Join(
|
||||||
|
s.dialogs.DeleteFolder(ctx, userID, filterID),
|
||||||
|
s.leaveJoinedChannelResults(ctx, userID, channelResults, date),
|
||||||
|
)
|
||||||
|
return domain.ChatlistJoinResult{}, errors.Join(err, rollbackErr)
|
||||||
|
}
|
||||||
|
return domain.ChatlistJoinResult{Folder: folder, Membership: membership, Date: date, ChannelResults: channelResults}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUpdates(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistUpdates, error) {
|
||||||
|
membership, folder, invite, err := s.memberFolderInvite(ctx, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrChatlistInviteExpired) {
|
||||||
|
return domain.ChatlistUpdates{Membership: membership}, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, domain.ErrChatlistInvalid) {
|
||||||
|
if owner, ownerErr := s.ownerFolder(ctx, userID, localFilterID); ownerErr == nil && owner.IsChatlist && owner.HasMyInvites {
|
||||||
|
return domain.ChatlistUpdates{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.ChatlistUpdates{}, err
|
||||||
|
}
|
||||||
|
if membership.HiddenUpdates {
|
||||||
|
return domain.ChatlistUpdates{Membership: membership}, nil
|
||||||
|
}
|
||||||
|
return domain.ChatlistUpdates{
|
||||||
|
Membership: membership,
|
||||||
|
Missing: peersDifference(invite.Peers, folderPeers(folder)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) JoinUpdates(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error) {
|
||||||
|
membership, folder, invite, err := s.memberFolderInvite(ctx, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
selected, err := selectInvitePeers(invite, peers, true)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.chatlists.SetMembershipHidden(ctx, userID, membership.LocalFilterID, false); err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
channelResults, err := s.joinChannelPeers(ctx, membership.OwnerUserID, userID, selected, date)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, err
|
||||||
|
}
|
||||||
|
folder.IncludePeers = mergeFolderPeers(folder.IncludePeers, selected)
|
||||||
|
folder.IsChatlist = true
|
||||||
|
if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil {
|
||||||
|
if rollbackErr := s.leaveJoinedChannelResults(ctx, userID, channelResults, date); rollbackErr != nil {
|
||||||
|
return domain.ChatlistJoinResult{}, errors.Join(fmt.Errorf("save chatlist updates: %w", err), rollbackErr)
|
||||||
|
}
|
||||||
|
return domain.ChatlistJoinResult{}, fmt.Errorf("save chatlist updates: %w", err)
|
||||||
|
}
|
||||||
|
return domain.ChatlistJoinResult{Folder: folder, Membership: membership, ChannelResults: channelResults}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HideUpdates(ctx context.Context, userID int64, localFilterID int) error {
|
||||||
|
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !found {
|
||||||
|
return domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
_, err := s.chatlists.SetMembershipHidden(ctx, userID, localFilterID, true)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Leave(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistLeaveResult, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||||
|
return domain.ChatlistLeaveResult{}, err
|
||||||
|
}
|
||||||
|
if _, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||||
|
return domain.ChatlistLeaveResult{}, err
|
||||||
|
} else if !found {
|
||||||
|
return domain.ChatlistLeaveResult{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
folder, found, err := s.dialogs.GetFolder(ctx, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistLeaveResult{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistLeaveResult{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
selected, err := selectPeersFromAllowed(folderPeerMap(channelFolderPeers(folderPeers(folder))), peers, false)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistLeaveResult{}, err
|
||||||
|
}
|
||||||
|
channelResults, err := s.leaveChannelPeers(ctx, userID, selected, date)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistLeaveResult{}, err
|
||||||
|
}
|
||||||
|
if err := s.dialogs.DeleteFolder(ctx, userID, localFilterID); err != nil {
|
||||||
|
return domain.ChatlistLeaveResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.chatlists.DeleteMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||||
|
restoreErr := s.dialogs.UpsertFolder(ctx, userID, folder)
|
||||||
|
return domain.ChatlistLeaveResult{}, errors.Join(err, restoreErr)
|
||||||
|
}
|
||||||
|
return domain.ChatlistLeaveResult{FilterID: localFilterID, ChannelResults: channelResults, RequestedLeaves: selected}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LeaveSuggestions(ctx context.Context, userID int64, localFilterID int) ([]domain.DialogFolderPeer, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !found {
|
||||||
|
return nil, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
folder, found, err := s.dialogs.GetFolder(ctx, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
return channelFolderPeers(folderPeers(folder)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ownerFolder(ctx context.Context, userID int64, filterID int) (domain.DialogFolder, error) {
|
||||||
|
if s == nil || s.chatlists == nil || s.dialogs == nil {
|
||||||
|
return domain.DialogFolder{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
folder, found, err := s.dialogs.GetFolder(ctx, userID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DialogFolder{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.DialogFolder{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
folder.ID = filterID
|
||||||
|
return folder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) inviteWithFolder(ctx context.Context, slug string) (domain.ChatlistInvite, domain.DialogFolder, error) {
|
||||||
|
invite, found, err := s.chatlists.GetInviteBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvite{}, domain.DialogFolder{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistInvite{}, domain.DialogFolder{}, domain.ErrChatlistInviteExpired
|
||||||
|
}
|
||||||
|
folder, found, err := s.dialogs.GetFolder(ctx, invite.OwnerUserID, invite.FilterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvite{}, domain.DialogFolder{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistInvite{}, domain.DialogFolder{}, domain.ErrChatlistInviteExpired
|
||||||
|
}
|
||||||
|
return invite, exportedChatlistFolder(folder, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) memberFolderInvite(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, domain.DialogFolder, domain.ChatlistInvite, error) {
|
||||||
|
if err := validateChatlistUserFilter(userID, localFilterID); err != nil {
|
||||||
|
return domain.ChatlistMembership{}, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
membership, found, err := s.chatlists.GetMembershipByLocalFilter(ctx, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistMembership{}, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.ChatlistMembership{}, domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
folder, found, err := s.dialogs.GetFolder(ctx, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return membership, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return membership, domain.DialogFolder{}, domain.ChatlistInvite{}, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
invite, found, err := s.chatlists.GetInviteBySlug(ctx, membership.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return membership, domain.DialogFolder{}, domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return membership, folder, domain.ChatlistInvite{}, domain.ErrChatlistInviteExpired
|
||||||
|
}
|
||||||
|
return membership, folder, invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) nextLocalFilterID(ctx context.Context, userID int64) (int, error) {
|
||||||
|
list, err := s.dialogs.ListFolders(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
used := make(map[int]struct{}, len(list.Folders))
|
||||||
|
for _, folder := range list.Folders {
|
||||||
|
used[folder.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
for id := domain.DialogCustomFolderMinID; id < domain.DialogCustomFolderMinID+domain.MaxDialogFolders; id++ {
|
||||||
|
if _, ok := used[id]; !ok {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, domain.ErrChatlistsTooMuch
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChatlistUserFilter(userID int64, filterID int) error {
|
||||||
|
if userID == 0 || filterID < domain.DialogCustomFolderMinID {
|
||||||
|
return domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderShareable(folder domain.DialogFolder) bool {
|
||||||
|
if len(folder.ExcludePeers) > 0 || folder.Contacts || folder.NonContacts || folder.Groups ||
|
||||||
|
folder.Broadcasts || folder.Bots || folder.ExcludeMuted || folder.ExcludeRead || folder.ExcludeArchived {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(folder.IncludePeers)+len(folder.PinnedPeers) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateShareableChannelPeers(ctx context.Context, userID int64, peers []domain.DialogFolderPeer) error {
|
||||||
|
if len(peers) == 0 {
|
||||||
|
return domain.ErrChatlistPeersEmpty
|
||||||
|
}
|
||||||
|
for _, item := range peers {
|
||||||
|
if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 {
|
||||||
|
return domain.ErrChatlistNotShareable
|
||||||
|
}
|
||||||
|
if s.channels == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view, err := s.channels.GetChannel(ctx, userID, item.Peer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !channelShareableForChatlist(view) {
|
||||||
|
return domain.ErrChatlistNotShareable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelShareableForChatlist(view domain.ChannelView) bool {
|
||||||
|
if view.Channel.Deleted || view.Forbidden {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return channelCanInviteForChatlist(view) || channelPublicJoinableForChatlist(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelCanInviteForChatlist(view domain.ChannelView) bool {
|
||||||
|
switch view.Self.Role {
|
||||||
|
case domain.ChannelRoleCreator:
|
||||||
|
return true
|
||||||
|
case domain.ChannelRoleAdmin:
|
||||||
|
if view.Self.AdminRights.InviteUsers {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelPublicJoinableForChatlist(view domain.ChannelView) bool {
|
||||||
|
if view.Channel.Deleted || view.Forbidden {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return view.Channel.Username != "" && !view.Channel.JoinRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) joinChannelPeers(ctx context.Context, ownerUserID, userID int64, peers []domain.DialogFolderPeer, date int) ([]domain.CreateChannelResult, error) {
|
||||||
|
if s.channels == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
type joinPlan struct {
|
||||||
|
peer domain.DialogFolderPeer
|
||||||
|
useInvite bool
|
||||||
|
}
|
||||||
|
channelPeers := channelFolderPeers(peers)
|
||||||
|
plans := make([]joinPlan, 0, len(channelPeers))
|
||||||
|
for _, item := range channelPeers {
|
||||||
|
view, err := s.channels.GetChannel(ctx, ownerUserID, item.Peer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !channelShareableForChatlist(view) {
|
||||||
|
return nil, domain.ErrChatlistNotShareable
|
||||||
|
}
|
||||||
|
plans = append(plans, joinPlan{peer: item, useInvite: channelCanInviteForChatlist(view)})
|
||||||
|
}
|
||||||
|
results := make([]domain.CreateChannelResult, 0, len(plans))
|
||||||
|
for _, plan := range plans {
|
||||||
|
var res domain.CreateChannelResult
|
||||||
|
var err error
|
||||||
|
if plan.useInvite {
|
||||||
|
res, err = s.channels.InviteToChannel(ctx, ownerUserID, plan.peer.Peer.ID, []int64{userID}, date)
|
||||||
|
} else {
|
||||||
|
res, err = s.channels.JoinChannel(ctx, userID, plan.peer.Peer.ID, date)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrUserAlreadyParticipant) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) leaveChannelPeers(ctx context.Context, userID int64, peers []domain.DialogFolderPeer, date int) ([]domain.CreateChannelResult, error) {
|
||||||
|
if s.channels == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
results := make([]domain.CreateChannelResult, 0)
|
||||||
|
for _, item := range channelFolderPeers(peers) {
|
||||||
|
res, err := s.channels.LeaveChannel(ctx, userID, item.Peer.ID, date)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrUserNotParticipant) || errors.Is(err, domain.ErrChannelPrivate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) leaveJoinedChannelResults(ctx context.Context, userID int64, results []domain.CreateChannelResult, date int) error {
|
||||||
|
if s.channels == nil || len(results) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var joinedErr error
|
||||||
|
for _, res := range results {
|
||||||
|
if res.Channel.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.channels.LeaveChannel(ctx, userID, res.Channel.ID, date); err != nil &&
|
||||||
|
!errors.Is(err, domain.ErrUserNotParticipant) &&
|
||||||
|
!errors.Is(err, domain.ErrChannelPrivate) {
|
||||||
|
joinedErr = errors.Join(joinedErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return joinedErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deleteInviteIfSaved(ctx context.Context, ownerUserID int64, filterID int, slug string) error {
|
||||||
|
if slug == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := s.chatlists.DeleteInvite(ctx, ownerUserID, filterID, slug); err != nil {
|
||||||
|
return fmt.Errorf("rollback chatlist invite: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) invitesLimit(ctx context.Context, userID int64) int {
|
||||||
|
if s != nil && s.premium != nil && s.premium(ctx, userID) {
|
||||||
|
return domain.MaxChatlistInvitesPremium
|
||||||
|
}
|
||||||
|
return domain.MaxChatlistInvitesDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) joinedLimit(ctx context.Context, userID int64) int {
|
||||||
|
if s != nil && s.premium != nil && s.premium(ctx, userID) {
|
||||||
|
return domain.MaxChatlistsJoinedPremium
|
||||||
|
}
|
||||||
|
return domain.MaxChatlistsJoinedDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportedChatlistFolder(folder domain.DialogFolder, hasMyInvites bool) domain.DialogFolder {
|
||||||
|
folder.Contacts = false
|
||||||
|
folder.NonContacts = false
|
||||||
|
folder.Groups = false
|
||||||
|
folder.Broadcasts = false
|
||||||
|
folder.Bots = false
|
||||||
|
folder.ExcludeMuted = false
|
||||||
|
folder.ExcludeRead = false
|
||||||
|
folder.ExcludeArchived = false
|
||||||
|
folder.ExcludePeers = nil
|
||||||
|
folder.IsChatlist = true
|
||||||
|
folder.HasMyInvites = hasMyInvites
|
||||||
|
return cloneDialogFolder(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedChatlistFolder(source domain.DialogFolder, filterID int, peers []domain.DialogFolderPeer) domain.DialogFolder {
|
||||||
|
source = exportedChatlistFolder(source, false)
|
||||||
|
source.ID = filterID
|
||||||
|
source.PinnedPeers = nil
|
||||||
|
source.IncludePeers = cloneFolderPeers(peers)
|
||||||
|
source.HasMyInvites = false
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectChatlistPeers(folder domain.DialogFolder, requested []domain.DialogFolderPeer, requireNonEmpty bool) ([]domain.DialogFolderPeer, error) {
|
||||||
|
allowed := folderPeerMap(folderPeers(folder))
|
||||||
|
return selectPeersFromAllowed(allowed, requested, requireNonEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectInvitePeers(invite domain.ChatlistInvite, requested []domain.DialogFolderPeer, requireNonEmpty bool) ([]domain.DialogFolderPeer, error) {
|
||||||
|
if len(requested) == 0 {
|
||||||
|
requested = invite.Peers
|
||||||
|
}
|
||||||
|
return selectPeersFromAllowed(folderPeerMap(invite.Peers), requested, requireNonEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPeersFromAllowed(allowed map[domain.Peer]domain.DialogFolderPeer, requested []domain.DialogFolderPeer, requireNonEmpty bool) ([]domain.DialogFolderPeer, error) {
|
||||||
|
if len(requested) > domain.MaxChatlistInvitePeers {
|
||||||
|
return nil, domain.ErrChatlistPeersTooMuch
|
||||||
|
}
|
||||||
|
out := make([]domain.DialogFolderPeer, 0, len(requested))
|
||||||
|
seen := make(map[domain.Peer]struct{}, len(requested))
|
||||||
|
for _, item := range requested {
|
||||||
|
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||||
|
return nil, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
allowedPeer, ok := allowed[item.Peer]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
if _, ok := seen[item.Peer]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Peer] = struct{}{}
|
||||||
|
if item.AccessHash == 0 {
|
||||||
|
item.AccessHash = allowedPeer.AccessHash
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if requireNonEmpty && len(out) == 0 {
|
||||||
|
return nil, domain.ErrChatlistPeersEmpty
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelFolderPeers(peers []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||||
|
out := make([]domain.DialogFolderPeer, 0, len(peers))
|
||||||
|
for _, item := range peers {
|
||||||
|
if item.Peer.Type == domain.PeerTypeChannel && item.Peer.ID != 0 {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderPeers(folder domain.DialogFolder) []domain.DialogFolderPeer {
|
||||||
|
return mergeFolderPeers(folder.PinnedPeers, folder.IncludePeers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderPeerMap(peers []domain.DialogFolderPeer) map[domain.Peer]domain.DialogFolderPeer {
|
||||||
|
out := make(map[domain.Peer]domain.DialogFolderPeer, len(peers))
|
||||||
|
for _, item := range peers {
|
||||||
|
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := out[item.Peer]; !ok {
|
||||||
|
out[item.Peer] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFolderPeers(a, b []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||||
|
out := make([]domain.DialogFolderPeer, 0, len(a)+len(b))
|
||||||
|
seen := make(map[domain.Peer]struct{}, len(a)+len(b))
|
||||||
|
for _, list := range [][]domain.DialogFolderPeer{a, b} {
|
||||||
|
for _, item := range list {
|
||||||
|
if item.Peer.Type == "" || item.Peer.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[item.Peer]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Peer] = struct{}{}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func peersDifference(all, existing []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||||
|
existingMap := folderPeerMap(existing)
|
||||||
|
out := make([]domain.DialogFolderPeer, 0)
|
||||||
|
for _, item := range all {
|
||||||
|
if _, ok := existingMap[item.Peer]; !ok {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func peersIntersection(all, existing []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||||
|
existingMap := folderPeerMap(existing)
|
||||||
|
out := make([]domain.DialogFolderPeer, 0)
|
||||||
|
for _, item := range all {
|
||||||
|
if _, ok := existingMap[item.Peer]; ok {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder {
|
||||||
|
folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...)
|
||||||
|
folder.PinnedPeers = cloneFolderPeers(folder.PinnedPeers)
|
||||||
|
folder.IncludePeers = cloneFolderPeers(folder.IncludePeers)
|
||||||
|
folder.ExcludePeers = cloneFolderPeers(folder.ExcludePeers)
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFolderPeers(peers []domain.DialogFolderPeer) []domain.DialogFolderPeer {
|
||||||
|
return append([]domain.DialogFolderPeer(nil), peers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanSlug(raw string) string {
|
||||||
|
return links.CleanChatlistSlug(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomChatlistSlug() (string, error) {
|
||||||
|
var b [12]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("chatlist slug rand: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
488
internal/app/chatlists/service_test.go
Normal file
488
internal/app/chatlists/service_test.go
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
package chatlists
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSharedFolderInviteJoinUpdatesAndLeave(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
chatlists := memory.NewChatlistStore()
|
||||||
|
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) { return "slug-one", nil }))
|
||||||
|
|
||||||
|
ownerID := int64(1001)
|
||||||
|
viewerID := int64(2002)
|
||||||
|
peerA := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
peerB := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3002}, AccessHash: 32}
|
||||||
|
peerC := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3003}, AccessHash: 33}
|
||||||
|
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peerA},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed owner folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folder, invite, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peerA}, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportInvite: %v", err)
|
||||||
|
}
|
||||||
|
if invite.Slug != "slug-one" || !folder.IsChatlist || !folder.HasMyInvites {
|
||||||
|
t.Fatalf("export = folder %+v invite %+v, want chatlist with invite slug", folder, invite)
|
||||||
|
}
|
||||||
|
persisted, found, err := dialogs.GetFolder(ctx, ownerID, 2)
|
||||||
|
if err != nil || !found || !persisted.IsChatlist || !persisted.HasMyInvites {
|
||||||
|
t.Fatalf("persisted folder = %+v found %v err %v, want exported chatlist", persisted, found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := svc.CheckInvite(ctx, viewerID, "https://t.me/addlist/slug-one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckInvite before join: %v", err)
|
||||||
|
}
|
||||||
|
if len(preview.Missing) != 1 || preview.Missing[0].Peer != peerA.Peer || preview.LocalFolder != nil {
|
||||||
|
t.Fatalf("preview before join = %+v, want missing peerA and no local folder", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined, err := svc.JoinInvite(ctx, viewerID, "slug-one", nil, 101)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinInvite: %v", err)
|
||||||
|
}
|
||||||
|
if joined.Folder.ID != 2 || !joined.Folder.IsChatlist || joined.Folder.HasMyInvites || len(joined.Folder.IncludePeers) != 1 {
|
||||||
|
t.Fatalf("joined folder = %+v, want imported local chatlist with peerA", joined.Folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err = svc.CheckInvite(ctx, viewerID, "slug-one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckInvite after join: %v", err)
|
||||||
|
}
|
||||||
|
if preview.LocalFolder == nil || preview.Membership == nil || len(preview.Already) != 1 || len(preview.Missing) != 0 {
|
||||||
|
t.Fatalf("preview after join = %+v, want already imported", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IsChatlist: true,
|
||||||
|
HasMyInvites: true,
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peerA, peerB},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("extend owner folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-one", nil, &[]domain.DialogFolderPeer{peerA, peerB}, false); err != nil {
|
||||||
|
t.Fatalf("EditInvite add peerB: %v", err)
|
||||||
|
}
|
||||||
|
updates, err := svc.GetUpdates(ctx, viewerID, joined.Folder.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUpdates: %v", err)
|
||||||
|
}
|
||||||
|
if len(updates.Missing) != 1 || updates.Missing[0].Peer != peerB.Peer {
|
||||||
|
t.Fatalf("updates = %+v, want missing peerB", updates)
|
||||||
|
}
|
||||||
|
ownerUpdates, err := svc.GetUpdates(ctx, ownerID, folder.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUpdates owner exported folder: %v", err)
|
||||||
|
}
|
||||||
|
if len(ownerUpdates.Missing) != 0 {
|
||||||
|
t.Fatalf("owner updates = %+v, want empty", ownerUpdates)
|
||||||
|
}
|
||||||
|
updated, err := svc.JoinUpdates(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerB}, 102)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinUpdates: %v", err)
|
||||||
|
}
|
||||||
|
if len(updated.Folder.IncludePeers) != 2 {
|
||||||
|
t.Fatalf("updated folder peers = %+v, want 2 peers", updated.Folder.IncludePeers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IsChatlist: true,
|
||||||
|
HasMyInvites: true,
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peerA, peerB, peerC},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("extend owner folder with peerC: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-one", nil, &[]domain.DialogFolderPeer{peerA, peerB, peerC}, false); err != nil {
|
||||||
|
t.Fatalf("EditInvite add peerC: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.HideUpdates(ctx, viewerID, joined.Folder.ID); err != nil {
|
||||||
|
t.Fatalf("HideUpdates: %v", err)
|
||||||
|
}
|
||||||
|
updates, err = svc.GetUpdates(ctx, viewerID, joined.Folder.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUpdates after hide: %v", err)
|
||||||
|
}
|
||||||
|
if len(updates.Missing) != 0 {
|
||||||
|
t.Fatalf("updates after hide = %+v, want empty", updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions, err := svc.LeaveSuggestions(ctx, viewerID, joined.Folder.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LeaveSuggestions: %v", err)
|
||||||
|
}
|
||||||
|
if len(suggestions) != 2 {
|
||||||
|
t.Fatalf("leave suggestions = %+v, want current two imported peers", suggestions)
|
||||||
|
}
|
||||||
|
if _, err := svc.Leave(ctx, viewerID, joined.Folder.ID, suggestions, 103); err != nil {
|
||||||
|
t.Fatalf("Leave: %v", err)
|
||||||
|
}
|
||||||
|
if _, found, err := dialogs.GetFolder(ctx, viewerID, joined.Folder.ID); err != nil || found {
|
||||||
|
t.Fatalf("local folder after leave found=%v err=%v, want deleted", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteLastSharedFolderInviteClearsOwnerFlag(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
chatlists := memory.NewChatlistStore()
|
||||||
|
slugs := []string{"slug-a", "slug-b"}
|
||||||
|
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) {
|
||||||
|
slug := slugs[0]
|
||||||
|
slugs = slugs[1:]
|
||||||
|
return slug, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
ownerID := int64(1001)
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed owner folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "one", []domain.DialogFolderPeer{peer}, 100); err != nil {
|
||||||
|
t.Fatalf("ExportInvite one: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "two", []domain.DialogFolderPeer{peer}, 101); err != nil {
|
||||||
|
t.Fatalf("ExportInvite two: %v", err)
|
||||||
|
}
|
||||||
|
if folder, changed, err := svc.DeleteInvite(ctx, ownerID, 2, "slug-a"); err != nil || changed || folder.HasMyInvites {
|
||||||
|
t.Fatalf("DeleteInvite first = folder %+v changed %v err %v, want no folder update", folder, changed, err)
|
||||||
|
}
|
||||||
|
persisted, found, err := dialogs.GetFolder(ctx, ownerID, 2)
|
||||||
|
if err != nil || !found || !persisted.HasMyInvites {
|
||||||
|
t.Fatalf("folder after first delete = %+v found %v err %v, want has_my_invites", persisted, found, err)
|
||||||
|
}
|
||||||
|
folder, changed, err := svc.DeleteInvite(ctx, ownerID, 2, "slug-b")
|
||||||
|
if err != nil || !changed || folder.HasMyInvites {
|
||||||
|
t.Fatalf("DeleteInvite last = folder %+v changed %v err %v, want cleared flag", folder, changed, err)
|
||||||
|
}
|
||||||
|
persisted, found, err = dialogs.GetFolder(ctx, ownerID, 2)
|
||||||
|
if err != nil || !found || persisted.HasMyInvites {
|
||||||
|
t.Fatalf("folder after last delete = %+v found %v err %v, want has_my_invites=false", persisted, found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedFolderSlugValidation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := NewService(memory.NewChatlistStore(), memory.NewDialogStore())
|
||||||
|
if _, err := svc.CheckInvite(ctx, 2002, "bad!"); !errors.Is(err, domain.ErrChatlistInviteInvalid) {
|
||||||
|
t.Fatalf("CheckInvite bad slug err = %v, want ErrChatlistInviteInvalid", err)
|
||||||
|
}
|
||||||
|
longSlug := strings.Repeat("a", links.MaxChatlistSlugBytes+1)
|
||||||
|
if _, err := svc.JoinInvite(ctx, 2002, longSlug, nil, 100); !errors.Is(err, domain.ErrChatlistInviteInvalid) {
|
||||||
|
t.Fatalf("JoinInvite long slug err = %v, want ErrChatlistInviteInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokedSharedFolderInviteRemainsListedButCannotBeImported(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
chatlists := memory.NewChatlistStore()
|
||||||
|
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) { return "slug-revoke", nil }))
|
||||||
|
|
||||||
|
ownerID := int64(1001)
|
||||||
|
viewerID := int64(2002)
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed owner folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peer}, 100); err != nil {
|
||||||
|
t.Fatalf("ExportInvite: %v", err)
|
||||||
|
}
|
||||||
|
revoked, err := svc.EditInvite(ctx, ownerID, 2, "slug-revoke", nil, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EditInvite revoke: %v", err)
|
||||||
|
}
|
||||||
|
if !revoked.Revoked {
|
||||||
|
t.Fatalf("revoked invite = %+v, want Revoked=true", revoked)
|
||||||
|
}
|
||||||
|
invites, err := svc.ListInvites(ctx, ownerID, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListInvites: %v", err)
|
||||||
|
}
|
||||||
|
if len(invites) != 1 || !invites[0].Revoked {
|
||||||
|
t.Fatalf("listed invites = %+v, want revoked invite visible to owner", invites)
|
||||||
|
}
|
||||||
|
if _, err := svc.CheckInvite(ctx, viewerID, "slug-revoke"); !errors.Is(err, domain.ErrChatlistInviteExpired) {
|
||||||
|
t.Fatalf("CheckInvite revoked err = %v, want ErrChatlistInviteExpired", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.JoinInvite(ctx, viewerID, "slug-revoke", nil, 101); !errors.Is(err, domain.ErrChatlistInviteExpired) {
|
||||||
|
t.Fatalf("JoinInvite revoked err = %v, want ErrChatlistInviteExpired", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedFolderJoinUpdatesAndLeaveUseChannelMemberships(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
channels := &fakeChatlistChannels{}
|
||||||
|
svc := NewService(
|
||||||
|
memory.NewChatlistStore(),
|
||||||
|
dialogs,
|
||||||
|
WithChannels(channels),
|
||||||
|
WithSlugGenerator(func() (string, error) { return "slug-channel", nil }),
|
||||||
|
)
|
||||||
|
|
||||||
|
ownerID := int64(1001)
|
||||||
|
viewerID := int64(2002)
|
||||||
|
peerA := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
peerB := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3002}, AccessHash: 32}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peerA, peerB},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed owner folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peerA, peerB}, 100); err != nil {
|
||||||
|
t.Fatalf("ExportInvite: %v", err)
|
||||||
|
}
|
||||||
|
if got := channels.getIDs; len(got) != 2 || got[0] != peerA.Peer.ID || got[1] != peerB.Peer.ID {
|
||||||
|
t.Fatalf("shareability channel checks = %v, want peerA/peerB", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined, err := svc.JoinInvite(ctx, viewerID, "slug-channel", []domain.DialogFolderPeer{peerA}, 101)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinInvite peerA: %v", err)
|
||||||
|
}
|
||||||
|
if got := channels.inviteIDs; len(got) != 1 || got[0] != peerA.Peer.ID {
|
||||||
|
t.Fatalf("invite channel calls = %v, want peerA", got)
|
||||||
|
}
|
||||||
|
if len(joined.ChannelResults) != 1 || joined.ChannelResults[0].Channel.ID != peerA.Peer.ID {
|
||||||
|
t.Fatalf("join channel results = %+v, want peerA result", joined.ChannelResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := svc.JoinUpdates(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerB}, 102)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JoinUpdates peerB: %v", err)
|
||||||
|
}
|
||||||
|
if got := channels.inviteIDs; len(got) != 2 || got[1] != peerB.Peer.ID {
|
||||||
|
t.Fatalf("invite channel calls after updates = %v, want peerB appended", got)
|
||||||
|
}
|
||||||
|
if len(updated.ChannelResults) != 1 || updated.ChannelResults[0].Channel.ID != peerB.Peer.ID {
|
||||||
|
t.Fatalf("update channel results = %+v, want peerB result", updated.ChannelResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
leave, err := svc.Leave(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerA}, 103)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Leave peerA: %v", err)
|
||||||
|
}
|
||||||
|
if got := channels.leaveIDs; len(got) != 1 || got[0] != peerA.Peer.ID {
|
||||||
|
t.Fatalf("leave channel calls = %v, want peerA", got)
|
||||||
|
}
|
||||||
|
if len(leave.ChannelResults) != 1 || leave.ChannelResults[0].Channel.ID != peerA.Peer.ID {
|
||||||
|
t.Fatalf("leave channel results = %+v, want peerA result", leave.ChannelResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedFolderPublicPeerFallsBackToSelfJoin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
channels := &fakeChatlistChannels{publicOnly: true}
|
||||||
|
svc := NewService(
|
||||||
|
memory.NewChatlistStore(),
|
||||||
|
dialogs,
|
||||||
|
WithChannels(channels),
|
||||||
|
WithSlugGenerator(func() (string, error) { return "slug-public", nil }),
|
||||||
|
)
|
||||||
|
|
||||||
|
ownerID := int64(1001)
|
||||||
|
viewerID := int64(2002)
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Public",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed owner folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 100); err != nil {
|
||||||
|
t.Fatalf("ExportInvite: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.JoinInvite(ctx, viewerID, "slug-public", []domain.DialogFolderPeer{peer}, 101); err != nil {
|
||||||
|
t.Fatalf("JoinInvite public peer: %v", err)
|
||||||
|
}
|
||||||
|
if len(channels.inviteIDs) != 0 {
|
||||||
|
t.Fatalf("invite channel calls = %v, want none for public-only owner", channels.inviteIDs)
|
||||||
|
}
|
||||||
|
if got := channels.joinIDs; len(got) != 1 || got[0] != peer.Peer.ID {
|
||||||
|
t.Fatalf("join channel calls = %v, want self-join peer", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportInviteRejectsRuleBasedFolder(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
svc := NewService(memory.NewChatlistStore(), dialogs, WithSlugGenerator(func() (string, error) { return "slug-two", nil }))
|
||||||
|
ownerID := int64(1001)
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Rule based",
|
||||||
|
Groups: true,
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 0); !errors.Is(err, domain.ErrChatlistNotShareable) {
|
||||||
|
t.Fatalf("ExportInvite rule folder err = %v, want ErrChatlistNotShareable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportInviteRejectsUserPeers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
svc := NewService(memory.NewChatlistStore(), dialogs, WithSlugGenerator(func() (string, error) { return "slug-user-peer", nil }))
|
||||||
|
ownerID := int64(1001)
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, AccessHash: 22}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Users",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed folder: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 0); !errors.Is(err, domain.ErrChatlistNotShareable) {
|
||||||
|
t.Fatalf("ExportInvite user peer err = %v, want ErrChatlistNotShareable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatlistInviteLimitUsesPremiumTier(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dialogs := memory.NewDialogStore()
|
||||||
|
chatlists := memory.NewChatlistStore()
|
||||||
|
ownerID := int64(1001)
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
|
||||||
|
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed folder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlug := 0
|
||||||
|
slugger := func() (string, error) {
|
||||||
|
nextSlug++
|
||||||
|
return "slug-limit-" + string(rune('a'+nextSlug)), nil
|
||||||
|
}
|
||||||
|
svc := NewService(chatlists, dialogs, WithSlugGenerator(slugger))
|
||||||
|
for i := 0; i < domain.MaxChatlistInvitesDefault; i++ {
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, i+1); err != nil {
|
||||||
|
t.Fatalf("ExportInvite default #%d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 10); !errors.Is(err, domain.ErrChatlistInvitesTooMuch) {
|
||||||
|
t.Fatalf("ExportInvite over default limit err = %v, want ErrChatlistInvitesTooMuch", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-limit-b", nil, nil, true); err != nil {
|
||||||
|
t.Fatalf("EditInvite revoke first default link: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 11); err != nil {
|
||||||
|
t.Fatalf("ExportInvite after revoked link freed active limit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
premiumSvc := NewService(
|
||||||
|
chatlists,
|
||||||
|
dialogs,
|
||||||
|
WithSlugGenerator(slugger),
|
||||||
|
WithPremiumChecker(func(context.Context, int64) bool { return true }),
|
||||||
|
)
|
||||||
|
if _, _, err := premiumSvc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 11); err != nil {
|
||||||
|
t.Fatalf("ExportInvite premium extra link: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeChatlistChannels struct {
|
||||||
|
getIDs []int64
|
||||||
|
inviteIDs []int64
|
||||||
|
joinIDs []int64
|
||||||
|
leaveIDs []int64
|
||||||
|
publicOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistChannels) GetChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||||
|
f.getIDs = append(f.getIDs, channelID)
|
||||||
|
role := domain.ChannelRoleCreator
|
||||||
|
if f.publicOnly {
|
||||||
|
role = domain.ChannelRoleMember
|
||||||
|
}
|
||||||
|
return domain.ChannelView{
|
||||||
|
Channel: domain.Channel{
|
||||||
|
ID: channelID,
|
||||||
|
AccessHash: channelID * 10,
|
||||||
|
Title: "Team",
|
||||||
|
Username: "team",
|
||||||
|
Megagroup: true,
|
||||||
|
},
|
||||||
|
Self: domain.ChannelMember{
|
||||||
|
ChannelID: channelID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: role,
|
||||||
|
Status: domain.ChannelMemberActive,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistChannels) InviteToChannel(_ context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
|
||||||
|
f.inviteIDs = append(f.inviteIDs, channelID)
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return domain.CreateChannelResult{}, domain.ErrUsersTooMuch
|
||||||
|
}
|
||||||
|
return fakeChatlistChannelResult(userIDs[0], channelID, domain.ChannelMemberActive, date), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistChannels) JoinChannel(_ context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
|
||||||
|
f.joinIDs = append(f.joinIDs, channelID)
|
||||||
|
return fakeChatlistChannelResult(userID, channelID, domain.ChannelMemberActive, date), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistChannels) LeaveChannel(_ context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
|
||||||
|
f.leaveIDs = append(f.leaveIDs, channelID)
|
||||||
|
return fakeChatlistChannelResult(userID, channelID, domain.ChannelMemberLeft, date), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeChatlistChannelResult(userID, channelID int64, status domain.ChannelMemberStatus, date int) domain.CreateChannelResult {
|
||||||
|
return domain.CreateChannelResult{
|
||||||
|
Channel: domain.Channel{
|
||||||
|
ID: channelID,
|
||||||
|
AccessHash: channelID * 10,
|
||||||
|
Title: "Team",
|
||||||
|
Username: "team",
|
||||||
|
Megagroup: true,
|
||||||
|
Date: date,
|
||||||
|
},
|
||||||
|
Members: []domain.ChannelMember{{
|
||||||
|
ChannelID: channelID,
|
||||||
|
UserID: userID,
|
||||||
|
Role: domain.ChannelRoleMember,
|
||||||
|
Status: status,
|
||||||
|
JoinedAt: date,
|
||||||
|
}},
|
||||||
|
Recipients: []int64{userID},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,21 +10,36 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service 是群通话业务服务。
|
// Service 是群通话业务服务。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store store.GroupCallStore
|
store store.GroupCallStore
|
||||||
|
publicBaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
func WithPublicBaseURL(baseURL string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService 创建群通话服务。
|
// NewService 创建群通话服务。
|
||||||
func NewService(st store.GroupCallStore) *Service {
|
func NewService(st store.GroupCallStore, opts ...Option) *Service {
|
||||||
return &Service{store: st}
|
s := &Service{store: st, publicBaseURL: links.DefaultPublicBaseURL}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 分配 id/access_hash 并建会。rtmpStream=true 创建 RTMP 直播房间;
|
// Create 分配 id/access_hash 并建会。rtmpStream=true 创建 RTMP 直播房间;
|
||||||
|
|
@ -135,7 +150,7 @@ func (s *Service) CreateConference(ctx context.Context, creatorUserID, randomID,
|
||||||
Version: 1,
|
Version: 1,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
InviteSlug: slug,
|
InviteSlug: slug,
|
||||||
InviteLink: conferenceInviteLink(slug),
|
InviteLink: s.conferenceInviteLink(slug),
|
||||||
RandomID: randomID,
|
RandomID: randomID,
|
||||||
MigratedFromPhoneCallID: migratedFromPhoneCallID,
|
MigratedFromPhoneCallID: migratedFromPhoneCallID,
|
||||||
})
|
})
|
||||||
|
|
@ -265,6 +280,10 @@ func randomSlug() (string, error) {
|
||||||
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
|
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func conferenceInviteLink(slug string) string {
|
func (s *Service) conferenceInviteLink(slug string) string {
|
||||||
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
baseURL := links.DefaultPublicBaseURL
|
||||||
|
if s != nil && s.publicBaseURL != "" {
|
||||||
|
baseURL = s.publicBaseURL
|
||||||
|
}
|
||||||
|
return links.Build(baseURL, "call/"+slug, url.Values{"slug": []string{slug}})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,15 @@ const tdesktopClient = "tdesktop"
|
||||||
// dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/
|
// dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/
|
||||||
// stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce,
|
// stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce,
|
||||||
// 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20,
|
// 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20,
|
||||||
// 见 compatibility-matrix todo);chatlists 和 story 配额/商业化 key 不下发
|
// 见 compatibility-matrix todo);chatlist_update_period 与 chatlist 双档限额
|
||||||
|
// 对齐 shared folders 最小真实实现。story 配额/商业化 key 不下发
|
||||||
// (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端
|
// (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端
|
||||||
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
||||||
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
||||||
// 直接读取这些 key 做本地输入限制和示例数量。
|
// 直接读取这些 key 做本地输入限制和示例数量。
|
||||||
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"rich_message_posting":"enabled","upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||||
|
|
||||||
const defaultAppConfigHash = 20 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
const defaultAppConfigHash = 21 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||||
|
|
||||||
// Service 提供客户端启动配置与国家区号目录。
|
// Service 提供客户端启动配置与国家区号目录。
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
||||||
"channels_limit_premium": 1000,
|
"channels_limit_premium": 1000,
|
||||||
"dialog_filters_limit_default": 10,
|
"dialog_filters_limit_default": 10,
|
||||||
"dialog_filters_limit_premium": 20,
|
"dialog_filters_limit_premium": 20,
|
||||||
|
"chatlist_update_period": 3600,
|
||||||
|
"chatlist_invites_limit_default": 3,
|
||||||
|
"chatlist_invites_limit_premium": 20,
|
||||||
|
"chatlists_joined_limit_default": 2,
|
||||||
|
"chatlists_joined_limit_premium": 20,
|
||||||
"upload_max_fileparts_default": 4000,
|
"upload_max_fileparts_default": 4000,
|
||||||
"upload_max_fileparts_premium": 8000,
|
"upload_max_fileparts_premium": 8000,
|
||||||
"aicompose_tone_examples_num": 3,
|
"aicompose_tone_examples_num": 3,
|
||||||
|
|
@ -68,7 +73,7 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 未实现功能族的 key 不得下发(诱导客户端进入未实现路径)。
|
// 未实现功能族的 key 不得下发(诱导客户端进入未实现路径)。
|
||||||
for _, forbidden := range []string{"chatlists_joined_limit_default", "stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} {
|
for _, forbidden := range []string{"stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} {
|
||||||
if _, ok := decoded[forbidden]; ok {
|
if _, ok := decoded[forbidden]; ok {
|
||||||
t.Errorf("appConfig 不应包含 %q", forbidden)
|
t.Errorf("appConfig 不应包含 %q", forbidden)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
"telesrv/internal/links"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildConfig 构造 help.getConfig 返回的 tg.Config,含自建 DC 的 DCOptions。
|
// BuildConfig 构造 help.getConfig 返回的 tg.Config,含自建 DC 的 DCOptions。
|
||||||
//
|
//
|
||||||
// 字段值取 Telegram 常见默认;TDesktop 联调阶段按客户端实际需要微调
|
// 字段值取 Telegram 常见默认;TDesktop 联调阶段按客户端实际需要微调
|
||||||
// (记录于 docs/compatibility-matrix.md)。
|
// (记录于 docs/compatibility-matrix.md)。
|
||||||
func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
|
func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL string) *tg.Config {
|
||||||
|
meURLPrefix := links.NormalizeBaseURL(publicBaseURL) + "/"
|
||||||
return &tg.Config{
|
return &tg.Config{
|
||||||
Date: int(now.Unix()),
|
Date: int(now.Unix()),
|
||||||
Expires: int(now.Add(time.Hour).Unix()),
|
Expires: int(now.Add(time.Hour).Unix()),
|
||||||
|
|
@ -47,7 +50,7 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
|
||||||
CallRingTimeoutMs: 90000,
|
CallRingTimeoutMs: 90000,
|
||||||
CallConnectTimeoutMs: 30000,
|
CallConnectTimeoutMs: 30000,
|
||||||
CallPacketTimeoutMs: 10000,
|
CallPacketTimeoutMs: 10000,
|
||||||
MeURLPrefix: "https://telesrv.net/",
|
MeURLPrefix: meURLPrefix,
|
||||||
CaptionLengthMax: 1024,
|
CaptionLengthMax: 1024,
|
||||||
MessageLengthMax: 4096,
|
MessageLengthMax: 4096,
|
||||||
WebfileDCID: dc,
|
WebfileDCID: dc,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
appConfigHash = 16 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||||
countriesListHash = 1
|
countriesListHash = 1
|
||||||
timezonesListHash = 1
|
timezonesListHash = 1
|
||||||
)
|
)
|
||||||
|
|
@ -44,6 +44,11 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
|
||||||
{Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}},
|
{Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}},
|
||||||
// dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
|
// dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
|
||||||
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
|
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
|
||||||
|
{Key: "chatlist_update_period", Value: &tg.JSONNumber{Value: 3600}},
|
||||||
|
{Key: "chatlist_invites_limit_default", Value: &tg.JSONNumber{Value: 3}},
|
||||||
|
{Key: "chatlist_invites_limit_premium", Value: &tg.JSONNumber{Value: 20}},
|
||||||
|
{Key: "chatlists_joined_limit_default", Value: &tg.JSONNumber{Value: 2}},
|
||||||
|
{Key: "chatlists_joined_limit_premium", Value: &tg.JSONNumber{Value: 20}},
|
||||||
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},
|
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},
|
||||||
{Key: "stories_stealth_past_period", Value: &tg.JSONNumber{Value: 300}},
|
{Key: "stories_stealth_past_period", Value: &tg.JSONNumber{Value: 300}},
|
||||||
{Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}},
|
{Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/links"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigFile = ".env"
|
const defaultConfigFile = ".env"
|
||||||
|
|
@ -40,11 +42,12 @@ type Config struct {
|
||||||
AdminAPIAddr string
|
AdminAPIAddr string
|
||||||
// AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。
|
// AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。
|
||||||
AdminAPIToken string
|
AdminAPIToken string
|
||||||
// StickerWebAddr 是公开 sticker/custom emoji deep link 落地页监听地址;为空关闭。
|
// PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。
|
||||||
// 生产应只监听 loopback,并由 nginx 将 /addstickers/ 与 /addemoji/ 反代到该地址。
|
// 生产默认 https://telesrv.net;本地可设为 http://127.0.0.1:2401。
|
||||||
StickerWebAddr string
|
PublicBaseURL string
|
||||||
// StickerWebPublicURL 是生成 canonical telesrv.net 链接的公开根 URL。
|
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
|
||||||
StickerWebPublicURL string
|
// 生产应只监听 loopback,并由 nginx 将 /addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
|
||||||
|
PublicLinkWebAddr string
|
||||||
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
||||||
AdminUIAddr string
|
AdminUIAddr string
|
||||||
AdminUIPassword string
|
AdminUIPassword string
|
||||||
|
|
@ -281,6 +284,8 @@ func Load() (Config, error) {
|
||||||
envInt64Or := fileEnv.envInt64Or
|
envInt64Or := fileEnv.envInt64Or
|
||||||
envDurationOr := fileEnv.envDurationOr
|
envDurationOr := fileEnv.envDurationOr
|
||||||
|
|
||||||
|
publicBaseURL := links.NormalizeBaseURL(envOr("TELESRV_PUBLIC_BASE_URL", links.DefaultPublicBaseURL))
|
||||||
|
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
|
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
|
||||||
WebSocketEnable: envBoolOr("TELESRV_WEBSOCKET_ENABLE", true),
|
WebSocketEnable: envBoolOr("TELESRV_WEBSOCKET_ENABLE", true),
|
||||||
|
|
@ -291,19 +296,19 @@ func Load() (Config, error) {
|
||||||
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions,
|
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions,
|
||||||
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。
|
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。
|
||||||
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
|
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
|
||||||
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
|
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
|
||||||
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
|
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
|
||||||
DC: envIntOr("TELESRV_DC", 2),
|
DC: envIntOr("TELESRV_DC", 2),
|
||||||
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
|
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
|
||||||
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
|
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
|
||||||
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
|
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
|
||||||
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
|
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
|
||||||
StickerWebAddr: envOr("TELESRV_STICKER_WEB_ADDR", ""),
|
PublicBaseURL: publicBaseURL,
|
||||||
StickerWebPublicURL: envOr("TELESRV_STICKER_WEB_PUBLIC_URL", "https://telesrv.net"),
|
PublicLinkWebAddr: envOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
|
||||||
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
|
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
|
||||||
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
|
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
|
||||||
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
|
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
|
||||||
AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""),
|
AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""),
|
||||||
|
|
||||||
// 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker
|
// 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker
|
||||||
// Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost
|
// Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
t.Setenv("TELESRV_ADVERTISE_IP", "")
|
t.Setenv("TELESRV_ADVERTISE_IP", "")
|
||||||
|
t.Setenv("TELESRV_PUBLIC_BASE_URL", "")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -17,9 +19,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
||||||
if cfg.AdvertiseIP != "127.0.0.1" {
|
if cfg.AdvertiseIP != "127.0.0.1" {
|
||||||
t.Fatalf("AdvertiseIP = %q, want loopback default", cfg.AdvertiseIP)
|
t.Fatalf("AdvertiseIP = %q, want loopback default", cfg.AdvertiseIP)
|
||||||
}
|
}
|
||||||
|
if cfg.PublicBaseURL != "https://telesrv.net" {
|
||||||
|
t.Fatalf("PublicBaseURL = %q, want https://telesrv.net", cfg.PublicBaseURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
|
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|
@ -32,6 +38,7 @@ func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadBusinessAIProvider(t *testing.T) {
|
func TestLoadBusinessAIProvider(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "echo")
|
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "echo")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|
@ -44,6 +51,7 @@ func TestLoadBusinessAIProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
|
func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "")
|
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|
@ -56,6 +64,7 @@ func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
|
func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
t.Setenv("TELESRV_ADMIN_UI_ADDR", "")
|
t.Setenv("TELESRV_ADMIN_UI_ADDR", "")
|
||||||
t.Setenv("TELESRV_LIVESTREAM_RTMP_ADDR", "")
|
t.Setenv("TELESRV_LIVESTREAM_RTMP_ADDR", "")
|
||||||
|
|
||||||
|
|
@ -75,6 +84,7 @@ func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAIProviders(t *testing.T) {
|
func TestLoadAIProviders(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
t.Setenv("TELESRV_AI_PROVIDERS", "local,openai,gemini")
|
t.Setenv("TELESRV_AI_PROVIDERS", "local,openai,gemini")
|
||||||
t.Setenv("TELESRV_AI_OPENAI_API_KEY", "openai-key")
|
t.Setenv("TELESRV_AI_OPENAI_API_KEY", "openai-key")
|
||||||
t.Setenv("TELESRV_AI_OPENAI_MODEL", "gpt-test")
|
t.Setenv("TELESRV_AI_OPENAI_MODEL", "gpt-test")
|
||||||
|
|
@ -115,8 +125,8 @@ TELESRV_MAPBOX_TOKEN="file-token"
|
||||||
TELESRV_POSTGRES_MAX_CONNS=77
|
TELESRV_POSTGRES_MAX_CONNS=77
|
||||||
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
|
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
|
||||||
TELESRV_CALL_RING_TIMEOUT=2m
|
TELESRV_CALL_RING_TIMEOUT=2m
|
||||||
TELESRV_STICKER_WEB_ADDR=127.0.0.1:2401
|
TELESRV_PUBLIC_BASE_URL=links.example.test/root
|
||||||
TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test
|
TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401
|
||||||
`)
|
`)
|
||||||
t.Setenv("TELESRV_CONFIG", path)
|
t.Setenv("TELESRV_CONFIG", path)
|
||||||
|
|
||||||
|
|
@ -136,11 +146,24 @@ TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test
|
||||||
if cfg.CallRingTimeout != 2*time.Minute {
|
if cfg.CallRingTimeout != 2*time.Minute {
|
||||||
t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout)
|
t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout)
|
||||||
}
|
}
|
||||||
if cfg.StickerWebAddr != "127.0.0.1:2401" {
|
if cfg.PublicLinkWebAddr != "127.0.0.1:2401" {
|
||||||
t.Fatalf("StickerWebAddr = %q, want 127.0.0.1:2401", cfg.StickerWebAddr)
|
t.Fatalf("PublicLinkWebAddr = %q, want 127.0.0.1:2401", cfg.PublicLinkWebAddr)
|
||||||
}
|
}
|
||||||
if cfg.StickerWebPublicURL != "https://packs.example.test" {
|
if cfg.PublicBaseURL != "https://links.example.test/root" {
|
||||||
t.Fatalf("StickerWebPublicURL = %q, want https://packs.example.test", cfg.StickerWebPublicURL)
|
t.Fatalf("PublicBaseURL = %q, want https://links.example.test/root", cfg.PublicBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
|
||||||
|
disableDefaultConfigFile(t)
|
||||||
|
t.Setenv("TELESRV_PUBLIC_BASE_URL", "http://127.0.0.1:2401/")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.PublicBaseURL != "http://127.0.0.1:2401" {
|
||||||
|
t.Fatalf("PublicBaseURL = %q, want http://127.0.0.1:2401", cfg.PublicBaseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,3 +206,8 @@ func writeConfigFile(t *testing.T, path, body string) {
|
||||||
t.Fatalf("write config file: %v", err)
|
t.Fatalf("write config file: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disableDefaultConfigFile(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("TELESRV_CONFIG", "")
|
||||||
|
}
|
||||||
|
|
|
||||||
82
internal/domain/chatlist.go
Normal file
82
internal/domain/chatlist.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxChatlistInvitePeers bounds one exported shared folder link snapshot.
|
||||||
|
MaxChatlistInvitePeers = MaxDialogFolderPeers
|
||||||
|
// MaxChatlistInvitesDefault follows the non-premium appConfig limit.
|
||||||
|
MaxChatlistInvitesDefault = 3
|
||||||
|
// MaxChatlistInvitesPremium follows the premium appConfig limit.
|
||||||
|
MaxChatlistInvitesPremium = 20
|
||||||
|
// MaxChatlistsJoinedDefault follows the non-premium appConfig limit.
|
||||||
|
MaxChatlistsJoinedDefault = 2
|
||||||
|
// MaxChatlistsJoinedPremium follows the premium appConfig limit.
|
||||||
|
MaxChatlistsJoinedPremium = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrChatlistInvalid = errors.New("chatlist invalid")
|
||||||
|
ErrChatlistInviteInvalid = errors.New("chatlist invite invalid")
|
||||||
|
ErrChatlistInviteExpired = errors.New("chatlist invite expired")
|
||||||
|
ErrChatlistInviteConflict = errors.New("chatlist invite conflict")
|
||||||
|
ErrChatlistInvitesTooMuch = errors.New("chatlist invites too much")
|
||||||
|
ErrChatlistsTooMuch = errors.New("chatlists too much")
|
||||||
|
ErrChatlistPeersEmpty = errors.New("chatlist peers empty")
|
||||||
|
ErrChatlistPeersTooMuch = errors.New("chatlist peers too much")
|
||||||
|
ErrChatlistNotShareable = errors.New("chatlist not shareable")
|
||||||
|
ErrChatlistSlugOccupied = errors.New("chatlist slug occupied")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatlistInvite is one exported shared-folder link. Peers is a bounded
|
||||||
|
// snapshot selected by the owner for this link.
|
||||||
|
type ChatlistInvite struct {
|
||||||
|
ID int64
|
||||||
|
OwnerUserID int64
|
||||||
|
FilterID int
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Peers []DialogFolderPeer
|
||||||
|
Date int
|
||||||
|
Revoked bool
|
||||||
|
Deleted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatlistMembership binds an imported shared-folder link to the viewer's
|
||||||
|
// local dialog filter.
|
||||||
|
type ChatlistMembership struct {
|
||||||
|
UserID int64
|
||||||
|
LocalFilterID int
|
||||||
|
OwnerUserID int64
|
||||||
|
OwnerFilterID int
|
||||||
|
Slug string
|
||||||
|
HiddenUpdates bool
|
||||||
|
Date int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatlistInvitePreview struct {
|
||||||
|
Invite ChatlistInvite
|
||||||
|
OwnerFolder DialogFolder
|
||||||
|
LocalFolder *DialogFolder
|
||||||
|
Membership *ChatlistMembership
|
||||||
|
Missing []DialogFolderPeer
|
||||||
|
Already []DialogFolderPeer
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatlistJoinResult struct {
|
||||||
|
Folder DialogFolder
|
||||||
|
Membership ChatlistMembership
|
||||||
|
Date int
|
||||||
|
ChannelResults []CreateChannelResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatlistUpdates struct {
|
||||||
|
Membership ChatlistMembership
|
||||||
|
Missing []DialogFolderPeer
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatlistLeaveResult struct {
|
||||||
|
FilterID int
|
||||||
|
ChannelResults []CreateChannelResult
|
||||||
|
RequestedLeaves []DialogFolderPeer
|
||||||
|
}
|
||||||
|
|
@ -205,6 +205,7 @@ type DialogFolder struct {
|
||||||
HasEmoticon bool
|
HasEmoticon bool
|
||||||
Color int
|
Color int
|
||||||
HasColor bool
|
HasColor bool
|
||||||
|
HasMyInvites bool
|
||||||
PinnedPeers []DialogFolderPeer
|
PinnedPeers []DialogFolderPeer
|
||||||
IncludePeers []DialogFolderPeer
|
IncludePeers []DialogFolderPeer
|
||||||
ExcludePeers []DialogFolderPeer
|
ExcludePeers []DialogFolderPeer
|
||||||
|
|
|
||||||
94
internal/links/links.go
Normal file
94
internal/links/links.go
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package links
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPublicBaseURL = "https://telesrv.net"
|
||||||
|
const MaxChatlistSlugBytes = 128
|
||||||
|
|
||||||
|
func NormalizeBaseURL(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
raw = DefaultPublicBaseURL
|
||||||
|
}
|
||||||
|
if !strings.Contains(raw, "://") {
|
||||||
|
raw = "https://" + raw
|
||||||
|
}
|
||||||
|
return strings.TrimRight(raw, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Build(baseURL, path string, query url.Values) string {
|
||||||
|
baseURL = NormalizeBaseURL(baseURL)
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
baseURL = DefaultPublicBaseURL
|
||||||
|
parsed, _ = url.Parse(baseURL)
|
||||||
|
}
|
||||||
|
basePath := strings.TrimRight(parsed.Path, "/")
|
||||||
|
path = strings.TrimLeft(path, "/")
|
||||||
|
if path != "" {
|
||||||
|
parsed.Path = basePath + "/" + path
|
||||||
|
} else if basePath != "" {
|
||||||
|
parsed.Path = basePath
|
||||||
|
}
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Host(baseURL string) string {
|
||||||
|
parsed, err := url.Parse(NormalizeBaseURL(baseURL))
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return "telesrv.net"
|
||||||
|
}
|
||||||
|
if host := parsed.Hostname(); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return parsed.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanChatlistSlug(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, "://") {
|
||||||
|
if parsed, err := url.Parse(raw); err == nil {
|
||||||
|
if slug := parsed.Query().Get("slug"); slug != "" {
|
||||||
|
raw = slug
|
||||||
|
} else {
|
||||||
|
raw = strings.Trim(parsed.Path, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw = strings.TrimPrefix(raw, "addlist/")
|
||||||
|
raw = strings.Trim(raw, "/")
|
||||||
|
if idx := strings.LastIndex(raw, "/"); idx >= 0 {
|
||||||
|
raw = raw[idx+1:]
|
||||||
|
}
|
||||||
|
if idx := strings.IndexAny(raw, "?#"); idx >= 0 {
|
||||||
|
raw = raw[:idx]
|
||||||
|
}
|
||||||
|
if decoded, err := url.PathUnescape(raw); err == nil {
|
||||||
|
raw = decoded
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidChatlistSlug(slug string) bool {
|
||||||
|
if slug == "" || len(slug) > MaxChatlistSlugBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range slug {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
case r == '_' || r == '-' || r == '.':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
70
internal/links/links_test.go
Normal file
70
internal/links/links_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package links
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeBaseURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "default", raw: "", want: "https://telesrv.net"},
|
||||||
|
{name: "host only", raw: "telesrv.net/", want: "https://telesrv.net"},
|
||||||
|
{name: "local http", raw: "http://127.0.0.1:2401/", want: "http://127.0.0.1:2401"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := NormalizeBaseURL(tt.raw); got != tt.want {
|
||||||
|
t.Fatalf("NormalizeBaseURL(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPreservesBasePathAndQuery(t *testing.T) {
|
||||||
|
got := Build("http://127.0.0.1:2401/root/", "/call/abc", url.Values{"slug": []string{"abc"}})
|
||||||
|
if want := "http://127.0.0.1:2401/root/call/abc?slug=abc"; got != want {
|
||||||
|
t.Fatalf("Build = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDoesNotDoubleEscapeBasePath(t *testing.T) {
|
||||||
|
got := Build("http://127.0.0.1:2401/root%20path/", "/addlist/slug", nil)
|
||||||
|
if want := "http://127.0.0.1:2401/root%20path/addlist/slug"; got != want {
|
||||||
|
t.Fatalf("Build encoded path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDropsPort(t *testing.T) {
|
||||||
|
if got, want := Host("http://127.0.0.1:2401"), "127.0.0.1"; got != want {
|
||||||
|
t.Fatalf("Host = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanAndValidateChatlistSlug(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
clean string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{name: "raw", raw: "abc.DEF-12", clean: "abc.DEF-12", valid: true},
|
||||||
|
{name: "public url", raw: "http://127.0.0.1:2401/addlist/abc-12?x=1", clean: "abc-12", valid: true},
|
||||||
|
{name: "app url", raw: "telesrv://addlist?slug=abc_12", clean: "abc_12", valid: true},
|
||||||
|
{name: "bad char", raw: "abc/../bad!", clean: "bad!", valid: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := CleanChatlistSlug(tt.raw)
|
||||||
|
if got != tt.clean {
|
||||||
|
t.Fatalf("CleanChatlistSlug(%q) = %q, want %q", tt.raw, got, tt.clean)
|
||||||
|
}
|
||||||
|
if valid := ValidChatlistSlug(got); valid != tt.valid {
|
||||||
|
t.Fatalf("ValidChatlistSlug(%q) = %v, want %v", got, valid, tt.valid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -455,7 +455,7 @@ func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUser
|
||||||
if botUser, found, err := r.connectedBusinessBotByID(ctx, ownerUserID, bot.BotUserID); err != nil {
|
if botUser, found, err := r.connectedBusinessBotByID(ctx, ownerUserID, bot.BotUserID); err != nil {
|
||||||
return domain.PeerSettings{}, err
|
return domain.PeerSettings{}, err
|
||||||
} else if found {
|
} else if found {
|
||||||
settings.BusinessBotManageURL = connectedBusinessBotManageURL(botUser)
|
settings.BusinessBotManageURL = r.connectedBusinessBotManageURL(botUser)
|
||||||
}
|
}
|
||||||
if settings.BusinessBotManageURL == "" {
|
if settings.BusinessBotManageURL == "" {
|
||||||
settings.BusinessBotManageURL = "telesrv://business-bot"
|
settings.BusinessBotManageURL = "telesrv://business-bot"
|
||||||
|
|
@ -477,11 +477,11 @@ func (r *Router) connectedBusinessPeerFacts(ctx context.Context, ownerUserID, pe
|
||||||
return existingChat, isContact
|
return existingChat, isContact
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectedBusinessBotManageURL(bot domain.User) string {
|
func (r *Router) connectedBusinessBotManageURL(bot domain.User) string {
|
||||||
if bot.Username == "" {
|
if bot.Username == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "https://telesrv.net/" + bot.Username
|
return r.publicLink(bot.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) recordConnectedBusinessPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error {
|
func (r *Router) recordConnectedBusinessPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error {
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInsta
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onAccountGetTheme 按 id 或 slug(深链 telesrv.net/addtheme/<slug>)取主题。
|
// onAccountGetTheme 按 id 或 slug(公开 addtheme 深链)取主题。
|
||||||
func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) {
|
func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, themeInvalidErr()
|
return nil, themeInvalidErr()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
const aiComposeToneWebPageType = "telegram_aicomposetone"
|
const aiComposeToneWebPageType = "telegram_aicomposetone"
|
||||||
|
|
||||||
func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) {
|
func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) {
|
||||||
link, ok := parseAIComposeStyleLink(rawURL)
|
link, ok := parseAIComposeStyleLink(rawURL, r.publicLinkHost())
|
||||||
if !ok || r.deps.AICompose == nil {
|
if !ok || r.deps.AICompose == nil {
|
||||||
return domain.MessageWebPage{}, false
|
return domain.MessageWebPage{}, false
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ type aiComposeStyleLink struct {
|
||||||
slug string
|
slug string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) {
|
func parseAIComposeStyleLink(raw, publicHost string) (aiComposeStyleLink, bool) {
|
||||||
normalized, ok := domain.NormalizeWebPageURL(raw)
|
normalized, ok := domain.NormalizeWebPageURL(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return aiComposeStyleLink{}, false
|
return aiComposeStyleLink{}, false
|
||||||
|
|
@ -72,7 +72,7 @@ func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) {
|
||||||
return aiComposeStyleLink{}, false
|
return aiComposeStyleLink{}, false
|
||||||
}
|
}
|
||||||
host := strings.ToLower(u.Hostname())
|
host := strings.ToLower(u.Hostname())
|
||||||
if !aiComposeStyleHostAllowed(host) {
|
if !aiComposeStyleHostAllowed(host, publicHost) {
|
||||||
return aiComposeStyleLink{}, false
|
return aiComposeStyleLink{}, false
|
||||||
}
|
}
|
||||||
parts := strings.Split(strings.Trim(strings.ToLower(u.EscapedPath()), "/"), "/")
|
parts := strings.Split(strings.Trim(strings.ToLower(u.EscapedPath()), "/"), "/")
|
||||||
|
|
@ -96,7 +96,10 @@ func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) {
|
||||||
return aiComposeStyleLink{normalized: normalized, display: display, slug: slug}, true
|
return aiComposeStyleLink{normalized: normalized, display: display, slug: slug}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func aiComposeStyleHostAllowed(host string) bool {
|
func aiComposeStyleHostAllowed(host, publicHost string) bool {
|
||||||
|
if publicHost != "" && strings.EqualFold(host, publicHost) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
switch host {
|
switch host {
|
||||||
case "t.me", "telegram.me", "telesrv.net", "localhost", "127.0.0.1":
|
case "t.me", "telegram.me", "telesrv.net", "localhost", "127.0.0.1":
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
|
|
||||||
func startParamInvalidErr() error { return tgerr.New(400, "START_PARAM_INVALID") }
|
func startParamInvalidErr() error { return tgerr.New(400, "START_PARAM_INVALID") }
|
||||||
|
|
||||||
// onMessagesStartBot 处理 messages.startBot(深链 telesrv.net/bot?start=payload 与「启动 bot」
|
// onMessagesStartBot 处理 messages.startBot(公开 bot?start 深链与「启动 bot」
|
||||||
// 入口)。语义:向 bot 发送一条可见的 "/start" 或 "/start <param>" 普通私聊消息,走标准
|
// 入口)。语义:向 bot 发送一条可见的 "/start" 或 "/start <param>" 普通私聊消息,走标准
|
||||||
// SendPrivateText 双盒+outbox,返回真实 Updates(I7)。bot 经此收到 start_param。
|
// SendPrivateText 双盒+outbox,返回真实 Updates(I7)。bot 经此收到 start_param。
|
||||||
// P3 仅私聊启动;peer 为群(加 bot 进群)后移(P4,群内 bot)。
|
// P3 仅私聊启动;peer 为群(加 bot 进群)后移(P4,群内 bot)。
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
full := tgChannelFull(view)
|
full := tgChannelFull(view, r.cfg.PublicBaseURL)
|
||||||
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
|
r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full)
|
||||||
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
|
userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID}
|
||||||
// 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
|
// 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"github.com/gotd/td/tgerr"
|
"github.com/gotd/td/tgerr"
|
||||||
"strings"
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,7 +43,7 @@ func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.Message
|
||||||
return nil, channelInviteErr(err)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
r.invalidateRPCProjectionForChannel(res.Channel.ID)
|
r.invalidateRPCProjectionForChannel(res.Channel.ID)
|
||||||
return tgExportedChannelInvite(res.Invite), nil
|
return r.tgExportedChannelInvite(res.Invite), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) {
|
func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) {
|
||||||
|
|
@ -141,7 +143,7 @@ func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.M
|
||||||
userIDs := []int64{adminID}
|
userIDs := []int64{adminID}
|
||||||
invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites))
|
invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites))
|
||||||
for _, invite := range list.Invites {
|
for _, invite := range list.Invites {
|
||||||
invites = append(invites, tgExportedChannelInvite(invite))
|
invites = append(invites, r.tgExportedChannelInvite(invite))
|
||||||
userIDs = append(userIDs, invite.AdminUserID)
|
userIDs = append(userIDs, invite.AdminUserID)
|
||||||
}
|
}
|
||||||
return &tg.MessagesExportedChatInvites{
|
return &tg.MessagesExportedChatInvites{
|
||||||
|
|
@ -169,7 +171,7 @@ func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.Me
|
||||||
return nil, channelInviteErr(err)
|
return nil, channelInviteErr(err)
|
||||||
}
|
}
|
||||||
return &tg.MessagesExportedChatInvite{
|
return &tg.MessagesExportedChatInvite{
|
||||||
Invite: tgExportedChannelInvite(invite),
|
Invite: r.tgExportedChannelInvite(invite),
|
||||||
Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}),
|
Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -212,12 +214,12 @@ func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.M
|
||||||
users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID})
|
users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID})
|
||||||
if edited.NewInvite != nil {
|
if edited.NewInvite != nil {
|
||||||
return &tg.MessagesExportedChatInviteReplaced{
|
return &tg.MessagesExportedChatInviteReplaced{
|
||||||
Invite: tgExportedChannelInvite(edited.Invite),
|
Invite: r.tgExportedChannelInvite(edited.Invite),
|
||||||
NewInvite: tgExportedChannelInvite(*edited.NewInvite),
|
NewInvite: r.tgExportedChannelInvite(*edited.NewInvite),
|
||||||
Users: users,
|
Users: users,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return &tg.MessagesExportedChatInvite{Invite: tgExportedChannelInvite(edited.Invite), Users: users}, nil
|
return &tg.MessagesExportedChatInvite{Invite: r.tgExportedChannelInvite(edited.Invite), Users: users}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) {
|
func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) {
|
||||||
|
|
@ -353,12 +355,16 @@ func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass {
|
func (r *Router) tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass {
|
||||||
|
return tgExportedChannelInvite(invite, r.cfg.PublicBaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tgExportedChannelInvite(invite domain.ChannelInvite, publicBaseURL string) tg.ExportedChatInviteClass {
|
||||||
out := &tg.ChatInviteExported{
|
out := &tg.ChatInviteExported{
|
||||||
Revoked: invite.Revoked,
|
Revoked: invite.Revoked,
|
||||||
Permanent: invite.Permanent,
|
Permanent: invite.Permanent,
|
||||||
RequestNeeded: invite.RequestNeeded,
|
RequestNeeded: invite.RequestNeeded,
|
||||||
Link: "https://telesrv.net/+" + invite.Hash,
|
Link: publicLinkWithBaseURL(publicBaseURL, "+"+invite.Hash),
|
||||||
AdminID: invite.AdminUserID,
|
AdminID: invite.AdminUserID,
|
||||||
Date: invite.Date,
|
Date: invite.Date,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/gotd/td/tg"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"telesrv/internal/domain"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) {
|
func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) {
|
||||||
|
|
@ -28,9 +30,9 @@ func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.Channe
|
||||||
}
|
}
|
||||||
link := ""
|
link := ""
|
||||||
if view.Channel.Username != "" {
|
if view.Channel.Username != "" {
|
||||||
link = "https://telesrv.net/" + view.Channel.Username + "/" + strconv.Itoa(req.ID)
|
link = r.publicLink(view.Channel.Username + "/" + strconv.Itoa(req.ID))
|
||||||
} else {
|
} else {
|
||||||
link = "https://telesrv.net/c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID)
|
link = r.publicLink("c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID))
|
||||||
}
|
}
|
||||||
if req.Thread {
|
if req.Thread {
|
||||||
if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID {
|
if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID {
|
||||||
|
|
|
||||||
526
internal/rpc/chatlists.go
Normal file
526
internal/rpc/chatlists.go
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Router) registerChatlists(d *tg.ServerDispatcher) {
|
||||||
|
d.OnChatlistsExportChatlistInvite(r.onChatlistsExportChatlistInvite)
|
||||||
|
d.OnChatlistsDeleteExportedInvite(r.onChatlistsDeleteExportedInvite)
|
||||||
|
d.OnChatlistsEditExportedInvite(r.onChatlistsEditExportedInvite)
|
||||||
|
d.OnChatlistsGetExportedInvites(r.onChatlistsGetExportedInvites)
|
||||||
|
d.OnChatlistsCheckChatlistInvite(r.onChatlistsCheckChatlistInvite)
|
||||||
|
d.OnChatlistsJoinChatlistInvite(r.onChatlistsJoinChatlistInvite)
|
||||||
|
d.OnChatlistsGetChatlistUpdates(r.onChatlistsGetChatlistUpdates)
|
||||||
|
d.OnChatlistsJoinChatlistUpdates(r.onChatlistsJoinChatlistUpdates)
|
||||||
|
d.OnChatlistsHideChatlistUpdates(r.onChatlistsHideChatlistUpdates)
|
||||||
|
d.OnChatlistsGetLeaveChatlistSuggestions(r.onChatlistsGetLeaveChatlistSuggestions)
|
||||||
|
d.OnChatlistsLeaveChatlist(r.onChatlistsLeaveChatlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsExportChatlistInvite(ctx context.Context, req *tg.ChatlistsExportChatlistInviteRequest) (*tg.ChatlistsExportedChatlistInvite, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folder, invite, err := r.deps.Chatlists.ExportInvite(ctx, userID, chatlistFilterID(req.Chatlist), req.Title, peers, int(r.clock.Now().Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
if err := r.recordChatlistFilterUpdate(ctx, userID, folder.ID, &folder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tg.ChatlistsExportedChatlistInvite{
|
||||||
|
Filter: tgDialogFilter(folder),
|
||||||
|
Invite: *r.tgExportedChatlistInvite(invite),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsDeleteExportedInvite(ctx context.Context, req *tg.ChatlistsDeleteExportedInviteRequest) (bool, error) {
|
||||||
|
if req == nil {
|
||||||
|
return false, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return false, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
folder, changed, err := r.deps.Chatlists.DeleteInvite(ctx, userID, chatlistFilterID(req.Chatlist), req.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return false, chatlistErr(err)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := r.recordChatlistFilterUpdate(ctx, userID, folder.ID, &folder); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsEditExportedInvite(ctx context.Context, req *tg.ChatlistsEditExportedInviteRequest) (*tg.ExportedChatlistInvite, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
var title *string
|
||||||
|
if v, ok := req.GetTitle(); ok {
|
||||||
|
title = &v
|
||||||
|
}
|
||||||
|
var peersPtr *[]domain.DialogFolderPeer
|
||||||
|
if v, ok := req.GetPeers(); ok {
|
||||||
|
peers, err := r.dialogFolderPeersFromInput(ctx, userID, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
peersPtr = &peers
|
||||||
|
}
|
||||||
|
invite, err := r.deps.Chatlists.EditInvite(ctx, userID, chatlistFilterID(req.Chatlist), req.Slug, title, peersPtr, req.Flags.Has(0))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
return r.tgExportedChatlistInvite(invite), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsGetExportedInvites(ctx context.Context, chatlist tg.InputChatlistDialogFilter) (*tg.ChatlistsExportedInvites, error) {
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return &tg.ChatlistsExportedInvites{}, nil
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
invites, err := r.deps.Chatlists.ListInvites(ctx, userID, chatlistFilterID(chatlist))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
out := &tg.ChatlistsExportedInvites{
|
||||||
|
Invites: make([]tg.ExportedChatlistInvite, 0, len(invites)),
|
||||||
|
}
|
||||||
|
allPeers := make([]domain.DialogFolderPeer, 0)
|
||||||
|
for _, invite := range invites {
|
||||||
|
out.Invites = append(out.Invites, *r.tgExportedChatlistInvite(invite))
|
||||||
|
allPeers = append(allPeers, invite.Peers...)
|
||||||
|
}
|
||||||
|
out.Users, out.Chats = r.tgChatlistPeerEnvelope(ctx, userID, allPeers)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsCheckChatlistInvite(ctx context.Context, slug string) (tg.ChatlistsChatlistInviteClass, error) {
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
preview, err := r.deps.Chatlists.CheckInvite(ctx, userID, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
if preview.LocalFolder != nil {
|
||||||
|
filterID := preview.LocalFolder.ID
|
||||||
|
if preview.Membership != nil {
|
||||||
|
filterID = preview.Membership.LocalFilterID
|
||||||
|
}
|
||||||
|
users, chats, channels := r.tgChatlistPeerEnvelopeWithChannels(ctx, userID, append(preview.Missing, preview.Already...))
|
||||||
|
if preview.Membership != nil {
|
||||||
|
r.pushChatlistJoinedChannelNudges(ctx, userID, channels)
|
||||||
|
}
|
||||||
|
return &tg.ChatlistsChatlistInviteAlready{
|
||||||
|
FilterID: filterID,
|
||||||
|
MissingPeers: tgChatlistPeers(preview.Missing),
|
||||||
|
AlreadyPeers: tgChatlistPeers(preview.Already),
|
||||||
|
Chats: chats,
|
||||||
|
Users: users,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
users, chats := r.tgChatlistPeerEnvelope(ctx, userID, preview.Missing)
|
||||||
|
out := &tg.ChatlistsChatlistInvite{
|
||||||
|
TitleNoanimate: preview.OwnerFolder.TitleNoanimate,
|
||||||
|
Title: tg.TextWithEntities{
|
||||||
|
Text: preview.OwnerFolder.Title,
|
||||||
|
Entities: tgMessageEntities(preview.OwnerFolder.TitleEntities),
|
||||||
|
},
|
||||||
|
Peers: tgChatlistPeers(preview.Missing),
|
||||||
|
Chats: chats,
|
||||||
|
Users: users,
|
||||||
|
}
|
||||||
|
if preview.OwnerFolder.HasEmoticon {
|
||||||
|
out.SetEmoticon(preview.OwnerFolder.Emoticon)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsJoinChatlistInvite(ctx context.Context, req *tg.ChatlistsJoinChatlistInviteRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := r.deps.Chatlists.JoinInvite(ctx, userID, req.Slug, peers, int(r.clock.Now().Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
return r.chatlistOperationUpdates(ctx, userID, result.Folder.ID, &result.Folder, result.ChannelResults, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsGetChatlistUpdates(ctx context.Context, chatlist tg.InputChatlistDialogFilter) (*tg.ChatlistsChatlistUpdates, error) {
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return &tg.ChatlistsChatlistUpdates{}, nil
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
updates, err := r.deps.Chatlists.GetUpdates(ctx, userID, chatlistFilterID(chatlist))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
users, chats := r.tgChatlistPeerEnvelope(ctx, userID, updates.Missing)
|
||||||
|
return &tg.ChatlistsChatlistUpdates{
|
||||||
|
MissingPeers: tgChatlistPeers(updates.Missing),
|
||||||
|
Chats: chats,
|
||||||
|
Users: users,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsJoinChatlistUpdates(ctx context.Context, req *tg.ChatlistsJoinChatlistUpdatesRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := r.deps.Chatlists.JoinUpdates(ctx, userID, chatlistFilterID(req.Chatlist), peers, int(r.clock.Now().Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
return r.chatlistOperationUpdates(ctx, userID, result.Folder.ID, &result.Folder, result.ChannelResults, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsHideChatlistUpdates(ctx context.Context, chatlist tg.InputChatlistDialogFilter) (bool, error) {
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return false, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
if err := r.deps.Chatlists.HideUpdates(ctx, userID, chatlistFilterID(chatlist)); err != nil {
|
||||||
|
return false, chatlistErr(err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsGetLeaveChatlistSuggestions(ctx context.Context, chatlist tg.InputChatlistDialogFilter) ([]tg.PeerClass, error) {
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return []tg.PeerClass{}, nil
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
peers, err := r.deps.Chatlists.LeaveSuggestions(ctx, userID, chatlistFilterID(chatlist))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
return tgChatlistPeers(peers), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) onChatlistsLeaveChatlist(ctx context.Context, req *tg.ChatlistsLeaveChatlistRequest) (tg.UpdatesClass, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, inputRequestInvalidErr()
|
||||||
|
}
|
||||||
|
if r.deps.Chatlists == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
peers, err := r.dialogFolderPeersFromInput(ctx, userID, req.Peers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := r.deps.Chatlists.Leave(ctx, userID, chatlistFilterID(req.Chatlist), peers, int(r.clock.Now().Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, chatlistErr(err)
|
||||||
|
}
|
||||||
|
return r.chatlistOperationUpdates(ctx, userID, result.FilterID, nil, result.ChannelResults, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) tgExportedChatlistInvite(invite domain.ChatlistInvite) *tg.ExportedChatlistInvite {
|
||||||
|
out := &tg.ExportedChatlistInvite{
|
||||||
|
Title: invite.Title,
|
||||||
|
URL: r.publicLink("addlist/" + invite.Slug),
|
||||||
|
Peers: tgChatlistPeers(invite.Peers),
|
||||||
|
}
|
||||||
|
if invite.Revoked {
|
||||||
|
out.Flags.Set(0)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) tgChatlistPeerEnvelope(ctx context.Context, userID int64, peers []domain.DialogFolderPeer) ([]tg.UserClass, []tg.ChatClass) {
|
||||||
|
users, chats, _ := r.tgChatlistPeerEnvelopeWithChannels(ctx, userID, peers)
|
||||||
|
return users, chats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) tgChatlistPeerEnvelopeWithChannels(ctx context.Context, userID int64, peers []domain.DialogFolderPeer) ([]tg.UserClass, []tg.ChatClass, []domain.Channel) {
|
||||||
|
userIDs := make([]int64, 0)
|
||||||
|
channelIDs := make([]int64, 0)
|
||||||
|
seenUsers := make(map[int64]struct{})
|
||||||
|
seenChannels := make(map[int64]struct{})
|
||||||
|
for _, item := range peers {
|
||||||
|
switch item.Peer.Type {
|
||||||
|
case domain.PeerTypeUser:
|
||||||
|
if item.Peer.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenUsers[item.Peer.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenUsers[item.Peer.ID] = struct{}{}
|
||||||
|
userIDs = append(userIDs, item.Peer.ID)
|
||||||
|
case domain.PeerTypeChannel:
|
||||||
|
if item.Peer.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenChannels[item.Peer.ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenChannels[item.Peer.ID] = struct{}{}
|
||||||
|
channelIDs = append(channelIDs, item.Peer.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chats := make([]tg.ChatClass, 0, len(channelIDs))
|
||||||
|
channels := make([]domain.Channel, 0, len(channelIDs))
|
||||||
|
if r.deps.Channels != nil && len(channelIDs) > 0 {
|
||||||
|
if views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs); err == nil {
|
||||||
|
for _, view := range views {
|
||||||
|
if view.Channel.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channels = append(channels, view.Channel)
|
||||||
|
chats = append(chats, tgChannelChatForView(userID, view))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.tgUsersForIDs(ctx, userID, userIDs), chats, channels
|
||||||
|
}
|
||||||
|
|
||||||
|
func tgChatlistPeers(peers []domain.DialogFolderPeer) []tg.PeerClass {
|
||||||
|
out := make([]tg.PeerClass, 0, len(peers))
|
||||||
|
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||||
|
for _, item := range peers {
|
||||||
|
if _, ok := seen[item.Peer]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.Peer] = struct{}{}
|
||||||
|
if peer := tgPeer(item.Peer); peer != nil {
|
||||||
|
out = append(out, peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatlistFilterID(chatlist tg.InputChatlistDialogFilter) int {
|
||||||
|
return chatlist.FilterID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) recordChatlistFilterUpdate(ctx context.Context, userID int64, filterID int, folder *domain.DialogFolder) error {
|
||||||
|
event := domain.UpdateEvent{
|
||||||
|
Type: domain.UpdateEventDialogFilter,
|
||||||
|
FilterID: filterID,
|
||||||
|
DialogFilter: folder,
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if r.deps.Updates != nil {
|
||||||
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
|
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.bookkeepAuxPtsForCurrentSession(ctx, event)
|
||||||
|
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) chatlistFilterUpdates(ctx context.Context, userID int64, filterID int, folder *domain.DialogFolder) (tg.UpdatesClass, error) {
|
||||||
|
event := domain.UpdateEvent{
|
||||||
|
Type: domain.UpdateEventDialogFilter,
|
||||||
|
FilterID: filterID,
|
||||||
|
DialogFilter: folder,
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if r.deps.Updates != nil {
|
||||||
|
authKeyID, _ := AuthKeyIDFrom(ctx)
|
||||||
|
sessionID, _ := SessionIDFrom(ctx)
|
||||||
|
event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, filterID, folder, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.bookkeepAuxPtsForCurrentSession(ctx, event)
|
||||||
|
out := tgUpdateForOutboxEvent(event)
|
||||||
|
if out == nil {
|
||||||
|
out = &tg.Updates{Date: event.Date}
|
||||||
|
}
|
||||||
|
if folder != nil {
|
||||||
|
out.Users, out.Chats = r.tgChatlistPeerEnvelope(ctx, userID, append(folder.PinnedPeers, folder.IncludePeers...))
|
||||||
|
}
|
||||||
|
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) chatlistOperationUpdates(ctx context.Context, userID int64, filterID int, folder *domain.DialogFolder, channelResults []domain.CreateChannelResult, leaving bool) (tg.UpdatesClass, error) {
|
||||||
|
base, err := r.chatlistFilterUpdates(ctx, userID, filterID, folder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, ok := base.(*tg.Updates)
|
||||||
|
if !ok {
|
||||||
|
out = &tg.Updates{Date: int(r.clock.Now().Unix())}
|
||||||
|
}
|
||||||
|
for _, res := range channelResults {
|
||||||
|
if res.Channel.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID)
|
||||||
|
if leaving {
|
||||||
|
r.removeOnlineChannelMemberships(res.Channel.ID, userID)
|
||||||
|
r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true)
|
||||||
|
} else {
|
||||||
|
r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...)
|
||||||
|
}
|
||||||
|
channelUpdates := r.channelOperationUpdates(ctx, userID, res)
|
||||||
|
out = mergeUpdates(out, channelUpdates)
|
||||||
|
if !leaving {
|
||||||
|
if nudge := chatlistJoinedChannelNudge(res.Channel); nudge != nil {
|
||||||
|
out.Updates = append(out.Updates, nudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
|
||||||
|
return r.channelOperationUpdates(ctx, viewerUserID, res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatlistJoinedChannelNudge(channel domain.Channel) tg.UpdateClass {
|
||||||
|
if channel.ID == 0 || channel.Pts <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
update := &tg.UpdateChannelTooLong{ChannelID: channel.ID}
|
||||||
|
update.SetPts(channel.Pts)
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) pushChatlistJoinedChannelNudges(ctx context.Context, userID int64, channels []domain.Channel) {
|
||||||
|
if userID == 0 || len(channels) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := make([]tg.UpdateClass, 0, len(channels))
|
||||||
|
for _, channel := range channels {
|
||||||
|
if nudge := chatlistJoinedChannelNudge(channel); nudge != nil {
|
||||||
|
updates = append(updates, nudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := &tg.Updates{
|
||||||
|
Updates: updates,
|
||||||
|
Users: []tg.UserClass{},
|
||||||
|
Chats: []tg.ChatClass{},
|
||||||
|
Date: int(r.clock.Now().Unix()),
|
||||||
|
Seq: 0,
|
||||||
|
}
|
||||||
|
if _, ok := SessionIDFrom(ctx); ok {
|
||||||
|
r.pushCurrentSessionMessage(ctx, "push chatlist joined channel nudges", out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pushUserUpdates(ctx, userID, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatlistErr(err error) error {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return nil
|
||||||
|
case errors.Is(err, domain.ErrInviteRequestSent),
|
||||||
|
errors.Is(err, domain.ErrUserAlreadyParticipant),
|
||||||
|
errors.Is(err, domain.ErrChannelUserBanned),
|
||||||
|
errors.Is(err, domain.ErrUserKicked),
|
||||||
|
errors.Is(err, domain.ErrBotGroupsBlocked):
|
||||||
|
return channelInviteErr(err)
|
||||||
|
case errors.Is(err, domain.ErrChannelUserCreator),
|
||||||
|
errors.Is(err, domain.ErrUserNotParticipant),
|
||||||
|
errors.Is(err, domain.ErrChannelAdminRequired):
|
||||||
|
return channelAdminErr(err)
|
||||||
|
case errors.Is(err, domain.ErrChannelInvalid),
|
||||||
|
errors.Is(err, domain.ErrChannelPrivate):
|
||||||
|
return channelInvalidErr(err)
|
||||||
|
case errors.Is(err, domain.ErrChatlistInviteInvalid):
|
||||||
|
return inviteSlugEmptyErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistInviteExpired):
|
||||||
|
return inviteSlugExpiredErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistInvitesTooMuch):
|
||||||
|
return invitesTooMuchErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistsTooMuch):
|
||||||
|
return chatlistsTooMuchErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistPeersEmpty):
|
||||||
|
return peersListEmptyErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistPeersTooMuch):
|
||||||
|
return limitInvalidErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistNotShareable):
|
||||||
|
return filterNotSupportedErr()
|
||||||
|
case errors.Is(err, domain.ErrChatlistInvalid):
|
||||||
|
return filterIDInvalidErr()
|
||||||
|
default:
|
||||||
|
return internalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
307
internal/rpc/chatlists_rpc_test.go
Normal file
307
internal/rpc/chatlists_rpc_test.go
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotd/td/bin"
|
||||||
|
"github.com/gotd/td/clock"
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatlistsExportInviteRPCShape(t *testing.T) {
|
||||||
|
fake := &fakeChatlistsService{
|
||||||
|
exportFolder: domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
IsChatlist: true,
|
||||||
|
HasMyInvites: true,
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200}},
|
||||||
|
},
|
||||||
|
exportInvite: domain.ChatlistInvite{
|
||||||
|
OwnerUserID: 1001,
|
||||||
|
FilterID: 2,
|
||||||
|
Slug: "slug-rpc",
|
||||||
|
Title: "Main link",
|
||||||
|
Peers: []domain.DialogFolderPeer{{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200}},
|
||||||
|
Date: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := New(Config{PublicBaseURL: "http://127.0.0.1:2401"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
|
||||||
|
got, err := r.onChatlistsExportChatlistInvite(WithUserID(context.Background(), 1001), &tg.ChatlistsExportChatlistInviteRequest{
|
||||||
|
Chatlist: tg.InputChatlistDialogFilter{FilterID: 2},
|
||||||
|
Title: "Main link",
|
||||||
|
Peers: []tg.InputPeerClass{&tg.InputPeerChannel{ChannelID: 42, AccessHash: 4200}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export rpc: %v", err)
|
||||||
|
}
|
||||||
|
filter, ok := got.Filter.(*tg.DialogFilterChatlist)
|
||||||
|
if !ok || !filter.HasMyInvites || filter.ID != 2 {
|
||||||
|
t.Fatalf("filter = %#v, want dialogFilterChatlist with has_my_invites", got.Filter)
|
||||||
|
}
|
||||||
|
if got.Invite.URL != "http://127.0.0.1:2401/addlist/slug-rpc" || len(got.Invite.Peers) != 1 {
|
||||||
|
t.Fatalf("invite = %+v, want url and one peer", got.Invite)
|
||||||
|
}
|
||||||
|
if fake.exportPeers[0].Peer.Type != domain.PeerTypeChannel || fake.exportPeers[0].Peer.ID != 42 || fake.exportPeers[0].AccessHash != 4200 {
|
||||||
|
t.Fatalf("service peers = %+v, want parsed input peer", fake.exportPeers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatlistsExportInvitePublicBaseURLIsRouterScoped(t *testing.T) {
|
||||||
|
fake := &fakeChatlistsService{
|
||||||
|
exportFolder: domain.DialogFolder{ID: 2, Title: "Team", IsChatlist: true, HasMyInvites: true},
|
||||||
|
exportInvite: domain.ChatlistInvite{
|
||||||
|
OwnerUserID: 1001,
|
||||||
|
FilterID: 2,
|
||||||
|
Slug: "slug-rpc",
|
||||||
|
Title: "Main link",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local := New(Config{PublicBaseURL: "http://127.0.0.1:2401"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
|
||||||
|
_ = New(Config{PublicBaseURL: "https://telesrv.net"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
|
||||||
|
got, err := local.onChatlistsExportChatlistInvite(WithUserID(context.Background(), 1001), &tg.ChatlistsExportChatlistInviteRequest{
|
||||||
|
Chatlist: tg.InputChatlistDialogFilter{FilterID: 2},
|
||||||
|
Title: "Main link",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export rpc: %v", err)
|
||||||
|
}
|
||||||
|
if got.Invite.URL != "http://127.0.0.1:2401/addlist/slug-rpc" {
|
||||||
|
t.Fatalf("invite url = %q, want local router base URL", got.Invite.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatlistsJoinInviteReturnsDialogFilterUpdate(t *testing.T) {
|
||||||
|
fake := &fakeChatlistsService{
|
||||||
|
joinResult: domain.ChatlistJoinResult{
|
||||||
|
Folder: domain.DialogFolder{
|
||||||
|
ID: 3,
|
||||||
|
Title: "Team",
|
||||||
|
IsChatlist: true,
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{
|
||||||
|
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ChannelResults: []domain.CreateChannelResult{
|
||||||
|
{
|
||||||
|
Channel: domain.Channel{
|
||||||
|
ID: 42,
|
||||||
|
AccessHash: 4200,
|
||||||
|
Title: "Team channel",
|
||||||
|
Megagroup: true,
|
||||||
|
Pts: 9,
|
||||||
|
},
|
||||||
|
Members: []domain.ChannelMember{
|
||||||
|
{ChannelID: 42, UserID: 2002, Status: domain.ChannelMemberActive},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Date: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
|
||||||
|
got, err := r.onChatlistsJoinChatlistInvite(WithUserID(context.Background(), 2002), &tg.ChatlistsJoinChatlistInviteRequest{
|
||||||
|
Slug: "slug-rpc",
|
||||||
|
Peers: []tg.InputPeerClass{&tg.InputPeerChannel{ChannelID: 42, AccessHash: 4200}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join rpc: %v", err)
|
||||||
|
}
|
||||||
|
updates, ok := got.(*tg.Updates)
|
||||||
|
if !ok || len(updates.Updates) == 0 {
|
||||||
|
t.Fatalf("updates = %#v, want updates with updateDialogFilter", got)
|
||||||
|
}
|
||||||
|
update, ok := updates.Updates[0].(*tg.UpdateDialogFilter)
|
||||||
|
if !ok || update.ID != 3 {
|
||||||
|
t.Fatalf("first update = %#v, want updateDialogFilter id=3", updates.Updates[0])
|
||||||
|
}
|
||||||
|
filter, ok := update.Filter.(*tg.DialogFilterChatlist)
|
||||||
|
if !ok || filter.ID != 3 || len(filter.IncludePeers) != 1 {
|
||||||
|
t.Fatalf("update filter = %#v, want joined chatlist filter", update.Filter)
|
||||||
|
}
|
||||||
|
if fake.joinPeers[0].Peer.Type != domain.PeerTypeChannel || fake.joinPeers[0].Peer.ID != 42 || fake.joinPeers[0].AccessHash != 4200 {
|
||||||
|
t.Fatalf("service join peers = %+v, want parsed input peer", fake.joinPeers)
|
||||||
|
}
|
||||||
|
var gotChannelNudge bool
|
||||||
|
for _, u := range updates.Updates {
|
||||||
|
nudge, ok := u.(*tg.UpdateChannelTooLong)
|
||||||
|
if !ok || nudge.ChannelID != 42 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pts, ok := nudge.GetPts()
|
||||||
|
if !ok || pts != 9 {
|
||||||
|
t.Fatalf("channel nudge pts = %d ok %v, want 9 true", pts, ok)
|
||||||
|
}
|
||||||
|
gotChannelNudge = true
|
||||||
|
}
|
||||||
|
if !gotChannelNudge {
|
||||||
|
t.Fatalf("updates = %#v, want UpdateChannelTooLong for joined channel", updates.Updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatlistsJoinedChannelNudgesCarryPts(t *testing.T) {
|
||||||
|
sessions := &captureSessions{}
|
||||||
|
r := New(Config{}, Deps{Sessions: sessions}, zap.NewNop(), clock.System)
|
||||||
|
r.pushChatlistJoinedChannelNudges(context.Background(), 2002, []domain.Channel{
|
||||||
|
{ID: 42, Pts: 9},
|
||||||
|
{ID: 43},
|
||||||
|
})
|
||||||
|
updates, ok := sessions.lastUserPush().(*tg.Updates)
|
||||||
|
if !ok || len(updates.Updates) != 1 {
|
||||||
|
t.Fatalf("pushed updates = %#v, want one UpdateChannelTooLong", sessions.lastUserPush())
|
||||||
|
}
|
||||||
|
nudge, ok := updates.Updates[0].(*tg.UpdateChannelTooLong)
|
||||||
|
if !ok || nudge.ChannelID != 42 {
|
||||||
|
t.Fatalf("pushed update = %#v, want channel 42 nudge", updates.Updates[0])
|
||||||
|
}
|
||||||
|
pts, ok := nudge.GetPts()
|
||||||
|
if !ok || pts != 9 {
|
||||||
|
t.Fatalf("pushed nudge pts = %d ok %v, want 9 true", pts, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.clearMessages()
|
||||||
|
r.pushChatlistJoinedChannelNudges(WithSessionID(context.Background(), 77), 2002, []domain.Channel{{ID: 44, Pts: 11}})
|
||||||
|
snap := sessions.snapshot()
|
||||||
|
currentUpdates, ok := snap.message.(*tg.Updates)
|
||||||
|
if !ok || len(currentUpdates.Updates) != 1 {
|
||||||
|
t.Fatalf("current session push = %#v, want one UpdateChannelTooLong", snap.message)
|
||||||
|
}
|
||||||
|
if ids := sessions.pushedUserIDs(); len(ids) != 0 {
|
||||||
|
t.Fatalf("current session nudge pushed to user ids = %v, want none", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatlistsCheckInviteRPCAlreadyAndFreshShapes(t *testing.T) {
|
||||||
|
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, AccessHash: 4200}
|
||||||
|
fake := &fakeChatlistsService{
|
||||||
|
checkPreview: domain.ChatlistInvitePreview{
|
||||||
|
Invite: domain.ChatlistInvite{Slug: "slug-rpc", Peers: []domain.DialogFolderPeer{peer}},
|
||||||
|
OwnerFolder: domain.DialogFolder{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Team",
|
||||||
|
HasEmoticon: true,
|
||||||
|
Emoticon: ":)",
|
||||||
|
IncludePeers: []domain.DialogFolderPeer{peer},
|
||||||
|
},
|
||||||
|
Missing: []domain.DialogFolderPeer{peer},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := New(Config{}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
|
||||||
|
fresh, err := r.onChatlistsCheckChatlistInvite(WithUserID(context.Background(), 2002), "slug-rpc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check fresh rpc: %v", err)
|
||||||
|
}
|
||||||
|
invite, ok := fresh.(*tg.ChatlistsChatlistInvite)
|
||||||
|
if !ok || invite.Title.Text != "Team" || len(invite.Peers) != 1 {
|
||||||
|
t.Fatalf("fresh = %#v, want chatlistInvite with title and peer", fresh)
|
||||||
|
}
|
||||||
|
if emoticon, ok := invite.GetEmoticon(); !ok || emoticon != ":)" {
|
||||||
|
t.Fatalf("fresh emoticon = %q ok %v, want folder emoji", emoticon, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
local := domain.DialogFolder{ID: 5, Title: "Team", IsChatlist: true, IncludePeers: []domain.DialogFolderPeer{peer}}
|
||||||
|
fake.checkPreview.LocalFolder = &local
|
||||||
|
fake.checkPreview.Membership = &domain.ChatlistMembership{UserID: 2002, LocalFilterID: 5, Slug: "slug-rpc"}
|
||||||
|
fake.checkPreview.Missing = nil
|
||||||
|
fake.checkPreview.Already = []domain.DialogFolderPeer{peer}
|
||||||
|
already, err := r.onChatlistsCheckChatlistInvite(WithUserID(context.Background(), 2002), "slug-rpc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check already rpc: %v", err)
|
||||||
|
}
|
||||||
|
gotAlready, ok := already.(*tg.ChatlistsChatlistInviteAlready)
|
||||||
|
if !ok || gotAlready.FilterID != 5 || len(gotAlready.AlreadyPeers) != 1 {
|
||||||
|
t.Fatalf("already = %#v, want already filter id and peer", already)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatlistsEditInviteRevokedFlagRoundTrips(t *testing.T) {
|
||||||
|
fake := &fakeChatlistsService{
|
||||||
|
exportInvite: domain.ChatlistInvite{
|
||||||
|
OwnerUserID: 1001,
|
||||||
|
FilterID: 2,
|
||||||
|
Slug: "slug-rpc",
|
||||||
|
Title: "Main link",
|
||||||
|
Revoked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r := New(Config{PublicBaseURL: "https://telesrv.net"}, Deps{Chatlists: fake}, zap.NewNop(), clock.System)
|
||||||
|
var flags bin.Fields
|
||||||
|
flags.Set(0)
|
||||||
|
got, err := r.onChatlistsEditExportedInvite(WithUserID(context.Background(), 1001), &tg.ChatlistsEditExportedInviteRequest{
|
||||||
|
Flags: flags,
|
||||||
|
Chatlist: tg.InputChatlistDialogFilter{FilterID: 2},
|
||||||
|
Slug: "slug-rpc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("edit rpc: %v", err)
|
||||||
|
}
|
||||||
|
if !fake.editRevoke {
|
||||||
|
t.Fatalf("service revoke = false, want true")
|
||||||
|
}
|
||||||
|
if !got.Flags.Has(0) {
|
||||||
|
t.Fatalf("edited invite flags = %v, want revoked bit", got.Flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeChatlistsService struct {
|
||||||
|
exportFolder domain.DialogFolder
|
||||||
|
exportInvite domain.ChatlistInvite
|
||||||
|
exportPeers []domain.DialogFolderPeer
|
||||||
|
checkPreview domain.ChatlistInvitePreview
|
||||||
|
joinResult domain.ChatlistJoinResult
|
||||||
|
joinPeers []domain.DialogFolderPeer
|
||||||
|
updates domain.ChatlistUpdates
|
||||||
|
joinUpdFolder domain.DialogFolder
|
||||||
|
editRevoke bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) ExportInvite(_ context.Context, _ int64, _ int, _ string, peers []domain.DialogFolderPeer, _ int) (domain.DialogFolder, domain.ChatlistInvite, error) {
|
||||||
|
f.exportPeers = append([]domain.DialogFolderPeer(nil), peers...)
|
||||||
|
return f.exportFolder, f.exportInvite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) ListInvites(context.Context, int64, int) ([]domain.ChatlistInvite, error) {
|
||||||
|
return []domain.ChatlistInvite{f.exportInvite}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) EditInvite(_ context.Context, _ int64, _ int, _ string, _ *string, _ *[]domain.DialogFolderPeer, revoke bool) (domain.ChatlistInvite, error) {
|
||||||
|
f.editRevoke = revoke
|
||||||
|
return f.exportInvite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) DeleteInvite(context.Context, int64, int, string) (domain.DialogFolder, bool, error) {
|
||||||
|
return domain.DialogFolder{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) CheckInvite(context.Context, int64, string) (domain.ChatlistInvitePreview, error) {
|
||||||
|
return f.checkPreview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) JoinInvite(_ context.Context, _ int64, _ string, peers []domain.DialogFolderPeer, _ int) (domain.ChatlistJoinResult, error) {
|
||||||
|
f.joinPeers = append([]domain.DialogFolderPeer(nil), peers...)
|
||||||
|
return f.joinResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) GetUpdates(context.Context, int64, int) (domain.ChatlistUpdates, error) {
|
||||||
|
return f.updates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) JoinUpdates(context.Context, int64, int, []domain.DialogFolderPeer, int) (domain.ChatlistJoinResult, error) {
|
||||||
|
return domain.ChatlistJoinResult{Folder: f.joinUpdFolder}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) HideUpdates(context.Context, int64, int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) Leave(context.Context, int64, int, []domain.DialogFolderPeer, int) (domain.ChatlistLeaveResult, error) {
|
||||||
|
return domain.ChatlistLeaveResult{FilterID: 3}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeChatlistsService) LeaveSuggestions(context.Context, int64, int) ([]domain.DialogFolderPeer, error) {
|
||||||
|
return f.exportInvite.Peers, nil
|
||||||
|
}
|
||||||
|
|
@ -466,7 +466,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgChannelFull(view domain.ChannelView) *tg.ChannelFull {
|
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
|
||||||
ch := view.Channel
|
ch := view.Channel
|
||||||
full := &tg.ChannelFull{
|
full := &tg.ChannelFull{
|
||||||
// 广播频道的订阅者列表仅管理员可见(官方语义):非管理员订阅者拿到
|
// 广播频道的订阅者列表仅管理员可见(官方语义):非管理员订阅者拿到
|
||||||
|
|
@ -496,7 +496,11 @@ func tgChannelFull(view domain.ChannelView) *tg.ChannelFull {
|
||||||
full.SetAvailableMinID(view.Self.AvailableMinID)
|
full.SetAvailableMinID(view.Self.AvailableMinID)
|
||||||
}
|
}
|
||||||
if view.ExportedInvite != nil && !view.ExportedInvite.Revoked {
|
if view.ExportedInvite != nil && !view.ExportedInvite.Revoked {
|
||||||
full.SetExportedInvite(tgExportedChannelInvite(*view.ExportedInvite))
|
baseURL := ""
|
||||||
|
if len(publicBaseURL) > 0 {
|
||||||
|
baseURL = publicBaseURL[0]
|
||||||
|
}
|
||||||
|
full.SetExportedInvite(tgExportedChannelInvite(*view.ExportedInvite, baseURL))
|
||||||
}
|
}
|
||||||
if ch.AdminsCount > 0 {
|
if ch.AdminsCount > 0 {
|
||||||
full.SetAdminsCount(ch.AdminsCount)
|
full.SetAdminsCount(ch.AdminsCount)
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,7 @@ func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass {
|
||||||
title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)}
|
title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)}
|
||||||
if folder.IsChatlist {
|
if folder.IsChatlist {
|
||||||
out := &tg.DialogFilterChatlist{
|
out := &tg.DialogFilterChatlist{
|
||||||
|
HasMyInvites: folder.HasMyInvites,
|
||||||
TitleNoanimate: folder.TitleNoanimate,
|
TitleNoanimate: folder.TitleNoanimate,
|
||||||
ID: folder.ID,
|
ID: folder.ID,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const groupCallUnmutedVideoLimit = 30
|
||||||
var groupCallStreamDCID = 2
|
var groupCallStreamDCID = 2
|
||||||
|
|
||||||
// tgGroupCall 把 call 行转为 TL groupCall。
|
// tgGroupCall 把 call 行转为 TL groupCall。
|
||||||
func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.GroupCallClass {
|
func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool, publicBaseURL ...string) tg.GroupCallClass {
|
||||||
if !call.Active() {
|
if !call.Active() {
|
||||||
return &tg.GroupCallDiscarded{ID: call.ID, AccessHash: call.AccessHash, Duration: call.Duration}
|
return &tg.GroupCallDiscarded{ID: call.ID, AccessHash: call.AccessHash, Duration: call.Duration}
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +64,11 @@ func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.G
|
||||||
out.SetTitle(call.Title)
|
out.SetTitle(call.Title)
|
||||||
}
|
}
|
||||||
if call.Conference() {
|
if call.Conference() {
|
||||||
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
baseURL := ""
|
||||||
|
if len(publicBaseURL) > 0 {
|
||||||
|
baseURL = publicBaseURL[0]
|
||||||
|
}
|
||||||
|
if link := conferenceCanonicalInviteLink(call.InviteSlug, baseURL); link != "" {
|
||||||
out.SetInviteLink(link)
|
out.SetInviteLink(link)
|
||||||
} else if call.InviteLink != "" {
|
} else if call.InviteLink != "" {
|
||||||
out.SetInviteLink(call.InviteLink)
|
out.SetInviteLink(call.InviteLink)
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,21 @@ type DialogsService interface {
|
||||||
EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
|
EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatlistsService 抽象 Telegram shared folders / chatlists 业务。
|
||||||
|
type ChatlistsService interface {
|
||||||
|
ExportInvite(ctx context.Context, userID int64, filterID int, title string, peers []domain.DialogFolderPeer, date int) (domain.DialogFolder, domain.ChatlistInvite, error)
|
||||||
|
ListInvites(ctx context.Context, userID int64, filterID int) ([]domain.ChatlistInvite, error)
|
||||||
|
EditInvite(ctx context.Context, userID int64, filterID int, slug string, title *string, peers *[]domain.DialogFolderPeer, revoke bool) (domain.ChatlistInvite, error)
|
||||||
|
DeleteInvite(ctx context.Context, userID int64, filterID int, slug string) (domain.DialogFolder, bool, error)
|
||||||
|
CheckInvite(ctx context.Context, userID int64, slug string) (domain.ChatlistInvitePreview, error)
|
||||||
|
JoinInvite(ctx context.Context, userID int64, slug string, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error)
|
||||||
|
GetUpdates(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistUpdates, error)
|
||||||
|
JoinUpdates(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistJoinResult, error)
|
||||||
|
HideUpdates(ctx context.Context, userID int64, localFilterID int) error
|
||||||
|
Leave(ctx context.Context, userID int64, localFilterID int, peers []domain.DialogFolderPeer, date int) (domain.ChatlistLeaveResult, error)
|
||||||
|
LeaveSuggestions(ctx context.Context, userID int64, localFilterID int) ([]domain.DialogFolderPeer, error)
|
||||||
|
}
|
||||||
|
|
||||||
// MessagesService 抽象消息历史、搜索与已读。
|
// MessagesService 抽象消息历史、搜索与已读。
|
||||||
type MessagesService interface {
|
type MessagesService interface {
|
||||||
SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error)
|
SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error)
|
||||||
|
|
@ -697,6 +712,7 @@ type Deps struct {
|
||||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||||
Contacts ContactsService
|
Contacts ContactsService
|
||||||
Dialogs DialogsService
|
Dialogs DialogsService
|
||||||
|
Chatlists ChatlistsService
|
||||||
Messages MessagesService
|
Messages MessagesService
|
||||||
Stories StoriesService
|
Stories StoriesService
|
||||||
Channels ChannelsService
|
Channels ChannelsService
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,18 @@ func filterIncludeEmptyErr() error { return tgerr.New(400, "FILTER_INCLUDE_EMPTY
|
||||||
|
|
||||||
func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") }
|
func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") }
|
||||||
|
|
||||||
|
func filterNotSupportedErr() error { return tgerr.New(400, "FILTER_NOT_SUPPORTED") }
|
||||||
|
|
||||||
|
func inviteSlugEmptyErr() error { return tgerr.New(400, "INVITE_SLUG_EMPTY") }
|
||||||
|
|
||||||
|
func inviteSlugExpiredErr() error { return tgerr.New(400, "INVITE_SLUG_EXPIRED") }
|
||||||
|
|
||||||
|
func invitesTooMuchErr() error { return tgerr.New(400, "INVITES_TOO_MUCH") }
|
||||||
|
|
||||||
|
func chatlistsTooMuchErr() error { return tgerr.New(400, "CHATLISTS_TOO_MUCH") }
|
||||||
|
|
||||||
|
func peersListEmptyErr() error { return tgerr.New(400, "PEERS_LIST_EMPTY") }
|
||||||
|
|
||||||
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
|
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
|
||||||
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") }
|
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
// registerHelp 注册 help.* RPC handler(DC 配置、最近 DC)。
|
// registerHelp 注册 help.* RPC handler(DC 配置、最近 DC)。
|
||||||
func (r *Router) registerHelp(d *tg.ServerDispatcher) {
|
func (r *Router) registerHelp(d *tg.ServerDispatcher) {
|
||||||
d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) {
|
d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) {
|
||||||
return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now()), nil
|
return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL), nil
|
||||||
})
|
})
|
||||||
d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) {
|
d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) {
|
||||||
return tdesktop.NearestDC(r.cfg.DC), nil
|
return tdesktop.NearestDC(r.cfg.DC), nil
|
||||||
|
|
|
||||||
|
|
@ -966,6 +966,7 @@ func (r *Router) dialogFolderFromTG(ctx context.Context, userID int64, id int, f
|
||||||
color, hasColor := f.GetColor()
|
color, hasColor := f.GetColor()
|
||||||
return domain.DialogFolder{
|
return domain.DialogFolder{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
HasMyInvites: f.HasMyInvites,
|
||||||
TitleNoanimate: f.TitleNoanimate,
|
TitleNoanimate: f.TitleNoanimate,
|
||||||
Title: title,
|
Title: title,
|
||||||
TitleEntities: domainMessageEntities(f.Title.Entities),
|
TitleEntities: domainMessageEntities(f.Title.Entities),
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,11 @@ func (r *Router) domainFolderPeerFromInputPeer(ctx context.Context, userID int64
|
||||||
return domain.Peer{}, 0, peerIDInvalidErr()
|
return domain.Peer{}, 0, peerIDInvalidErr()
|
||||||
}
|
}
|
||||||
return out, 0, nil
|
return out, 0, nil
|
||||||
|
case *tg.InputPeerChat:
|
||||||
|
if p.ChatID <= 0 {
|
||||||
|
return domain.Peer{}, 0, peerIDInvalidErr()
|
||||||
|
}
|
||||||
|
return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChatID}, 0, nil
|
||||||
case *tg.InputPeerSelf:
|
case *tg.InputPeerSelf:
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
return domain.Peer{}, 0, peerIDInvalidErr()
|
return domain.Peer{}, 0, peerIDInvalidErr()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
@ -40,7 +41,7 @@ func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneC
|
||||||
return nil, groupCallErr(err)
|
return nil, groupCallErr(err)
|
||||||
}
|
}
|
||||||
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
out := r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||||
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}, []int64{userID})
|
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true, r.cfg.PublicBaseURL)}, []int64{userID})
|
||||||
if !req.Join {
|
if !req.Join {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +82,7 @@ func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.Phone
|
||||||
return nil, groupCallInvalidErr()
|
return nil, groupCallInvalidErr()
|
||||||
}
|
}
|
||||||
if scope.call.Conference() {
|
if scope.call.Conference() {
|
||||||
link := conferenceExportInviteLink(scope.call)
|
link := conferenceExportInviteLink(scope.call, r.cfg.PublicBaseURL)
|
||||||
if link == "" {
|
if link == "" {
|
||||||
return nil, groupCallInvalidErr()
|
return nil, groupCallInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
@ -93,21 +94,25 @@ func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.Phone
|
||||||
if scope.channel.Username == "" {
|
if scope.channel.Username == "" {
|
||||||
return nil, publicChannelMissingErr()
|
return nil, publicChannelMissingErr()
|
||||||
}
|
}
|
||||||
return &tg.PhoneExportedGroupCallInvite{Link: "https://telesrv.net/" + scope.channel.Username}, nil
|
return &tg.PhoneExportedGroupCallInvite{Link: r.publicLink(scope.channel.Username)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func conferenceExportInviteLink(call domain.GroupCall) string {
|
func conferenceExportInviteLink(call domain.GroupCall, publicBaseURL ...string) string {
|
||||||
if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" {
|
if link := conferenceCanonicalInviteLink(call.InviteSlug, publicBaseURL...); link != "" {
|
||||||
return link
|
return link
|
||||||
}
|
}
|
||||||
return call.InviteLink
|
return call.InviteLink
|
||||||
}
|
}
|
||||||
|
|
||||||
func conferenceCanonicalInviteLink(slug string) string {
|
func conferenceCanonicalInviteLink(slug string, publicBaseURL ...string) string {
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return "https://telesrv.net/call/" + slug + "?slug=" + slug
|
baseURL := ""
|
||||||
|
if len(publicBaseURL) > 0 {
|
||||||
|
baseURL = publicBaseURL[0]
|
||||||
|
}
|
||||||
|
return publicLinkQueryWithBaseURL(baseURL, "call/"+slug, url.Values{"slug": []string{slug}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onPhoneInviteConferenceCallParticipant(ctx context.Context, req *tg.PhoneInviteConferenceCallParticipantRequest) (tg.UpdatesClass, error) {
|
||||||
|
|
@ -208,7 +213,7 @@ func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID i
|
||||||
}
|
}
|
||||||
r.pushConferenceGroupCallUpdate(ctx, call)
|
r.pushConferenceGroupCallUpdate(ctx, call)
|
||||||
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
return r.groupCallUpdateContainer(ctx, userID, domain.Channel{},
|
||||||
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
|
&tg.UpdateGroupCall{Call: tgGroupCall(call, userID, userID == call.CreatorUserID, r.cfg.PublicBaseURL)}, []int64{inv.InviterUserID, inv.InviteeUserID}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onPhoneDeleteConferenceCallParticipants(ctx context.Context, req *tg.PhoneDeleteConferenceCallParticipantsRequest) (tg.UpdatesClass, error) {
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreate
|
||||||
r.pushGroupCallServiceMessage(ctx, userID, serviceRes)
|
r.pushGroupCallServiceMessage(ctx, userID, serviceRes)
|
||||||
}
|
}
|
||||||
// 响应:updateGroupCall + 服务消息(发起设备视角)。TDesktop 创建后自行 joinGroupCall。
|
// 响应:updateGroupCall + 服务消息(发起设备视角)。TDesktop 创建后自行 joinGroupCall。
|
||||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)}
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true, r.cfg.PublicBaseURL)}
|
||||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
out := r.groupCallUpdateContainer(ctx, userID, channel, update, []int64{userID})
|
out := r.groupCallUpdateContainer(ctx, userID, channel, update, []int64{userID})
|
||||||
if serviceRes.Event.Pts != 0 {
|
if serviceRes.Event.Pts != 0 {
|
||||||
|
|
@ -368,7 +368,7 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou
|
||||||
Version: mut.Call.Version,
|
Version: mut.Call.Version,
|
||||||
}, []int64{scope.userID})
|
}, []int64{scope.userID})
|
||||||
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}})
|
||||||
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)}
|
||||||
if channel.ID != 0 {
|
if channel.ID != 0 {
|
||||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
}
|
}
|
||||||
|
|
@ -395,7 +395,7 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
|
||||||
if errors.Is(err, domain.ErrGroupCallNotJoined) {
|
if errors.Is(err, domain.ErrGroupCallNotJoined) {
|
||||||
// 幂等:重复 leave / sweeper 已清,返回当前快照。
|
// 幂等:重复 leave / sweeper 已清,返回当前快照。
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
||||||
groupCallUpdateFor(scope.channel, scope.call, scope.userID, false), nil), nil
|
groupCallUpdateFor(scope.channel, scope.call, scope.userID, false, r.cfg.PublicBaseURL), nil), nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, groupCallErr(err)
|
return nil, groupCallErr(err)
|
||||||
|
|
@ -411,7 +411,7 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr
|
||||||
Version: mut.Call.Version,
|
Version: mut.Call.Version,
|
||||||
}, []int64{scope.userID})
|
}, []int64{scope.userID})
|
||||||
if mut.Call.Conference() && !mut.Call.Active() {
|
if mut.Call.Conference() && !mut.Call.Active() {
|
||||||
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID))
|
out.Updates = append(out.Updates, groupCallUpdateFor(domain.Channel{}, mut.Call, scope.userID, scope.userID == mut.Call.CreatorUserID, r.cfg.PublicBaseURL))
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +435,7 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
|
||||||
if call.Conference() {
|
if call.Conference() {
|
||||||
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
|
r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard))
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
return r.groupCallUpdateContainer(ctx, scope.userID, domain.Channel{},
|
||||||
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true), nil), nil
|
groupCallUpdateFor(domain.Channel{}, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
|
||||||
}
|
}
|
||||||
// RTMP 直播结束:断开推流并清空缓冲(观众后续拉流转 resync/停止)。
|
// RTMP 直播结束:断开推流并清空缓冲(观众后续拉流转 resync/停止)。
|
||||||
if call.RtmpStream && r.deps.LiveStreams != nil {
|
if call.RtmpStream && r.deps.LiveStreams != nil {
|
||||||
|
|
@ -463,7 +463,7 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa
|
||||||
r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes)
|
r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes)
|
||||||
}
|
}
|
||||||
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||||
groupCallUpdateFor(channel, call, scope.userID, true), nil)
|
groupCallUpdateFor(channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil)
|
||||||
if serviceRes.Event.Pts != 0 {
|
if serviceRes.Event.Pts != 0 {
|
||||||
if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil {
|
if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil {
|
||||||
out.Updates = append(out.Updates, msgUpdate)
|
out.Updates = append(out.Updates, msgUpdate)
|
||||||
|
|
@ -499,7 +499,7 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC
|
||||||
// 定时通话:回填 viewer 自己的开播提醒订阅(客户端 reload 全量重建本地状态)。
|
// 定时通话:回填 viewer 自己的开播提醒订阅(客户端 reload 全量重建本地状态)。
|
||||||
call := r.applyScheduleSubscription(ctx, scope.call, scope.userID)
|
call := r.applyScheduleSubscription(ctx, scope.call, scope.userID)
|
||||||
return &tg.PhoneGroupCall{
|
return &tg.PhoneGroupCall{
|
||||||
Call: tgGroupCall(call, scope.userID, scope.canManage()),
|
Call: tgGroupCall(call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL),
|
||||||
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
Participants: tgGroupCallParticipants(page.Participants, scope.userID),
|
||||||
ParticipantsNextOffset: page.NextOffset,
|
ParticipantsNextOffset: page.NextOffset,
|
||||||
Chats: chats,
|
Chats: chats,
|
||||||
|
|
@ -589,8 +589,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall {
|
func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool, publicBaseURL ...string) *tg.UpdateGroupCall {
|
||||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)}
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage, publicBaseURL...)}
|
||||||
if channel.ID != 0 {
|
if channel.ID != 0 {
|
||||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ func (r *Router) onPhoneEditGroupCallTitle(ctx context.Context, req *tg.PhoneEdi
|
||||||
}
|
}
|
||||||
r.pushGroupCallUpdate(ctx, scope.channel, call)
|
r.pushGroupCallUpdate(ctx, scope.channel, call)
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
||||||
groupCallUpdateFor(scope.channel, call, scope.userID, true), nil), nil
|
groupCallUpdateFor(scope.channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.PhoneToggleGroupCallSettingsRequest) (tg.UpdatesClass, error) {
|
func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.PhoneToggleGroupCallSettingsRequest) (tg.UpdatesClass, error) {
|
||||||
|
|
@ -261,7 +261,7 @@ func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.Pho
|
||||||
}
|
}
|
||||||
r.pushGroupCallUpdate(ctx, scope.channel, call)
|
r.pushGroupCallUpdate(ctx, scope.channel, call)
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
||||||
groupCallUpdateFor(scope.channel, call, scope.userID, true), nil), nil
|
groupCallUpdateFor(scope.channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxInviteToGroupCallUsers = 10
|
const maxInviteToGroupCallUsers = 10
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ func (r *Router) onPhoneLeaveGroupCallPresentation(ctx context.Context, call tg.
|
||||||
// 幂等快照:未在共享/已清理时返回当前态而非报错(屏幕共享既已下架即达成目的)。
|
// 幂等快照:未在共享/已清理时返回当前态而非报错(屏幕共享既已下架即达成目的)。
|
||||||
idempotentSnapshot := func() (tg.UpdatesClass, error) {
|
idempotentSnapshot := func() (tg.UpdatesClass, error) {
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel,
|
||||||
groupCallUpdateFor(scope.channel, scope.call, scope.userID, scope.canManage()), nil), nil
|
groupCallUpdateFor(scope.channel, scope.call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL), nil), nil
|
||||||
}
|
}
|
||||||
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
|
self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ func (r *Router) joinRtmpGroupCall(ctx context.Context, scope *groupCallScope, r
|
||||||
// updateGroupCall 必须先于 updateGroupCallConnection:TDesktop 按序 applyUpdates,
|
// updateGroupCall 必须先于 updateGroupCallConnection:TDesktop 按序 applyUpdates,
|
||||||
// 处理 connection 时若还没从 groupCall 读到 stream_dc_id 会打
|
// 处理 connection 时若还没从 groupCall 读到 stream_dc_id 会打
|
||||||
// "Api Error: Empty stream_dc_id" 并 fallback 主 DC。
|
// "Api Error: Empty stream_dc_id" 并 fallback 主 DC。
|
||||||
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())}
|
callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)}
|
||||||
if channel.ID != 0 {
|
if channel.ID != 0 {
|
||||||
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.Input
|
||||||
if !changed {
|
if !changed {
|
||||||
// 幂等:已开始,只回快照,不重复扇出/服务消息。
|
// 幂等:已开始,只回快照,不重复扇出/服务消息。
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
return r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||||
groupCallUpdateFor(channel, call, scope.userID, true), nil), nil
|
groupCallUpdateFor(channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil), nil
|
||||||
}
|
}
|
||||||
now := int(r.clock.Now().Unix())
|
now := int(r.clock.Now().Unix())
|
||||||
// started 服务消息(与即时创建的 started 同构)。
|
// started 服务消息(与即时创建的 started 同构)。
|
||||||
|
|
@ -75,7 +75,7 @@ func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.Input
|
||||||
r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes)
|
r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes)
|
||||||
}
|
}
|
||||||
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
out := r.groupCallUpdateContainer(ctx, scope.userID, channel,
|
||||||
groupCallUpdateFor(channel, call, scope.userID, true), nil)
|
groupCallUpdateFor(channel, call, scope.userID, true, r.cfg.PublicBaseURL), nil)
|
||||||
if serviceRes.Event.Pts != 0 {
|
if serviceRes.Event.Pts != 0 {
|
||||||
if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil {
|
if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil {
|
||||||
out.Updates = append(out.Updates, msgUpdate)
|
out.Updates = append(out.Updates, msgUpdate)
|
||||||
|
|
@ -105,7 +105,7 @@ func (r *Router) onPhoneToggleGroupCallStartSubscription(ctx context.Context, re
|
||||||
call := scope.call
|
call := scope.call
|
||||||
call.ScheduleStartSubscribed = req.Subscribed
|
call.ScheduleStartSubscribed = req.Subscribed
|
||||||
// 订阅是 per-viewer 私有状态:响应给本设备,推送同步本人其它在线设备即可。
|
// 订阅是 per-viewer 私有状态:响应给本设备,推送同步本人其它在线设备即可。
|
||||||
update := groupCallUpdateFor(scope.channel, call, scope.userID, scope.canManage())
|
update := groupCallUpdateFor(scope.channel, call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)
|
||||||
r.pushUserMessage(ctx, scope.userID, "schedule subscription update",
|
r.pushUserMessage(ctx, scope.userID, "schedule subscription update",
|
||||||
r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil))
|
r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil))
|
||||||
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil), nil
|
return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil), nil
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel
|
||||||
if subscribed != nil {
|
if subscribed != nil {
|
||||||
_, viewerCall.ScheduleStartSubscribed = subscribed[viewerID]
|
_, viewerCall.ScheduleStartSubscribed = subscribed[viewerID]
|
||||||
}
|
}
|
||||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(viewerCall, viewerID, false)}
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(viewerCall, viewerID, false, r.cfg.PublicBaseURL)}
|
||||||
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID})
|
||||||
r.pushUserMessage(ctx, viewerID, "group call update",
|
r.pushUserMessage(ctx, viewerID, "group call update",
|
||||||
r.groupCallUpdateContainer(ctx, viewerID, channel, update, []int64{call.CreatorUserID}))
|
r.groupCallUpdateContainer(ctx, viewerID, channel, update, []int64{call.CreatorUserID}))
|
||||||
|
|
@ -138,7 +138,7 @@ func (r *Router) pushConferenceGroupCallUpdate(ctx context.Context, call domain.
|
||||||
func (r *Router) pushConferenceGroupCallUpdateTo(ctx context.Context, call domain.GroupCall, extraUserIDs []int64) {
|
func (r *Router) pushConferenceGroupCallUpdateTo(ctx context.Context, call domain.GroupCall, extraUserIDs []int64) {
|
||||||
recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs)
|
recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs)
|
||||||
for _, viewerID := range recipients {
|
for _, viewerID := range recipients {
|
||||||
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID)}
|
update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, viewerID == call.CreatorUserID, r.cfg.PublicBaseURL)}
|
||||||
r.pushUserMessage(ctx, viewerID, "conference call update",
|
r.pushUserMessage(ctx, viewerID, "conference call update",
|
||||||
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID}))
|
r.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeer
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, premiumBoostErr(err)
|
return nil, premiumBoostErr(err)
|
||||||
}
|
}
|
||||||
return tgPremiumBoostsStatus(view.Channel.ID, status), nil
|
return tgPremiumBoostsStatus(view.Channel.ID, status, r.cfg.PublicBaseURL), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) onPremiumGetBoostsList(ctx context.Context, req *tg.PremiumGetBoostsListRequest) (*tg.PremiumBoostsList, error) {
|
func (r *Router) onPremiumGetBoostsList(ctx context.Context, req *tg.PremiumGetBoostsListRequest) (*tg.PremiumBoostsList, error) {
|
||||||
|
|
@ -190,13 +190,13 @@ func (r *Router) currentPremiumUntil(ctx context.Context) (int64, int, error) {
|
||||||
return userID, u.PremiumUntil, nil
|
return userID, u.PremiumUntil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func tgPremiumBoostsStatus(channelID int64, in domain.PremiumBoostStatus) *tg.PremiumBoostsStatus {
|
func tgPremiumBoostsStatus(channelID int64, in domain.PremiumBoostStatus, publicBaseURL string) *tg.PremiumBoostsStatus {
|
||||||
out := &tg.PremiumBoostsStatus{
|
out := &tg.PremiumBoostsStatus{
|
||||||
MyBoost: len(in.MyBoostSlots) > 0,
|
MyBoost: len(in.MyBoostSlots) > 0,
|
||||||
Level: in.Level,
|
Level: in.Level,
|
||||||
CurrentLevelBoosts: in.CurrentLevelBoosts,
|
CurrentLevelBoosts: in.CurrentLevelBoosts,
|
||||||
Boosts: in.Boosts,
|
Boosts: in.Boosts,
|
||||||
BoostURL: fmt.Sprintf("https://telesrv.net/boost?c=%d", channelID),
|
BoostURL: publicLinkParamWithBaseURL(publicBaseURL, "boost", "c", fmt.Sprintf("%d", channelID)),
|
||||||
}
|
}
|
||||||
if in.GiftBoosts > 0 {
|
if in.GiftBoosts > 0 {
|
||||||
out.SetGiftBoosts(in.GiftBoosts)
|
out.SetGiftBoosts(in.GiftBoosts)
|
||||||
|
|
|
||||||
35
internal/rpc/public_links.go
Normal file
35
internal/rpc/public_links.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"telesrv/internal/links"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Router) publicLink(path string) string {
|
||||||
|
return publicLinkWithBaseURL(r.cfg.PublicBaseURL, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) publicLinkQuery(path string, query url.Values) string {
|
||||||
|
return publicLinkQueryWithBaseURL(r.cfg.PublicBaseURL, path, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) publicLinkParam(path, key, value string) string {
|
||||||
|
return r.publicLinkQuery(path, url.Values{key: []string{value}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) publicLinkHost() string {
|
||||||
|
return links.Host(r.cfg.PublicBaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicLinkWithBaseURL(baseURL, path string) string {
|
||||||
|
return links.Build(baseURL, path, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicLinkQueryWithBaseURL(baseURL, path string, query url.Values) string {
|
||||||
|
return links.Build(baseURL, path, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicLinkParamWithBaseURL(baseURL, path, key, value string) string {
|
||||||
|
return publicLinkQueryWithBaseURL(baseURL, path, url.Values{key: []string{value}})
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,8 @@ type Config struct {
|
||||||
// RtmpIngestURL 是 getGroupCallStreamRtmpUrl 返回给推流端(OBS)的服务器地址,
|
// RtmpIngestURL 是 getGroupCallStreamRtmpUrl 返回给推流端(OBS)的服务器地址,
|
||||||
// 形如 "rtmp://<host>:<port>/live"。为空时回落 "rtmp://<AdvertiseIP>:2400/live"。
|
// 形如 "rtmp://<host>:<port>/live"。为空时回落 "rtmp://<AdvertiseIP>:2400/live"。
|
||||||
RtmpIngestURL string
|
RtmpIngestURL string
|
||||||
|
// PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。
|
||||||
|
PublicBaseURL string
|
||||||
// TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key
|
// TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key
|
||||||
// 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。
|
// 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。
|
||||||
// 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的
|
// 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的
|
||||||
|
|
@ -186,6 +188,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
|
||||||
r.registerUpload(d)
|
r.registerUpload(d)
|
||||||
r.registerPhotos(d)
|
r.registerPhotos(d)
|
||||||
r.registerFolders(d)
|
r.registerFolders(d)
|
||||||
|
r.registerChatlists(d)
|
||||||
r.registerContacts(d)
|
r.registerContacts(d)
|
||||||
r.registerLangpack(d)
|
r.registerLangpack(d)
|
||||||
r.registerStories(d)
|
r.registerStories(d)
|
||||||
|
|
|
||||||
|
|
@ -495,7 +495,7 @@ func (r *Router) onStoriesExportStoryLink(ctx context.Context, req *tg.StoriesEx
|
||||||
return nil, storyIDInvalidErr()
|
return nil, storyIDInvalidErr()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &tg.ExportedStoryLink{Link: storyExportLink(peer, req.ID)}, nil
|
return &tg.ExportedStoryLink{Link: r.storyExportLink(peer, req.ID)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateStoriesExportStoryLinkRequest(req *tg.StoriesExportStoryLinkRequest) error {
|
func validateStoriesExportStoryLinkRequest(req *tg.StoriesExportStoryLinkRequest) error {
|
||||||
|
|
@ -3018,8 +3018,8 @@ func uniqueStoryIDs(ids []int) []int {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func storyExportLink(peer domain.Peer, storyID int) string {
|
func (r *Router) storyExportLink(peer domain.Peer, storyID int) string {
|
||||||
return fmt.Sprintf("https://telesrv.local/story/%s/%d/%d", peer.Type, peer.ID, storyID)
|
return r.publicLink(fmt.Sprintf("story/%s/%d/%d", peer.Type, peer.ID, storyID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) recordStoryChange(ctx context.Context, userID int64, story domain.Story) error {
|
func (r *Router) recordStoryChange(ctx context.Context, userID int64, story domain.Story) error {
|
||||||
|
|
|
||||||
|
|
@ -5889,7 +5889,7 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("export story link: %v", err)
|
t.Fatalf("export story link: %v", err)
|
||||||
}
|
}
|
||||||
if link.Link != "https://telesrv.local/story/user/9311/1" {
|
if link.Link != "https://telesrv.net/story/user/9311/1" {
|
||||||
t.Fatalf("export story link = %q, want deterministic telesrv link", link.Link)
|
t.Fatalf("export story link = %q, want deterministic telesrv link", link.Link)
|
||||||
}
|
}
|
||||||
if _, err := r.onStoriesExportStoryLink(reqCtx, &tg.StoriesExportStoryLinkRequest{
|
if _, err := r.onStoriesExportStoryLink(reqCtx, &tg.StoriesExportStoryLinkRequest{
|
||||||
|
|
|
||||||
25
internal/store/chatlist.go
Normal file
25
internal/store/chatlist.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatlistStore persists exported shared-folder links and imported memberships.
|
||||||
|
type ChatlistStore interface {
|
||||||
|
CountInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error)
|
||||||
|
CountActiveInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error)
|
||||||
|
SaveInvite(ctx context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error)
|
||||||
|
GetInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error)
|
||||||
|
GetInviteBySlug(ctx context.Context, slug string) (domain.ChatlistInvite, bool, error)
|
||||||
|
ListInvites(ctx context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error)
|
||||||
|
DeleteInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (bool, error)
|
||||||
|
|
||||||
|
CountMemberships(ctx context.Context, userID int64) (int, error)
|
||||||
|
SaveMembership(ctx context.Context, membership domain.ChatlistMembership) error
|
||||||
|
GetMembershipBySlug(ctx context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error)
|
||||||
|
GetMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error)
|
||||||
|
DeleteMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (bool, error)
|
||||||
|
SetMembershipHidden(ctx context.Context, userID int64, localFilterID int, hidden bool) (bool, error)
|
||||||
|
}
|
||||||
212
internal/store/memory/chatlists.go
Normal file
212
internal/store/memory/chatlists.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatlistStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
nextInvite int64
|
||||||
|
invites map[string]domain.ChatlistInvite
|
||||||
|
memberships map[chatlistMembershipKey]domain.ChatlistMembership
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatlistMembershipKey struct {
|
||||||
|
userID int64
|
||||||
|
localFilterID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChatlistStore creates an in-memory ChatlistStore.
|
||||||
|
func NewChatlistStore() *ChatlistStore {
|
||||||
|
return &ChatlistStore{
|
||||||
|
nextInvite: 1,
|
||||||
|
invites: make(map[string]domain.ChatlistInvite),
|
||||||
|
memberships: make(map[chatlistMembershipKey]domain.ChatlistMembership),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) CountInvites(_ context.Context, ownerUserID int64, filterID int) (int, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
count := 0
|
||||||
|
for _, invite := range s.invites {
|
||||||
|
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) CountActiveInvites(_ context.Context, ownerUserID int64, filterID int) (int, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
count := 0
|
||||||
|
for _, invite := range s.invites {
|
||||||
|
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted && !invite.Revoked {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) SaveInvite(_ context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if invite.Slug == "" {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistInviteInvalid
|
||||||
|
}
|
||||||
|
if existing, ok := s.invites[invite.Slug]; ok && (existing.OwnerUserID != invite.OwnerUserID || existing.FilterID != invite.FilterID) {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
|
||||||
|
}
|
||||||
|
if invite.ID == 0 {
|
||||||
|
if existing, ok := s.invites[invite.Slug]; ok {
|
||||||
|
invite.ID = existing.ID
|
||||||
|
if invite.Date == 0 {
|
||||||
|
invite.Date = existing.Date
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
invite.ID = s.nextInvite
|
||||||
|
s.nextInvite++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invite.Deleted = false
|
||||||
|
s.invites[invite.Slug] = cloneChatlistInvite(invite)
|
||||||
|
return cloneChatlistInvite(invite), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetInvite(_ context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
invite, ok := s.invites[slug]
|
||||||
|
if !ok || invite.Deleted || invite.OwnerUserID != ownerUserID || invite.FilterID != filterID {
|
||||||
|
return domain.ChatlistInvite{}, false, nil
|
||||||
|
}
|
||||||
|
return cloneChatlistInvite(invite), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetInviteBySlug(_ context.Context, slug string) (domain.ChatlistInvite, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
invite, ok := s.invites[slug]
|
||||||
|
if !ok || invite.Deleted || invite.Revoked {
|
||||||
|
return domain.ChatlistInvite{}, false, nil
|
||||||
|
}
|
||||||
|
return cloneChatlistInvite(invite), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) ListInvites(_ context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]domain.ChatlistInvite, 0)
|
||||||
|
for _, invite := range s.invites {
|
||||||
|
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted {
|
||||||
|
out = append(out, cloneChatlistInvite(invite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Date != out[j].Date {
|
||||||
|
return out[i].Date < out[j].Date
|
||||||
|
}
|
||||||
|
return out[i].Slug < out[j].Slug
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) DeleteInvite(_ context.Context, ownerUserID int64, filterID int, slug string) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
invite, ok := s.invites[slug]
|
||||||
|
if !ok || invite.Deleted || invite.OwnerUserID != ownerUserID || invite.FilterID != filterID {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
invite.Deleted = true
|
||||||
|
s.invites[slug] = invite
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) CountMemberships(_ context.Context, userID int64) (int, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
count := 0
|
||||||
|
for key := range s.memberships {
|
||||||
|
if key.userID == userID {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) SaveMembership(_ context.Context, membership domain.ChatlistMembership) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if membership.UserID == 0 || membership.LocalFilterID == 0 || membership.Slug == "" {
|
||||||
|
return domain.ErrChatlistInvalid
|
||||||
|
}
|
||||||
|
key := chatlistMembershipKey{userID: membership.UserID, localFilterID: membership.LocalFilterID}
|
||||||
|
for existingKey, existing := range s.memberships {
|
||||||
|
if existingKey != key && existing.UserID == membership.UserID && existing.Slug == membership.Slug {
|
||||||
|
delete(s.memberships, existingKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.memberships[key] = cloneChatlistMembership(membership)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetMembershipBySlug(_ context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, membership := range s.memberships {
|
||||||
|
if membership.UserID == userID && membership.Slug == slug {
|
||||||
|
return cloneChatlistMembership(membership), true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.ChatlistMembership{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetMembershipByLocalFilter(_ context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
membership, ok := s.memberships[chatlistMembershipKey{userID: userID, localFilterID: localFilterID}]
|
||||||
|
if !ok {
|
||||||
|
return domain.ChatlistMembership{}, false, nil
|
||||||
|
}
|
||||||
|
return cloneChatlistMembership(membership), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) DeleteMembershipByLocalFilter(_ context.Context, userID int64, localFilterID int) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
key := chatlistMembershipKey{userID: userID, localFilterID: localFilterID}
|
||||||
|
if _, ok := s.memberships[key]; !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
delete(s.memberships, key)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) SetMembershipHidden(_ context.Context, userID int64, localFilterID int, hidden bool) (bool, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
key := chatlistMembershipKey{userID: userID, localFilterID: localFilterID}
|
||||||
|
membership, ok := s.memberships[key]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
changed := membership.HiddenUpdates != hidden
|
||||||
|
membership.HiddenUpdates = hidden
|
||||||
|
s.memberships[key] = membership
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneChatlistInvite(invite domain.ChatlistInvite) domain.ChatlistInvite {
|
||||||
|
invite.Peers = append([]domain.DialogFolderPeer(nil), invite.Peers...)
|
||||||
|
return invite
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneChatlistMembership(membership domain.ChatlistMembership) domain.ChatlistMembership {
|
||||||
|
return membership
|
||||||
|
}
|
||||||
279
internal/store/postgres/chatlist.go
Normal file
279
internal/store/postgres/chatlist.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/postgres/sqlcgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatlistStore struct {
|
||||||
|
db sqlcgen.DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatlistStore(db sqlcgen.DBTX) *ChatlistStore {
|
||||||
|
return &ChatlistStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) CountInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT count(*)::int
|
||||||
|
FROM chatlist_invites
|
||||||
|
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted`, ownerUserID, filterID).Scan(&count); err != nil {
|
||||||
|
return 0, fmt.Errorf("count chatlist invites: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) CountActiveInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT count(*)::int
|
||||||
|
FROM chatlist_invites
|
||||||
|
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted AND NOT revoked`, ownerUserID, filterID).Scan(&count); err != nil {
|
||||||
|
return 0, fmt.Errorf("count active chatlist invites: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) SaveInvite(ctx context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error) {
|
||||||
|
peers, err := json.Marshal(invite.Peers)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChatlistInvite{}, fmt.Errorf("marshal chatlist invite peers: %w", err)
|
||||||
|
}
|
||||||
|
row := s.db.QueryRow(ctx, `
|
||||||
|
INSERT INTO chatlist_invites (owner_user_id, filter_id, slug, title, peers, revoked, deleted, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6, false,
|
||||||
|
CASE WHEN $7::int > 0 THEN to_timestamp($7::int) ELSE now() END, now())
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
peers = EXCLUDED.peers,
|
||||||
|
revoked = EXCLUDED.revoked,
|
||||||
|
deleted = false,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE chatlist_invites.owner_user_id = EXCLUDED.owner_user_id
|
||||||
|
AND chatlist_invites.filter_id = EXCLUDED.filter_id
|
||||||
|
RETURNING id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
|
||||||
|
EXTRACT(EPOCH FROM created_at)::int`, invite.OwnerUserID, invite.FilterID, invite.Slug, invite.Title, peers, invite.Revoked, invite.Date)
|
||||||
|
out, err := scanChatlistInvite(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
|
||||||
|
}
|
||||||
|
return domain.ChatlistInvite{}, fmt.Errorf("save chatlist invite: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error) {
|
||||||
|
invite, err := scanChatlistInvite(s.db.QueryRow(ctx, `
|
||||||
|
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
|
||||||
|
EXTRACT(EPOCH FROM created_at)::int
|
||||||
|
FROM chatlist_invites
|
||||||
|
WHERE owner_user_id = $1 AND filter_id = $2 AND slug = $3 AND NOT deleted`, ownerUserID, filterID, slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ChatlistInvite{}, false, nil
|
||||||
|
}
|
||||||
|
return domain.ChatlistInvite{}, false, fmt.Errorf("get chatlist invite: %w", err)
|
||||||
|
}
|
||||||
|
return invite, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetInviteBySlug(ctx context.Context, slug string) (domain.ChatlistInvite, bool, error) {
|
||||||
|
invite, err := scanChatlistInvite(s.db.QueryRow(ctx, `
|
||||||
|
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
|
||||||
|
EXTRACT(EPOCH FROM created_at)::int
|
||||||
|
FROM chatlist_invites
|
||||||
|
WHERE slug = $1 AND NOT deleted AND NOT revoked`, slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ChatlistInvite{}, false, nil
|
||||||
|
}
|
||||||
|
return domain.ChatlistInvite{}, false, fmt.Errorf("get chatlist invite by slug: %w", err)
|
||||||
|
}
|
||||||
|
return invite, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) ListInvites(ctx context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error) {
|
||||||
|
rows, err := s.db.Query(ctx, `
|
||||||
|
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
|
||||||
|
EXTRACT(EPOCH FROM created_at)::int
|
||||||
|
FROM chatlist_invites
|
||||||
|
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted
|
||||||
|
ORDER BY created_at ASC, slug ASC`, ownerUserID, filterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list chatlist invites: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]domain.ChatlistInvite, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
invite, err := scanChatlistInvite(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, invite)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("list chatlist invites rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) DeleteInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (bool, error) {
|
||||||
|
tag, err := s.db.Exec(ctx, `
|
||||||
|
UPDATE chatlist_invites
|
||||||
|
SET deleted = true, updated_at = now()
|
||||||
|
WHERE owner_user_id = $1 AND filter_id = $2 AND slug = $3 AND NOT deleted`, ownerUserID, filterID, slug)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("delete chatlist invite: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) CountMemberships(ctx context.Context, userID int64) (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRow(ctx, `
|
||||||
|
SELECT count(*)::int
|
||||||
|
FROM chatlist_memberships
|
||||||
|
WHERE user_id = $1`, userID).Scan(&count); err != nil {
|
||||||
|
return 0, fmt.Errorf("count chatlist memberships: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) SaveMembership(ctx context.Context, membership domain.ChatlistMembership) error {
|
||||||
|
if membership.Date == 0 {
|
||||||
|
membership.Date = nowUnix()
|
||||||
|
}
|
||||||
|
exec := func(ctx context.Context, db sqlcgen.DBTX) error {
|
||||||
|
if _, err := db.Exec(ctx, `
|
||||||
|
DELETE FROM chatlist_memberships
|
||||||
|
WHERE user_id = $1 AND slug = $2 AND local_filter_id <> $3`, membership.UserID, membership.Slug, membership.LocalFilterID); err != nil {
|
||||||
|
return fmt.Errorf("dedupe chatlist membership: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(ctx, `
|
||||||
|
INSERT INTO chatlist_memberships (
|
||||||
|
user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates, joined_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
CASE WHEN $7::int > 0 THEN to_timestamp($7::int) ELSE now() END,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (user_id, local_filter_id) DO UPDATE SET
|
||||||
|
owner_user_id = EXCLUDED.owner_user_id,
|
||||||
|
owner_filter_id = EXCLUDED.owner_filter_id,
|
||||||
|
slug = EXCLUDED.slug,
|
||||||
|
hidden_updates = EXCLUDED.hidden_updates,
|
||||||
|
updated_at = now()`, membership.UserID, membership.LocalFilterID, membership.OwnerUserID, membership.OwnerFilterID, membership.Slug, membership.HiddenUpdates, membership.Date); err != nil {
|
||||||
|
return fmt.Errorf("save chatlist membership: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
beginner, ok := s.db.(txBeginner)
|
||||||
|
if !ok {
|
||||||
|
return exec(ctx, s.db)
|
||||||
|
}
|
||||||
|
tx, err := beginner.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin save chatlist membership: %w", err)
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := exec(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit save chatlist membership: %w", err)
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetMembershipBySlug(ctx context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error) {
|
||||||
|
membership, err := scanChatlistMembership(s.db.QueryRow(ctx, `
|
||||||
|
SELECT user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates,
|
||||||
|
EXTRACT(EPOCH FROM joined_at)::int
|
||||||
|
FROM chatlist_memberships
|
||||||
|
WHERE user_id = $1 AND slug = $2`, userID, slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ChatlistMembership{}, false, nil
|
||||||
|
}
|
||||||
|
return domain.ChatlistMembership{}, false, fmt.Errorf("get chatlist membership by slug: %w", err)
|
||||||
|
}
|
||||||
|
return membership, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) GetMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error) {
|
||||||
|
membership, err := scanChatlistMembership(s.db.QueryRow(ctx, `
|
||||||
|
SELECT user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates,
|
||||||
|
EXTRACT(EPOCH FROM joined_at)::int
|
||||||
|
FROM chatlist_memberships
|
||||||
|
WHERE user_id = $1 AND local_filter_id = $2`, userID, localFilterID))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ChatlistMembership{}, false, nil
|
||||||
|
}
|
||||||
|
return domain.ChatlistMembership{}, false, fmt.Errorf("get chatlist membership by local filter: %w", err)
|
||||||
|
}
|
||||||
|
return membership, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) DeleteMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (bool, error) {
|
||||||
|
tag, err := s.db.Exec(ctx, `
|
||||||
|
DELETE FROM chatlist_memberships
|
||||||
|
WHERE user_id = $1 AND local_filter_id = $2`, userID, localFilterID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("delete chatlist membership: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatlistStore) SetMembershipHidden(ctx context.Context, userID int64, localFilterID int, hidden bool) (bool, error) {
|
||||||
|
tag, err := s.db.Exec(ctx, `
|
||||||
|
UPDATE chatlist_memberships
|
||||||
|
SET hidden_updates = $3, updated_at = now()
|
||||||
|
WHERE user_id = $1 AND local_filter_id = $2 AND hidden_updates IS DISTINCT FROM $3`, userID, localFilterID, hidden)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("set chatlist membership hidden: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanChatlistInvite(row rowScanner) (domain.ChatlistInvite, error) {
|
||||||
|
var invite domain.ChatlistInvite
|
||||||
|
var peersJSON string
|
||||||
|
if err := row.Scan(&invite.ID, &invite.OwnerUserID, &invite.FilterID, &invite.Slug, &invite.Title, &peersJSON, &invite.Revoked, &invite.Deleted, &invite.Date); err != nil {
|
||||||
|
return domain.ChatlistInvite{}, err
|
||||||
|
}
|
||||||
|
if peersJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(peersJSON), &invite.Peers); err != nil {
|
||||||
|
return domain.ChatlistInvite{}, fmt.Errorf("decode chatlist invite peers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanChatlistMembership(row rowScanner) (domain.ChatlistMembership, error) {
|
||||||
|
var membership domain.ChatlistMembership
|
||||||
|
if err := row.Scan(&membership.UserID, &membership.LocalFilterID, &membership.OwnerUserID, &membership.OwnerFilterID, &membership.Slug, &membership.HiddenUpdates, &membership.Date); err != nil {
|
||||||
|
return domain.ChatlistMembership{}, err
|
||||||
|
}
|
||||||
|
return membership, nil
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
"telesrv/internal/store"
|
"telesrv/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,6 +24,10 @@ func baseNow() int {
|
||||||
return int(time.Now().Unix())
|
return int(time.Now().Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func conferenceInviteLink(slug string) string {
|
||||||
|
return links.Build(links.DefaultPublicBaseURL, "call/"+slug, url.Values{"slug": []string{slug}})
|
||||||
|
}
|
||||||
|
|
||||||
// GroupCallStoreFactory 为每个用例提供干净的 store 与不冲突的 channel id。
|
// GroupCallStoreFactory 为每个用例提供干净的 store 与不冲突的 channel id。
|
||||||
type GroupCallStoreFactory func(t *testing.T) (st store.GroupCallStore, channelID int64)
|
type GroupCallStoreFactory func(t *testing.T) (st store.GroupCallStore, channelID int64)
|
||||||
|
|
||||||
|
|
@ -340,7 +346,7 @@ func contractConferenceChainBlocks(t *testing.T, factory GroupCallStoreFactory)
|
||||||
slug := fmt.Sprintf("contract-chain-%d", channelID)
|
slug := fmt.Sprintf("contract-chain-%d", channelID)
|
||||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||||
ID: channelID*100 + 51, AccessHash: channelID*100 + 58, CreatorUserID: 1,
|
ID: channelID*100 + 51, AccessHash: channelID*100 + 58, CreatorUserID: 1,
|
||||||
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
|
InviteSlug: slug, InviteLink: conferenceInviteLink(slug),
|
||||||
RandomID: channelID*100 + 51, CreatedAt: now,
|
RandomID: channelID*100 + 51, CreatedAt: now,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -388,7 +394,7 @@ func contractConferenceRecipientsTerminalAccess(t *testing.T, factory GroupCallS
|
||||||
slug := fmt.Sprintf("contract-recipient-%d", channelID)
|
slug := fmt.Sprintf("contract-recipient-%d", channelID)
|
||||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||||
ID: channelID*100 + 61, AccessHash: channelID*100 + 68, CreatorUserID: 1,
|
ID: channelID*100 + 61, AccessHash: channelID*100 + 68, CreatorUserID: 1,
|
||||||
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
|
InviteSlug: slug, InviteLink: conferenceInviteLink(slug),
|
||||||
RandomID: channelID*100 + 61, CreatedAt: now,
|
RandomID: channelID*100 + 61, CreatedAt: now,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -434,10 +440,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory
|
||||||
st, channelID := factory(t)
|
st, channelID := factory(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := baseNow()
|
now := baseNow()
|
||||||
|
emptySlug := fmt.Sprintf("contract-empty-%d", channelID)
|
||||||
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||||
ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1,
|
ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1,
|
||||||
InviteSlug: fmt.Sprintf("contract-empty-%d", channelID),
|
InviteSlug: emptySlug,
|
||||||
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-empty-%d?slug=contract-empty-%d", channelID, channelID),
|
InviteLink: conferenceInviteLink(emptySlug),
|
||||||
RandomID: channelID*100 + 71,
|
RandomID: channelID*100 + 71,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -464,10 +471,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory
|
||||||
t.Fatalf("join empty discarded conference err = %v, want ErrGroupCallDiscarded", err)
|
t.Fatalf("join empty discarded conference err = %v, want ErrGroupCallDiscarded", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSlug := fmt.Sprintf("contract-reset-empty-%d", channelID)
|
||||||
resetCall, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
resetCall, err := st.CreateConferenceCall(ctx, domain.GroupCall{
|
||||||
ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1,
|
ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1,
|
||||||
InviteSlug: fmt.Sprintf("contract-reset-empty-%d", channelID),
|
InviteSlug: resetSlug,
|
||||||
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-reset-empty-%d?slug=contract-reset-empty-%d", channelID, channelID),
|
InviteLink: conferenceInviteLink(resetSlug),
|
||||||
RandomID: channelID*100 + 81,
|
RandomID: channelID*100 + 81,
|
||||||
CreatedAt: now + 10,
|
CreatedAt: now + 10,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,9 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/links"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPublicBaseURL = "https://telesrv.net"
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string
|
Addr string
|
||||||
PublicBaseURL string
|
PublicBaseURL string
|
||||||
|
|
@ -50,9 +49,9 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("Sticker link Web endpoint enabled", zap.String("addr", addr), zap.String("public_base_url", normalizePublicBaseURL(cfg.PublicBaseURL)))
|
logger.Info("Public link Web endpoint enabled", zap.String("addr", addr), zap.String("public_base_url", normalizePublicBaseURL(cfg.PublicBaseURL)))
|
||||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
logger.Warn("Sticker link Web endpoint exited", zap.Error(err))
|
logger.Warn("Public link Web endpoint exited", zap.Error(err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -73,6 +72,7 @@ func NewHandler(resolver Resolver, publicBaseURL string) http.Handler {
|
||||||
mux.HandleFunc("GET /healthz", h.healthz)
|
mux.HandleFunc("GET /healthz", h.healthz)
|
||||||
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
|
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
|
||||||
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
|
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
|
||||||
|
mux.HandleFunc("GET /addlist/{slug}", h.addList)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +94,30 @@ func (h *handler) addEmoji(w http.ResponseWriter, r *http.Request) {
|
||||||
h.serveSet(w, r, "addemoji")
|
h.serveSet(w, r, "addemoji")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||||
|
if !validSlugPath(slug) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app := appURL("addlist", "slug", slug)
|
||||||
|
data := pageData{
|
||||||
|
Title: "Shared Folder",
|
||||||
|
KindLabel: "shared folder",
|
||||||
|
Subtitle: slug,
|
||||||
|
Description: "This page opens the app so you can preview and add this shared folder.",
|
||||||
|
CanonicalURL: h.publicURL("addlist", slug),
|
||||||
|
AppURL: template.URL(app),
|
||||||
|
LegacyTgURL: template.URL(legacyTgURL("addlist", "slug", slug)),
|
||||||
|
}
|
||||||
|
data.AppURLJS = template.JS(strconv.Quote(app))
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||||
|
if err := landingTemplate.Execute(w, data); err != nil {
|
||||||
|
http.Error(w, "render shared folder page failed", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) {
|
func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) {
|
||||||
shortName := strings.TrimSpace(r.PathValue("shortName"))
|
shortName := strings.TrimSpace(r.PathValue("shortName"))
|
||||||
if !validShortNamePath(shortName) {
|
if !validShortNamePath(shortName) {
|
||||||
|
|
@ -114,23 +138,22 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri
|
||||||
}
|
}
|
||||||
canonicalKind := linkKind(set)
|
canonicalKind := linkKind(set)
|
||||||
if canonicalKind != pathKind {
|
if canonicalKind != pathKind {
|
||||||
http.Redirect(w, r, h.setURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect)
|
http.Redirect(w, r, h.publicURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
count := set.Count
|
count := set.Count
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
count = len(docs)
|
count = len(docs)
|
||||||
}
|
}
|
||||||
app := appURL(canonicalKind, set.ShortName)
|
app := appURL(canonicalKind, "set", set.ShortName)
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: fallbackTitle(set),
|
Title: fallbackTitle(set),
|
||||||
ShortName: set.ShortName,
|
|
||||||
Count: count,
|
|
||||||
KindLabel: kindLabel(set),
|
KindLabel: kindLabel(set),
|
||||||
ItemNoun: itemNoun(set, count),
|
Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)),
|
||||||
CanonicalURL: h.setURL(canonicalKind, set.ShortName),
|
Description: "This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.",
|
||||||
|
CanonicalURL: h.publicURL(canonicalKind, set.ShortName),
|
||||||
AppURL: template.URL(app),
|
AppURL: template.URL(app),
|
||||||
LegacyTgURL: template.URL(legacyTgURL(canonicalKind, set.ShortName)),
|
LegacyTgURL: template.URL(legacyTgURL(canonicalKind, "set", set.ShortName)),
|
||||||
}
|
}
|
||||||
data.AppURLJS = template.JS(strconv.Quote(app))
|
data.AppURLJS = template.JS(strconv.Quote(app))
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
@ -140,18 +163,14 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) setURL(kind, shortName string) string {
|
func (h *handler) publicURL(kind, value string) string {
|
||||||
return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(shortName)
|
return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePublicBaseURL(raw string) string {
|
func normalizePublicBaseURL(raw string) string {
|
||||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
u, err := url.Parse(links.NormalizeBaseURL(raw))
|
||||||
if raw == "" {
|
|
||||||
return defaultPublicBaseURL
|
|
||||||
}
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
return defaultPublicBaseURL
|
return links.DefaultPublicBaseURL
|
||||||
}
|
}
|
||||||
u.Path = strings.TrimRight(u.Path, "/")
|
u.Path = strings.TrimRight(u.Path, "/")
|
||||||
u.RawQuery = ""
|
u.RawQuery = ""
|
||||||
|
|
@ -176,6 +195,10 @@ func validShortNamePath(shortName string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validSlugPath(slug string) bool {
|
||||||
|
return links.ValidChatlistSlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
func linkKind(set domain.StickerSet) string {
|
func linkKind(set domain.StickerSet) string {
|
||||||
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
|
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
|
||||||
return "addemoji"
|
return "addemoji"
|
||||||
|
|
@ -214,20 +237,23 @@ func itemNoun(set domain.StickerSet, count int) string {
|
||||||
return "stickers"
|
return "stickers"
|
||||||
}
|
}
|
||||||
|
|
||||||
func appURL(kind, shortName string) string {
|
func appURL(kind, key, value string) string {
|
||||||
return "telesrv://" + kind + "?set=" + url.QueryEscape(shortName)
|
return schemeURL("telesrv", kind, key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func legacyTgURL(kind, shortName string) string {
|
func legacyTgURL(kind, key, value string) string {
|
||||||
return "tg://" + kind + "?set=" + url.QueryEscape(shortName)
|
return schemeURL("tg", kind, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemeURL(scheme, kind, key, value string) string {
|
||||||
|
return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
ShortName string
|
|
||||||
Count int
|
|
||||||
KindLabel string
|
KindLabel string
|
||||||
ItemNoun string
|
Subtitle string
|
||||||
|
Description string
|
||||||
CanonicalURL string
|
CanonicalURL string
|
||||||
AppURL template.URL
|
AppURL template.URL
|
||||||
LegacyTgURL template.URL
|
LegacyTgURL template.URL
|
||||||
|
|
@ -242,7 +268,7 @@ var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype htm
|
||||||
<title>{{.Title}} - telesrv</title>
|
<title>{{.Title}} - telesrv</title>
|
||||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||||
<meta property="og:title" content="{{.Title}}">
|
<meta property="og:title" content="{{.Title}}">
|
||||||
<meta property="og:description" content="{{.Count}} {{.ItemNoun}} in this {{.KindLabel}}.">
|
<meta property="og:description" content="{{.Description}}">
|
||||||
<meta property="og:url" content="{{.CanonicalURL}}">
|
<meta property="og:url" content="{{.CanonicalURL}}">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -267,9 +293,9 @@ var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype htm
|
||||||
<main>
|
<main>
|
||||||
<p class="meta">{{.KindLabel}}</p>
|
<p class="meta">{{.KindLabel}}</p>
|
||||||
<h1>{{.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
<p class="meta">@{{.ShortName}} · {{.Count}} {{.ItemNoun}}</p>
|
<p class="meta">{{.Subtitle}}</p>
|
||||||
<p><a class="button" href="{{.AppURL}}">Open in telesrv</a></p>
|
<p><a class="button" href="{{.AppURL}}">Open in telesrv</a></p>
|
||||||
<p>This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.</p>
|
<p>{{.Description}}</p>
|
||||||
<p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p>
|
<p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p>
|
||||||
<p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p>
|
<p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,32 @@ func TestHandlerServesEmojiLandingPage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerServesChatlistLandingPage(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/addlist/zNhytIbwRwjaC2GH", nil)
|
||||||
|
|
||||||
|
NewHandler(fakeResolver{}, "http://127.0.0.1:2401").ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Shared Folder",
|
||||||
|
"http://127.0.0.1:2401/addlist/zNhytIbwRwjaC2GH",
|
||||||
|
"telesrv://addlist?slug=zNhytIbwRwjaC2GH",
|
||||||
|
"tg://addlist?slug=zNhytIbwRwjaC2GH",
|
||||||
|
"preview and add this shared folder",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("body missing %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `window.location.href = "tg://`) {
|
||||||
|
t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
|
func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
|
||||||
resolver := fakeResolver{
|
resolver := fakeResolver{
|
||||||
"emoji_pack": {
|
"emoji_pack": {
|
||||||
|
|
@ -110,6 +136,8 @@ func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) {
|
||||||
"/addstickers/missing_pack",
|
"/addstickers/missing_pack",
|
||||||
"/addstickers/bad-name",
|
"/addstickers/bad-name",
|
||||||
"/addemoji/%E4%B8%AD%E6%96%87",
|
"/addemoji/%E4%B8%AD%E6%96%87",
|
||||||
|
"/addlist/bad!slug",
|
||||||
|
"/addlist/%E4%B8%AD%E6%96%87",
|
||||||
} {
|
} {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue