From 2a9c12263fc910dfa18a100e4917ac1f5688fd90 Mon Sep 17 00:00:00 2001 From: A Date: Mon, 6 Jul 2026 21:06:33 +0800 Subject: [PATCH] feat: sync chatlist sharing support --- .env.example | 8 + README.md | 5 +- README.zh-CN.md | 5 +- cmd/telesrv/main.go | 22 +- deploy/migrations/0056_chatlists.down.sql | 2 + deploy/migrations/0056_chatlists.up.sql | 38 + .../0057_chatlist_invites_revoked.down.sql | 2 + .../0057_chatlist_invites_revoked.up.sql | 2 + docs/Fragment.md | 58 -- internal/app/account/business.go | 11 +- internal/app/account/service.go | 12 +- internal/app/bots/botfather.go | 2 +- internal/app/bots/service.go | 9 + internal/app/bots/stickersbot.go | 35 +- internal/app/chatlists/service.go | 834 ++++++++++++++++++ internal/app/chatlists/service_test.go | 488 ++++++++++ internal/app/groupcalls/service.go | 31 +- internal/app/help/service.go | 7 +- internal/app/help/service_premium_test.go | 7 +- internal/compat/tdesktop/config.go | 7 +- internal/compat/tdesktop/defaults.go | 7 +- internal/config/config.go | 41 +- internal/config/config_test.go | 40 +- internal/domain/chatlist.go | 82 ++ internal/domain/dialog.go | 1 + internal/links/links.go | 94 ++ internal/links/links_test.go | 70 ++ internal/rpc/account_business.go | 6 +- internal/rpc/account_themes.go | 2 +- internal/rpc/aicompose_webpage.go | 11 +- internal/rpc/bots_startbot.go | 2 +- internal/rpc/channels_core.go | 2 +- internal/rpc/channels_invites.go | 24 +- internal/rpc/channels_messages.go | 10 +- internal/rpc/chatlists.go | 526 +++++++++++ internal/rpc/chatlists_rpc_test.go | 307 +++++++ internal/rpc/convert_channels_core.go | 8 +- internal/rpc/convert_dialogs.go | 1 + internal/rpc/convert_phone_group.go | 8 +- internal/rpc/deps.go | 16 + internal/rpc/errors.go | 12 + internal/rpc/help.go | 2 +- internal/rpc/messages_dialogs.go | 1 + internal/rpc/messages_helpers.go | 5 + internal/rpc/phone_conference.go | 21 +- internal/rpc/phone_group_call.go | 18 +- internal/rpc/phone_group_call_edit.go | 4 +- internal/rpc/phone_group_call_presentation.go | 2 +- internal/rpc/phone_group_call_rtmp.go | 2 +- internal/rpc/phone_group_call_scheduled.go | 6 +- internal/rpc/phone_group_call_updates.go | 4 +- internal/rpc/premium.go | 6 +- internal/rpc/public_links.go | 35 + internal/rpc/router.go | 3 + internal/rpc/stories.go | 6 +- internal/rpc/stories_rpc_test.go | 2 +- internal/store/chatlist.go | 25 + internal/store/memory/chatlists.go | 212 +++++ internal/store/postgres/chatlist.go | 279 ++++++ internal/store/storetest/groupcall.go | 20 +- internal/web/stickerlinks/server.go | 84 +- internal/web/stickerlinks/server_test.go | 28 + 62 files changed, 3408 insertions(+), 212 deletions(-) create mode 100644 deploy/migrations/0056_chatlists.down.sql create mode 100644 deploy/migrations/0056_chatlists.up.sql create mode 100644 deploy/migrations/0057_chatlist_invites_revoked.down.sql create mode 100644 deploy/migrations/0057_chatlist_invites_revoked.up.sql delete mode 100644 docs/Fragment.md create mode 100644 internal/app/chatlists/service.go create mode 100644 internal/app/chatlists/service_test.go create mode 100644 internal/domain/chatlist.go create mode 100644 internal/links/links.go create mode 100644 internal/links/links_test.go create mode 100644 internal/rpc/chatlists.go create mode 100644 internal/rpc/chatlists_rpc_test.go create mode 100644 internal/rpc/public_links.go create mode 100644 internal/store/chatlist.go create mode 100644 internal/store/memory/chatlists.go create mode 100644 internal/store/postgres/chatlist.go diff --git a/.env.example b/.env.example index 6e31d0a3..d74edae0 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ TELESRV_ADVERTISE_IP=127.0.0.1 TELESRV_DC=2 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 配置 # # 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_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. # The local provider is deterministic and does not call external services. TELESRV_AI_ENABLED=true diff --git a/README.md b/README.md index 37a91207..fd397b97 100644 --- a/README.md +++ b/README.md @@ -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. | | ✅ | 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. | +| ✅ | 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. | | ✅ | 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. | @@ -94,11 +95,13 @@ Useful local environment variables: | `TELESRV_ADVERTISE_IP` | `127.0.0.1` | IP advertised to compatible clients | | `TELESRV_DC` | `2` | self-hosted DC id | | `TELESRV_DEV_AUTH_CODE` | `12345` | fixed login code for local development | +| `TELESRV_PUBLIC_BASE_URL` | `https://telesrv.net` | canonical base URL for public sticker/chatlist links | | `TELESRV_POSTGRES_DSN` | local Compose DSN | PostgreSQL connection string | | `TELESRV_REDIS_ADDR` | `127.0.0.1:6399` | Redis address | | `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | bundled language pack seed directory | | `TELESRV_BLOB_DIR` | `data/blobs` | local media blob directory | | `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | optional sticker/reaction seed directory | +| `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_PROVIDERS` | `local` | ordered AI provider chain, such as `local` or `kimi,local` | | `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 | | 12500-12999 | UDP | TURN relay port range | TURN relay | | 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) diff --git a/README.zh-CN.md b/README.zh-CN.md index c70d21b2..7f8d915d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 相关路径。 | | ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 | | ✅ | 会话与同步 | 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 相关路径。 | | ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 | | ✅ | 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_DC` | `2` | 自建 DC id | | `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_REDIS_ADDR` | `127.0.0.1:6399` | Redis 地址 | | `TELESRV_LANGPACK_SEED_DIR` | `data/langpack` | 内置语言包种子目录 | | `TELESRV_BLOB_DIR` | `data/blobs` | 本地媒体 blob 目录 | | `TELESRV_STICKER_SEED_DIR` | `data/sticker-seed` | 可选 sticker/reaction 种子目录 | +| `TELESRV_PUBLIC_LINK_WEB_ADDR` | 空 | 可选的 sticker/chatlist 公开链接落地页监听地址 | | `TELESRV_AI_ENABLED` | `true` | 启用 AI compose 入口 | | `TELESRV_AI_PROVIDERS` | `local` | AI provider 调用链,例如 `local` 或 `kimi,local` | | `TELESRV_AI_TIMEOUT` | `15s` | 单次 AI provider 调用超时 | @@ -128,7 +131,7 @@ go build -o bin/gramsrv.exe ./cmd/telesrv | 12400 | UDP | TURN/STUN 服务器 | 启用 P2P/通话 relay | | 12500-12999 | UDP | TURN relay 端口段 | 启用 TURN relay | | 可配置 | TCP | Bot API | 设置 `TELESRV_BOT_API_ADDR` 时 | -| 可配置 | TCP | Sticker Web 深链落地页 | 设置 `TELESRV_STICKER_WEB_ADDR` 时 | +| 可配置 | TCP | 公开链接深链落地页 | 设置 `TELESRV_PUBLIC_LINK_WEB_ADDR` 时 | ### 内部/调试端口(不要暴露到公网) diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 92a65131..a98068c9 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -29,6 +29,7 @@ import ( "telesrv/internal/app/auth" botsapp "telesrv/internal/app/bots" channelapp "telesrv/internal/app/channels" + chatlistsapp "telesrv/internal/app/chatlists" "telesrv/internal/app/contacts" "telesrv/internal/app/dialogs" filesapp "telesrv/internal/app/files" @@ -322,6 +323,7 @@ func run(logger *zap.Logger) error { secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool)) contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0) dialogStore := postgres.NewDialogStore(pool) + chatlistStore := postgres.NewChatlistStore(pool) messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator), postgres.WithMessageLogger(logger.Named("store").Named("messages"))) @@ -470,16 +472,18 @@ func run(logger *zap.Logger) error { account.WithUserStickerSets(passwordStore), account.WithSavedMusic(passwordStore), account.WithBusinessAutomation(passwordStore), - account.WithUsers(userStore)) + account.WithUsers(userStore), + account.WithPublicBaseURL(cfg.PublicBaseURL)) botsService := botsapp.NewService(userStore, botStore, messageStore, botsapp.WithLogger(logger.Named("bots")), botsapp.WithBlockChecker(contactStore), botsapp.WithPublicChannelUsernameResolver(channelStore), botsapp.WithUserCache(userCache), botsapp.WithStickerSetCreator(filesService), - botsapp.WithUserStickerSets(accountService)) + botsapp.WithUserStickerSets(accountService), + botsapp.WithPublicBaseURL(cfg.PublicBaseURL)) groupCallStore := postgres.NewGroupCallStore(pool) - groupCallsService := groupcallsapp.NewService(groupCallStore) + groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL)) // 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活 // 回报给信令侧保活水位(sweeper 双过期判据的实现);未启用则退化为纯信令(M0)。 sfuService := sfu.Service(sfu.Disabled()) @@ -591,6 +595,12 @@ func run(logger *zap.Logger) error { channelapp.WithReadModelVersions(readModelVersionStore), channelapp.WithSendPermissionChecker(adminService), ) + chatlistsService := chatlistsapp.NewService( + chatlistStore, + dialogStore, + chatlistsapp.WithChannels(channelsService), + chatlistsapp.WithPremiumChecker(usersService.PremiumActive), + ) businessAutomationOptions := newBusinessAutomationOptions(cfg, activeSessions, aiComposeService, logger) messagesService := messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore), @@ -617,6 +627,7 @@ func run(logger *zap.Logger) error { CallForceRelay: cfg.CallForceRelay, GroupCallMaxParticipants: cfg.GroupCallMaxParticipants, RtmpIngestURL: cfg.LiveStreamRtmpURL, + PublicBaseURL: cfg.PublicBaseURL, // PFS temp→perm 解析缓存 5s:削减每帧 ResolveAuthKey 的 PG 查询。显式撤销会清缓存并 // 断开连接;re-bind 即时失效(onAuthBindTempAuthKey)。 TempKeyResolveCacheTTL: 5 * time.Second, @@ -632,6 +643,7 @@ func run(logger *zap.Logger) error { BootstrapUpdates: bootstrapUpdateStore, Contacts: contactsService, Dialogs: dialogsService, + Chatlists: chatlistsService, Messages: messagesService, Channels: channelsService, Files: filesService, @@ -710,8 +722,8 @@ func run(logger *zap.Logger) error { return fmt.Errorf("start admin api: %w", err) } if _, err := stickerlinks.Start(ctx, stickerlinks.Config{ - Addr: cfg.StickerWebAddr, - PublicBaseURL: cfg.StickerWebPublicURL, + Addr: cfg.PublicLinkWebAddr, + PublicBaseURL: cfg.PublicBaseURL, }, filesService, logger.Named("stickerlinks")); err != nil { return fmt.Errorf("start sticker links: %w", err) } diff --git a/deploy/migrations/0056_chatlists.down.sql b/deploy/migrations/0056_chatlists.down.sql new file mode 100644 index 00000000..ab3dec17 --- /dev/null +++ b/deploy/migrations/0056_chatlists.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.chatlist_memberships; +DROP TABLE IF EXISTS public.chatlist_invites; diff --git a/deploy/migrations/0056_chatlists.up.sql b/deploy/migrations/0056_chatlists.up.sql new file mode 100644 index 00000000..fdb21c24 --- /dev/null +++ b/deploy/migrations/0056_chatlists.up.sql @@ -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); diff --git a/deploy/migrations/0057_chatlist_invites_revoked.down.sql b/deploy/migrations/0057_chatlist_invites_revoked.down.sql new file mode 100644 index 00000000..b31bfa12 --- /dev/null +++ b/deploy/migrations/0057_chatlist_invites_revoked.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.chatlist_invites + DROP COLUMN IF EXISTS revoked; diff --git a/deploy/migrations/0057_chatlist_invites_revoked.up.sql b/deploy/migrations/0057_chatlist_invites_revoked.up.sql new file mode 100644 index 00000000..05a7685c --- /dev/null +++ b/deploy/migrations/0057_chatlist_invites_revoked.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.chatlist_invites + ADD COLUMN IF NOT EXISTS revoked boolean NOT NULL DEFAULT false; diff --git a/docs/Fragment.md b/docs/Fragment.md deleted file mode 100644 index d364e029..00000000 --- a/docs/Fragment.md +++ /dev/null @@ -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 = 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 bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; - -channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; - ----functions--- - -account.toggleUsername#58d6b376 username:string active:Bool = Bool; -account.reorderUsernames#ef500eab order:Vector = Bool; - -bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = 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 = 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. \ No newline at end of file diff --git a/internal/app/account/business.go b/internal/app/account/business.go index 3581565c..f4095b8b 100644 --- a/internal/app/account/business.go +++ b/internal/app/account/business.go @@ -9,6 +9,7 @@ import ( "unicode/utf8" "telesrv/internal/domain" + "telesrv/internal/links" ) const ( @@ -113,7 +114,7 @@ func (s *Service) CreateBusinessChatLink(ctx context.Context, userID int64, inpu link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{ OwnerUserID: userID, Slug: slug, - Link: businessChatLinkURL(slug), + Link: s.businessChatLinkURL(slug), Message: normalized.Message, Entities: normalized.Entities, Title: normalized.Title, @@ -501,6 +502,10 @@ func randomBusinessChatLinkSlug() (string, error) { return fmt.Sprintf("%x", value), nil } -func businessChatLinkURL(slug string) string { - return "https://telesrv.net/m/" + slug +func (s *Service) businessChatLinkURL(slug string) string { + baseURL := links.DefaultPublicBaseURL + if s != nil && s.publicBaseURL != "" { + baseURL = s.publicBaseURL + } + return links.Build(baseURL, "m/"+slug, nil) } diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 6a68276b..a0d68200 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -8,6 +8,7 @@ import ( "time" "telesrv/internal/domain" + "telesrv/internal/links" "telesrv/internal/store" ) @@ -29,7 +30,8 @@ type Service struct { savedMusic store.SavedMusicStore business store.BusinessAutomationStore // users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。 - users store.UserStore + users store.UserStore + publicBaseURL string } // 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 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { - s := &Service{passwords: passwords} + s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL} for _, opt := range opts { opt(s) } diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index 8b86df95..4a8023da 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -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 { 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.") } diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index b0bea8b3..b24f15ce 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -17,6 +17,7 @@ import ( "go.uber.org/zap" "telesrv/internal/domain" + "telesrv/internal/links" "telesrv/internal/store" ) @@ -88,6 +89,7 @@ type Service struct { log *zap.Logger now func() time.Time chatBotStreamThrottle time.Duration + publicBaseURL string // replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。 replySeq atomic.Int64 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)后清缓存。 // 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。 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(), now: time.Now, chatBotStreamThrottle: defaultChatBotStreamThrottle, + publicBaseURL: links.DefaultPublicBaseURL, } for _, opt := range opts { opt(s) diff --git a/internal/app/bots/stickersbot.go b/internal/app/bots/stickersbot.go index 43d5692b..e951cc94 100644 --- a/internal/app/bots/stickersbot.go +++ b/internal/app/bots/stickersbot.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "strconv" "strings" "time" @@ -13,6 +14,7 @@ import ( "go.uber.org/zap" "telesrv/internal/domain" + "telesrv/internal/links" ) const ( @@ -409,7 +411,7 @@ func (s *Service) handleStickersAddEmoji(ctx context.Context, state domain.BotCh if s.hooks != nil { 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 { @@ -443,7 +445,7 @@ func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain if s.hooks != nil { 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 { @@ -484,7 +486,7 @@ func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotC if s.hooks != nil { 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 { @@ -542,7 +544,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep lines := make([]string, 0, len(sets)+1) lines = append(lines, "Your packs:") 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) { 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 { 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://addemoji?set=") raw = strings.TrimPrefix(raw, "tg://addstickers?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, " /")) } @@ -784,10 +795,18 @@ func validStickersBotEmoji(raw string) bool { return hasEmoji } -func stickersBotPublicURL(set domain.StickerSet) string { +func (s *Service) stickersBotPublicURL(set domain.StickerSet) string { part := "addstickers" if stickersBotSetKind(set) == domain.StickerSetKindEmoji { 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) } diff --git a/internal/app/chatlists/service.go b/internal/app/chatlists/service.go new file mode 100644 index 00000000..00dc57f5 --- /dev/null +++ b/internal/app/chatlists/service.go @@ -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 +} diff --git a/internal/app/chatlists/service_test.go b/internal/app/chatlists/service_test.go new file mode 100644 index 00000000..1242c040 --- /dev/null +++ b/internal/app/chatlists/service_test.go @@ -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}, + } +} diff --git a/internal/app/groupcalls/service.go b/internal/app/groupcalls/service.go index 6007b8fc..7912069e 100644 --- a/internal/app/groupcalls/service.go +++ b/internal/app/groupcalls/service.go @@ -10,21 +10,36 @@ import ( "encoding/base64" "encoding/binary" "fmt" + "net/url" "strconv" "strings" "telesrv/internal/domain" + "telesrv/internal/links" "telesrv/internal/store" ) // Service 是群通话业务服务。 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 创建群通话服务。 -func NewService(st store.GroupCallStore) *Service { - return &Service{store: st} +func NewService(st store.GroupCallStore, opts ...Option) *Service { + s := &Service{store: st, publicBaseURL: links.DefaultPublicBaseURL} + for _, opt := range opts { + opt(s) + } + return s } // Create 分配 id/access_hash 并建会。rtmpStream=true 创建 RTMP 直播房间; @@ -135,7 +150,7 @@ func (s *Service) CreateConference(ctx context.Context, creatorUserID, randomID, Version: 1, CreatedAt: now, InviteSlug: slug, - InviteLink: conferenceInviteLink(slug), + InviteLink: s.conferenceInviteLink(slug), RandomID: randomID, MigratedFromPhoneCallID: migratedFromPhoneCallID, }) @@ -265,6 +280,10 @@ func randomSlug() (string, error) { return base64.RawURLEncoding.EncodeToString(buf[:]), nil } -func conferenceInviteLink(slug string) string { - return "https://telesrv.net/call/" + slug + "?slug=" + slug +func (s *Service) conferenceInviteLink(slug string) string { + baseURL := links.DefaultPublicBaseURL + if s != nil && s.publicBaseURL != "" { + baseURL = s.publicBaseURL + } + return links.Build(baseURL, "call/"+slug, url.Values{"slug": []string{slug}}) } diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 211d4aa5..6d182ee4 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -41,14 +41,15 @@ const tdesktopClient = "tdesktop" // dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/ // stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce, // 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20, -// 见 compatibility-matrix todo);chatlists 和 story 配额/商业化 key 不下发 +// 见 compatibility-matrix todo);chatlist_update_period 与 chatlist 双档限额 +// 对齐 shared folders 最小真实实现。story 配额/商业化 key 不下发 // (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端 // 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。 // - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时 // 直接读取这些 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 提供客户端启动配置与国家区号目录。 // diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go index 91cdaf92..72c757a1 100644 --- a/internal/app/help/service_premium_test.go +++ b/internal/app/help/service_premium_test.go @@ -50,6 +50,11 @@ func TestAppConfigPremiumKeys(t *testing.T) { "channels_limit_premium": 1000, "dialog_filters_limit_default": 10, "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_premium": 8000, "aicompose_tone_examples_num": 3, @@ -68,7 +73,7 @@ func TestAppConfigPremiumKeys(t *testing.T) { } } // 未实现功能族的 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 { t.Errorf("appConfig 不应包含 %q", forbidden) } diff --git a/internal/compat/tdesktop/config.go b/internal/compat/tdesktop/config.go index 460299d2..0a9db37b 100644 --- a/internal/compat/tdesktop/config.go +++ b/internal/compat/tdesktop/config.go @@ -4,13 +4,16 @@ import ( "time" "github.com/gotd/td/tg" + + "telesrv/internal/links" ) // BuildConfig 构造 help.getConfig 返回的 tg.Config,含自建 DC 的 DCOptions。 // // 字段值取 Telegram 常见默认;TDesktop 联调阶段按客户端实际需要微调 // (记录于 docs/compatibility-matrix.md)。 -func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config { +func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL string) *tg.Config { + meURLPrefix := links.NormalizeBaseURL(publicBaseURL) + "/" return &tg.Config{ Date: int(now.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, CallConnectTimeoutMs: 30000, CallPacketTimeoutMs: 10000, - MeURLPrefix: "https://telesrv.net/", + MeURLPrefix: meURLPrefix, CaptionLengthMax: 1024, MessageLengthMax: 4096, WebfileDCID: dc, diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go index cde07a13..fc610cc8 100644 --- a/internal/compat/tdesktop/defaults.go +++ b/internal/compat/tdesktop/defaults.go @@ -7,7 +7,7 @@ import ( ) const ( - appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 + appConfigHash = 16 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 countriesListHash = 1 timezonesListHash = 1 ) @@ -44,6 +44,11 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject { {Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}}, // dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。 {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_past_period", Value: &tg.JSONNumber{Value: 300}}, {Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}}, diff --git a/internal/config/config.go b/internal/config/config.go index 583fe21c..d5488fe0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "time" + + "telesrv/internal/links" ) const defaultConfigFile = ".env" @@ -40,11 +42,12 @@ type Config struct { AdminAPIAddr string // AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。 AdminAPIToken string - // StickerWebAddr 是公开 sticker/custom emoji deep link 落地页监听地址;为空关闭。 - // 生产应只监听 loopback,并由 nginx 将 /addstickers/ 与 /addemoji/ 反代到该地址。 - StickerWebAddr string - // StickerWebPublicURL 是生成 canonical telesrv.net 链接的公开根 URL。 - StickerWebPublicURL string + // PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。 + // 生产默认 https://telesrv.net;本地可设为 http://127.0.0.1:2401。 + PublicBaseURL string + // PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。 + // 生产应只监听 loopback,并由 nginx 将 /addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。 + PublicLinkWebAddr string // Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。 AdminUIAddr string AdminUIPassword string @@ -281,6 +284,8 @@ func Load() (Config, error) { envInt64Or := fileEnv.envInt64Or envDurationOr := fileEnv.envDurationOr + publicBaseURL := links.NormalizeBaseURL(envOr("TELESRV_PUBLIC_BASE_URL", links.DefaultPublicBaseURL)) + cfg := Config{ ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), WebSocketEnable: envBoolOr("TELESRV_WEBSOCKET_ENABLE", true), @@ -291,19 +296,19 @@ func Load() (Config, error) { // AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions, // 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。 // 字段与默认值保留,供未来需要显式下发 DC 地址时使用。 - AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"), - RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), - DC: envIntOr("TELESRV_DC", 2), - DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"), - BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""), - AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""), - AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), - StickerWebAddr: envOr("TELESRV_STICKER_WEB_ADDR", ""), - StickerWebPublicURL: envOr("TELESRV_STICKER_WEB_PUBLIC_URL", "https://telesrv.net"), - AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), - AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), - AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""), - AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""), + AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"), + RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), + DC: envIntOr("TELESRV_DC", 2), + DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"), + BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""), + AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""), + AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), + PublicBaseURL: publicBaseURL, + PublicLinkWebAddr: envOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), + AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), + AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), + AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""), + AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""), // 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker // Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eb6eec1d..ab987ae2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -8,7 +8,9 @@ import ( ) func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { + disableDefaultConfigFile(t) t.Setenv("TELESRV_ADVERTISE_IP", "") + t.Setenv("TELESRV_PUBLIC_BASE_URL", "") cfg, err := Load() if err != nil { @@ -17,9 +19,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { if cfg.AdvertiseIP != "127.0.0.1" { 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) { + disableDefaultConfigFile(t) t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10") cfg, err := Load() @@ -32,6 +38,7 @@ func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { } func TestLoadBusinessAIProvider(t *testing.T) { + disableDefaultConfigFile(t) t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "echo") cfg, err := Load() @@ -44,6 +51,7 @@ func TestLoadBusinessAIProvider(t *testing.T) { } func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) { + disableDefaultConfigFile(t) t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "") cfg, err := Load() @@ -56,6 +64,7 @@ func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) { } func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) { + disableDefaultConfigFile(t) t.Setenv("TELESRV_ADMIN_UI_ADDR", "") t.Setenv("TELESRV_LIVESTREAM_RTMP_ADDR", "") @@ -75,6 +84,7 @@ func TestLoadKeepsAdminAndRtmpDefaultPortsSeparate(t *testing.T) { } func TestLoadAIProviders(t *testing.T) { + disableDefaultConfigFile(t) t.Setenv("TELESRV_AI_PROVIDERS", "local,openai,gemini") t.Setenv("TELESRV_AI_OPENAI_API_KEY", "openai-key") t.Setenv("TELESRV_AI_OPENAI_MODEL", "gpt-test") @@ -115,8 +125,8 @@ TELESRV_MAPBOX_TOKEN="file-token" TELESRV_POSTGRES_MAX_CONNS=77 TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example TELESRV_CALL_RING_TIMEOUT=2m -TELESRV_STICKER_WEB_ADDR=127.0.0.1:2401 -TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test +TELESRV_PUBLIC_BASE_URL=links.example.test/root +TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 `) t.Setenv("TELESRV_CONFIG", path) @@ -136,11 +146,24 @@ TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test if cfg.CallRingTimeout != 2*time.Minute { t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout) } - if cfg.StickerWebAddr != "127.0.0.1:2401" { - t.Fatalf("StickerWebAddr = %q, want 127.0.0.1:2401", cfg.StickerWebAddr) + if cfg.PublicLinkWebAddr != "127.0.0.1:2401" { + t.Fatalf("PublicLinkWebAddr = %q, want 127.0.0.1:2401", cfg.PublicLinkWebAddr) } - if cfg.StickerWebPublicURL != "https://packs.example.test" { - t.Fatalf("StickerWebPublicURL = %q, want https://packs.example.test", cfg.StickerWebPublicURL) + if cfg.PublicBaseURL != "https://links.example.test/root" { + 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) } } + +func disableDefaultConfigFile(t *testing.T) { + t.Helper() + t.Setenv("TELESRV_CONFIG", "") +} diff --git a/internal/domain/chatlist.go b/internal/domain/chatlist.go new file mode 100644 index 00000000..ab176bb7 --- /dev/null +++ b/internal/domain/chatlist.go @@ -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 +} diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go index 302c6770..2347fa4d 100644 --- a/internal/domain/dialog.go +++ b/internal/domain/dialog.go @@ -205,6 +205,7 @@ type DialogFolder struct { HasEmoticon bool Color int HasColor bool + HasMyInvites bool PinnedPeers []DialogFolderPeer IncludePeers []DialogFolderPeer ExcludePeers []DialogFolderPeer diff --git a/internal/links/links.go b/internal/links/links.go new file mode 100644 index 00000000..f61317b6 --- /dev/null +++ b/internal/links/links.go @@ -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 +} diff --git a/internal/links/links_test.go b/internal/links/links_test.go new file mode 100644 index 00000000..180ca7f2 --- /dev/null +++ b/internal/links/links_test.go @@ -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) + } + }) + } +} diff --git a/internal/rpc/account_business.go b/internal/rpc/account_business.go index a725ee68..6c3250da 100644 --- a/internal/rpc/account_business.go +++ b/internal/rpc/account_business.go @@ -455,7 +455,7 @@ func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUser if botUser, found, err := r.connectedBusinessBotByID(ctx, ownerUserID, bot.BotUserID); err != nil { return domain.PeerSettings{}, err } else if found { - settings.BusinessBotManageURL = connectedBusinessBotManageURL(botUser) + settings.BusinessBotManageURL = r.connectedBusinessBotManageURL(botUser) } if settings.BusinessBotManageURL == "" { settings.BusinessBotManageURL = "telesrv://business-bot" @@ -477,11 +477,11 @@ func (r *Router) connectedBusinessPeerFacts(ctx context.Context, ownerUserID, pe return existingChat, isContact } -func connectedBusinessBotManageURL(bot domain.User) string { +func (r *Router) connectedBusinessBotManageURL(bot domain.User) string { if bot.Username == "" { 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 { diff --git a/internal/rpc/account_themes.go b/internal/rpc/account_themes.go index 90d47fac..060661ee 100644 --- a/internal/rpc/account_themes.go +++ b/internal/rpc/account_themes.go @@ -238,7 +238,7 @@ func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInsta return true, nil } -// onAccountGetTheme 按 id 或 slug(深链 telesrv.net/addtheme/)取主题。 +// onAccountGetTheme 按 id 或 slug(公开 addtheme 深链)取主题。 func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) { if req == nil { return nil, themeInvalidErr() diff --git a/internal/rpc/aicompose_webpage.go b/internal/rpc/aicompose_webpage.go index 9c3a4b87..4e74a20b 100644 --- a/internal/rpc/aicompose_webpage.go +++ b/internal/rpc/aicompose_webpage.go @@ -14,7 +14,7 @@ import ( const aiComposeToneWebPageType = "telegram_aicomposetone" 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 { return domain.MessageWebPage{}, false } @@ -62,7 +62,7 @@ type aiComposeStyleLink struct { slug string } -func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) { +func parseAIComposeStyleLink(raw, publicHost string) (aiComposeStyleLink, bool) { normalized, ok := domain.NormalizeWebPageURL(raw) if !ok { return aiComposeStyleLink{}, false @@ -72,7 +72,7 @@ func parseAIComposeStyleLink(raw string) (aiComposeStyleLink, bool) { return aiComposeStyleLink{}, false } host := strings.ToLower(u.Hostname()) - if !aiComposeStyleHostAllowed(host) { + if !aiComposeStyleHostAllowed(host, publicHost) { return aiComposeStyleLink{}, false } 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 } -func aiComposeStyleHostAllowed(host string) bool { +func aiComposeStyleHostAllowed(host, publicHost string) bool { + if publicHost != "" && strings.EqualFold(host, publicHost) { + return true + } switch host { case "t.me", "telegram.me", "telesrv.net", "localhost", "127.0.0.1": return true diff --git a/internal/rpc/bots_startbot.go b/internal/rpc/bots_startbot.go index 2f14a350..6322df17 100644 --- a/internal/rpc/bots_startbot.go +++ b/internal/rpc/bots_startbot.go @@ -12,7 +12,7 @@ import ( 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 " 普通私聊消息,走标准 // SendPrivateText 双盒+outbox,返回真实 Updates(I7)。bot 经此收到 start_param。 // P3 仅私聊启动;peer 为群(加 bot 进群)后移(P4,群内 bot)。 diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go index 8fb78696..4ddd0500 100644 --- a/internal/rpc/channels_core.go +++ b/internal/rpc/channels_core.go @@ -135,7 +135,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha if err != nil { return nil, err } - full := tgChannelFull(view) + full := tgChannelFull(view, r.cfg.PublicBaseURL) r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full) userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID} // 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。 diff --git a/internal/rpc/channels_invites.go b/internal/rpc/channels_invites.go index 1586635e..84ea32e6 100644 --- a/internal/rpc/channels_invites.go +++ b/internal/rpc/channels_invites.go @@ -3,9 +3,11 @@ package rpc import ( "context" "errors" + "strings" + "github.com/gotd/td/tg" "github.com/gotd/td/tgerr" - "strings" + "telesrv/internal/domain" ) @@ -41,7 +43,7 @@ func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.Message return nil, channelInviteErr(err) } 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) { @@ -141,7 +143,7 @@ func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.M userIDs := []int64{adminID} invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites)) for _, invite := range list.Invites { - invites = append(invites, tgExportedChannelInvite(invite)) + invites = append(invites, r.tgExportedChannelInvite(invite)) userIDs = append(userIDs, invite.AdminUserID) } return &tg.MessagesExportedChatInvites{ @@ -169,7 +171,7 @@ func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.Me return nil, channelInviteErr(err) } return &tg.MessagesExportedChatInvite{ - Invite: tgExportedChannelInvite(invite), + Invite: r.tgExportedChannelInvite(invite), Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}), }, nil } @@ -212,12 +214,12 @@ func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.M users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID}) if edited.NewInvite != nil { return &tg.MessagesExportedChatInviteReplaced{ - Invite: tgExportedChannelInvite(edited.Invite), - NewInvite: tgExportedChannelInvite(*edited.NewInvite), + Invite: r.tgExportedChannelInvite(edited.Invite), + NewInvite: r.tgExportedChannelInvite(*edited.NewInvite), Users: users, }, 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) { @@ -353,12 +355,16 @@ func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 { 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{ Revoked: invite.Revoked, Permanent: invite.Permanent, RequestNeeded: invite.RequestNeeded, - Link: "https://telesrv.net/+" + invite.Hash, + Link: publicLinkWithBaseURL(publicBaseURL, "+"+invite.Hash), AdminID: invite.AdminUserID, Date: invite.Date, } diff --git a/internal/rpc/channels_messages.go b/internal/rpc/channels_messages.go index 79658080..d333dbe1 100644 --- a/internal/rpc/channels_messages.go +++ b/internal/rpc/channels_messages.go @@ -3,12 +3,14 @@ package rpc import ( "context" "errors" - "github.com/gotd/td/tg" "sort" "strconv" "strings" - "telesrv/internal/domain" "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" ) 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 := "" 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 { - 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 rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID { diff --git a/internal/rpc/chatlists.go b/internal/rpc/chatlists.go new file mode 100644 index 00000000..eb7e9647 --- /dev/null +++ b/internal/rpc/chatlists.go @@ -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() + } +} diff --git a/internal/rpc/chatlists_rpc_test.go b/internal/rpc/chatlists_rpc_test.go new file mode 100644 index 00000000..e63384ec --- /dev/null +++ b/internal/rpc/chatlists_rpc_test.go @@ -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 +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index dab03787..e0f3f0da 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -466,7 +466,7 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember return out } -func tgChannelFull(view domain.ChannelView) *tg.ChannelFull { +func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull { ch := view.Channel full := &tg.ChannelFull{ // 广播频道的订阅者列表仅管理员可见(官方语义):非管理员订阅者拿到 @@ -496,7 +496,11 @@ func tgChannelFull(view domain.ChannelView) *tg.ChannelFull { full.SetAvailableMinID(view.Self.AvailableMinID) } 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 { full.SetAdminsCount(ch.AdminsCount) diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go index 6e927257..f272be2c 100644 --- a/internal/rpc/convert_dialogs.go +++ b/internal/rpc/convert_dialogs.go @@ -234,6 +234,7 @@ func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass { title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)} if folder.IsChatlist { out := &tg.DialogFilterChatlist{ + HasMyInvites: folder.HasMyInvites, TitleNoanimate: folder.TitleNoanimate, ID: folder.ID, Title: title, diff --git a/internal/rpc/convert_phone_group.go b/internal/rpc/convert_phone_group.go index a7b7e448..4b89979f 100644 --- a/internal/rpc/convert_phone_group.go +++ b/internal/rpc/convert_phone_group.go @@ -26,7 +26,7 @@ const groupCallUnmutedVideoLimit = 30 var groupCallStreamDCID = 2 // 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() { 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) } 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) } else if call.InviteLink != "" { out.SetInviteLink(call.InviteLink) diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 8a679ce1..e8b82ce7 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -400,6 +400,21 @@ type DialogsService interface { 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 抽象消息历史、搜索与已读。 type MessagesService interface { SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) @@ -697,6 +712,7 @@ type Deps struct { BootstrapUpdates store.BootstrapUpdateJobStore Contacts ContactsService Dialogs DialogsService + Chatlists ChatlistsService Messages MessagesService Stories StoriesService Channels ChannelsService diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 1a21ca01..f58920da 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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 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 不存在或当前阶段不支持。 func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") } diff --git a/internal/rpc/help.go b/internal/rpc/help.go index c6eb95f1..db03f7f2 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -12,7 +12,7 @@ import ( // registerHelp 注册 help.* RPC handler(DC 配置、最近 DC)。 func (r *Router) registerHelp(d *tg.ServerDispatcher) { d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) { - return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now()), nil + 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) { return tdesktop.NearestDC(r.cfg.DC), nil diff --git a/internal/rpc/messages_dialogs.go b/internal/rpc/messages_dialogs.go index 8b7d8b70..9c962a3b 100644 --- a/internal/rpc/messages_dialogs.go +++ b/internal/rpc/messages_dialogs.go @@ -966,6 +966,7 @@ func (r *Router) dialogFolderFromTG(ctx context.Context, userID int64, id int, f color, hasColor := f.GetColor() return domain.DialogFolder{ ID: id, + HasMyInvites: f.HasMyInvites, TitleNoanimate: f.TitleNoanimate, Title: title, TitleEntities: domainMessageEntities(f.Title.Entities), diff --git a/internal/rpc/messages_helpers.go b/internal/rpc/messages_helpers.go index acd767b7..26a24ff8 100644 --- a/internal/rpc/messages_helpers.go +++ b/internal/rpc/messages_helpers.go @@ -419,6 +419,11 @@ func (r *Router) domainFolderPeerFromInputPeer(ctx context.Context, userID int64 return domain.Peer{}, 0, peerIDInvalidErr() } 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: if userID == 0 { return domain.Peer{}, 0, peerIDInvalidErr() diff --git a/internal/rpc/phone_conference.go b/internal/rpc/phone_conference.go index fc583aa6..21939b4d 100644 --- a/internal/rpc/phone_conference.go +++ b/internal/rpc/phone_conference.go @@ -3,6 +3,7 @@ package rpc import ( "context" "encoding/binary" + "net/url" "github.com/gotd/td/tg" "go.uber.org/zap" @@ -40,7 +41,7 @@ func (r *Router) onPhoneCreateConferenceCall(ctx context.Context, req *tg.PhoneC return nil, groupCallErr(err) } 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 { return out, nil } @@ -81,7 +82,7 @@ func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.Phone return nil, groupCallInvalidErr() } if scope.call.Conference() { - link := conferenceExportInviteLink(scope.call) + link := conferenceExportInviteLink(scope.call, r.cfg.PublicBaseURL) if link == "" { return nil, groupCallInvalidErr() } @@ -93,21 +94,25 @@ func (r *Router) onPhoneExportGroupCallInvite(ctx context.Context, req *tg.Phone if scope.channel.Username == "" { 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 { - if link := conferenceCanonicalInviteLink(call.InviteSlug); link != "" { +func conferenceExportInviteLink(call domain.GroupCall, publicBaseURL ...string) string { + if link := conferenceCanonicalInviteLink(call.InviteSlug, publicBaseURL...); link != "" { return link } return call.InviteLink } -func conferenceCanonicalInviteLink(slug string) string { +func conferenceCanonicalInviteLink(slug string, publicBaseURL ...string) string { if slug == "" { 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) { @@ -208,7 +213,7 @@ func (r *Router) onPhoneDeclineConferenceCallInvite(ctx context.Context, msgID i } r.pushConferenceGroupCallUpdate(ctx, call) 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) { diff --git a/internal/rpc/phone_group_call.go b/internal/rpc/phone_group_call.go index 6a9cf701..494c7c87 100644 --- a/internal/rpc/phone_group_call.go +++ b/internal/rpc/phone_group_call.go @@ -246,7 +246,7 @@ func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreate r.pushGroupCallServiceMessage(ctx, userID, serviceRes) } // 响应: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}) out := r.groupCallUpdateContainer(ctx, userID, channel, update, []int64{userID}) if serviceRes.Event.Pts != 0 { @@ -368,7 +368,7 @@ func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGrou Version: mut.Call.Version, }, []int64{scope.userID}) out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}}) - callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())} + callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage(), r.cfg.PublicBaseURL)} if channel.ID != 0 { 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) { // 幂等:重复 leave / sweeper 已清,返回当前快照。 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 { return nil, groupCallErr(err) @@ -411,7 +411,7 @@ func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGr Version: mut.Call.Version, }, []int64{scope.userID}) 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 } @@ -435,7 +435,7 @@ func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCa if call.Conference() { r.pushConferenceGroupCallUpdateTo(ctx, call, groupCallParticipantUserIDs(activeBeforeDiscard)) 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/停止)。 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) } 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 msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil { out.Updates = append(out.Updates, msgUpdate) @@ -499,7 +499,7 @@ func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupC // 定时通话:回填 viewer 自己的开播提醒订阅(客户端 reload 全量重建本地状态)。 call := r.applyScheduleSubscription(ctx, scope.call, scope.userID) 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), ParticipantsNextOffset: page.NextOffset, Chats: chats, @@ -589,8 +589,8 @@ func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGr return out, nil } -func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall { - update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)} +func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool, publicBaseURL ...string) *tg.UpdateGroupCall { + update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage, publicBaseURL...)} if channel.ID != 0 { update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) } diff --git a/internal/rpc/phone_group_call_edit.go b/internal/rpc/phone_group_call_edit.go index f3287914..4fb287b6 100644 --- a/internal/rpc/phone_group_call_edit.go +++ b/internal/rpc/phone_group_call_edit.go @@ -233,7 +233,7 @@ func (r *Router) onPhoneEditGroupCallTitle(ctx context.Context, req *tg.PhoneEdi } r.pushGroupCallUpdate(ctx, scope.channel, call) 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) { @@ -261,7 +261,7 @@ func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.Pho } r.pushGroupCallUpdate(ctx, scope.channel, call) 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 diff --git a/internal/rpc/phone_group_call_presentation.go b/internal/rpc/phone_group_call_presentation.go index fa8ad869..0abbce6a 100644 --- a/internal/rpc/phone_group_call_presentation.go +++ b/internal/rpc/phone_group_call_presentation.go @@ -106,7 +106,7 @@ func (r *Router) onPhoneLeaveGroupCallPresentation(ctx context.Context, call tg. // 幂等快照:未在共享/已清理时返回当前态而非报错(屏幕共享既已下架即达成目的)。 idempotentSnapshot := func() (tg.UpdatesClass, error) { 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) if err != nil { diff --git a/internal/rpc/phone_group_call_rtmp.go b/internal/rpc/phone_group_call_rtmp.go index 0ee2895a..81522d9e 100644 --- a/internal/rpc/phone_group_call_rtmp.go +++ b/internal/rpc/phone_group_call_rtmp.go @@ -96,7 +96,7 @@ func (r *Router) joinRtmpGroupCall(ctx context.Context, scope *groupCallScope, r // updateGroupCall 必须先于 updateGroupCallConnection:TDesktop 按序 applyUpdates, // 处理 connection 时若还没从 groupCall 读到 stream_dc_id 会打 // "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 { callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) } diff --git a/internal/rpc/phone_group_call_scheduled.go b/internal/rpc/phone_group_call_scheduled.go index a76706ee..15c9747d 100644 --- a/internal/rpc/phone_group_call_scheduled.go +++ b/internal/rpc/phone_group_call_scheduled.go @@ -52,7 +52,7 @@ func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.Input if !changed { // 幂等:已开始,只回快照,不重复扇出/服务消息。 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()) // started 服务消息(与即时创建的 started 同构)。 @@ -75,7 +75,7 @@ func (r *Router) onPhoneStartScheduledGroupCall(ctx context.Context, in tg.Input r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes) } 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 msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil { out.Updates = append(out.Updates, msgUpdate) @@ -105,7 +105,7 @@ func (r *Router) onPhoneToggleGroupCallStartSubscription(ctx context.Context, re call := scope.call call.ScheduleStartSubscribed = req.Subscribed // 订阅是 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.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil)) return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, nil), nil diff --git a/internal/rpc/phone_group_call_updates.go b/internal/rpc/phone_group_call_updates.go index 3d1b263f..c3e3abac 100644 --- a/internal/rpc/phone_group_call_updates.go +++ b/internal/rpc/phone_group_call_updates.go @@ -75,7 +75,7 @@ func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel if subscribed != nil { _, 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}) r.pushUserMessage(ctx, viewerID, "group call update", 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) { recipients := r.conferenceCallRecipientsWith(ctx, call.ID, extraUserIDs) 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.groupCallUpdateContainer(ctx, viewerID, domain.Channel{}, update, []int64{call.CreatorUserID})) } diff --git a/internal/rpc/premium.go b/internal/rpc/premium.go index 2b91caeb..d9e45729 100644 --- a/internal/rpc/premium.go +++ b/internal/rpc/premium.go @@ -31,7 +31,7 @@ func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeer if err != nil { 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) { @@ -190,13 +190,13 @@ func (r *Router) currentPremiumUntil(ctx context.Context) (int64, int, error) { 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{ MyBoost: len(in.MyBoostSlots) > 0, Level: in.Level, CurrentLevelBoosts: in.CurrentLevelBoosts, 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 { out.SetGiftBoosts(in.GiftBoosts) diff --git a/internal/rpc/public_links.go b/internal/rpc/public_links.go new file mode 100644 index 00000000..6fb0e808 --- /dev/null +++ b/internal/rpc/public_links.go @@ -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}}) +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index f5f03d12..10c9a85e 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -70,6 +70,8 @@ type Config struct { // RtmpIngestURL 是 getGroupCallStreamRtmpUrl 返回给推流端(OBS)的服务器地址, // 形如 "rtmp://:/live"。为空时回落 "rtmp://:2400/live"。 RtmpIngestURL string + // PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。 + PublicBaseURL string // TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key // 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。 // 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的 @@ -186,6 +188,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { r.registerUpload(d) r.registerPhotos(d) r.registerFolders(d) + r.registerChatlists(d) r.registerContacts(d) r.registerLangpack(d) r.registerStories(d) diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index 223ee8bd..3e6886c4 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -495,7 +495,7 @@ func (r *Router) onStoriesExportStoryLink(ctx context.Context, req *tg.StoriesEx 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 { @@ -3018,8 +3018,8 @@ func uniqueStoryIDs(ids []int) []int { return out } -func storyExportLink(peer domain.Peer, storyID int) string { - return fmt.Sprintf("https://telesrv.local/story/%s/%d/%d", peer.Type, peer.ID, storyID) +func (r *Router) storyExportLink(peer domain.Peer, storyID int) string { + 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 { diff --git a/internal/rpc/stories_rpc_test.go b/internal/rpc/stories_rpc_test.go index 565591b9..08387ab5 100644 --- a/internal/rpc/stories_rpc_test.go +++ b/internal/rpc/stories_rpc_test.go @@ -5889,7 +5889,7 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) { if err != nil { 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) } if _, err := r.onStoriesExportStoryLink(reqCtx, &tg.StoriesExportStoryLinkRequest{ diff --git a/internal/store/chatlist.go b/internal/store/chatlist.go new file mode 100644 index 00000000..127bb78e --- /dev/null +++ b/internal/store/chatlist.go @@ -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) +} diff --git a/internal/store/memory/chatlists.go b/internal/store/memory/chatlists.go new file mode 100644 index 00000000..dd07d7a1 --- /dev/null +++ b/internal/store/memory/chatlists.go @@ -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 +} diff --git a/internal/store/postgres/chatlist.go b/internal/store/postgres/chatlist.go new file mode 100644 index 00000000..45d6e8f3 --- /dev/null +++ b/internal/store/postgres/chatlist.go @@ -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 +} diff --git a/internal/store/storetest/groupcall.go b/internal/store/storetest/groupcall.go index 38edf207..876eddff 100644 --- a/internal/store/storetest/groupcall.go +++ b/internal/store/storetest/groupcall.go @@ -7,11 +7,13 @@ import ( "context" "errors" "fmt" + "net/url" "reflect" "testing" "time" "telesrv/internal/domain" + "telesrv/internal/links" "telesrv/internal/store" ) @@ -22,6 +24,10 @@ func baseNow() int { 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。 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) call, err := st.CreateConferenceCall(ctx, domain.GroupCall{ 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, }) if err != nil { @@ -388,7 +394,7 @@ func contractConferenceRecipientsTerminalAccess(t *testing.T, factory GroupCallS slug := fmt.Sprintf("contract-recipient-%d", channelID) call, err := st.CreateConferenceCall(ctx, domain.GroupCall{ 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, }) if err != nil { @@ -434,10 +440,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory st, channelID := factory(t) ctx := context.Background() now := baseNow() + emptySlug := fmt.Sprintf("contract-empty-%d", channelID) call, err := st.CreateConferenceCall(ctx, domain.GroupCall{ ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1, - InviteSlug: fmt.Sprintf("contract-empty-%d", channelID), - InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-empty-%d?slug=contract-empty-%d", channelID, channelID), + InviteSlug: emptySlug, + InviteLink: conferenceInviteLink(emptySlug), RandomID: channelID*100 + 71, CreatedAt: now, }) @@ -464,10 +471,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory 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{ ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1, - InviteSlug: fmt.Sprintf("contract-reset-empty-%d", channelID), - InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-reset-empty-%d?slug=contract-reset-empty-%d", channelID, channelID), + InviteSlug: resetSlug, + InviteLink: conferenceInviteLink(resetSlug), RandomID: channelID*100 + 81, CreatedAt: now + 10, }) diff --git a/internal/web/stickerlinks/server.go b/internal/web/stickerlinks/server.go index 31321e5d..1071712a 100644 --- a/internal/web/stickerlinks/server.go +++ b/internal/web/stickerlinks/server.go @@ -15,10 +15,9 @@ import ( "go.uber.org/zap" "telesrv/internal/domain" + "telesrv/internal/links" ) -const defaultPublicBaseURL = "https://telesrv.net" - type Config struct { Addr string PublicBaseURL string @@ -50,9 +49,9 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge return nil, err } 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) { - logger.Warn("Sticker link Web endpoint exited", zap.Error(err)) + logger.Warn("Public link Web endpoint exited", zap.Error(err)) } }() go func() { @@ -73,6 +72,7 @@ func NewHandler(resolver Resolver, publicBaseURL string) http.Handler { mux.HandleFunc("GET /healthz", h.healthz) mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) + mux.HandleFunc("GET /addlist/{slug}", h.addList) return mux } @@ -94,6 +94,30 @@ func (h *handler) addEmoji(w http.ResponseWriter, r *http.Request) { 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) { shortName := strings.TrimSpace(r.PathValue("shortName")) if !validShortNamePath(shortName) { @@ -114,23 +138,22 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri } canonicalKind := linkKind(set) 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 } count := set.Count if count == 0 { count = len(docs) } - app := appURL(canonicalKind, set.ShortName) + app := appURL(canonicalKind, "set", set.ShortName) data := pageData{ Title: fallbackTitle(set), - ShortName: set.ShortName, - Count: count, KindLabel: kindLabel(set), - ItemNoun: itemNoun(set, count), - CanonicalURL: h.setURL(canonicalKind, set.ShortName), + Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)), + 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), - LegacyTgURL: template.URL(legacyTgURL(canonicalKind, set.ShortName)), + LegacyTgURL: template.URL(legacyTgURL(canonicalKind, "set", set.ShortName)), } data.AppURLJS = template.JS(strconv.Quote(app)) 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 { - return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(shortName) +func (h *handler) publicURL(kind, value string) string { + return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(value) } func normalizePublicBaseURL(raw string) string { - raw = strings.TrimRight(strings.TrimSpace(raw), "/") - if raw == "" { - return defaultPublicBaseURL - } - u, err := url.Parse(raw) + u, err := url.Parse(links.NormalizeBaseURL(raw)) if err != nil || u.Scheme == "" || u.Host == "" { - return defaultPublicBaseURL + return links.DefaultPublicBaseURL } u.Path = strings.TrimRight(u.Path, "/") u.RawQuery = "" @@ -176,6 +195,10 @@ func validShortNamePath(shortName string) bool { return true } +func validSlugPath(slug string) bool { + return links.ValidChatlistSlug(slug) +} + func linkKind(set domain.StickerSet) string { if set.Kind == domain.StickerSetKindEmoji || set.Emojis { return "addemoji" @@ -214,20 +237,23 @@ func itemNoun(set domain.StickerSet, count int) string { return "stickers" } -func appURL(kind, shortName string) string { - return "telesrv://" + kind + "?set=" + url.QueryEscape(shortName) +func appURL(kind, key, value string) string { + return schemeURL("telesrv", kind, key, value) } -func legacyTgURL(kind, shortName string) string { - return "tg://" + kind + "?set=" + url.QueryEscape(shortName) +func legacyTgURL(kind, key, value string) string { + return schemeURL("tg", kind, key, value) +} + +func schemeURL(scheme, kind, key, value string) string { + return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value) } type pageData struct { Title string - ShortName string - Count int KindLabel string - ItemNoun string + Subtitle string + Description string CanonicalURL string AppURL template.URL LegacyTgURL template.URL @@ -242,7 +268,7 @@ var landingTemplate = template.Must(template.New("landing").Parse(`{{.Title}} - telesrv - +