feat: sync chatlist sharing support

This commit is contained in:
A 2026-07-06 21:06:33 +08:00
parent ec6e8fd13d
commit 2a9c12263f
62 changed files with 3408 additions and 212 deletions

View file

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

View file

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

View file

@ -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` 时 |
### 内部/调试端口(不要暴露到公网) ### 内部/调试端口(不要暴露到公网)

View file

@ -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 SFUM1+。SFU 的 liveness reporter 把媒体面存活 // 群通话媒体面:内嵌 pion SFUM1+。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)
} }

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.chatlist_memberships;
DROP TABLE IF EXISTS public.chatlist_invites;

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

View file

@ -0,0 +1,2 @@
ALTER TABLE public.chatlist_invites
DROP COLUMN IF EXISTS revoked;

View file

@ -0,0 +1,2 @@
ALTER TABLE public.chatlist_invites
ADD COLUMN IF NOT EXISTS revoked boolean NOT NULL DEFAULT false;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View 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},
}
}

View file

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

View file

@ -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 todochatlists 和 story 配额/商业化 key 不下发 // 见 compatibility-matrix todochatlist_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 提供客户端启动配置与国家区号目录。
// //

View file

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

View file

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

View file

@ -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=trueTDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。 // dialog_filters_enabled=trueTDesktop 据此(或已有文件夹)才显示 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}},

View file

@ -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 而非 localhostlocalhost 在 Windows 上会先解析到 IPv6 ::1而 Docker // 用 127.0.0.1 而非 localhostlocalhost 在 Windows 上会先解析到 IPv6 ::1而 Docker
// Desktop 的端口转发只在 IPv4 监听IPv6 连接要等 ~1s 超时才回退 IPv4实测 localhost // Desktop 的端口转发只在 IPv4 监听IPv6 连接要等 ~1s 超时才回退 IPv4实测 localhost

View file

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

View 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
}

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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返回真实 UpdatesI7。bot 经此收到 start_param。 // SendPrivateText 双盒+outbox返回真实 UpdatesI7。bot 经此收到 start_param。
// P3 仅私聊启动peer 为群(加 bot 进群后移P4群内 bot // P3 仅私聊启动peer 为群(加 bot 进群后移P4群内 bot

View file

@ -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 过滤实际会返回群内 botTestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。 // 注Bots 过滤实际会返回群内 botTestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。

View file

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

View file

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

View 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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import (
// registerHelp 注册 help.* RPC handlerDC 配置、最近 DC // registerHelp 注册 help.* RPC handlerDC 配置、最近 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -96,7 +96,7 @@ func (r *Router) joinRtmpGroupCall(ctx context.Context, scope *groupCallScope, r
// updateGroupCall 必须先于 updateGroupCallConnectionTDesktop 按序 applyUpdates // updateGroupCall 必须先于 updateGroupCallConnectionTDesktop 按序 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})
} }

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View 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
}

View 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
}

View file

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

View file

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

View file

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