diff --git a/cmd/botcheck/main.go b/cmd/bots/botcheck/main.go similarity index 98% rename from cmd/botcheck/main.go rename to cmd/bots/botcheck/main.go index a0667b18..d3cdb4e8 100644 --- a/cmd/botcheck/main.go +++ b/cmd/bots/botcheck/main.go @@ -6,8 +6,8 @@ // // 用法: // -// go run ./cmd/botcheck -token ":" # 仅登录自检 -// go run ./cmd/botcheck -token ":" -echo # 自检后持续 echo +// go run ./cmd/bots/botcheck -token ":" # 仅登录自检 +// go run ./cmd/bots/botcheck -token ":" -echo # 自检后持续 echo // // 连接生产 telesrv(obfuscated TCP)靠 DCOption.TCPObfuscatedOnly=true, // gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。 diff --git a/cmd/botdemo/main.go b/cmd/bots/botdemo/main.go similarity index 99% rename from cmd/botdemo/main.go rename to cmd/bots/botdemo/main.go index 1156e404..9d1d792c 100644 --- a/cmd/botdemo/main.go +++ b/cmd/bots/botdemo/main.go @@ -14,7 +14,7 @@ // // 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条): // -// go run ./cmd/botdemo \ +// go run ./cmd/bots/botdemo \ // -token ":" \ // -chat -chat-hash \ // -interval 15s -mode rotate diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 25361af8..f929cedd 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -60,6 +60,7 @@ import ( "telesrv/internal/store/postgres" "telesrv/internal/store/redisstore" "telesrv/internal/turnsrv" + "telesrv/internal/web/stickerlinks" ) func main() { @@ -405,11 +406,22 @@ func run(logger *zap.Logger) error { // userCache 与 users 服务共享同一实例:bot 元数据写入(version bump)后必须 // 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。 userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL) + accountService := account.NewService(passwordStore, + account.WithReactionSettings(passwordStore), + account.WithAccountSettings(passwordStore), + account.WithNotifySettings(passwordStore), + account.WithStickerCollections(passwordStore), + account.WithUserStickerSets(passwordStore), + account.WithSavedMusic(passwordStore), + account.WithBusinessAutomation(passwordStore), + account.WithUsers(userStore)) botsService := botsapp.NewService(userStore, botStore, messageStore, botsapp.WithLogger(logger.Named("bots")), botsapp.WithBlockChecker(contactStore), botsapp.WithPublicChannelUsernameResolver(channelStore), - botsapp.WithUserCache(userCache)) + botsapp.WithUserCache(userCache), + botsapp.WithStickerSetCreator(filesService), + botsapp.WithUserStickerSets(accountService)) groupCallStore := postgres.NewGroupCallStore(pool) groupCallsService := groupcallsapp.NewService(groupCallStore) // 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活 @@ -517,14 +529,6 @@ func run(logger *zap.Logger) error { messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...), ) authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore), auth.WithPasswords(passwordStore), auth.WithBotLogin(botStore), auth.WithPremiumGrant(cfg.PremiumGrantMonths)) - accountService := account.NewService(passwordStore, - account.WithReactionSettings(passwordStore), - account.WithAccountSettings(passwordStore), - account.WithNotifySettings(passwordStore), - account.WithStickerCollections(passwordStore), - account.WithSavedMusic(passwordStore), - account.WithBusinessAutomation(passwordStore), - account.WithUsers(userStore)) updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates"))) router := rpc.New(rpc.Config{ DC: cfg.DC, @@ -625,6 +629,12 @@ func run(logger *zap.Logger) error { if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil { return fmt.Errorf("start admin api: %w", err) } + if _, err := stickerlinks.Start(ctx, stickerlinks.Config{ + Addr: cfg.StickerWebAddr, + PublicBaseURL: cfg.StickerWebPublicURL, + }, filesService, logger.Named("stickerlinks")); err != nil { + return fmt.Errorf("start sticker links: %w", err) + } srv := mtprotoedge.New(mtprotoedge.Options{ Logger: logger.Named("mtprotoedge"), diff --git a/deploy/migrations/0041_user_sticker_sets.down.sql b/deploy/migrations/0041_user_sticker_sets.down.sql new file mode 100644 index 00000000..f6e2270b --- /dev/null +++ b/deploy/migrations/0041_user_sticker_sets.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.user_sticker_sets; diff --git a/deploy/migrations/0041_user_sticker_sets.up.sql b/deploy/migrations/0041_user_sticker_sets.up.sql new file mode 100644 index 00000000..18f383dd --- /dev/null +++ b/deploy/migrations/0041_user_sticker_sets.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS public.user_sticker_sets ( + owner_user_id bigint NOT NULL, + sticker_set_id bigint NOT NULL, + set_kind text DEFAULT 'stickers'::text NOT NULL, + archived boolean DEFAULT false NOT NULL, + installed_date integer DEFAULT 0 NOT NULL, + order_value bigint DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_sticker_sets_pkey PRIMARY KEY (owner_user_id, sticker_set_id), + CONSTRAINT user_sticker_sets_kind_check CHECK ((set_kind = ANY (ARRAY['stickers'::text, 'emoji'::text, 'masks'::text]))) +); + +CREATE INDEX IF NOT EXISTS user_sticker_sets_order_idx + ON public.user_sticker_sets USING btree (owner_user_id, set_kind, archived, order_value DESC, sticker_set_id DESC); diff --git a/deploy/migrations/0042_sticker_set_creator.down.sql b/deploy/migrations/0042_sticker_set_creator.down.sql new file mode 100644 index 00000000..fe7a72be --- /dev/null +++ b/deploy/migrations/0042_sticker_set_creator.down.sql @@ -0,0 +1,14 @@ +DROP INDEX IF EXISTS public.sticker_sets_creator_idx; +DROP INDEX IF EXISTS public.sticker_sets_short_name_lower_idx; + +CREATE UNIQUE INDEX IF NOT EXISTS sticker_sets_short_name_idx + ON public.sticker_sets USING btree (short_name) + WHERE (short_name <> ''::text); + +ALTER TABLE public.sticker_sets + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS keywords, + DROP COLUMN IF EXISTS software, + DROP COLUMN IF EXISTS deleted, + DROP COLUMN IF EXISTS text_color, + DROP COLUMN IF EXISTS creator_user_id; diff --git a/deploy/migrations/0042_sticker_set_creator.up.sql b/deploy/migrations/0042_sticker_set_creator.up.sql new file mode 100644 index 00000000..032d9664 --- /dev/null +++ b/deploy/migrations/0042_sticker_set_creator.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE public.sticker_sets + ADD COLUMN IF NOT EXISTS creator_user_id bigint DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS text_color boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS deleted boolean DEFAULT false NOT NULL, + ADD COLUMN IF NOT EXISTS software text DEFAULT ''::text NOT NULL, + ADD COLUMN IF NOT EXISTS keywords jsonb DEFAULT '[]'::jsonb NOT NULL, + ADD COLUMN IF NOT EXISTS updated_at timestamp with time zone DEFAULT now() NOT NULL; + +DROP INDEX IF EXISTS public.sticker_sets_short_name_idx; + +CREATE UNIQUE INDEX IF NOT EXISTS sticker_sets_short_name_lower_idx + ON public.sticker_sets USING btree (lower(short_name)) + WHERE (short_name <> ''::text AND deleted = false); + +CREATE INDEX IF NOT EXISTS sticker_sets_creator_idx + ON public.sticker_sets USING btree (creator_user_id, id DESC) + WHERE (creator_user_id <> 0 AND deleted = false); diff --git a/deploy/migrations/0043_sticker_sets_no_global_installed.down.sql b/deploy/migrations/0043_sticker_sets_no_global_installed.down.sql new file mode 100644 index 00000000..4adec06d --- /dev/null +++ b/deploy/migrations/0043_sticker_sets_no_global_installed.down.sql @@ -0,0 +1,6 @@ +UPDATE sticker_sets +SET installed = true, + installed_date = CASE WHEN installed_date = 0 THEN 1 ELSE installed_date END +WHERE set_kind <> 'system' + AND creator_user_id IS NULL + AND deleted = false; diff --git a/deploy/migrations/0043_sticker_sets_no_global_installed.up.sql b/deploy/migrations/0043_sticker_sets_no_global_installed.up.sql new file mode 100644 index 00000000..cb48b3b2 --- /dev/null +++ b/deploy/migrations/0043_sticker_sets_no_global_installed.up.sql @@ -0,0 +1,5 @@ +UPDATE sticker_sets +SET installed = false, + installed_date = 0 +WHERE installed = true + OR installed_date <> 0; diff --git a/deploy/migrations/0044_stickers_service_bot.down.sql b/deploy/migrations/0044_stickers_service_bot.down.sql new file mode 100644 index 00000000..ff25a85e --- /dev/null +++ b/deploy/migrations/0044_stickers_service_bot.down.sql @@ -0,0 +1,14 @@ +DELETE FROM public.bot_chat_states +WHERE bot_user_id = 1063110917; + +DELETE FROM public.bots +WHERE bot_user_id = 1063110917; + +DELETE FROM public.peer_usernames +WHERE peer_type = 'user' AND peer_id = 1063110917; + +DELETE FROM public.read_model_versions +WHERE owner_user_id = 1063110917 AND peer_type = 'user' AND peer_id = 1063110917; + +DELETE FROM public.users +WHERE id = 1063110917; diff --git a/deploy/migrations/0044_stickers_service_bot.up.sql b/deploy/migrations/0044_stickers_service_bot.up.sql new file mode 100644 index 00000000..eebe7a4a --- /dev/null +++ b/deploy/migrations/0044_stickers_service_bot.up.sql @@ -0,0 +1,74 @@ +INSERT INTO public.users ( + id, access_hash, phone, first_name, last_name, username, country_code, + created_at, updated_at, verified, support, about, last_seen_at, + default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, + emoji_status_document_id, emoji_status_until, color_set, color, + color_background_emoji_id, profile_color_set, profile_color, + profile_color_background_emoji_id +) VALUES ( + 1063110917, 5213187021149032991, '', 'Stickers', '', 'Stickers', '', + now(), now(), true, false, 'Create custom sticker and emoji packs for telesrv.', + 0, 0, true, 2, NULL, 0, 0, false, 0, 0, false, 0, 0 +) +ON CONFLICT (id) DO UPDATE SET + access_hash = EXCLUDED.access_hash, + phone = EXCLUDED.phone, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + username = EXCLUDED.username, + verified = EXCLUDED.verified, + support = EXCLUDED.support, + about = EXCLUDED.about, + is_bot = EXCLUDED.is_bot, + bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version), + updated_at = now(); + +INSERT INTO public.bots ( + bot_user_id, owner_user_id, token_secret, description, commands, + bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at, + menu_button_type, menu_button_text, menu_button_url, bot_inline_geo +) VALUES ( + 1063110917, 1063110917, '', + 'Create custom sticker and emoji packs for telesrv.', + '[ + {"command": "start", "description": "start the sticker pack assistant"}, + {"command": "help", "description": "show help"}, + {"command": "newpack", "description": "create a sticker pack"}, + {"command": "newemoji", "description": "create a custom emoji pack"}, + {"command": "addsticker", "description": "add to one of your packs"}, + {"command": "delsticker", "description": "remove one item from a pack"}, + {"command": "publish", "description": "publish the current pack"}, + {"command": "cancel", "description": "cancel the current operation"}, + {"command": "packs", "description": "list your created packs"} + ]'::jsonb, + false, false, '', now(), now(), 0, '', '', false +) +ON CONFLICT (bot_user_id) DO UPDATE SET + owner_user_id = EXCLUDED.owner_user_id, + token_secret = EXCLUDED.token_secret, + description = EXCLUDED.description, + commands = EXCLUDED.commands, + bot_chat_history = EXCLUDED.bot_chat_history, + bot_nochats = EXCLUDED.bot_nochats, + inline_placeholder = EXCLUDED.inline_placeholder, + menu_button_type = EXCLUDED.menu_button_type, + menu_button_text = EXCLUDED.menu_button_text, + menu_button_url = EXCLUDED.menu_button_url, + bot_inline_geo = EXCLUDED.bot_inline_geo, + updated_at = now(); + +INSERT INTO public.peer_usernames (username_lower, peer_type, peer_id, updated_at) +VALUES ('stickers', 'user', 1063110917, now()) +ON CONFLICT (username_lower) DO UPDATE SET + peer_type = EXCLUDED.peer_type, + peer_id = EXCLUDED.peer_id, + updated_at = now(); + +INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash) +VALUES + ('contact_account', 1063110917, 'user', 1063110917, 1, now(), 6110917000001), + ('channel_active_memberships', 1063110917, 'user', 1063110917, 1, now(), 6110917000002) +ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET + version = GREATEST(public.read_model_versions.version, EXCLUDED.version), + updated_at = now(), + hash = EXCLUDED.hash; diff --git a/deploy/migrations/0045_stickers_bot_edit_commands.down.sql b/deploy/migrations/0045_stickers_bot_edit_commands.down.sql new file mode 100644 index 00000000..3505ad99 --- /dev/null +++ b/deploy/migrations/0045_stickers_bot_edit_commands.down.sql @@ -0,0 +1,12 @@ +UPDATE public.bots +SET commands = '[ + {"command": "start", "description": "start the sticker pack assistant"}, + {"command": "help", "description": "show help"}, + {"command": "newpack", "description": "create a sticker pack"}, + {"command": "newemoji", "description": "create a custom emoji pack"}, + {"command": "publish", "description": "publish the current pack"}, + {"command": "cancel", "description": "cancel the current operation"}, + {"command": "packs", "description": "list your created packs"} + ]'::jsonb, + updated_at = now() +WHERE bot_user_id = 1063110917; diff --git a/deploy/migrations/0045_stickers_bot_edit_commands.up.sql b/deploy/migrations/0045_stickers_bot_edit_commands.up.sql new file mode 100644 index 00000000..aa11407a --- /dev/null +++ b/deploy/migrations/0045_stickers_bot_edit_commands.up.sql @@ -0,0 +1,19 @@ +UPDATE public.users +SET bot_info_version = GREATEST(bot_info_version, 2), + updated_at = now() +WHERE id = 1063110917; + +UPDATE public.bots +SET commands = '[ + {"command": "start", "description": "start the sticker pack assistant"}, + {"command": "help", "description": "show help"}, + {"command": "newpack", "description": "create a sticker pack"}, + {"command": "newemoji", "description": "create a custom emoji pack"}, + {"command": "addsticker", "description": "add to one of your packs"}, + {"command": "delsticker", "description": "remove one item from a pack"}, + {"command": "publish", "description": "publish the current pack"}, + {"command": "cancel", "description": "cancel the current operation"}, + {"command": "packs", "description": "list your created packs"} + ]'::jsonb, + updated_at = now() +WHERE bot_user_id = 1063110917; diff --git a/internal/app/account/service.go b/internal/app/account/service.go index f39afda1..6a68276b 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -20,13 +20,14 @@ const ( // Service 提供账号安全配置查询。 type Service struct { - passwords store.PasswordStore - reactions store.AccountReactionSettingsStore - settings store.AccountSettingsStore - notify store.NotifySettingsStore - stickers store.StickerCollectionStore - savedMusic store.SavedMusicStore - business store.BusinessAutomationStore + passwords store.PasswordStore + reactions store.AccountReactionSettingsStore + settings store.AccountSettingsStore + notify store.NotifySettingsStore + stickers store.StickerCollectionStore + stickerSets store.UserStickerSetStore + savedMusic store.SavedMusicStore + business store.BusinessAutomationStore // users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。 users store.UserStore } @@ -62,6 +63,13 @@ func WithStickerCollections(stickers store.StickerCollectionStore) ServiceOption } } +// WithUserStickerSets 注入账号级 installed sticker set 状态持久化。 +func WithUserStickerSets(stickerSets store.UserStickerSetStore) ServiceOption { + return func(s *Service) { + s.stickerSets = stickerSets + } +} + // WithSavedMusic 注入账号级 profile music 列表持久化。 func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption { return func(s *Service) { @@ -749,6 +757,42 @@ func (s *Service) ClearStickerCollection(ctx context.Context, userID int64, kind return s.stickers.ClearStickerCollection(ctx, userID, kind) } +// InstallUserStickerSet 安装或重新激活一个贴纸集,安装态是 per-user 事实。 +func (s *Service) InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error { + if s == nil || s.stickerSets == nil || userID == 0 || setID == 0 { + return nil + } + return s.stickerSets.InstallUserStickerSet(ctx, userID, setID, kind, archived, installedDate) +} + +func (s *Service) UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error { + if s == nil || s.stickerSets == nil || userID == 0 || setID == 0 { + return nil + } + return s.stickerSets.UninstallUserStickerSet(ctx, userID, setID) +} + +func (s *Service) SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error { + if s == nil || s.stickerSets == nil || userID == 0 || setID == 0 { + return nil + } + return s.stickerSets.SetUserStickerSetArchived(ctx, userID, setID, archived, now) +} + +func (s *Service) ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error { + if s == nil || s.stickerSets == nil || userID == 0 || len(order) == 0 { + return nil + } + return s.stickerSets.ReorderUserStickerSets(ctx, userID, kind, order, now) +} + +func (s *Service) ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) { + if s == nil || s.stickerSets == nil || userID == 0 { + return nil, 0, nil + } + return s.stickerSets.ListUserStickerSets(ctx, userID, kind, archived, offsetID, limit) +} + func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings { defaults := domain.DefaultAccountReactionSettings() settings.Notify = normalizeNotifySettings(settings.Notify) diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index 53ca1013..96298c37 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -69,47 +70,61 @@ type botReply struct { // HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。 func (s *Service) HandlesBot(botUserID int64) bool { - return s != nil && botUserID == domain.BotFatherUserID + return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID) } // OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。 // msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC // goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。 func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) { - if s == nil || s.messages == nil || botUserID != domain.BotFatherUserID { + if s == nil || s.messages == nil || !s.HandlesBot(botUserID) { return } userID := msg.From.ID if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID { return } - go s.respondAsBotFather(userID, msg.Body) + switch botUserID { + case domain.BotFatherUserID: + go s.respondAsBotFather(userID, msg.Body) + case domain.StickersBotUserID: + go s.respondAsStickers(userID, msg) + } } // respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。 // 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序, // 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。 func (s *Service) respondAsBotFather(userID int64, body string) { - mu := &s.replyLocks[uint64(userID)%replyLockStripes] + mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID) mu.Lock() defer mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() reply := s.handleBotFather(ctx, userID, body) - if reply.Text == "" { + s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply) +} + +func (s *Service) serviceBotReplyLock(botUserID, userID int64) *sync.Mutex { + key := uint64(userID) ^ (uint64(botUserID) * 11400714819323198485) + return &s.replyLocks[key%replyLockStripes] +} + +func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int64, reply botReply) { + if s == nil || s.messages == nil || reply.Text == "" { return } blocked := false if s.blocker != nil { - if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil { - s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err)) + if b, err := s.blocker.IsBlocked(ctx, userID, botUserID); err != nil { + s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err)) } else { blocked = b } } if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: domain.BotFatherUserID, + SenderUserID: botUserID, RecipientUserID: userID, RandomID: s.botReplyRandomID(), Message: reply.Text, @@ -117,12 +132,12 @@ func (s *Service) respondAsBotFather(userID int64, body string) { Date: int(s.now().Unix()), RecipientBlocked: blocked, }); err != nil { - s.log.Error("botfather: send reply", zap.Int64("user_id", userID), zap.Error(err)) + s.log.Error("service bot: send reply", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err)) } } // botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。 -// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用 +// 所有服务 bot 回复按各自 sender 命名空间唯一——用 // crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。 func (s *Service) botReplyRandomID() int64 { if v, err := randomInt64(); err == nil && v != 0 { diff --git a/internal/app/bots/manage_test.go b/internal/app/bots/manage_test.go index 7af2d252..39be659d 100644 --- a/internal/app/bots/manage_test.go +++ b/internal/app/bots/manage_test.go @@ -411,3 +411,5 @@ func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int c.pushedCommandsTo = botUserID c.pushedCommands = append([]domain.BotCommand(nil), commands...) } + +func (c *captureRevoker) PushStickerSetsChanged(context.Context, int64, domain.StickerSetKind) {} diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index ee805a60..83aaa8a6 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -29,15 +29,31 @@ type publicChannelUsernameResolver interface { ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) } +type stickerSetCreator interface { + CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) + ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) + ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) + GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) + AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) + RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) +} + +type userStickerSetInstaller interface { + InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error +} + // RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破 -// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现): +// router↔bots 的构造循环;两个能力都依赖 TL/连接层边界,不能在 app 层实现): // - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删 // authorization + 强制断连)。 // - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands // (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。 +// - PushStickerSetsChanged:@Stickers 发布后给 creator 当前在线 session 推 +// updateStickerSets;离线端靠持久化 install 状态 + 下次 getAllStickers 兜底。 type RouterHooks interface { RevokeBotSessions(ctx context.Context, botUserID int64) error PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) + PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind) } // replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、 @@ -51,6 +67,8 @@ type Service struct { messages store.MessageStore blocker blockChecker channels publicChannelUsernameResolver + stickers stickerSetCreator + installer userStickerSetInstaller hooks RouterHooks userCache store.UserCache cache *botProfileCache @@ -114,6 +132,24 @@ func WithUserCache(c store.UserCache) Option { } } +// WithStickerSetCreator 注入 sticker set 创建/查询能力,供内置 @Stickers bot 使用。 +func WithStickerSetCreator(c stickerSetCreator) Option { + return func(s *Service) { + if c != nil { + s.stickers = c + } + } +} + +// WithUserStickerSets 注入 per-user sticker set 安装状态写入能力。 +func WithUserStickerSets(c userStickerSetInstaller) Option { + return func(s *Service) { + if c != nil { + s.installer = c + } + } +} + // invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。 // 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。 func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) { diff --git a/internal/app/bots/stickersbot.go b/internal/app/bots/stickersbot.go new file mode 100644 index 00000000..43d5692b --- /dev/null +++ b/internal/app/bots/stickersbot.go @@ -0,0 +1,793 @@ +package bots + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode/utf8" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const ( + stickersBotCmdNewPack = "newpack" + stickersBotCmdNewEmoji = "newemoji" + stickersBotCmdPublish = "publish" + stickersBotCmdPacks = "packs" + stickersBotCmdAdd = "addsticker" + stickersBotCmdDel = "delsticker" + + stickersBotStepSet = "set" + stickersBotStepTitle = "title" + stickersBotStepDocument = "document" + stickersBotStepEmoji = "emoji" + stickersBotStepShortName = "short_name" + + stickersBotDraftKind = "kind" + stickersBotDraftTitle = "title" + stickersBotDraftSetID = "set_id" + stickersBotDraftSetAccessHash = "set_access_hash" + stickersBotDraftSetShortName = "set_short_name" + stickersBotDraftSetTitle = "set_title" + stickersBotDraftItems = "items" + stickersBotDraftPendingDocID = "pending_doc_id" + stickersBotDraftPendingDocHash = "pending_doc_access_hash" + stickersBotCreateSoftware = "telesrv-stickers-bot" + stickersBotCreatedListPageLimit = 20 +) + +const stickersBotHelpText = `I can help you create sticker and custom emoji packs for telesrv. + +Send /newpack to create a sticker pack. +Send /newemoji to create a custom emoji pack. +Send /addsticker to add an item to one of your packs. +Send /delsticker to remove an item from one of your packs. + +Send a sticker/custom emoji, or upload a TGS, Lottie JSON, WebP, WebM, or MP4 file as a document. Send /publish when your pack is ready, then choose a short name for the link. + +/packs - list your created packs +/cancel - cancel the current operation +/help - show this message` + +var stickersBotGlobalCommands = map[string]bool{ + "start": true, "help": true, "cancel": true, + stickersBotCmdNewPack: true, stickersBotCmdNewEmoji: true, + stickersBotCmdPublish: true, stickersBotCmdPacks: true, + stickersBotCmdAdd: true, stickersBotCmdDel: true, +} + +type stickersBotDraftItem struct { + DocumentID int64 `json:"document_id"` + DocumentAccessHash int64 `json:"document_access_hash"` + Emoji string `json:"emoji"` +} + +func (s *Service) respondAsStickers(userID int64, msg domain.Message) { + mu := s.serviceBotReplyLock(domain.StickersBotUserID, userID) + mu.Lock() + defer mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + reply := s.handleStickers(ctx, userID, msg) + s.sendServiceBotReply(ctx, domain.StickersBotUserID, userID, reply) +} + +func (s *Service) handleStickers(ctx context.Context, userID int64, msg domain.Message) botReply { + text := strings.TrimSpace(msg.Body) + state, found, err := s.bots.GetBotChatState(ctx, domain.StickersBotUserID, userID) + if err != nil { + s.log.Error("stickersbot: get chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if cmd, ok := parseBotCommand(text); ok { + if stickersBotGlobalCommands[cmd] { + return s.handleStickersCommand(ctx, userID, cmd, state, found) + } + return botReply{Text: "Unrecognized command. Send /help for a list of commands."} + } + if !found { + if stickersBotDocument(msg) != nil { + return botReply{Text: "Start a pack first with /newpack or /newemoji."} + } + return botReply{Text: "Send /newpack to create a sticker pack or /newemoji to create a custom emoji pack."} + } + switch state.Step { + case stickersBotStepSet: + if text == "" { + return stickersBotStepPrompt(state) + } + return s.handleStickersSet(ctx, state, text) + case stickersBotStepTitle: + if text == "" { + return stickersBotStepPrompt(state) + } + return s.handleStickersTitle(ctx, state, text) + case stickersBotStepDocument: + switch state.Command { + case stickersBotCmdAdd: + return s.handleStickersAddDocument(ctx, state, msg) + case stickersBotCmdDel: + return s.handleStickersDeleteDocument(ctx, state, msg) + } + return s.handleStickersDocument(ctx, state, msg) + case stickersBotStepEmoji: + if text == "" { + return stickersBotStepPrompt(state) + } + if state.Command == stickersBotCmdAdd { + return s.handleStickersAddEmoji(ctx, state, text) + } + return s.handleStickersEmoji(ctx, state, text) + case stickersBotStepShortName: + if text == "" { + return stickersBotStepPrompt(state) + } + return s.handleStickersShortName(ctx, state, text) + default: + if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID); err != nil { + s.log.Error("stickersbot: delete corrupt state", zap.Int64("user_id", userID), zap.Error(err)) + } + return botReply{Text: "Something went wrong, I forgot what we were doing. Send /newpack or /newemoji to start again."} + } +} + +func (s *Service) handleStickersCommand(ctx context.Context, userID int64, cmd string, state domain.BotChatState, found bool) botReply { + switch cmd { + case "start": + _ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID) + return botReply{Text: stickersBotHelpText} + case "help": + return botReply{Text: stickersBotHelpText} + case "cancel": + if !found { + return botReply{Text: "No active pack to cancel."} + } + if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID); err != nil { + s.log.Error("stickersbot: delete chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Cancelled. Send /newpack or /newemoji when you are ready."} + case stickersBotCmdNewPack: + return s.startStickersFlow(ctx, userID, stickersBotCmdNewPack, domain.StickerSetKindStickers) + case stickersBotCmdNewEmoji: + return s.startStickersFlow(ctx, userID, stickersBotCmdNewEmoji, domain.StickerSetKindEmoji) + case stickersBotCmdAdd: + return s.startStickersEditFlow(ctx, userID, stickersBotCmdAdd) + case stickersBotCmdDel: + return s.startStickersEditFlow(ctx, userID, stickersBotCmdDel) + case stickersBotCmdPublish: + if !found || !stickersBotCreateCommand(state.Command) { + return botReply{Text: "Start a pack first with /newpack or /newemoji."} + } + if len(stickersBotDraftItemsFromState(state)) == 0 { + return botReply{Text: "Add at least one sticker material document before publishing."} + } + state.Step = stickersBotStepShortName + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save publish state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Choose a short name for this pack. It will be used in the public link, for example: my_fun_pack"} + case stickersBotCmdPacks: + return s.listStickersBotPacks(ctx, userID) + default: + return botReply{Text: "Unrecognized command. Send /help for a list of commands."} + } +} + +func (s *Service) startStickersEditFlow(ctx context.Context, userID int64, cmd string) botReply { + state := domain.BotChatState{ + BotUserID: domain.StickersBotUserID, + UserID: userID, + Command: cmd, + Step: stickersBotStepSet, + Draft: map[string]string{}, + } + if err := s.bots.UpsertBotChatState(ctx, state); err != nil { + s.log.Error("stickersbot: save edit state", zap.Int64("user_id", userID), zap.String("command", cmd), zap.Error(err)) + return internalReply() + } + if cmd == stickersBotCmdDel { + return botReply{Text: "Send the short name or telesrv link of the pack you want to edit. Use /packs to see your packs."} + } + return botReply{Text: "Send the short name or telesrv link of the pack you want to add to. Use /packs to see your packs."} +} + +func (s *Service) startStickersFlow(ctx context.Context, userID int64, cmd string, kind domain.StickerSetKind) botReply { + state := domain.BotChatState{ + BotUserID: domain.StickersBotUserID, + UserID: userID, + Command: cmd, + Step: stickersBotStepTitle, + Draft: map[string]string{ + stickersBotDraftKind: string(kind), + }, + } + if err := s.bots.UpsertBotChatState(ctx, state); err != nil { + s.log.Error("stickersbot: save chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if kind == domain.StickerSetKindEmoji { + return botReply{Text: "Alright, a new custom emoji pack. Send me a title for it."} + } + return botReply{Text: "Alright, a new sticker pack. Send me a title for it."} +} + +func (s *Service) handleStickersSet(ctx context.Context, state domain.BotChatState, raw string) botReply { + if s.stickers == nil { + return botReply{Text: "Sticker pack editing is not available right now."} + } + shortName := normalizeStickersBotShortName(raw) + if shortName == "" || strings.HasPrefix(shortName, "/") { + return botReply{Text: "Send the pack short name or telesrv link. Use /packs to list your packs, or /cancel."} + } + set, _, found, err := s.stickers.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName}) + if err != nil { + s.log.Error("stickersbot: resolve edit set", zap.Int64("user_id", state.UserID), zap.String("short_name", shortName), zap.Error(err)) + return internalReply() + } + if !found || set.Deleted || set.ID == 0 { + return botReply{Text: "I couldn't find that pack. Send a short name from /packs, or /cancel."} + } + if set.CreatorUserID != state.UserID { + return botReply{Text: "I can only edit packs created by you. Send one of your pack links, or /cancel."} + } + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft[stickersBotDraftSetID] = strconv.FormatInt(set.ID, 10) + state.Draft[stickersBotDraftSetAccessHash] = strconv.FormatInt(set.AccessHash, 10) + state.Draft[stickersBotDraftSetShortName] = set.ShortName + state.Draft[stickersBotDraftSetTitle] = set.Title + state.Draft[stickersBotDraftKind] = string(stickersBotSetKind(set)) + state.Step = stickersBotStepDocument + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save edit set", zap.Int64("user_id", state.UserID), zap.Int64("set_id", set.ID), zap.Error(err)) + return internalReply() + } + if state.Command == stickersBotCmdDel { + return botReply{Text: fmt.Sprintf("Selected %s. Send the sticker or custom emoji from this pack that you want to remove.", stickersBotSetTitleFromState(state))} + } + return botReply{Text: fmt.Sprintf("Selected %s. Now send the sticker material document to add.", stickersBotSetTitleFromState(state))} +} + +func (s *Service) handleStickersTitle(ctx context.Context, state domain.BotChatState, title string) botReply { + title = strings.TrimSpace(title) + if title == "" || utf8.RuneCountInString(title) > domain.MaxStickerSetTitleLen { + return botReply{Text: fmt.Sprintf("The title must be 1-%d characters. Send another title or /cancel.", domain.MaxStickerSetTitleLen)} + } + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft[stickersBotDraftTitle] = title + if _, ok := state.Draft[stickersBotDraftKind]; !ok { + state.Draft[stickersBotDraftKind] = string(stickersBotKindFromState(state)) + } + state.Step = stickersBotStepDocument + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save title", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Good. Now send me a sticker, custom emoji, or a TGS/Lottie JSON/WebP/WebM/MP4 document to add to this pack."} +} + +func (s *Service) handleStickersDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply { + doc := stickersBotDocument(msg) + if doc == nil || doc.ID == 0 || doc.AccessHash == 0 || !doc.IsStickerSetMaterial() { + return botReply{Text: "Please send a sticker, custom emoji, TGS, Lottie JSON, or WebP document. WebM/MP4 must include video metadata. /cancel to stop."} + } + if len(stickersBotDraftItemsFromState(state)) >= domain.MaxStickerSetItems { + return botReply{Text: fmt.Sprintf("This pack already has the maximum of %d items. Send /publish to finish.", domain.MaxStickerSetItems)} + } + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft[stickersBotDraftPendingDocID] = strconv.FormatInt(doc.ID, 10) + state.Draft[stickersBotDraftPendingDocHash] = strconv.FormatInt(doc.AccessHash, 10) + state.Step = stickersBotStepEmoji + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save document", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Now send the emoji that should be associated with this item."} +} + +func (s *Service) handleStickersAddDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply { + doc := stickersBotDocument(msg) + if doc == nil || doc.ID == 0 || doc.AccessHash == 0 || !doc.IsStickerSetMaterial() { + return botReply{Text: "Please send a sticker, custom emoji, TGS, Lottie JSON, or WebP document to add. WebM/MP4 must include video metadata. /cancel to stop."} + } + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft[stickersBotDraftPendingDocID] = strconv.FormatInt(doc.ID, 10) + state.Draft[stickersBotDraftPendingDocHash] = strconv.FormatInt(doc.AccessHash, 10) + state.Step = stickersBotStepEmoji + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save add document", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Now send the emoji that should be associated with this added item."} +} + +func (s *Service) handleStickersEmoji(ctx context.Context, state domain.BotChatState, emoji string) botReply { + emoji = strings.TrimSpace(emoji) + if !validStickersBotEmoji(emoji) { + return botReply{Text: "That doesn't look like a valid emoji. Send an emoji like 🙂, or /cancel."} + } + docID, docHash := pendingStickersBotDocument(state) + if docID == 0 || docHash == 0 { + state.Step = stickersBotStepDocument + delete(state.Draft, stickersBotDraftPendingDocID) + delete(state.Draft, stickersBotDraftPendingDocHash) + _ = s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)) + return botReply{Text: "I lost the document for this item. Please send it again."} + } + items := stickersBotDraftItemsFromState(state) + for _, item := range items { + if item.DocumentID == docID { + state.Step = stickersBotStepDocument + delete(state.Draft, stickersBotDraftPendingDocID) + delete(state.Draft, stickersBotDraftPendingDocHash) + _ = s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)) + return botReply{Text: "That document is already in this pack. Send another document or /publish."} + } + } + items = append(items, stickersBotDraftItem{DocumentID: docID, DocumentAccessHash: docHash, Emoji: emoji}) + if err := setStickersBotDraftItems(&state, items); err != nil { + s.log.Error("stickersbot: encode items", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + delete(state.Draft, stickersBotDraftPendingDocID) + delete(state.Draft, stickersBotDraftPendingDocHash) + state.Step = stickersBotStepDocument + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save emoji", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: fmt.Sprintf("Added. This pack has %d item(s). Send another sticker material document, or /publish when ready.", len(items))} +} + +func (s *Service) handleStickersAddEmoji(ctx context.Context, state domain.BotChatState, emoji string) botReply { + if s.stickers == nil { + return botReply{Text: "Sticker pack editing is not available right now."} + } + emoji = strings.TrimSpace(emoji) + if !validStickersBotEmoji(emoji) { + return botReply{Text: "That doesn't look like a valid emoji. Send an emoji like 🙂, or /cancel."} + } + docID, docHash := pendingStickersBotDocument(state) + if docID == 0 || docHash == 0 { + state.Step = stickersBotStepDocument + delete(state.Draft, stickersBotDraftPendingDocID) + delete(state.Draft, stickersBotDraftPendingDocHash) + _ = s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)) + return botReply{Text: "I lost the document for this item. Please send it again."} + } + ref, ok := stickersBotSetRefFromState(state) + if !ok { + _ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID) + return botReply{Text: "I lost the pack selection. Send /addsticker to start again."} + } + set, _, found, err := s.stickers.ResolveStickerSet(ctx, ref) + if err != nil { + s.log.Error("stickersbot: resolve add set", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + if !found || set.Deleted || set.CreatorUserID != state.UserID { + _ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID) + return botReply{Text: "That pack is no longer available. Send /addsticker to start again."} + } + if containsInt64(set.DocumentIDs, docID) { + state.Step = stickersBotStepDocument + delete(state.Draft, stickersBotDraftPendingDocID) + delete(state.Draft, stickersBotDraftPendingDocHash) + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save duplicate add state", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "That document is already in this pack. Send another sticker material document, or /cancel."} + } + set, _, err = s.stickers.AddStickerToSet(ctx, state.UserID, ref, domain.StickerSetItemInput{ + DocumentID: docID, + DocumentAccessHash: docHash, + Emoji: emoji, + }) + if err != nil { + return s.stickersBotEditError(state.UserID, err) + } + if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID); err != nil { + s.log.Error("stickersbot: delete add state", zap.Int64("user_id", state.UserID), zap.Error(err)) + } + 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))} +} + +func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply { + if s.stickers == nil { + return botReply{Text: "Sticker pack editing is not available right now."} + } + doc, err := s.stickersBotDocumentForDelete(ctx, msg) + if err != nil { + s.log.Error("stickersbot: load delete document", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + if doc == nil || doc.ID == 0 || doc.AccessHash == 0 || !doc.IsStickerLike() { + return botReply{Text: "Please send the sticker or custom emoji from the selected pack that you want to remove, or /cancel."} + } + setID, setHash, ok := stickersBotSetIdentityFromState(state) + if !ok { + _ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID) + return botReply{Text: "I lost the pack selection. Send /delsticker to start again."} + } + docSetID, docSetHash, docHasSet := doc.StickerSetRef() + if !docHasSet || docSetID != setID || docSetHash != setHash { + return botReply{Text: "That sticker is not from the selected pack. Send a sticker from that pack, or /cancel."} + } + set, _, err := s.stickers.RemoveStickerFromSet(ctx, state.UserID, doc.ID, doc.AccessHash) + if err != nil { + return s.stickersBotEditError(state.UserID, err) + } + if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID); err != nil { + s.log.Error("stickersbot: delete remove state", zap.Int64("user_id", state.UserID), zap.Error(err)) + } + 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))} +} + +func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotChatState, raw string) botReply { + if s.stickers == nil || s.installer == nil { + return botReply{Text: "Sticker pack creation is not available right now. Please try again later."} + } + shortName := normalizeStickersBotShortName(raw) + items := stickersBotDraftItemsFromState(state) + if len(items) == 0 { + state.Step = stickersBotStepDocument + if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil { + s.log.Error("stickersbot: save empty publish state", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Add at least one sticker material document before publishing."} + } + kind := stickersBotKindFromState(state) + set, _, err := s.stickers.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: state.UserID, + Title: state.Draft[stickersBotDraftTitle], + ShortName: shortName, + Kind: kind, + Items: stickersBotCreateItems(items), + Software: stickersBotCreateSoftware, + Date: int(s.now().Unix()), + }) + if err != nil { + return s.stickersBotCreateError(state.UserID, err) + } + installKind := stickersBotSetKind(set) + if err := s.installer.InstallUserStickerSet(ctx, state.UserID, set.ID, installKind, false, int(s.now().Unix())); err != nil { + s.log.Error("stickersbot: install created set", zap.Int64("user_id", state.UserID), zap.Int64("set_id", set.ID), zap.Error(err)) + return internalReply() + } + if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID); err != nil { + s.log.Error("stickersbot: delete published state", zap.Int64("user_id", state.UserID), zap.Error(err)) + } + 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)} +} + +func (s *Service) stickersBotCreateError(userID int64, err error) botReply { + switch { + case errors.Is(err, domain.ErrStickerSetShortNameInvalid): + return botReply{Text: "That short name is invalid. Use 5-32 lowercase letters, digits or underscores, starting with a letter."} + case errors.Is(err, domain.ErrStickerSetShortNameOccupied): + return botReply{Text: "That short name is already taken. Please send another one."} + case errors.Is(err, domain.ErrStickerSetTitleInvalid): + return botReply{Text: "The title is invalid. Send /cancel and start again."} + case errors.Is(err, domain.ErrStickerSetEmpty): + return botReply{Text: "Add at least one sticker material document before publishing."} + case errors.Is(err, domain.ErrStickerSetEmojiInvalid): + return botReply{Text: "One of the emoji values is invalid. Send /cancel and start again."} + case errors.Is(err, domain.ErrStickerSetFileInvalid): + return botReply{Text: "One of the documents is no longer a valid sticker material file. Send /cancel and start again."} + default: + s.log.Error("stickersbot: create sticker set", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } +} + +func (s *Service) stickersBotEditError(userID int64, err error) botReply { + switch { + case errors.Is(err, domain.ErrStickerSetInvalid): + return botReply{Text: "That pack is no longer available. Send /packs and try again."} + case errors.Is(err, domain.ErrStickerSetNotOwned): + return botReply{Text: "I can only edit packs created by you."} + case errors.Is(err, domain.ErrStickerSetTooMuch): + return botReply{Text: fmt.Sprintf("That pack already has the maximum of %d items.", domain.MaxStickerSetItems)} + case errors.Is(err, domain.ErrStickerSetEmpty): + return botReply{Text: "A pack must keep at least one item. Add another item before removing this one."} + case errors.Is(err, domain.ErrStickerSetEmojiInvalid): + return botReply{Text: "That doesn't look like a valid emoji. Send /addsticker and try again."} + case errors.Is(err, domain.ErrStickerSetFileInvalid): + return botReply{Text: "That document is not a valid sticker material or is not in the selected pack. Send the command again and try another document."} + default: + s.log.Error("stickersbot: edit sticker set", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } +} + +func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botReply { + if s.stickers == nil { + return botReply{Text: "Sticker pack listing is not available right now."} + } + sets, total, err := s.stickers.ListCreatedStickerSets(ctx, userID, 0, stickersBotCreatedListPageLimit) + if err != nil { + s.log.Error("stickersbot: list packs", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if len(sets) == 0 { + return botReply{Text: "You don't have any packs yet. Use /newpack or /newemoji to create one."} + } + 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))) + } + if total > len(sets) { + lines = append(lines, fmt.Sprintf("Showing %d of %d.", len(sets), total)) + } + lines = append(lines, "Use /addsticker to add to a pack, or /delsticker to remove one item.") + return botReply{Text: strings.Join(lines, "\n")} +} + +func stickersBotStepPrompt(state domain.BotChatState) botReply { + switch state.Step { + case stickersBotStepSet: + return botReply{Text: "Send the pack short name or telesrv link, or /cancel."} + case stickersBotStepTitle: + return botReply{Text: "Send a title for this pack, or /cancel."} + case stickersBotStepDocument: + switch state.Command { + case stickersBotCmdAdd: + return botReply{Text: "Send a sticker material document to add, or /cancel."} + case stickersBotCmdDel: + return botReply{Text: "Send the sticker or custom emoji from the selected pack to remove, or /cancel."} + } + return botReply{Text: "Send a sticker material document, or /publish if the pack already has items."} + case stickersBotStepEmoji: + return botReply{Text: "Send the emoji for the last document, or /cancel."} + case stickersBotStepShortName: + return botReply{Text: "Send a short name for the public link, or /cancel."} + default: + return botReply{Text: "Send /help for a list of commands."} + } +} + +func stickersBotDocument(msg domain.Message) *domain.Document { + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindDocument || msg.Media.Document == nil { + return nil + } + doc := *msg.Media.Document + return &doc +} + +func (s *Service) stickersBotDocumentForDelete(ctx context.Context, msg domain.Message) (*domain.Document, error) { + if doc := stickersBotDocument(msg); doc != nil { + return doc, nil + } + for _, entity := range msg.Entities { + if entity.Type != domain.MessageEntityCustomEmoji || entity.DocumentID == 0 { + continue + } + docs, err := s.stickers.GetDocuments(ctx, []int64{entity.DocumentID}) + if err != nil { + return nil, err + } + for _, doc := range docs { + if doc.ID == entity.DocumentID { + doc := doc + return &doc, nil + } + } + return nil, nil + } + return nil, nil +} + +func stickersBotKindFromState(state domain.BotChatState) domain.StickerSetKind { + if state.Draft != nil { + switch domain.StickerSetKind(state.Draft[stickersBotDraftKind]) { + case domain.StickerSetKindEmoji: + return domain.StickerSetKindEmoji + case domain.StickerSetKindMasks: + return domain.StickerSetKindMasks + } + } + if state.Command == stickersBotCmdNewEmoji { + return domain.StickerSetKindEmoji + } + return domain.StickerSetKindStickers +} + +func stickersBotCreateCommand(command string) bool { + return command == stickersBotCmdNewPack || command == stickersBotCmdNewEmoji +} + +func stickersBotSetKind(set domain.StickerSet) domain.StickerSetKind { + switch { + case set.Kind == domain.StickerSetKindMasks || set.Masks: + return domain.StickerSetKindMasks + case set.Kind == domain.StickerSetKindEmoji || set.Emojis: + return domain.StickerSetKindEmoji + default: + return domain.StickerSetKindStickers + } +} + +func stickersBotSetTitle(set domain.StickerSet) string { + title := strings.TrimSpace(set.Title) + if title == "" { + title = set.ShortName + } + if title == "" { + return "the pack" + } + return title +} + +func stickersBotSetTitleFromState(state domain.BotChatState) string { + if state.Draft != nil { + if title := strings.TrimSpace(state.Draft[stickersBotDraftSetTitle]); title != "" { + return title + } + if shortName := strings.TrimSpace(state.Draft[stickersBotDraftSetShortName]); shortName != "" { + return shortName + } + } + return "the pack" +} + +func stickersBotSetRefFromState(state domain.BotChatState) (domain.StickerSetRef, bool) { + id, accessHash, ok := stickersBotSetIdentityFromState(state) + if ok { + return domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: id, AccessHash: accessHash}, true + } + if state.Draft != nil { + if shortName := strings.TrimSpace(state.Draft[stickersBotDraftSetShortName]); shortName != "" { + return domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName}, true + } + } + return domain.StickerSetRef{}, false +} + +func stickersBotSetIdentityFromState(state domain.BotChatState) (int64, int64, bool) { + if state.Draft == nil { + return 0, 0, false + } + id, _ := strconv.ParseInt(state.Draft[stickersBotDraftSetID], 10, 64) + accessHash, _ := strconv.ParseInt(state.Draft[stickersBotDraftSetAccessHash], 10, 64) + return id, accessHash, id != 0 && accessHash != 0 +} + +func stickersBotDraftItemsFromState(state domain.BotChatState) []stickersBotDraftItem { + if state.Draft == nil || strings.TrimSpace(state.Draft[stickersBotDraftItems]) == "" { + return nil + } + var items []stickersBotDraftItem + if err := json.Unmarshal([]byte(state.Draft[stickersBotDraftItems]), &items); err != nil { + return nil + } + return items +} + +func setStickersBotDraftItems(state *domain.BotChatState, items []stickersBotDraftItem) error { + if state.Draft == nil { + state.Draft = map[string]string{} + } + raw, err := json.Marshal(items) + if err != nil { + return err + } + state.Draft[stickersBotDraftItems] = string(raw) + return nil +} + +func pendingStickersBotDocument(state domain.BotChatState) (int64, int64) { + if state.Draft == nil { + return 0, 0 + } + id, _ := strconv.ParseInt(state.Draft[stickersBotDraftPendingDocID], 10, 64) + accessHash, _ := strconv.ParseInt(state.Draft[stickersBotDraftPendingDocHash], 10, 64) + return id, accessHash +} + +func stickersBotCreateItems(items []stickersBotDraftItem) []domain.StickerSetItemInput { + out := make([]domain.StickerSetItemInput, 0, len(items)) + for _, item := range items { + out = append(out, domain.StickerSetItemInput{ + DocumentID: item.DocumentID, + DocumentAccessHash: item.DocumentAccessHash, + Emoji: item.Emoji, + }) + } + return out +} + +func containsInt64(values []int64, want int64) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func cloneStickersBotState(state domain.BotChatState) domain.BotChatState { + out := state + if state.Draft != nil { + out.Draft = make(map[string]string, len(state.Draft)) + for k, v := range state.Draft { + out.Draft[k] = v + } + } + return out +} + +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=") + return strings.ToLower(strings.Trim(raw, " /")) +} + +func validStickersBotEmoji(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || strings.HasPrefix(raw, "/") || utf8.RuneCountInString(raw) > 64 { + return false + } + hasEmoji := false + for _, r := range raw { + switch { + case r == 0x200D || r == 0xFE0E || r == 0xFE0F: + continue + case r >= 0x1F3FB && r <= 0x1F3FF: + continue + case r == 0x20E3: + hasEmoji = true + case r == '#' || r == '*' || (r >= '0' && r <= '9'): + continue + case r == 0x00A9 || r == 0x00AE || r == 0x3030 || r == 0x303D || r == 0x3297 || r == 0x3299: + hasEmoji = true + case r >= 0x2600 && r <= 0x27BF: + hasEmoji = true + case r >= 0x1F000 && r <= 0x1FAFF: + hasEmoji = true + default: + return false + } + } + return hasEmoji +} + +func stickersBotPublicURL(set domain.StickerSet) string { + part := "addstickers" + if stickersBotSetKind(set) == domain.StickerSetKindEmoji { + part = "addemoji" + } + return "https://telesrv.net/" + part + "/" + set.ShortName +} diff --git a/internal/app/bots/stickersbot_test.go b/internal/app/bots/stickersbot_test.go new file mode 100644 index 00000000..32ff6916 --- /dev/null +++ b/internal/app/bots/stickersbot_test.go @@ -0,0 +1,624 @@ +package bots + +import ( + "context" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func sendTextToStickers(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string { + t.Helper() + return sendMessageToStickers(t, svc, messages, owner, domain.Message{Body: text}) +} + +func sendDocumentToStickers(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, doc domain.Document) string { + t.Helper() + return sendMessageToStickers(t, svc, messages, owner, domain.Message{ + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &doc, + }, + }) +} + +func sendMessageToStickers(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, msg domain.Message) string { + t.Helper() + msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + msg.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID} + svc.respondAsStickers(owner.ID, msg) + list, err := messages.ListByUser(context.Background(), owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID}, + Limit: 100, + }) + if err != nil { + t.Fatalf("list stickers history: %v", err) + } + var latest domain.Message + for _, msg := range list.Messages { + if msg.From.ID == domain.StickersBotUserID && msg.ID > latest.ID { + latest = msg + } + } + if latest.ID == 0 { + t.Fatalf("no Stickers reply after message %+v", msg) + } + return latest.Body +} + +func newStickersBotTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore, *stickersBotFakeCreator, *stickersBotFakeInstaller) { + t.Helper() + users := memory.NewUserStore() + bots := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + creator := &stickersBotFakeCreator{} + installer := &stickersBotFakeInstaller{} + svc := NewService(users, bots, messages, WithStickerSetCreator(creator), WithUserStickerSets(installer)) + return svc, users, bots, messages, creator, installer +} + +func stickerBotTestDocument(id, accessHash int64, attr domain.DocumentAttributeKind) domain.Document { + return domain.Document{ + ID: id, + AccessHash: accessHash, + DCID: 2, + MimeType: "image/webp", + Attributes: []domain.DocumentAttribute{{Kind: attr}}, + } +} + +func stickerBotUploadDocument(id, accessHash int64, mimeType, fileName string) domain.Document { + return domain.Document{ + ID: id, + AccessHash: accessHash, + DCID: 2, + MimeType: mimeType, + Size: 4096, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: fileName}}, + } +} + +func stickerBotSetDocument(id, accessHash, setID, setAccessHash int64) domain.Document { + return domain.Document{ + ID: id, + AccessHash: accessHash, + DCID: 2, + MimeType: "image/webp", + Attributes: []domain.DocumentAttribute{{ + Kind: domain.DocAttrSticker, + StickerSetID: setID, + StickerSetAccessHash: setAccessHash, + }}, + } +} + +func stickerBotSetCustomEmojiDocument(id, accessHash, setID, setAccessHash int64) domain.Document { + return domain.Document{ + ID: id, + AccessHash: accessHash, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{ + Kind: domain.DocAttrCustomEmoji, + StickerSetID: setID, + StickerSetAccessHash: setAccessHash, + }}, + } +} + +func TestStickersBotSystemSeedStartAndCancel(t *testing.T) { + svc, users, bots, messages, _, _ := newStickersBotTestService(t) + owner := newOwner(t, users, "+3000") + ctx := context.Background() + + if !svc.HandlesBot(domain.BotFatherUserID) || !svc.HandlesBot(domain.StickersBotUserID) { + t.Fatal("service should handle BotFather and Stickers") + } + u, found, err := users.ByUsername(ctx, "Stickers") + if err != nil || !found { + t.Fatalf("@Stickers user not seeded: found=%v err=%v", found, err) + } + if u.ID != domain.StickersBotUserID || !u.Bot || u.BotInfoVersion < 1 { + t.Fatalf("@Stickers user = %+v, want seeded bot", u) + } + profile, found, err := bots.GetBot(ctx, domain.StickersBotUserID) + if err != nil || !found { + t.Fatalf("@Stickers profile not seeded: found=%v err=%v", found, err) + } + if !botCommandExists(profile.Commands, "newpack") || !botCommandExists(profile.Commands, "newemoji") || + !botCommandExists(profile.Commands, "publish") || !botCommandExists(profile.Commands, "addsticker") || + !botCommandExists(profile.Commands, "delsticker") { + t.Fatalf("@Stickers commands = %+v, want newpack/newemoji/publish/addsticker/delsticker", profile.Commands) + } + + if reply := sendTextToStickers(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newpack") || !strings.Contains(reply, "/newemoji") || !strings.Contains(reply, "/addsticker") { + t.Fatalf("/start reply = %q, want help text", reply) + } + sendTextToStickers(t, svc, messages, owner, "/newpack") + if reply := sendTextToStickers(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "Cancelled") { + t.Fatalf("/cancel reply = %q, want cancelled", reply) + } + if _, found, _ := bots.GetBotChatState(ctx, domain.StickersBotUserID, owner.ID); found { + t.Fatal("stickers bot state still present after /cancel") + } +} + +func TestStickersBotNewPackNoMaterialAndInvalidEmoji(t *testing.T) { + svc, users, _, messages, creator, _ := newStickersBotTestService(t) + owner := newOwner(t, users, "+3001") + + if reply := sendTextToStickers(t, svc, messages, owner, "/newpack"); !strings.Contains(reply, "sticker pack") { + t.Fatalf("/newpack reply = %q, want sticker pack prompt", reply) + } + if reply := sendTextToStickers(t, svc, messages, owner, "My Pack"); !strings.Contains(reply, "Lottie JSON") { + t.Fatalf("title reply = %q, want document prompt", reply) + } + if reply := sendTextToStickers(t, svc, messages, owner, "/publish"); !strings.Contains(reply, "Add at least one") { + t.Fatalf("empty publish reply = %q, want no material notice", reply) + } + if reply := sendTextToStickers(t, svc, messages, owner, "not a document"); !strings.Contains(reply, "WebM/MP4 must include video metadata") { + t.Fatalf("text in document step reply = %q, want material prompt", reply) + } + if reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotTestDocument(101, 1101, domain.DocAttrSticker)); !strings.Contains(reply, "emoji") { + t.Fatalf("document reply = %q, want emoji prompt", reply) + } + if reply := sendTextToStickers(t, svc, messages, owner, "notemoji"); !strings.Contains(reply, "valid emoji") { + t.Fatalf("invalid emoji reply = %q, want emoji validation", reply) + } + if len(creator.created) != 0 { + t.Fatalf("creator called before valid publish: %+v", creator.created) + } +} + +func TestStickersBotPublishStickerPack(t *testing.T) { + svc, users, bots, messages, creator, installer := newStickersBotTestService(t) + hooks := &stickersBotHookRecorder{} + svc.SetRouterHooks(hooks) + owner := newOwner(t, users, "+3002") + + sendTextToStickers(t, svc, messages, owner, "/newpack") + sendTextToStickers(t, svc, messages, owner, "Fresh Pack") + sendDocumentToStickers(t, svc, messages, owner, stickerBotTestDocument(201, 2201, domain.DocAttrSticker)) + sendTextToStickers(t, svc, messages, owner, "🙂") + sendTextToStickers(t, svc, messages, owner, "/publish") + reply := sendTextToStickers(t, svc, messages, owner, "fresh_pack") + + if !strings.Contains(reply, "https://telesrv.net/addstickers/fresh_pack") { + t.Fatalf("publish reply = %q, want addstickers link", reply) + } + if len(creator.created) != 1 { + t.Fatalf("created requests = %d, want 1", len(creator.created)) + } + req := creator.created[0] + if req.CreatorUserID != owner.ID || req.Title != "Fresh Pack" || req.ShortName != "fresh_pack" || req.Kind != domain.StickerSetKindStickers { + t.Fatalf("create request = %+v, want owner/title/short/kind", req) + } + if len(req.Items) != 1 || req.Items[0].DocumentID != 201 || req.Items[0].DocumentAccessHash != 2201 || req.Items[0].Emoji != "🙂" { + t.Fatalf("create items = %+v, want doc+emoji", req.Items) + } + if len(installer.installs) != 1 || installer.installs[0].userID != owner.ID || installer.installs[0].setID != creator.sets[0].ID { + t.Fatalf("installs = %+v, want creator install", installer.installs) + } + if hooks.userID != owner.ID || hooks.kind != domain.StickerSetKindStickers { + t.Fatalf("sticker update hook = user %d kind %q, want creator stickers", hooks.userID, hooks.kind) + } + if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found { + t.Fatal("stickers bot state still present after publish") + } +} + +func TestStickersBotPublishUploadedTGS(t *testing.T) { + svc, users, _, messages, creator, installer := newStickersBotTestService(t) + owner := newOwner(t, users, "+3004") + + sendTextToStickers(t, svc, messages, owner, "/newemoji") + sendTextToStickers(t, svc, messages, owner, "Local Emoji") + reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotUploadDocument(401, 4401, "application/octet-stream", "wave.tgs")) + if !strings.Contains(reply, "emoji") { + t.Fatalf("uploaded tgs reply = %q, want emoji prompt", reply) + } + sendTextToStickers(t, svc, messages, owner, "👋") + sendTextToStickers(t, svc, messages, owner, "/publish") + reply = sendTextToStickers(t, svc, messages, owner, "local_emoji") + + if !strings.Contains(reply, "https://telesrv.net/addemoji/local_emoji") { + t.Fatalf("publish uploaded tgs reply = %q, want addemoji link", reply) + } + if len(creator.created) != 1 { + t.Fatalf("created requests = %d, want 1", len(creator.created)) + } + req := creator.created[0] + if req.Kind != domain.StickerSetKindEmoji || len(req.Items) != 1 || req.Items[0].DocumentID != 401 || req.Items[0].DocumentAccessHash != 4401 { + t.Fatalf("create request = %+v, want uploaded tgs item in emoji pack", req) + } + if len(installer.installs) != 1 || installer.installs[0].kind != domain.StickerSetKindEmoji { + t.Fatalf("installs = %+v, want emoji install", installer.installs) + } +} + +func TestStickersBotPublishUploadedLottieJSON(t *testing.T) { + svc, users, _, messages, creator, installer := newStickersBotTestService(t) + owner := newOwner(t, users, "+3005") + + sendTextToStickers(t, svc, messages, owner, "/newpack") + sendTextToStickers(t, svc, messages, owner, "Lottie Pack") + reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotUploadDocument(402, 4402, "application/json", "wave.json")) + if !strings.Contains(reply, "emoji") { + t.Fatalf("uploaded lottie reply = %q, want emoji prompt", reply) + } + sendTextToStickers(t, svc, messages, owner, "👋") + sendTextToStickers(t, svc, messages, owner, "/publish") + reply = sendTextToStickers(t, svc, messages, owner, "lottie_pack") + + if !strings.Contains(reply, "https://telesrv.net/addstickers/lottie_pack") { + t.Fatalf("publish uploaded lottie reply = %q, want addstickers link", reply) + } + if len(creator.created) != 1 { + t.Fatalf("created requests = %d, want 1", len(creator.created)) + } + req := creator.created[0] + if req.Kind != domain.StickerSetKindStickers || len(req.Items) != 1 || req.Items[0].DocumentID != 402 || req.Items[0].DocumentAccessHash != 4402 { + t.Fatalf("create request = %+v, want uploaded lottie item in sticker pack", req) + } + if len(installer.installs) != 1 || installer.installs[0].kind != domain.StickerSetKindStickers { + t.Fatalf("installs = %+v, want sticker install", installer.installs) + } +} + +func TestStickersBotPublishCustomEmojiPack(t *testing.T) { + svc, users, _, messages, creator, installer := newStickersBotTestService(t) + owner := newOwner(t, users, "+3003") + + sendTextToStickers(t, svc, messages, owner, "/newemoji") + sendTextToStickers(t, svc, messages, owner, "Emoji Pack") + sendDocumentToStickers(t, svc, messages, owner, stickerBotTestDocument(301, 3301, domain.DocAttrCustomEmoji)) + sendTextToStickers(t, svc, messages, owner, "🔥") + sendTextToStickers(t, svc, messages, owner, "/publish") + reply := sendTextToStickers(t, svc, messages, owner, "emoji_pack") + + if !strings.Contains(reply, "https://telesrv.net/addemoji/emoji_pack") { + t.Fatalf("publish emoji reply = %q, want addemoji link", reply) + } + if len(creator.created) != 1 || creator.created[0].Kind != domain.StickerSetKindEmoji { + t.Fatalf("created emoji requests = %+v, want kind emoji", creator.created) + } + if len(installer.installs) != 1 || installer.installs[0].kind != domain.StickerSetKindEmoji { + t.Fatalf("emoji installs = %+v, want emoji kind install", installer.installs) + } +} + +func TestStickersBotAddStickerToExistingPack(t *testing.T) { + svc, users, bots, messages, manager, _ := newStickersBotTestService(t) + hooks := &stickersBotHookRecorder{} + svc.SetRouterHooks(hooks) + owner := newOwner(t, users, "+3006") + manager.sets = append(manager.sets, domain.StickerSet{ + ID: 7100, + AccessHash: 8100, + ShortName: "fresh_pack", + Title: "Fresh Pack", + Kind: domain.StickerSetKindStickers, + Creator: true, + CreatorUserID: owner.ID, + Count: 1, + DocumentIDs: []int64{501}, + }) + + if reply := sendTextToStickers(t, svc, messages, owner, "/addsticker"); !strings.Contains(reply, "short name") { + t.Fatalf("/addsticker reply = %q, want short name prompt", reply) + } + if reply := sendTextToStickers(t, svc, messages, owner, "https://telesrv.net/addstickers/fresh_pack"); !strings.Contains(reply, "Selected Fresh Pack") { + t.Fatalf("select pack reply = %q, want selected pack", reply) + } + if reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotUploadDocument(502, 5502, "application/json", "new.json")); !strings.Contains(reply, "emoji") { + t.Fatalf("add document reply = %q, want emoji prompt", reply) + } + reply := sendTextToStickers(t, svc, messages, owner, "😄") + if !strings.Contains(reply, "Done. Added to Fresh Pack") || !strings.Contains(reply, "https://telesrv.net/addstickers/fresh_pack") { + t.Fatalf("add final reply = %q, want done link", reply) + } + if len(manager.adds) != 1 { + t.Fatalf("adds = %+v, want one add", manager.adds) + } + add := manager.adds[0] + if add.userID != owner.ID || add.ref.ID != 7100 || add.item.DocumentID != 502 || add.item.DocumentAccessHash != 5502 || add.item.Emoji != "😄" { + t.Fatalf("add call = %+v, want owner/set/doc/emoji", add) + } + if hooks.userID != owner.ID || hooks.kind != domain.StickerSetKindStickers { + t.Fatalf("hook = user %d kind %q, want owner stickers", hooks.userID, hooks.kind) + } + if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found { + t.Fatal("stickers bot state still present after add") + } +} + +func TestStickersBotAddStickerDuplicateStaysInFlow(t *testing.T) { + svc, users, bots, messages, manager, _ := newStickersBotTestService(t) + owner := newOwner(t, users, "+3008") + manager.sets = append(manager.sets, domain.StickerSet{ + ID: 7300, + AccessHash: 8300, + ShortName: "fresh_pack", + Title: "Fresh Pack", + Kind: domain.StickerSetKindStickers, + CreatorUserID: owner.ID, + Count: 1, + DocumentIDs: []int64{501}, + }) + + sendTextToStickers(t, svc, messages, owner, "/addsticker") + sendTextToStickers(t, svc, messages, owner, "fresh_pack") + sendDocumentToStickers(t, svc, messages, owner, stickerBotSetDocument(501, 5501, 7300, 8300)) + reply := sendTextToStickers(t, svc, messages, owner, "😄") + + if !strings.Contains(reply, "already in this pack") { + t.Fatalf("duplicate add reply = %q, want already-in-pack notice", reply) + } + if len(manager.adds) != 0 { + t.Fatalf("adds = %+v, want no manager add for duplicate", manager.adds) + } + state, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID) + if !found || state.Step != stickersBotStepDocument { + t.Fatalf("state after duplicate = %+v found=%v, want document step", state, found) + } +} + +func TestStickersBotDeleteStickerFromExistingPack(t *testing.T) { + svc, users, bots, messages, manager, _ := newStickersBotTestService(t) + hooks := &stickersBotHookRecorder{} + svc.SetRouterHooks(hooks) + owner := newOwner(t, users, "+3007") + manager.sets = append(manager.sets, domain.StickerSet{ + ID: 7200, + AccessHash: 8200, + ShortName: "old_pack", + Title: "Old Pack", + Kind: domain.StickerSetKindStickers, + Creator: true, + CreatorUserID: owner.ID, + Count: 2, + DocumentIDs: []int64{601, 602}, + }) + + sendTextToStickers(t, svc, messages, owner, "/delsticker") + if reply := sendTextToStickers(t, svc, messages, owner, "old_pack"); !strings.Contains(reply, "Selected Old Pack") { + t.Fatalf("select delete pack reply = %q, want selected pack", reply) + } + doc := stickerBotSetDocument(601, 6601, 7200, 8200) + reply := sendDocumentToStickers(t, svc, messages, owner, doc) + if !strings.Contains(reply, "Done. Removed from Old Pack") || !strings.Contains(reply, "https://telesrv.net/addstickers/old_pack") { + t.Fatalf("delete final reply = %q, want done link", reply) + } + if len(manager.removes) != 1 || manager.removes[0].documentID != 601 || manager.removes[0].accessHash != 6601 { + t.Fatalf("removes = %+v, want document 601", manager.removes) + } + if hooks.userID != owner.ID || hooks.kind != domain.StickerSetKindStickers { + t.Fatalf("hook = user %d kind %q, want owner stickers", hooks.userID, hooks.kind) + } + if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found { + t.Fatal("stickers bot state still present after delete") + } +} + +func TestStickersBotDeleteCustomEmojiEntityFromExistingPack(t *testing.T) { + svc, users, bots, messages, manager, _ := newStickersBotTestService(t) + owner := newOwner(t, users, "+3009") + manager.sets = append(manager.sets, domain.StickerSet{ + ID: 7400, + AccessHash: 8400, + ShortName: "emoji_pack", + Title: "Emoji Pack", + Kind: domain.StickerSetKindEmoji, + Emojis: true, + CreatorUserID: owner.ID, + Count: 2, + DocumentIDs: []int64{701, 702}, + }) + manager.docs = map[int64]domain.Document{ + 701: stickerBotSetCustomEmojiDocument(701, 7701, 7400, 8400), + } + + sendTextToStickers(t, svc, messages, owner, "/delsticker") + sendTextToStickers(t, svc, messages, owner, "emoji_pack") + reply := sendMessageToStickers(t, svc, messages, owner, domain.Message{ + Body: "🔥", + Entities: []domain.MessageEntity{{ + Type: domain.MessageEntityCustomEmoji, + Offset: 0, + Length: 2, + DocumentID: 701, + }}, + }) + + if !strings.Contains(reply, "Done. Removed from Emoji Pack") || !strings.Contains(reply, "https://telesrv.net/addemoji/emoji_pack") { + t.Fatalf("delete custom emoji reply = %q, want done emoji link", reply) + } + if len(manager.removes) != 1 || manager.removes[0].documentID != 701 || manager.removes[0].accessHash != 7701 { + t.Fatalf("removes = %+v, want custom emoji document 701", manager.removes) + } + if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found { + t.Fatal("stickers bot state still present after custom emoji delete") + } +} + +func botCommandExists(commands []domain.BotCommand, want string) bool { + for _, c := range commands { + if c.Command == want { + return true + } + } + return false +} + +type stickersBotFakeCreator struct { + created []domain.CreateStickerSetRequest + sets []domain.StickerSet + docs map[int64]domain.Document + adds []stickersBotAdd + removes []stickersBotRemove +} + +type stickersBotAdd struct { + userID int64 + ref domain.StickerSetRef + item domain.StickerSetItemInput +} + +type stickersBotRemove struct { + userID int64 + documentID int64 + accessHash int64 +} + +func (f *stickersBotFakeCreator) CreateStickerSet(_ context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) { + f.created = append(f.created, req) + docIDs := make([]int64, 0, len(req.Items)) + for _, item := range req.Items { + docIDs = append(docIDs, item.DocumentID) + } + kind := req.Kind + if kind == "" { + kind = domain.StickerSetKindStickers + } + set := domain.StickerSet{ + ID: 7000 + int64(len(f.sets)), + AccessHash: 8000 + int64(len(f.sets)), + ShortName: strings.ToLower(strings.TrimSpace(req.ShortName)), + Title: req.Title, + Kind: kind, + Emojis: kind == domain.StickerSetKindEmoji, + Creator: true, + CreatorUserID: req.CreatorUserID, + Count: len(docIDs), + DocumentIDs: docIDs, + } + f.sets = append(f.sets, set) + return set, nil, nil +} + +func (f *stickersBotFakeCreator) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + idx := f.indexSet(ref) + if idx < 0 { + return domain.StickerSet{}, nil, false, nil + } + return f.sets[idx], nil, true, nil +} + +func (f *stickersBotFakeCreator) ListCreatedStickerSets(_ context.Context, userID int64, _ int64, limit int) ([]domain.StickerSet, int, error) { + var out []domain.StickerSet + for _, set := range f.sets { + if set.CreatorUserID == userID { + out = append(out, set) + } + } + total := len(out) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, total, nil +} + +func (f *stickersBotFakeCreator) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) { + out := make([]domain.Document, 0, len(ids)) + for _, id := range ids { + if doc, ok := f.docs[id]; ok { + out = append(out, doc) + } + } + return out, nil +} + +func (f *stickersBotFakeCreator) AddStickerToSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) { + f.adds = append(f.adds, stickersBotAdd{userID: actorUserID, ref: ref, item: item}) + idx := f.indexSet(ref) + if idx < 0 || f.sets[idx].CreatorUserID != actorUserID { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + set := f.sets[idx] + if !stickersBotTestContainsInt64(set.DocumentIDs, item.DocumentID) { + set.DocumentIDs = append(set.DocumentIDs, item.DocumentID) + set.Count = len(set.DocumentIDs) + } + f.sets[idx] = set + return set, nil, nil +} + +func (f *stickersBotFakeCreator) RemoveStickerFromSet(_ context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) { + f.removes = append(f.removes, stickersBotRemove{userID: actorUserID, documentID: documentID, accessHash: accessHash}) + for i, set := range f.sets { + if set.CreatorUserID != actorUserID { + continue + } + for idx, id := range set.DocumentIDs { + if id != documentID { + continue + } + set.DocumentIDs = append(append([]int64(nil), set.DocumentIDs[:idx]...), set.DocumentIDs[idx+1:]...) + set.Count = len(set.DocumentIDs) + f.sets[i] = set + return set, nil, nil + } + } + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid +} + +func (f *stickersBotFakeCreator) indexSet(ref domain.StickerSetRef) int { + for i, set := range f.sets { + switch ref.Kind { + case domain.StickerSetRefByID: + if set.ID == ref.ID && (ref.AccessHash == 0 || set.AccessHash == ref.AccessHash) { + return i + } + case domain.StickerSetRefByShortName: + if strings.EqualFold(set.ShortName, ref.ShortName) { + return i + } + } + } + return -1 +} + +func stickersBotTestContainsInt64(values []int64, want int64) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +type stickersBotFakeInstaller struct { + installs []stickersBotInstall +} + +type stickersBotInstall struct { + userID int64 + setID int64 + kind domain.StickerSetKind +} + +func (f *stickersBotFakeInstaller) InstallUserStickerSet(_ context.Context, userID int64, setID int64, kind domain.StickerSetKind, _ bool, _ int) error { + f.installs = append(f.installs, stickersBotInstall{userID: userID, setID: setID, kind: kind}) + return nil +} + +type stickersBotHookRecorder struct { + userID int64 + kind domain.StickerSetKind +} + +func (h *stickersBotHookRecorder) RevokeBotSessions(context.Context, int64) error { + return nil +} + +func (h *stickersBotHookRecorder) PushBotCommandsChanged(context.Context, int64, []domain.BotCommand) { +} + +func (h *stickersBotHookRecorder) PushStickerSetsChanged(_ context.Context, userID int64, kind domain.StickerSetKind) { + h.userID = userID + h.kind = kind +} diff --git a/internal/app/files/blobcache.go b/internal/app/files/blobcache.go index 14483c2b..74dc4fc6 100644 --- a/internal/app/files/blobcache.go +++ b/internal/app/files/blobcache.go @@ -70,6 +70,19 @@ func (c *stickerSetNegativeCache) put(ref domain.StickerSetRef) { c.entries[key] = time.Now().Add(c.ttl) } +func (c *stickerSetNegativeCache) delete(refs ...domain.StickerSetRef) { + if c == nil || len(refs) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for _, ref := range refs { + if key := stickerSetRefKey(ref); key != "" { + delete(c.entries, key) + } + } +} + // blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile // 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/ // reaction/头像更被大量用户重复拉)。 @@ -266,12 +279,31 @@ func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document) } } +func (c *stickerSetFullCache) delete(set domain.StickerSet) { + if c == nil || set.ID == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.byID, set.ID) + if set.ShortName != "" { + delete(c.byShort, set.ShortName) + } + if set.SystemKey != "" { + delete(c.bySystem, set.SystemKey) + } +} + func copyStickerSet(set domain.StickerSet) domain.StickerSet { set.DocumentIDs = append([]int64(nil), set.DocumentIDs...) set.Packs = append([]domain.StickerPack(nil), set.Packs...) for i := range set.Packs { set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...) } + set.Keywords = append([]domain.StickerKeyword(nil), set.Keywords...) + for i := range set.Keywords { + set.Keywords[i].Keywords = append([]string(nil), set.Keywords[i].Keywords...) + } set.Thumbs = copyPhotoSizes(set.Thumbs) return set } diff --git a/internal/app/files/blobcache_test.go b/internal/app/files/blobcache_test.go index 7cc9ca5a..9f069d14 100644 --- a/internal/app/files/blobcache_test.go +++ b/internal/app/files/blobcache_test.go @@ -6,6 +6,9 @@ import ( "testing" "telesrv/internal/domain" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" ) func TestBlobMetaCacheGetPutEvict(t *testing.T) { @@ -107,6 +110,58 @@ func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) { } } +func TestGetFileLogsCacheHitMiss(t *testing.T) { + ctx := context.Background() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + objectKey, err := local.Put(ctx, []byte("0123456789")) + if err != nil { + t.Fatalf("put: %v", err) + } + media := newFakeMediaStore() + if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil { + t.Fatalf("put blob: %v", err) + } + blobs := &countingBlobBackend{BlobBackend: local} + core, logs := observer.New(zap.InfoLevel) + svc := NewService(media, blobs, 2, WithLogger(zap.New(core))) + + if _, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:log", Offset: 0, Limit: 5}); err != nil || !ok { + t.Fatalf("first getfile ok=%v err=%v", ok, err) + } + if _, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:log", Offset: 5, Limit: 5}); err != nil || !ok { + t.Fatalf("second getfile ok=%v err=%v", ok, err) + } + + entries := logs.FilterMessage("upload.getFile cache").All() + if len(entries) != 2 { + t.Fatalf("cache log entries = %d, want 2", len(entries)) + } + first := entries[0].ContextMap() + if first["source"] != "backend_fill_byte_cache" || + first["meta_cache_hit"] != false || + first["meta_cache_filled"] != true || + first["byte_cache_hit"] != false || + first["byte_cache_filled"] != true || + first["backend_read"] != true || + first["returned_bytes"] != int64(5) { + t.Fatalf("first cache log = %#v, want backend fill miss", first) + } + second := entries[1].ContextMap() + if second["source"] != "byte_cache" || + second["meta_cache_hit"] != true || + second["byte_cache_hit"] != true || + second["backend_read"] != false || + second["returned_bytes"] != int64(5) { + t.Fatalf("second cache log = %#v, want byte cache hit", second) + } + if blobs.getRangeCalls != 1 { + t.Fatalf("GetRange calls = %d, want only first miss to read backend", blobs.getRangeCalls) + } +} + func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) { ctx := context.Background() local, err := NewLocalFS(t.TempDir()) diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go index 7d4a973e..cdd3add5 100644 --- a/internal/app/files/seed.go +++ b/internal/app/files/seed.go @@ -602,7 +602,7 @@ func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKi } func seedStickerSetInstalled(kind domain.StickerSetKind) bool { - return kind != domain.StickerSetKindSystem + return false } // ---- JSON → domain 转换 ---- diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go index e18da80e..82c4e9c0 100644 --- a/internal/app/files/seed_test.go +++ b/internal/app/files/seed_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" "testing" "time" @@ -209,17 +210,63 @@ func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) f.sets[set.ID] = set return nil } +func (f *fakeMediaStore) CreateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, existing := range f.sets { + if existing.ShortName != "" && strings.EqualFold(existing.ShortName, set.ShortName) { + return domain.ErrStickerSetShortNameOccupied + } + } + f.sets[set.ID] = set + if f.docs == nil { + f.docs = map[int64]domain.Document{} + } + for _, doc := range docs { + f.docs[doc.ID] = doc + } + return nil +} +func (f *fakeMediaStore) UpdateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.sets[set.ID]; !ok { + return domain.ErrStickerSetInvalid + } + f.sets[set.ID] = set + if f.docs == nil { + f.docs = map[int64]domain.Document{} + } + for _, doc := range docs { + f.docs[doc.ID] = doc + } + return nil +} +func (f *fakeMediaStore) DeleteStickerSet(_ context.Context, setID int64, creatorUserID int64) error { + f.mu.Lock() + defer f.mu.Unlock() + set, ok := f.sets[setID] + if !ok || set.Deleted || set.CreatorUserID != creatorUserID { + return domain.ErrStickerSetInvalid + } + set.Deleted = true + f.sets[setID] = set + return nil +} func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) { f.mu.Lock() defer f.mu.Unlock() s, ok := f.sets[id] + if ok && s.Deleted { + return domain.StickerSet{}, false, nil + } return s, ok, nil } func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) { f.mu.Lock() defer f.mu.Unlock() for _, s := range f.sets { - if s.ShortName == name { + if strings.EqualFold(s.ShortName, name) && !s.Deleted { return s, true, nil } } @@ -240,12 +287,48 @@ func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerS defer f.mu.Unlock() var out []domain.StickerSet for _, s := range f.sets { - if s.Kind == kind { + if s.Kind == kind && !s.Deleted { out = append(out, s) } } return out, nil } +func (f *fakeMediaStore) ListStickerSetsByCreator(_ context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) { + f.mu.Lock() + defer f.mu.Unlock() + var all []domain.StickerSet + for _, s := range f.sets { + if s.CreatorUserID == creatorUserID && !s.Deleted { + s.Creator = true + all = append(all, s) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].ID > all[j].ID }) + total := len(all) + if offsetID != 0 { + filtered := all[:0] + for _, s := range all { + if s.ID < offsetID { + filtered = append(filtered, s) + } + } + all = filtered + } + if limit > 0 && len(all) > limit { + all = all[:limit] + } + return append([]domain.StickerSet(nil), all...), total, nil +} +func (f *fakeMediaStore) StickerSetShortNameAvailable(_ context.Context, shortName string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sets { + if s.ShortName != "" && strings.EqualFold(s.ShortName, shortName) && !s.Deleted { + return false, nil + } + } + return true, nil +} func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) { f.mu.Lock() defer f.mu.Unlock() @@ -648,15 +731,15 @@ func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) { } } -func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) { +func TestSeedStickerSetInstalledFlagNeverMarksViewerState(t *testing.T) { cases := []struct { name string kind domain.StickerSetKind want bool }{ - {name: "regular stickers", kind: domain.StickerSetKindStickers, want: true}, - {name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true}, - {name: "masks", kind: domain.StickerSetKindMasks, want: true}, + {name: "regular stickers", kind: domain.StickerSetKindStickers, want: false}, + {name: "custom emoji", kind: domain.StickerSetKindEmoji, want: false}, + {name: "masks", kind: domain.StickerSetKindMasks, want: false}, {name: "system resources", kind: domain.StickerSetKindSystem, want: false}, } for _, tc := range cases { diff --git a/internal/app/files/service.go b/internal/app/files/service.go index f97ae7fd..fc3b74b5 100644 --- a/internal/app/files/service.go +++ b/internal/app/files/service.go @@ -263,23 +263,48 @@ func (s *Service) DeleteExpiredUploadParts(ctx context.Context, before time.Time // 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker / // reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。 type blobMetaResult struct { - blob domain.FileBlob - found bool + blob domain.FileBlob + found bool + cacheHit bool + cacheFilled bool } type blobBytesResult struct { - data []byte - total int64 - cacheable bool + data []byte + total int64 + cacheable bool + cacheHit bool + cacheFilled bool } -func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) { +type getFileCacheLog struct { + start time.Time + metaCacheHit bool + metaCacheFilled bool + metaSingleflight bool + byteCacheEligible bool + byteCacheHit bool + byteCacheFilled bool + byteSingleflight bool + backendRead bool + source string +} + +func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (chunk domain.FileChunk, found bool, err error) { + cacheLog := getFileCacheLog{start: time.Now(), source: "unknown"} + var blob domain.FileBlob + defer func() { + s.logGetFileCache(req, blob, found, chunk, cacheLog, err) + }() + blob, ok := s.blobCache.get(req.LocationKey) - if !ok { + if ok { + cacheLog.metaCacheHit = true + } else { // 同一 location_key 的并发首访合并成一次 PG GetFileBlob。 - v, err, _ := s.blobMetaSF.Do(req.LocationKey, func() (any, error) { + v, err, shared := s.blobMetaSF.Do(req.LocationKey, func() (any, error) { if cached, ok := s.blobCache.get(req.LocationKey); ok { - return blobMetaResult{blob: cached, found: true}, nil + return blobMetaResult{blob: cached, found: true, cacheHit: true}, nil } b, found, err := s.media.GetFileBlob(ctx, req.LocationKey) if err != nil { @@ -288,19 +313,26 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) ( if found { s.blobCache.put(req.LocationKey, b) } - return blobMetaResult{blob: b, found: found}, nil + return blobMetaResult{blob: b, found: found, cacheFilled: found}, nil }) + cacheLog.metaSingleflight = shared if err != nil { return domain.FileChunk{}, false, err } res := v.(blobMetaResult) + cacheLog.metaCacheHit = res.cacheHit + cacheLog.metaCacheFilled = res.cacheFilled if !res.found { + cacheLog.source = "metadata_miss" return domain.FileChunk{}, false, nil } blob = res.blob } if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes { + cacheLog.byteCacheEligible = true if data, ok := s.byteCache.get(blob.ObjectKey); ok { + cacheLog.byteCacheHit = true + cacheLog.source = "byte_cache" return domain.FileChunk{ Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)), MimeType: blob.MimeType, @@ -308,9 +340,9 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) ( }, true, nil } // 同一 object_key 的小 blob 并发首访合并成一次 backend 全量读 + 一次 byteCache 填充。 - v, err, _ := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) { + v, err, shared := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) { if cached, ok := s.byteCache.get(blob.ObjectKey); ok { - return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true}, nil + return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true, cacheHit: true}, nil } data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) if err != nil { @@ -318,15 +350,24 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) ( } if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total { s.byteCache.put(blob.ObjectKey, data) - return blobBytesResult{data: data, total: total, cacheable: true}, nil + return blobBytesResult{data: data, total: total, cacheable: true, cacheFilled: true}, nil } return blobBytesResult{cacheable: false}, nil }) + cacheLog.byteSingleflight = shared if err != nil { return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) } // res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。 if res := v.(blobBytesResult); res.cacheable { + cacheLog.byteCacheHit = res.cacheHit + cacheLog.byteCacheFilled = res.cacheFilled + cacheLog.backendRead = res.cacheFilled + if res.cacheHit { + cacheLog.source = "byte_cache" + } else { + cacheLog.source = "backend_fill_byte_cache" + } return domain.FileChunk{ Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)), MimeType: blob.MimeType, @@ -334,6 +375,11 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) ( }, true, nil } // 大小不符/超限:落到下面的按需 range 读(与原行为一致)。 + cacheLog.source = "backend_range_uncacheable" + } + cacheLog.backendRead = true + if cacheLog.source == "unknown" { + cacheLog.source = "backend_range" } data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit)) if err != nil { @@ -346,6 +392,38 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) ( }, true, nil } +func (s *Service) logGetFileCache(req domain.FileDownloadRequest, blob domain.FileBlob, found bool, chunk domain.FileChunk, cacheLog getFileCacheLog, err error) { + fields := []zap.Field{ + zap.String("location_key", req.LocationKey), + zap.Int64("offset", req.Offset), + zap.Int("limit", req.Limit), + zap.Bool("found", found), + zap.String("source", cacheLog.source), + zap.Bool("meta_cache_hit", cacheLog.metaCacheHit), + zap.Bool("meta_cache_filled", cacheLog.metaCacheFilled), + zap.Bool("meta_singleflight_shared", cacheLog.metaSingleflight), + zap.Bool("byte_cache_eligible", cacheLog.byteCacheEligible), + zap.Bool("byte_cache_hit", cacheLog.byteCacheHit), + zap.Bool("byte_cache_filled", cacheLog.byteCacheFilled), + zap.Bool("byte_singleflight_shared", cacheLog.byteSingleflight), + zap.Bool("backend_read", cacheLog.backendRead), + zap.Int("returned_bytes", len(chunk.Bytes)), + zap.Int64("total_bytes", chunk.Total), + zap.Duration("dur", time.Since(cacheLog.start)), + } + if blob.ObjectKey != "" { + fields = append(fields, + zap.String("object_key", blob.ObjectKey), + zap.Int64("blob_size", blob.Size), + zap.String("mime_type", blob.MimeType), + ) + } + if err != nil { + fields = append(fields, zap.Error(err)) + } + s.log.Info("upload.getFile cache", fields...) +} + func sliceBlobBytes(data []byte, offset, limit int64) []byte { total := int64(len(data)) if offset < 0 { diff --git a/internal/app/files/sticker_creator.go b/internal/app/files/sticker_creator.go new file mode 100644 index 00000000..d4446d8a --- /dev/null +++ b/internal/app/files/sticker_creator.go @@ -0,0 +1,661 @@ +package files + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "io" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" + + "telesrv/internal/domain" +) + +func (s *Service) CheckStickerSetShortName(ctx context.Context, shortName string) (bool, error) { + shortName = normalizeStickerSetShortName(shortName) + if err := validateStickerSetShortName(shortName); err != nil { + return false, err + } + return s.media.StickerSetShortNameAvailable(ctx, shortName) +} + +func (s *Service) SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error) { + if userID <= 0 { + return "", domain.ErrStickerSetCreatorInvalid + } + title = strings.TrimSpace(title) + if err := validateStickerSetTitle(title); err != nil { + return "", err + } + base := stickerSetShortNameBase(title) + candidates := []string{base, base + "_pack"} + if suffix := userIDSuffix(userID); suffix != "" { + candidates = append(candidates, base+"_"+suffix) + } + for i := 2; i <= 99; i++ { + candidates = append(candidates, trimStickerSetShortNameBase(base, 3)+"_"+itoaSmall(i)) + } + for _, candidate := range candidates { + if err := validateStickerSetShortName(candidate); err != nil { + continue + } + available, err := s.media.StickerSetShortNameAvailable(ctx, candidate) + if err != nil { + return "", err + } + if available { + return candidate, nil + } + } + return "", domain.ErrStickerSetShortNameOccupied +} + +func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) { + if req.CreatorUserID <= 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid + } + title := strings.TrimSpace(req.Title) + if err := validateStickerSetTitle(title); err != nil { + return domain.StickerSet{}, nil, err + } + kind := normalizeStickerSetKind(req.Kind) + if len(req.Items) == 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty + } + if len(req.Items) > domain.MaxStickerSetItems { + return domain.StickerSet{}, nil, domain.ErrStickerSetTooMuch + } + shortName := normalizeStickerSetShortName(req.ShortName) + var err error + if shortName == "" { + shortName, err = s.SuggestStickerSetShortName(ctx, title, req.CreatorUserID) + if err != nil { + return domain.StickerSet{}, nil, err + } + } else { + if err := validateStickerSetShortName(shortName); err != nil { + return domain.StickerSet{}, nil, err + } + available, err := s.media.StickerSetShortNameAvailable(ctx, shortName) + if err != nil { + return domain.StickerSet{}, nil, err + } + if !available { + return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied + } + } + + docIDs, docAccess, thumbID, thumbAccess, err := stickerSetInputDocumentRefs(req) + if err != nil { + return domain.StickerSet{}, nil, err + } + loaded, err := s.media.GetDocuments(ctx, docIDs) + if err != nil { + return domain.StickerSet{}, nil, err + } + docByID := documentsByID(loaded) + documentIDs := make([]int64, 0, len(req.Items)) + packs := make([]domain.StickerPack, 0, len(req.Items)) + packIndex := map[string]int{} + keywords := []domain.StickerKeyword{} + items := make([]domain.StickerSetItemInput, 0, len(req.Items)) + seenDocs := map[int64]struct{}{} + for _, item := range req.Items { + doc, ok := docByID[item.DocumentID] + if !ok || doc.AccessHash != docAccess[item.DocumentID] || !doc.IsStickerSetMaterial() { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + if _, dup := seenDocs[item.DocumentID]; dup { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + seenDocs[item.DocumentID] = struct{}{} + emoji := strings.TrimSpace(item.Emoji) + if err := validateStickerEmoji(emoji); err != nil { + return domain.StickerSet{}, nil, err + } + item.Emoji = emoji + documentIDs = append(documentIDs, item.DocumentID) + if idx, ok := packIndex[emoji]; ok { + packs[idx].DocumentIDs = append(packs[idx].DocumentIDs, item.DocumentID) + } else { + packIndex[emoji] = len(packs) + packs = append(packs, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{item.DocumentID}}) + } + if kw := parseStickerKeywords(item.DocumentID, item.Keywords); len(kw.Keywords) > 0 { + keywords = append(keywords, kw) + } + items = append(items, item) + } + + set := domain.StickerSet{ + ID: randomID(), + AccessHash: randomID(), + ShortName: shortName, + Title: title, + Count: len(documentIDs), + Kind: kind, + Emojis: kind == domain.StickerSetKindEmoji, + Masks: kind == domain.StickerSetKindMasks, + TextColor: kind == domain.StickerSetKindEmoji && req.TextColor, + Creator: true, + CreatorUserID: req.CreatorUserID, + DocumentIDs: documentIDs, + Packs: packs, + Keywords: keywords, + Software: strings.TrimSpace(req.Software), + } + if thumbID != 0 { + thumb, ok := docByID[thumbID] + if !ok || thumb.AccessHash != thumbAccess { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + set.ThumbDocumentID = thumb.ID + set.Thumbs = copyPhotoSizes(thumb.Thumbs) + set.ThumbDCID = thumb.DCID + if len(set.Thumbs) > 0 { + set.ThumbVersion = 1 + } + } + set.Hash = stickerSetHash(set) + + updatedDocs := make([]domain.Document, 0, len(items)) + for _, item := range items { + doc := docByID[item.DocumentID] + doc, err = s.prepareStickerSetDocument(ctx, doc, set, item.Emoji) + if err != nil { + return domain.StickerSet{}, nil, err + } + docByID[item.DocumentID] = doc + updatedDocs = append(updatedDocs, doc) + } + if err := s.media.CreateStickerSet(ctx, set, updatedDocs); err != nil { + if errors.Is(err, domain.ErrStickerSetShortNameOccupied) { + return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied + } + return domain.StickerSet{}, nil, err + } + ordered := orderDocuments(updatedDocs, set.DocumentIDs) + s.cacheStickerSet(set, ordered) + return set, ordered, nil +} + +func (s *Service) ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) { + if userID <= 0 { + return nil, 0, domain.ErrStickerSetCreatorInvalid + } + return s.media.ListStickerSetsByCreator(ctx, userID, offsetID, limit) +} + +func (s *Service) cacheStickerSet(set domain.StickerSet, docs []domain.Document) { + if s.stickerSetNegCache != nil { + refs := []domain.StickerSetRef{{Kind: domain.StickerSetRefByID, ID: set.ID}} + if set.ShortName != "" { + refs = append(refs, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: set.ShortName}) + } + if set.SystemKey != "" { + refs = append(refs, domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: set.SystemKey}) + } + s.stickerSetNegCache.delete(refs...) + } + if s.stickerSetCache != nil { + s.stickerSetCache.put(set, docs) + } +} + +func stickerSetInputDocumentRefs(req domain.CreateStickerSetRequest) ([]int64, map[int64]int64, int64, int64, error) { + ids := make([]int64, 0, len(req.Items)+1) + access := make(map[int64]int64, len(req.Items)) + seen := map[int64]struct{}{} + for _, item := range req.Items { + if item.DocumentID == 0 || item.DocumentAccessHash == 0 { + return nil, nil, 0, 0, domain.ErrStickerSetFileInvalid + } + if _, ok := seen[item.DocumentID]; !ok { + ids = append(ids, item.DocumentID) + seen[item.DocumentID] = struct{}{} + } + access[item.DocumentID] = item.DocumentAccessHash + } + if req.ThumbDocumentID != 0 { + if req.ThumbAccessHash == 0 { + return nil, nil, 0, 0, domain.ErrStickerSetFileInvalid + } + if _, ok := seen[req.ThumbDocumentID]; !ok { + ids = append(ids, req.ThumbDocumentID) + } + } + return ids, access, req.ThumbDocumentID, req.ThumbAccessHash, nil +} + +func documentsByID(docs []domain.Document) map[int64]domain.Document { + out := make(map[int64]domain.Document, len(docs)) + for _, doc := range docs { + out[doc.ID] = doc + } + return out +} + +func attachStickerSetToDocument(doc domain.Document, set domain.StickerSet, emoji string) domain.Document { + want := domain.DocAttrSticker + if set.Kind == domain.StickerSetKindEmoji || set.Emojis { + want = domain.DocAttrCustomEmoji + } + attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...) + replaced := false + for i := range attrs { + if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji { + continue + } + attrs[i].Kind = want + attrs[i].Alt = emoji + attrs[i].StickerSetID = set.ID + attrs[i].StickerSetAccessHash = set.AccessHash + attrs[i].Mask = set.Kind == domain.StickerSetKindMasks || set.Masks + attrs[i].TextColor = set.TextColor + replaced = true + break + } + if !replaced { + attrs = append(attrs, domain.DocumentAttribute{ + Kind: want, + Alt: emoji, + Mask: set.Kind == domain.StickerSetKindMasks || set.Masks, + StickerSetID: set.ID, + StickerSetAccessHash: set.AccessHash, + TextColor: set.TextColor, + }) + } + doc.Attributes = attrs + return doc +} + +func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Document, set domain.StickerSet, emoji string) (domain.Document, error) { + doc, err := s.ensureStickerMaterialShape(ctx, doc) + if err != nil { + return domain.Document{}, err + } + return attachStickerSetToDocument(doc, set, emoji), nil +} + +func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Document) (domain.Document, error) { + if doc.IsStickerLike() { + return doc, nil + } + mimeType := doc.StickerSetMaterialMime() + hasImageSize := false + hasVideo := false + for _, attr := range doc.Attributes { + switch attr.Kind { + case domain.DocAttrImageSize: + hasImageSize = true + case domain.DocAttrVideo: + hasVideo = true + } + } + switch mimeType { + case "application/json": + data, ok := s.readStickerMaterialBlob(ctx, doc) + if !ok { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + lottieJSON := normalizeLottieStickerJSON(data) + if _, _, ok := lottieStickerDimensions(lottieJSON); !ok { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + tgsData, err := gzipLottieStickerData(lottieJSON) + if err != nil || int64(len(tgsData)) > domain.MaxStickerMaterialDocumentSize { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + if err := s.rewriteStickerMaterialBlob(ctx, doc.ID, tgsData, "application/x-tgsticker"); err != nil { + return domain.Document{}, err + } + doc.MimeType = "application/x-tgsticker" + doc.Size = int64(len(tgsData)) + doc.Attributes = replaceStickerMaterialFilename(doc.Attributes, "sticker.tgs") + if !hasImageSize { + doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{ + Kind: domain.DocAttrImageSize, + W: 512, + H: 512, + }) + } + case "application/x-tgsticker": + if data, ok := s.readStickerMaterialBlob(ctx, doc); ok && !validTGSStickerData(data) { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + if !hasImageSize { + doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{ + Kind: domain.DocAttrImageSize, + W: 512, + H: 512, + }) + } + case "image/webp": + if !hasImageSize { + data, ok := s.readStickerMaterialBlob(ctx, doc) + if !ok { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + w, h := imageDimensions(data, 0, 0) + if w <= 0 || h <= 0 { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{ + Kind: domain.DocAttrImageSize, + W: w, + H: h, + }) + } + case "video/webm", "video/mp4": + if !hasVideo { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + default: + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + return doc, nil +} + +func (s *Service) readStickerMaterialBlob(ctx context.Context, doc domain.Document) ([]byte, bool) { + if s == nil || s.media == nil || s.blobs == nil || doc.ID == 0 || doc.Size <= 0 || doc.Size > domain.MaxStickerMaterialDocumentSize { + return nil, false + } + blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID)) + if err != nil || !found || blob.Size <= 0 || blob.Size > domain.MaxStickerMaterialDocumentSize { + return nil, false + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size) + if err != nil || int64(len(data)) != total || total != blob.Size { + return nil, false + } + return data, true +} + +func (s *Service) rewriteStickerMaterialBlob(ctx context.Context, docID int64, data []byte, mimeType string) error { + if s == nil || s.media == nil || s.blobs == nil || docID == 0 || len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize { + return domain.ErrStickerSetFileInvalid + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return err + } + sum := sha256.Sum256(data) + blob := domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", docID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + SHA256: append([]byte(nil), sum[:]...), + MimeType: mimeType, + } + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return err + } + if s.blobCache != nil { + s.blobCache.put(blob.LocationKey, blob) + } + if s.byteCache != nil { + s.byteCache.put(blob.ObjectKey, data) + } + return nil +} + +func replaceStickerMaterialFilename(attrs []domain.DocumentAttribute, fallback string) []domain.DocumentAttribute { + out := append([]domain.DocumentAttribute(nil), attrs...) + for i := range out { + if out[i].Kind != domain.DocAttrFilename { + continue + } + out[i].FileName = tgsFileName(out[i].FileName, fallback) + return out + } + return append(out, domain.DocumentAttribute{ + Kind: domain.DocAttrFilename, + FileName: fallback, + }) +} + +func tgsFileName(fileName, fallback string) string { + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return fallback + } + ext := filepath.Ext(fileName) + if ext == "" { + return fileName + ".tgs" + } + return strings.TrimSuffix(fileName, ext) + ".tgs" +} + +func validTGSStickerData(data []byte) bool { + if len(data) == 0 { + return false + } + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return false + } + defer gz.Close() + data, err = io.ReadAll(io.LimitReader(gz, domain.MaxStickerMaterialDocumentSize+1)) + if err != nil || int64(len(data)) > domain.MaxStickerMaterialDocumentSize { + return false + } + _, _, ok := lottieStickerDimensions(normalizeLottieStickerJSON(data)) + return ok +} + +func normalizeLottieStickerJSON(data []byte) []byte { + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + return bytes.TrimSpace(data) +} + +func lottieStickerDimensions(data []byte) (int, int, bool) { + if len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize { + return 0, 0, false + } + var root struct { + Version string `json:"v"` + W int `json:"w"` + H int `json:"h"` + } + if err := json.NewDecoder(bytes.NewReader(data)).Decode(&root); err != nil { + return 0, 0, false + } + return root.W, root.H, root.Version != "" && root.W > 0 && root.H > 0 +} + +func gzipLottieStickerData(data []byte) ([]byte, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(data); err != nil { + _ = gz.Close() + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func normalizeStickerSetKind(kind domain.StickerSetKind) domain.StickerSetKind { + switch kind { + case domain.StickerSetKindEmoji, domain.StickerSetKindMasks: + return kind + default: + return domain.StickerSetKindStickers + } +} + +func validateStickerSetTitle(title string) error { + if title == "" || utf8.RuneCountInString(title) > domain.MaxStickerSetTitleLen { + return domain.ErrStickerSetTitleInvalid + } + return nil +} + +func normalizeStickerSetShortName(shortName string) string { + return strings.ToLower(strings.TrimSpace(shortName)) +} + +func validateStickerSetShortName(shortName string) error { + if len(shortName) < domain.MinStickerSetShortNameLen || len(shortName) > domain.MaxStickerSetShortNameLen { + return domain.ErrStickerSetShortNameInvalid + } + prevUnderscore := false + for i := 0; i < len(shortName); i++ { + ch := shortName[i] + switch { + case ch >= 'a' && ch <= 'z': + case ch >= '0' && ch <= '9': + if i == 0 { + return domain.ErrStickerSetShortNameInvalid + } + case ch == '_': + if i == 0 || i == len(shortName)-1 || prevUnderscore { + return domain.ErrStickerSetShortNameInvalid + } + prevUnderscore = true + continue + default: + return domain.ErrStickerSetShortNameInvalid + } + prevUnderscore = false + } + return nil +} + +func validateStickerEmoji(emoji string) error { + if emoji == "" || utf8.RuneCountInString(emoji) > 64 { + return domain.ErrStickerSetEmojiInvalid + } + return nil +} + +func stickerSetShortNameBase(title string) string { + var b strings.Builder + prevUnderscore := false + for _, r := range strings.ToLower(title) { + var out rune + switch { + case r >= 'a' && r <= 'z': + out = r + case r >= '0' && r <= '9': + out = r + case unicode.IsSpace(r) || r == '-' || r == '_': + out = '_' + default: + continue + } + if out == '_' { + if b.Len() == 0 || prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + b.WriteRune(out) + if b.Len() >= domain.MaxStickerSetShortNameLen { + break + } + } + base := strings.Trim(b.String(), "_") + if base == "" || base[0] < 'a' || base[0] > 'z' { + base = "stickers_" + base + } + base = strings.Trim(base, "_") + if len(base) < domain.MinStickerSetShortNameLen { + base += "_pack" + } + return trimStickerSetShortNameBase(base, 0) +} + +func trimStickerSetShortNameBase(base string, suffixReserve int) string { + max := domain.MaxStickerSetShortNameLen - suffixReserve + if max < domain.MinStickerSetShortNameLen { + max = domain.MinStickerSetShortNameLen + } + if len(base) <= max { + return strings.Trim(base, "_") + } + return strings.Trim(base[:max], "_") +} + +func userIDSuffix(userID int64) string { + if userID <= 0 { + return "" + } + return itoaSmall(int(userID % 10000)) +} + +func itoaSmall(v int) string { + if v == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for v > 0 { + i-- + buf[i] = byte('0' + v%10) + v /= 10 + } + return string(buf[i:]) +} + +func parseStickerKeywords(documentID int64, raw string) domain.StickerKeyword { + parts := strings.Split(raw, ",") + seen := map[string]struct{}{} + keywords := make([]string, 0, len(parts)) + for _, part := range parts { + kw := strings.ToLower(strings.TrimSpace(part)) + if kw == "" || utf8.RuneCountInString(kw) > domain.MaxStickerSetKeywordLen { + continue + } + if _, ok := seen[kw]; ok { + continue + } + seen[kw] = struct{}{} + keywords = append(keywords, kw) + if len(keywords) >= domain.MaxStickerSetKeywords { + break + } + } + return domain.StickerKeyword{DocumentID: documentID, Keywords: keywords} +} + +func stickerSetHash(set domain.StickerSet) int { + h := fnv.New32a() + writeHashString(h, set.ShortName) + writeHashString(h, set.Title) + writeHashString(h, string(set.Kind)) + var buf [8]byte + for _, id := range set.DocumentIDs { + binary.LittleEndian.PutUint64(buf[:], uint64(id)) + _, _ = h.Write(buf[:]) + } + for _, pack := range set.Packs { + writeHashString(h, pack.Emoticon) + for _, id := range pack.DocumentIDs { + binary.LittleEndian.PutUint64(buf[:], uint64(id)) + _, _ = h.Write(buf[:]) + } + } + sum := int(h.Sum32() & 0x7fffffff) + if sum == 0 { + return 1 + } + return sum +} + +func writeHashString(h interface{ Write([]byte) (int, error) }, s string) { + _, _ = h.Write([]byte(s)) + _, _ = h.Write([]byte{0}) +} diff --git a/internal/app/files/sticker_creator_test.go b/internal/app/files/sticker_creator_test.go new file mode 100644 index 00000000..b1fcdb4d --- /dev/null +++ b/internal/app/files/sticker_creator_test.go @@ -0,0 +1,338 @@ +package files + +import ( + "context" + "errors" + "strings" + "testing" + + "telesrv/internal/domain" +) + +func TestCreateStickerSetInvalidatesNegativeCacheAndLinksDocuments(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 1001, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + photos: map[int64]domain.Photo{}, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + _, _, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}) + if err != nil { + t.Fatalf("resolve before create: %v", err) + } + if found { + t.Fatalf("resolve before create found set, want miss") + } + + set, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 101, + DocumentAccessHash: 1001, + Emoji: "🙂", + Keywords: "fresh, happy, fresh", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + if set.ShortName != "fresh_pack" || !set.Creator || set.CreatorUserID != 1000000001 || set.Count != 1 { + t.Fatalf("created set = %+v, want creator-owned fresh_pack with one item", set) + } + if len(set.Keywords) != 1 || len(set.Keywords[0].Keywords) != 2 { + t.Fatalf("keywords = %+v, want deduped keyword list", set.Keywords) + } + if len(docs) != 1 { + t.Fatalf("created docs = %d, want 1", len(docs)) + } + id, hash, ok := docs[0].StickerSetRef() + if !ok || id != set.ID || hash != set.AccessHash { + t.Fatalf("document sticker set ref = %d/%d/%v, want %d/%d/true", id, hash, ok, set.ID, set.AccessHash) + } + + resolved, resolvedDocs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}) + if err != nil { + t.Fatalf("resolve after create: %v", err) + } + if !found || resolved.ID != set.ID || len(resolvedDocs) != 1 { + t.Fatalf("resolve after create = found %v set %+v docs %d, want created set", found, resolved, len(resolvedDocs)) + } +} + +func TestCreateStickerSetAcceptsUploadedStickerMaterial(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 201: { + ID: 201, + AccessHash: 2001, + DCID: 2, + MimeType: "application/octet-stream", + Size: 4096, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "local.tgs"}}, + }, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + set, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Uploads", + ShortName: "uploads_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 201, + DocumentAccessHash: 2001, + Emoji: "👋", + }}, + }) + if err != nil { + t.Fatalf("create with uploaded material: %v", err) + } + if len(docs) != 1 || !docs[0].IsSticker() { + t.Fatalf("created docs = %+v, want sticker-tagged uploaded document", docs) + } + if id, hash, ok := docs[0].StickerSetRef(); !ok || id != set.ID || hash != set.AccessHash { + t.Fatalf("uploaded doc sticker ref = %d/%d/%v, want %d/%d/true", id, hash, ok, set.ID, set.AccessHash) + } + if !documentHasAttr(docs[0], domain.DocAttrImageSize) || !documentHasAttr(docs[0], domain.DocAttrFilename) { + t.Fatalf("uploaded doc attrs = %+v, want filename preserved and image size added", docs[0].Attributes) + } +} + +func TestCreateStickerSetAcceptsWebPMaterialWithClientImageSize(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 202: { + ID: 202, + AccessHash: 2002, + DCID: 2, + MimeType: "image/webp", + Size: 4096, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrImageSize, W: 512, H: 512}}, + }, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + _, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "WebP Uploads", + ShortName: "webp_uploads", + Items: []domain.StickerSetItemInput{{ + DocumentID: 202, + DocumentAccessHash: 2002, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create with client-sized webp: %v", err) + } + if len(docs) != 1 || !docs[0].IsSticker() || !documentHasAttr(docs[0], domain.DocAttrImageSize) { + t.Fatalf("created docs = %+v, want sticker with image size preserved", docs) + } +} + +func TestCreateStickerSetConvertsLottieJSONMaterialToTGS(t *testing.T) { + ctx := context.Background() + raw := testLottieJSON() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + objectKey, err := blobs.Put(ctx, raw) + if err != nil { + t.Fatalf("put lottie json blob: %v", err) + } + media := newFakeMediaStore() + media.docs[204] = domain.Document{ + ID: 204, + AccessHash: 2004, + DCID: 2, + MimeType: "application/json", + Size: int64(len(raw)), + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "wave.json"}}, + } + if err := media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: "doc:204", + Backend: domain.MediaBackend(blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(raw)), + MimeType: "application/json", + }); err != nil { + t.Fatalf("put lottie file blob: %v", err) + } + svc := NewService(media, blobs, 2) + + _, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Lottie Uploads", + ShortName: "lottie_uploads", + Items: []domain.StickerSetItemInput{{ + DocumentID: 204, + DocumentAccessHash: 2004, + Emoji: "👋", + }}, + }) + if err != nil { + t.Fatalf("create with lottie json: %v", err) + } + if len(docs) != 1 || !docs[0].IsSticker() || docs[0].MimeType != "application/x-tgsticker" { + t.Fatalf("created docs = %+v, want sticker-tagged tgs document", docs) + } + if !documentHasAttr(docs[0], domain.DocAttrImageSize) { + t.Fatalf("converted doc attrs = %+v, want image size", docs[0].Attributes) + } + if got := documentFileName(docs[0]); got != "wave.tgs" { + t.Fatalf("converted filename = %q, want wave.tgs", got) + } + blob, found, err := media.GetFileBlob(ctx, "doc:204") + if err != nil || !found { + t.Fatalf("converted file blob found=%v err=%v", found, err) + } + if blob.MimeType != "application/x-tgsticker" || blob.Size != docs[0].Size { + t.Fatalf("converted file blob = %+v doc size %d, want tgs metadata", blob, docs[0].Size) + } + data, total, err := blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size) + if err != nil { + t.Fatalf("read converted tgs blob: %v", err) + } + if int64(len(data)) != total || !validTGSStickerData(data) { + t.Fatalf("converted blob len=%d total=%d valid=%v, want valid tgs", len(data), total, validTGSStickerData(data)) + } +} + +func TestCreateStickerSetRejectsInvalidLottieJSONMaterial(t *testing.T) { + ctx := context.Background() + raw := []byte(`{"v":"5.7.4","layers":[]}`) + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + objectKey, err := blobs.Put(ctx, raw) + if err != nil { + t.Fatalf("put invalid lottie json blob: %v", err) + } + media := newFakeMediaStore() + media.docs[205] = domain.Document{ + ID: 205, + AccessHash: 2005, + DCID: 2, + MimeType: "application/json", + Size: int64(len(raw)), + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "bad.json"}}, + } + if err := media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: "doc:205", + Backend: domain.MediaBackend(blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(raw)), + MimeType: "application/json", + }); err != nil { + t.Fatalf("put invalid lottie file blob: %v", err) + } + svc := NewService(media, blobs, 2) + + _, _, err = svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Bad Lottie Uploads", + ShortName: "bad_lottie_uploads", + Items: []domain.StickerSetItemInput{{ + DocumentID: 205, + DocumentAccessHash: 2005, + Emoji: "👋", + }}, + }) + if !errors.Is(err, domain.ErrStickerSetFileInvalid) { + t.Fatalf("create with invalid lottie json err = %v, want ErrStickerSetFileInvalid", err) + } +} + +func TestCreateStickerSetRejectsWebPMaterialWithoutShape(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 203: {ID: 203, AccessHash: 2003, MimeType: "image/webp", Size: 4096}, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + _, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Bad WebP Uploads", + ShortName: "bad_webp_uploads", + Items: []domain.StickerSetItemInput{{ + DocumentID: 203, + DocumentAccessHash: 2003, + Emoji: "🙂", + }}, + }) + if !errors.Is(err, domain.ErrStickerSetFileInvalid) { + t.Fatalf("create with unsized webp err = %v, want ErrStickerSetFileInvalid", err) + } +} + +func TestCreateStickerSetRejectsDuplicateShortNameCaseInsensitive(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 1001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[int64]domain.StickerSet{ + 10: {ID: 10, ShortName: "Fresh_Pack", Kind: domain.StickerSetKindStickers}, + }, + } + svc := NewService(media, nil, 2) + _, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Other", + ShortName: "fresh_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 101, + DocumentAccessHash: 1001, + Emoji: "🙂", + }}, + }) + if !errors.Is(err, domain.ErrStickerSetShortNameOccupied) { + t.Fatalf("duplicate create err = %v, want ErrStickerSetShortNameOccupied", err) + } +} + +func documentHasAttr(doc domain.Document, kind domain.DocumentAttributeKind) bool { + for _, attr := range doc.Attributes { + if attr.Kind == kind { + return true + } + } + return false +} + +func documentFileName(doc domain.Document) string { + for _, attr := range doc.Attributes { + if attr.Kind == domain.DocAttrFilename { + return attr.FileName + } + } + return "" +} + +func testLottieJSON() []byte { + return []byte(strings.TrimSpace(`{ + "v": "5.7.4", + "fr": 30, + "ip": 0, + "op": 30, + "w": 512, + "h": 512, + "layers": [] + }`)) +} diff --git a/internal/app/files/sticker_management.go b/internal/app/files/sticker_management.go new file mode 100644 index 00000000..2de6b5b7 --- /dev/null +++ b/internal/app/files/sticker_management.go @@ -0,0 +1,343 @@ +package files + +import ( + "context" + "strings" + + "telesrv/internal/domain" +) + +func (s *Service) AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) { + set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, ref) + if err != nil { + return domain.StickerSet{}, nil, err + } + if len(set.DocumentIDs) >= domain.MaxStickerSetItems { + return domain.StickerSet{}, nil, domain.ErrStickerSetTooMuch + } + doc, err := s.loadStickerMaterialDocument(ctx, item.DocumentID, item.DocumentAccessHash) + if err != nil { + return domain.StickerSet{}, nil, err + } + if ownedSetID, _, ok := doc.StickerSetRef(); ok && ownedSetID != set.ID { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + if containsInt64(set.DocumentIDs, doc.ID) { + return set, docs, nil + } + emoji := strings.TrimSpace(item.Emoji) + if err := validateStickerEmoji(emoji); err != nil { + return domain.StickerSet{}, nil, err + } + doc, err = s.prepareStickerSetDocument(ctx, doc, set, emoji) + if err != nil { + return domain.StickerSet{}, nil, err + } + set.DocumentIDs = append(set.DocumentIDs, doc.ID) + set.Count = len(set.DocumentIDs) + set.Packs = addDocumentToStickerPacks(set.Packs, emoji, doc.ID) + set.Keywords = upsertStickerKeywords(set.Keywords, parseStickerKeywords(doc.ID, item.Keywords)) + if set.ThumbDocumentID == 0 { + setStickerSetThumbFromDocument(&set, doc) + } + set.Hash = stickerSetHash(set) + docs = append(docs, doc) + return s.persistStickerSetMutation(ctx, set, docs, []domain.Document{doc}) +} + +func (s *Service) RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) { + doc, err := s.loadStickerInputDocument(ctx, documentID, accessHash) + if err != nil { + return domain.StickerSet{}, nil, err + } + setID, setAccessHash, ok := doc.StickerSetRef() + if !ok || setID == 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash}) + if err != nil { + return domain.StickerSet{}, nil, err + } + if len(set.DocumentIDs) <= 1 { + return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty + } + idx := indexInt64(set.DocumentIDs, documentID) + if idx < 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + set.DocumentIDs = removeInt64At(set.DocumentIDs, idx) + set.Count = len(set.DocumentIDs) + set.Packs = removeDocumentFromStickerPacks(set.Packs, documentID) + set.Keywords = removeStickerKeywords(set.Keywords, documentID) + doc = detachStickerSetFromDocument(doc) + docs = removeDocumentByID(docs, documentID) + if set.ThumbDocumentID == documentID { + clearStickerSetThumb(&set) + if len(docs) > 0 { + setStickerSetThumbFromDocument(&set, docs[0]) + } + } + set.Hash = stickerSetHash(set) + return s.persistStickerSetMutation(ctx, set, docs, []domain.Document{doc}) +} + +func (s *Service) ChangeStickerPosition(ctx context.Context, actorUserID int64, documentID int64, accessHash int64, position int) (domain.StickerSet, []domain.Document, error) { + doc, err := s.loadStickerInputDocument(ctx, documentID, accessHash) + if err != nil { + return domain.StickerSet{}, nil, err + } + setID, setAccessHash, ok := doc.StickerSetRef() + if !ok || setID == 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash}) + if err != nil { + return domain.StickerSet{}, nil, err + } + if position < 0 || position >= len(set.DocumentIDs) { + return domain.StickerSet{}, nil, domain.ErrStickerSetPositionInvalid + } + from := indexInt64(set.DocumentIDs, documentID) + if from < 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + set.DocumentIDs = moveInt64(set.DocumentIDs, from, position) + docs = orderDocuments(docs, set.DocumentIDs) + set.Hash = stickerSetHash(set) + return s.persistStickerSetMutation(ctx, set, docs, nil) +} + +func (s *Service) RenameStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, title string) (domain.StickerSet, []domain.Document, error) { + set, docs, err := s.resolveOwnedStickerSet(ctx, actorUserID, ref) + if err != nil { + return domain.StickerSet{}, nil, err + } + title = strings.TrimSpace(title) + if err := validateStickerSetTitle(title); err != nil { + return domain.StickerSet{}, nil, err + } + set.Title = title + set.Hash = stickerSetHash(set) + return s.persistStickerSetMutation(ctx, set, docs, nil) +} + +func (s *Service) DeleteStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSetKind, error) { + set, _, err := s.resolveOwnedStickerSet(ctx, actorUserID, ref) + if err != nil { + return "", err + } + if err := s.media.DeleteStickerSet(ctx, set.ID, actorUserID); err != nil { + return "", err + } + s.deleteCachedStickerSet(set) + return set.Kind, nil +} + +func (s *Service) resolveOwnedStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, error) { + if actorUserID <= 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid + } + if ref.Kind != domain.StickerSetRefByID && ref.Kind != domain.StickerSetRefByShortName { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + set, docs, found, err := s.ResolveStickerSet(ctx, ref) + if err != nil { + return domain.StickerSet{}, nil, err + } + if !found || set.ID == 0 || set.Deleted { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + if ref.Kind == domain.StickerSetRefByID && set.AccessHash != ref.AccessHash { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + if set.CreatorUserID != actorUserID { + return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned + } + return set, docs, nil +} + +func (s *Service) loadStickerInputDocument(ctx context.Context, documentID int64, accessHash int64) (domain.Document, error) { + if documentID == 0 || accessHash == 0 { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + docs, err := s.media.GetDocuments(ctx, []int64{documentID}) + if err != nil { + return domain.Document{}, err + } + if len(docs) != 1 || docs[0].ID != documentID || docs[0].AccessHash != accessHash || !docs[0].IsStickerLike() { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + return docs[0], nil +} + +func (s *Service) loadStickerMaterialDocument(ctx context.Context, documentID int64, accessHash int64) (domain.Document, error) { + if documentID == 0 || accessHash == 0 { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + docs, err := s.media.GetDocuments(ctx, []int64{documentID}) + if err != nil { + return domain.Document{}, err + } + if len(docs) != 1 || docs[0].ID != documentID || docs[0].AccessHash != accessHash || !docs[0].IsStickerSetMaterial() { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + return docs[0], nil +} + +func (s *Service) persistStickerSetMutation(ctx context.Context, set domain.StickerSet, docs []domain.Document, changedDocs []domain.Document) (domain.StickerSet, []domain.Document, error) { + if err := s.media.UpdateStickerSet(ctx, set, changedDocs); err != nil { + return domain.StickerSet{}, nil, err + } + ordered := orderDocuments(docs, set.DocumentIDs) + s.cacheStickerSet(set, ordered) + return set, ordered, nil +} + +func (s *Service) deleteCachedStickerSet(set domain.StickerSet) { + if s.stickerSetNegCache != nil { + s.stickerSetNegCache.put(domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID}) + if set.ShortName != "" { + s.stickerSetNegCache.put(domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: set.ShortName}) + } + } + if s.stickerSetCache != nil { + s.stickerSetCache.delete(set) + } +} + +func addDocumentToStickerPacks(packs []domain.StickerPack, emoji string, documentID int64) []domain.StickerPack { + out := copyStickerPacks(packs) + for i := range out { + if out[i].Emoticon == emoji { + if !containsInt64(out[i].DocumentIDs, documentID) { + out[i].DocumentIDs = append(out[i].DocumentIDs, documentID) + } + return out + } + } + return append(out, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{documentID}}) +} + +func removeDocumentFromStickerPacks(packs []domain.StickerPack, documentID int64) []domain.StickerPack { + out := make([]domain.StickerPack, 0, len(packs)) + for _, pack := range packs { + ids := removeInt64Value(pack.DocumentIDs, documentID) + if len(ids) == 0 { + continue + } + out = append(out, domain.StickerPack{Emoticon: pack.Emoticon, DocumentIDs: ids}) + } + return out +} + +func upsertStickerKeywords(in []domain.StickerKeyword, kw domain.StickerKeyword) []domain.StickerKeyword { + out := removeStickerKeywords(in, kw.DocumentID) + if len(kw.Keywords) == 0 { + return out + } + return append(out, kw) +} + +func removeStickerKeywords(in []domain.StickerKeyword, documentID int64) []domain.StickerKeyword { + out := make([]domain.StickerKeyword, 0, len(in)) + for _, kw := range in { + if kw.DocumentID == documentID { + continue + } + out = append(out, domain.StickerKeyword{DocumentID: kw.DocumentID, Keywords: append([]string(nil), kw.Keywords...)}) + } + return out +} + +func detachStickerSetFromDocument(doc domain.Document) domain.Document { + attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...) + for i := range attrs { + if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji { + continue + } + attrs[i].StickerSetID = 0 + attrs[i].StickerSetAccessHash = 0 + attrs[i].Mask = false + attrs[i].TextColor = false + break + } + doc.Attributes = attrs + return doc +} + +func setStickerSetThumbFromDocument(set *domain.StickerSet, doc domain.Document) { + set.ThumbDocumentID = doc.ID + set.Thumbs = copyPhotoSizes(doc.Thumbs) + set.ThumbDCID = doc.DCID + set.ThumbVersion = 0 + if len(set.Thumbs) > 0 { + set.ThumbVersion = 1 + } +} + +func clearStickerSetThumb(set *domain.StickerSet) { + set.ThumbDocumentID = 0 + set.Thumbs = nil + set.ThumbDCID = 0 + set.ThumbVersion = 0 +} + +func copyStickerPacks(packs []domain.StickerPack) []domain.StickerPack { + out := append([]domain.StickerPack(nil), packs...) + for i := range out { + out[i].DocumentIDs = append([]int64(nil), out[i].DocumentIDs...) + } + return out +} + +func containsInt64(in []int64, value int64) bool { + return indexInt64(in, value) >= 0 +} + +func indexInt64(in []int64, value int64) int { + for i, v := range in { + if v == value { + return i + } + } + return -1 +} + +func removeInt64At(in []int64, idx int) []int64 { + out := append([]int64(nil), in[:idx]...) + return append(out, in[idx+1:]...) +} + +func removeInt64Value(in []int64, value int64) []int64 { + out := make([]int64, 0, len(in)) + for _, v := range in { + if v != value { + out = append(out, v) + } + } + return out +} + +func moveInt64(in []int64, from, to int) []int64 { + out := append([]int64(nil), in...) + if from == to { + return out + } + value := out[from] + out = append(out[:from], out[from+1:]...) + if to >= len(out) { + return append(out, value) + } + out = append(out[:to], append([]int64{value}, out[to:]...)...) + return out +} + +func removeDocumentByID(docs []domain.Document, documentID int64) []domain.Document { + out := make([]domain.Document, 0, len(docs)) + for _, doc := range docs { + if doc.ID != documentID { + out = append(out, doc) + } + } + return out +} diff --git a/internal/app/files/sticker_management_test.go b/internal/app/files/sticker_management_test.go new file mode 100644 index 00000000..4b4198d4 --- /dev/null +++ b/internal/app/files/sticker_management_test.go @@ -0,0 +1,180 @@ +package files + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestManageStickerSetMutationsKeepSetAndDocumentsConsistent(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 1001, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 102: {ID: 102, AccessHash: 1002, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + set, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 101, + DocumentAccessHash: 1001, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + originalHash := set.Hash + if len(docs) != 1 { + t.Fatalf("created docs = %d, want 1", len(docs)) + } + + set, docs, err = svc.AddStickerToSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}, domain.StickerSetItemInput{ + DocumentID: 102, + DocumentAccessHash: 1002, + Emoji: "😄", + Keywords: "smile, fresh", + }) + if err != nil { + t.Fatalf("add sticker: %v", err) + } + if set.Count != 2 || len(set.DocumentIDs) != 2 || len(docs) != 2 || set.Hash == originalHash { + t.Fatalf("after add set=%+v docs=%d originalHash=%d, want two docs and bumped hash", set, len(docs), originalHash) + } + if id, hash, ok := docs[1].StickerSetRef(); !ok || id != set.ID || hash != set.AccessHash { + t.Fatalf("added doc sticker ref = %d/%d/%v, want %d/%d/true", id, hash, ok, set.ID, set.AccessHash) + } + + set, docs, err = svc.ChangeStickerPosition(ctx, 1000000001, 102, 1002, 0) + if err != nil { + t.Fatalf("change sticker position: %v", err) + } + if got := set.DocumentIDs; len(got) != 2 || got[0] != 102 || got[1] != 101 { + t.Fatalf("document order after move = %v, want [102 101]", got) + } + if len(docs) != 2 || docs[0].ID != 102 || docs[1].ID != 101 { + t.Fatalf("returned docs after move = %+v, want 102 then 101", docs) + } + + set, docs, err = svc.RenameStickerSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}, "Renamed Pack") + if err != nil { + t.Fatalf("rename sticker set: %v", err) + } + if set.Title != "Renamed Pack" || len(docs) != 2 { + t.Fatalf("renamed set=%+v docs=%d, want renamed with docs intact", set, len(docs)) + } + + set, docs, err = svc.RemoveStickerFromSet(ctx, 1000000001, 102, 1002) + if err != nil { + t.Fatalf("remove sticker: %v", err) + } + if set.Count != 1 || len(set.DocumentIDs) != 1 || set.DocumentIDs[0] != 101 || len(docs) != 1 || docs[0].ID != 101 { + t.Fatalf("after remove set=%+v docs=%+v, want only doc 101", set, docs) + } + detached, ok := media.docs[102] + if !ok { + t.Fatalf("detached doc missing from fake store") + } + if id, _, ok := detached.StickerSetRef(); ok || id != 0 { + t.Fatalf("removed doc sticker ref = %d/%v, want detached", id, ok) + } + + _, _, err = svc.RemoveStickerFromSet(ctx, 1000000001, 101, 1001) + if !errors.Is(err, domain.ErrStickerSetEmpty) { + t.Fatalf("remove last sticker err = %v, want ErrStickerSetEmpty", err) + } + + kind, err := svc.DeleteStickerSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}) + if err != nil { + t.Fatalf("delete sticker set: %v", err) + } + if kind != domain.StickerSetKindStickers { + t.Fatalf("delete kind = %q, want stickers", kind) + } + if _, _, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}); err != nil || found { + t.Fatalf("resolve deleted set = found %v err %v, want miss", found, err) + } +} + +func TestManageStickerSetRejectsNonCreator(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 1001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 102: {ID: 102, AccessHash: 1002, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + set, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 101, + DocumentAccessHash: 1001, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + _, _, err = svc.AddStickerToSet(ctx, 1000000002, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}, domain.StickerSetItemInput{ + DocumentID: 102, + DocumentAccessHash: 1002, + Emoji: "😄", + }) + if !errors.Is(err, domain.ErrStickerSetNotOwned) { + t.Fatalf("non-creator add err = %v, want ErrStickerSetNotOwned", err) + } +} + +func TestAddStickerToSetAcceptsUploadedMaterial(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 1001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 202: {ID: 202, AccessHash: 2002, MimeType: "video/mp4", Size: 4096, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 512, H: 512, Duration: 1}}}, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + set, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 101, + DocumentAccessHash: 1001, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + set, docs, err := svc.AddStickerToSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: set.ID, AccessHash: set.AccessHash}, domain.StickerSetItemInput{ + DocumentID: 202, + DocumentAccessHash: 2002, + Emoji: "🎬", + }) + if err != nil { + t.Fatalf("add uploaded material: %v", err) + } + if set.Count != 2 || len(docs) != 2 { + t.Fatalf("after add set=%+v docs=%d, want two items", set, len(docs)) + } + added := docs[1] + if added.ID != 202 || !added.IsSticker() || !documentHasAttr(added, domain.DocAttrVideo) { + t.Fatalf("added doc = %+v, want sticker-tagged video material", added) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 09a817bd..f4474cb2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,11 @@ 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 // Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。 AdminUIAddr string AdminUIPassword string @@ -250,17 +255,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", ""), - AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"), - 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", ""), + 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:2400"), + 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 31b74ab3..d0512baa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,13 +20,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { } func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { - t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10") + t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } - if cfg.AdvertiseIP != "203.0.113.10" { + if cfg.AdvertiseIP != "192.0.2.10" { t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP) } } @@ -62,6 +62,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 `) t.Setenv("TELESRV_CONFIG", path) @@ -81,6 +83,12 @@ TELESRV_CALL_RING_TIMEOUT=2m 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.StickerWebPublicURL != "https://packs.example.test" { + t.Fatalf("StickerWebPublicURL = %q, want https://packs.example.test", cfg.StickerWebPublicURL) + } } func TestLoadEnvironmentOverridesConfigFile(t *testing.T) { diff --git a/internal/domain/media.go b/internal/domain/media.go index aebf430b..72dc4899 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -1,5 +1,10 @@ package domain +import ( + "path/filepath" + "strings" +) + // 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。 // 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。 // @@ -238,6 +243,91 @@ func (d Document) IsSticker() bool { return false } +func (d Document) IsCustomEmoji() bool { + for _, attr := range d.Attributes { + if attr.Kind == DocAttrCustomEmoji { + return true + } + } + return false +} + +func (d Document) IsStickerLike() bool { + return d.IsSticker() || d.IsCustomEmoji() +} + +// MaxStickerMaterialDocumentSize bounds uploaded documents that can be promoted +// into user-created sticker/custom emoji sets without server-side transcoding. +const MaxStickerMaterialDocumentSize int64 = 20 << 20 + +// IsStickerSetMaterial reports whether an existing Document can be used as an +// input item for stickers.createStickerSet/stickers.addStickerToSet. Already +// tagged sticker/custom emoji documents always pass. Untagged TGS/WebP/Lottie +// JSON uploads can be shaped later from their MIME/body; video uploads must +// already carry a video attribute because this layer does not transcode or +// probe containers. +func (d Document) IsStickerSetMaterial() bool { + if d.IsStickerLike() { + return true + } + if d.ID == 0 || d.AccessHash == 0 || d.Size > MaxStickerMaterialDocumentSize { + return false + } + switch d.stickerMaterialMimeOrExt() { + case "application/x-tgsticker", "image/webp", "application/json": + return true + case "video/webm", "video/mp4": + return d.hasDocumentAttribute(DocAttrVideo) + default: + return false + } +} + +func (d Document) hasDocumentAttribute(kind DocumentAttributeKind) bool { + for _, attr := range d.Attributes { + if attr.Kind == kind { + return true + } + } + return false +} + +// StickerSetMaterialMime returns the normalized sticker material MIME inferred +// from the document MIME type or a filename attribute. +func (d Document) StickerSetMaterialMime() string { + return d.stickerMaterialMimeOrExt() +} + +func (d Document) stickerMaterialMimeOrExt() string { + mimeType := strings.ToLower(strings.TrimSpace(d.MimeType)) + switch mimeType { + case "application/x-tgsticker", "image/webp", "video/webm", "video/mp4", + "application/json", "application/lottie+json", "text/json": + if mimeType == "application/lottie+json" || mimeType == "text/json" { + return "application/json" + } + return mimeType + } + for _, attr := range d.Attributes { + if attr.Kind != DocAttrFilename { + continue + } + switch strings.ToLower(filepath.Ext(strings.TrimSpace(attr.FileName))) { + case ".tgs": + return "application/x-tgsticker" + case ".webp": + return "image/webp" + case ".json": + return "application/json" + case ".webm": + return "video/webm" + case ".mp4": + return "video/mp4" + } + } + return mimeType +} + // IsGif reports whether the document is a savable GIF (documentAttributeAnimated). func (d Document) IsGif() bool { for _, attr := range d.Attributes { @@ -558,6 +648,11 @@ type StickerPack struct { DocumentIDs []int64 `json:"document_ids"` } +type StickerKeyword struct { + DocumentID int64 `json:"document_id"` + Keywords []string `json:"keywords"` +} + // StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses // 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集 // (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。 @@ -576,32 +671,67 @@ const ( // StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。 type StickerSet struct { - ID int64 `json:"id"` - AccessHash int64 `json:"access_hash"` - ShortName string `json:"short_name"` - Title string `json:"title"` - Count int `json:"count"` - Hash int `json:"hash"` - Kind StickerSetKind `json:"set_kind"` - Official bool `json:"official,omitempty"` - Animated bool `json:"animated,omitempty"` - Videos bool `json:"videos,omitempty"` - Emojis bool `json:"emojis,omitempty"` - Masks bool `json:"masks,omitempty"` - Installed bool `json:"installed,omitempty"` - Archived bool `json:"archived,omitempty"` - InstalledDate int `json:"installed_date,omitempty"` - ThumbDocumentID int64 `json:"thumb_document_id,omitempty"` - Thumbs []PhotoSize `json:"thumbs,omitempty"` - ThumbDCID int `json:"thumb_dc_id,omitempty"` - ThumbVersion int `json:"thumb_version,omitempty"` - DocumentIDs []int64 `json:"document_ids,omitempty"` - Packs []StickerPack `json:"packs,omitempty"` - SortOrder int `json:"sort_order,omitempty"` + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + ShortName string `json:"short_name"` + Title string `json:"title"` + Count int `json:"count"` + Hash int `json:"hash"` + Kind StickerSetKind `json:"set_kind"` + Official bool `json:"official,omitempty"` + Animated bool `json:"animated,omitempty"` + Videos bool `json:"videos,omitempty"` + Emojis bool `json:"emojis,omitempty"` + Masks bool `json:"masks,omitempty"` + TextColor bool `json:"text_color,omitempty"` + Creator bool `json:"creator,omitempty"` + CreatorUserID int64 `json:"creator_user_id,omitempty"` + Installed bool `json:"installed,omitempty"` + Archived bool `json:"archived,omitempty"` + Deleted bool `json:"deleted,omitempty"` + InstalledDate int `json:"installed_date,omitempty"` + ThumbDocumentID int64 `json:"thumb_document_id,omitempty"` + Thumbs []PhotoSize `json:"thumbs,omitempty"` + ThumbDCID int `json:"thumb_dc_id,omitempty"` + ThumbVersion int `json:"thumb_version,omitempty"` + DocumentIDs []int64 `json:"document_ids,omitempty"` + Packs []StickerPack `json:"packs,omitempty"` + Keywords []StickerKeyword `json:"keywords,omitempty"` + SortOrder int `json:"sort_order,omitempty"` + Software string `json:"software,omitempty"` // SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。 SystemKey string `json:"system_key,omitempty"` } +const ( + MinStickerSetShortNameLen = 5 + MaxStickerSetShortNameLen = 32 + MaxStickerSetTitleLen = 64 + MaxStickerSetItems = 120 + MaxStickerSetKeywords = 20 + MaxStickerSetKeywordLen = 32 +) + +type StickerSetItemInput struct { + DocumentID int64 + DocumentAccessHash int64 + Emoji string + Keywords string +} + +type CreateStickerSetRequest struct { + CreatorUserID int64 + Title string + ShortName string + Kind StickerSetKind + TextColor bool + ThumbDocumentID int64 + ThumbAccessHash int64 + Items []StickerSetItemInput + Software string + Date int +} + // ProfilePhotoKind distinguishes a user's real profile photo from the fallback // public photo shown when privacy hides the real one. type ProfilePhotoKind string diff --git a/internal/domain/media_test.go b/internal/domain/media_test.go new file mode 100644 index 00000000..94c9f802 --- /dev/null +++ b/internal/domain/media_test.go @@ -0,0 +1,74 @@ +package domain + +import "testing" + +func TestDocumentIsStickerSetMaterialAcceptsUploadedFormats(t *testing.T) { + tests := []struct { + name string + doc Document + want bool + wantMime string + }{ + { + name: "existing sticker", + doc: Document{ID: 1, AccessHash: 11, Size: MaxStickerMaterialDocumentSize + 1, Attributes: []DocumentAttribute{{Kind: DocAttrSticker}}}, + want: true, + wantMime: "", + }, + { + name: "tgs mime", + doc: Document{ID: 2, AccessHash: 22, MimeType: "application/x-tgsticker", Size: 4096}, + want: true, + wantMime: "application/x-tgsticker", + }, + { + name: "webp extension", + doc: Document{ID: 3, AccessHash: 33, MimeType: "application/octet-stream", Size: 4096, Attributes: []DocumentAttribute{{Kind: DocAttrFilename, FileName: "sticker.WEBP"}}}, + want: true, + wantMime: "image/webp", + }, + { + name: "lottie json mime", + doc: Document{ID: 31, AccessHash: 331, MimeType: "application/lottie+json", Size: 4096}, + want: true, + wantMime: "application/json", + }, + { + name: "lottie json extension", + doc: Document{ID: 32, AccessHash: 332, MimeType: "application/octet-stream", Size: 4096, Attributes: []DocumentAttribute{{Kind: DocAttrFilename, FileName: "wave.JSON"}}}, + want: true, + wantMime: "application/json", + }, + { + name: "mp4 mime", + doc: Document{ID: 4, AccessHash: 44, MimeType: "video/mp4", Size: 4096, Attributes: []DocumentAttribute{{Kind: DocAttrVideo, W: 512, H: 512, Duration: 1}}}, + want: true, + wantMime: "video/mp4", + }, + { + name: "mp4 without video attribute", + doc: Document{ID: 40, AccessHash: 440, MimeType: "video/mp4", Size: 4096}, + wantMime: "video/mp4", + }, + { + name: "arbitrary file", + doc: Document{ID: 5, AccessHash: 55, MimeType: "application/pdf", Size: 4096}, + wantMime: "application/pdf", + }, + { + name: "oversized upload", + doc: Document{ID: 6, AccessHash: 66, MimeType: "image/webp", Size: MaxStickerMaterialDocumentSize + 1}, + wantMime: "image/webp", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.doc.IsStickerSetMaterial(); got != tt.want { + t.Fatalf("IsStickerSetMaterial() = %v, want %v", got, tt.want) + } + if got := tt.doc.StickerSetMaterialMime(); got != tt.wantMime { + t.Fatalf("StickerSetMaterialMime() = %q, want %q", got, tt.wantMime) + } + }) + } +} diff --git a/internal/domain/sticker_collection.go b/internal/domain/sticker_collection.go index 523e6b7e..304bb573 100644 --- a/internal/domain/sticker_collection.go +++ b/internal/domain/sticker_collection.go @@ -5,6 +5,21 @@ import "errors" // ErrStickerInvalid 表示输入文档不是合法的贴纸/GIF(faveSticker/saveGif 等)。 var ErrStickerInvalid = errors.New("sticker document invalid") +var ( + ErrStickerSetTitleInvalid = errors.New("sticker set title invalid") + ErrStickerSetShortNameInvalid = errors.New("sticker set short name invalid") + ErrStickerSetShortNameOccupied = errors.New("sticker set short name occupied") + ErrStickerSetTypeInvalid = errors.New("sticker set type invalid") + ErrStickerSetEmpty = errors.New("sticker set empty") + ErrStickerSetTooMuch = errors.New("sticker set too much") + ErrStickerSetEmojiInvalid = errors.New("sticker set emoji invalid") + ErrStickerSetFileInvalid = errors.New("sticker set file invalid") + ErrStickerSetCreatorInvalid = errors.New("sticker set creator invalid") + ErrStickerSetInvalid = errors.New("sticker set invalid") + ErrStickerSetNotOwned = errors.New("sticker set not owned") + ErrStickerSetPositionInvalid = errors.New("sticker set position invalid") +) + // StickerCollectionKind 区分一个用户的几类个人贴纸集合。 type StickerCollectionKind string @@ -22,9 +37,11 @@ const ( // 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额; // faved 不做 premium 分档(用户决策范围外),统一一个上界。 const ( - MaxFavedStickers = 100 - MaxRecentStickers = 30 - MaxSavedGifs = 200 + MaxFavedStickers = 100 + MaxRecentStickers = 30 + MaxSavedGifs = 200 + MaxInstalledStickerSets = 200 + MaxCreatedStickerSets = 200 ) // MaxStickerCollectionItems 返回某类集合的容量上界。 @@ -44,3 +61,14 @@ type StickerCollectionItem struct { DocumentID int64 Date int } + +// UserStickerSet 是某个账号安装/归档的一条 sticker set 状态。 +// set 元数据、文档和 packs 仍由 media/files 模块维护;这里仅保存用户视角。 +type UserStickerSet struct { + OwnerUserID int64 + StickerSetID int64 + Kind StickerSetKind + Archived bool + InstalledDate int + OrderValue int64 +} diff --git a/internal/domain/system.go b/internal/domain/system.go index ef9c31ed..5f1d45ad 100644 --- a/internal/domain/system.go +++ b/internal/domain/system.go @@ -8,6 +8,12 @@ const ( BotFatherUserID int64 = 93372553 // BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。 BotFatherAccessHash int64 = 7421896403922962293 + + // StickersBotUserID 是内置 @Stickers 账号。它是 server 内置 service bot, + // 不走外部 Bot API 进程。 + StickersBotUserID int64 = 1063110917 + // StickersBotAccessHash 固定不变;与 postgres 种子行双写,必须保持一致。 + StickersBotAccessHash int64 = 5213187021149032991 ) // OfficialSystemUser 返回第一阶段内置的官方系统账号。 @@ -36,6 +42,19 @@ func BotFatherUser() User { } } +// StickersBotUser 返回内置 @Stickers 账号。username 不以 bot 结尾属种子例外(与官方一致)。 +func StickersBotUser() User { + return User{ + ID: StickersBotUserID, + AccessHash: StickersBotAccessHash, + FirstName: "Stickers", + Username: "Stickers", + Verified: true, + Bot: true, + BotInfoVersion: 2, + } +} + // SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。 // 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。 func SystemUserByID(id int64) (User, bool) { @@ -44,6 +63,8 @@ func SystemUserByID(id int64) (User, bool) { return OfficialSystemUser(), true case BotFatherUserID: return BotFatherUser(), true + case StickersBotUserID: + return StickersBotUser(), true } return User{}, false } @@ -55,7 +76,7 @@ func IsSystemUserID(id int64) bool { func SystemUserByPhone(phone string) (User, bool) { phone = NormalizePhone(phone) - for _, id := range []int64{OfficialSystemUserID, BotFatherUserID} { + for _, id := range []int64{OfficialSystemUserID, BotFatherUserID, StickersBotUserID} { u, ok := SystemUserByID(id) if !ok || u.Phone == "" { continue diff --git a/internal/rpc/bots_hooks.go b/internal/rpc/bots_hooks.go index fb3d4026..ac86db6b 100644 --- a/internal/rpc/bots_hooks.go +++ b/internal/rpc/bots_hooks.go @@ -11,7 +11,8 @@ import ( ) // 本文件实现 app/bots 的 RouterHooks 回调:token revoke 后的 session 失效闭环, -// 以及命令变更后的 updateBotCommands 在线推送。Router 创建后经 +// 命令变更后的 updateBotCommands 在线推送,以及 @Stickers 发布后的 +// updateStickerSets 在线提示。Router 创建后经 // botsService.SetRouterHooks(router) 装配(见 cmd/telesrv/main.go)。 // maxBotCommandsPushPeers 限制单次命令变更的推送扇出(bot 的最近 dialog peer 数)。 @@ -60,6 +61,26 @@ func (r *Router) PushBotCommandsChanged(ctx context.Context, botUserID int64, co go r.pushBotCommandsChanged(context.WithoutCancel(ctx), botUserID, cmds) } +// PushStickerSetsChanged 给单个用户在线 session 推 updateStickerSets。该 update 无 +// pts,不进 getDifference;权威安装态已写 user_sticker_sets,离线端下次 +// messages.getAllStickers/messages.getEmojiStickers 会重建。 +func (r *Router) PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind) { + if userID == 0 { + return + } + r.invalidateStickerCatalog(kind) + go func() { + defer func() { + if rec := recover(); rec != nil { + r.log.Error("push sticker sets panicked", zap.Int64("user_id", userID), zap.Any("panic", rec)) + } + }() + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + r.pushStickerSetsUpdate(ctx, userID, kind) + }() +} + func (r *Router) pushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) { defer func() { if rec := recover(); rec != nil { diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index 6b4a9bb0..1bb20d53 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -578,6 +578,8 @@ func tgStickerSet(set domain.StickerSet) tg.StickerSet { Official: set.Official, Masks: set.Masks, Emojis: set.Emojis, + TextColor: set.TextColor, + Creator: set.Creator, ID: set.ID, AccessHash: set.AccessHash, Title: set.Title, @@ -633,12 +635,23 @@ func tgStickerPacks(packs []domain.StickerPack) []tg.StickerPack { return out } +func tgStickerKeywords(keywords []domain.StickerKeyword) []tg.StickerKeyword { + out := make([]tg.StickerKeyword, 0, len(keywords)) + for _, kw := range keywords { + if kw.DocumentID == 0 || len(kw.Keywords) == 0 { + continue + } + out = append(out, tg.StickerKeyword{DocumentID: kw.DocumentID, Keyword: append([]string(nil), kw.Keywords...)}) + } + return out +} + // tgMessagesStickerSet 构造完整 messages.stickerSet(set + packs + documents)。 func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet { return &tg.MessagesStickerSet{ Set: tgStickerSet(set), Packs: tgStickerPacks(set.Packs), - Keywords: []tg.StickerKeyword{}, + Keywords: tgStickerKeywords(set.Keywords), Documents: tgDocuments(docs), } } diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 3e18707f..037e8ce1 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -125,7 +125,7 @@ func applyTgUserBotFields(out *tg.User, u domain.User) { version = 1 } out.SetBotInfoVersion(version) - if u.ID != domain.BotFatherUserID { + if !domain.IsSystemUserID(u.ID) { out.SetBotBusiness(true) } out.Phone = "" diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 18bb4a3e..d92e8695 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -607,6 +607,15 @@ type FilesService interface { GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (set domain.StickerSet, documents []domain.Document, found bool, err error) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) + CheckStickerSetShortName(ctx context.Context, shortName string) (bool, error) + SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error) + CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) + ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) + AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) + RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) + ChangeStickerPosition(ctx context.Context, actorUserID int64, documentID int64, accessHash int64, position int) (domain.StickerSet, []domain.Document, error) + RenameStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, title string) (domain.StickerSet, []domain.Document, error) + DeleteStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSetKind, error) // 头像(profile photo)与消息媒体组装。 CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error) diff --git a/internal/rpc/messages_compat.go b/internal/rpc/messages_compat.go index edd31dfa..c43c2298 100644 --- a/internal/rpc/messages_compat.go +++ b/internal/rpc/messages_compat.go @@ -517,17 +517,12 @@ func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, use } func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass { - if hash != 0 { - return &tg.MessagesAllStickersNotModified{} - } - return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}} + return &tg.MessagesAllStickers{Hash: 0, Sets: []tg.StickerSet{}} } func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass { - if hash != 0 { - return &tg.MessagesFeaturedStickersNotModified{Count: 0} - } return &tg.MessagesFeaturedStickers{ + Hash: 0, Count: 0, Sets: []tg.StickerSetCoveredClass{}, Unread: []int64{}, diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index b2e03c02..ba060d87 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -91,6 +91,11 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) { d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions) d.OnMessagesGetAvailableEffects(r.onMessagesGetAvailableEffects) d.OnMessagesGetStickers(r.onMessagesGetStickers) + d.OnMessagesInstallStickerSet(r.onMessagesInstallStickerSet) + d.OnMessagesUninstallStickerSet(r.onMessagesUninstallStickerSet) + d.OnMessagesReorderStickerSets(r.onMessagesReorderStickerSets) + d.OnMessagesToggleStickerSets(r.onMessagesToggleStickerSets) + d.OnMessagesGetMyStickers(r.onMessagesGetMyStickers) d.OnMessagesGetArchivedStickers(func(ctx context.Context, req *tg.MessagesGetArchivedStickersRequest) (*tg.MessagesArchivedStickers, error) { return &tg.MessagesArchivedStickers{ Count: 0, diff --git a/internal/rpc/messages_sticker_sets.go b/internal/rpc/messages_sticker_sets.go new file mode 100644 index 00000000..4e6db4bd --- /dev/null +++ b/internal/rpc/messages_sticker_sets.go @@ -0,0 +1,263 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +type userStickerSetService interface { + InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error + UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error + SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error + ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error + ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) +} + +func (r *Router) userStickerSetSvc() (userStickerSetService, bool) { + svc, ok := r.deps.Account.(userStickerSetService) + return svc, ok +} + +func (r *Router) onMessagesInstallStickerSet(ctx context.Context, req *tg.MessagesInstallStickerSetRequest) (tg.MessagesStickerSetInstallResultClass, error) { + if req == nil { + return nil, stickersetInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + set, _, err := r.resolveInstallableStickerSet(ctx, req.Stickerset) + if err != nil { + return nil, err + } + if svc, ok := r.userStickerSetSvc(); ok { + if err := svc.InstallUserStickerSet(ctx, userID, set.ID, userStickerSetKind(set), req.Archived, int(r.clock.Now().Unix())); err != nil { + return nil, internalErr() + } + } + r.pushStickerSetsUpdate(ctx, userID, userStickerSetKind(set)) + return &tg.MessagesStickerSetInstallResultSuccess{}, nil +} + +func (r *Router) onMessagesUninstallStickerSet(ctx context.Context, input tg.InputStickerSetClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + set, _, err := r.resolveInstallableStickerSet(ctx, input) + if err != nil { + return false, err + } + if svc, ok := r.userStickerSetSvc(); ok { + if err := svc.UninstallUserStickerSet(ctx, userID, set.ID); err != nil { + return false, internalErr() + } + } + r.pushStickerSetsUpdate(ctx, userID, userStickerSetKind(set)) + return true, nil +} + +func (r *Router) onMessagesReorderStickerSets(ctx context.Context, req *tg.MessagesReorderStickerSetsRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + kind := stickerSetKindFromFlags(req.Masks, req.Emojis) + order := uniqueNonZeroInt64s(req.Order, domain.MaxInstalledStickerSets) + if len(order) == 0 { + return true, nil + } + if svc, ok := r.userStickerSetSvc(); ok { + if err := svc.ReorderUserStickerSets(ctx, userID, kind, order, int(r.clock.Now().Unix())); err != nil { + return false, internalErr() + } + } + r.pushStickerSetsOrderUpdate(ctx, userID, kind, order) + return true, nil +} + +func (r *Router) onMessagesToggleStickerSets(ctx context.Context, req *tg.MessagesToggleStickerSetsRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.Stickersets) > domain.MaxInstalledStickerSets { + return false, limitInvalidErr() + } + svc, hasSvc := r.userStickerSetSvc() + kinds := make(map[domain.StickerSetKind]bool) + now := int(r.clock.Now().Unix()) + for _, input := range req.Stickersets { + set, _, err := r.resolveInstallableStickerSet(ctx, input) + if err != nil { + return false, err + } + kind := userStickerSetKind(set) + kinds[kind] = true + if !hasSvc { + continue + } + switch { + case req.Uninstall: + if err := svc.UninstallUserStickerSet(ctx, userID, set.ID); err != nil { + return false, internalErr() + } + case req.Archive: + if err := svc.SetUserStickerSetArchived(ctx, userID, set.ID, true, now); err != nil { + return false, internalErr() + } + case req.Unarchive: + if err := svc.SetUserStickerSetArchived(ctx, userID, set.ID, false, now); err != nil { + return false, internalErr() + } + default: + if err := svc.InstallUserStickerSet(ctx, userID, set.ID, kind, false, now); err != nil { + return false, internalErr() + } + } + } + for kind := range kinds { + r.pushStickerSetsUpdate(ctx, userID, kind) + } + return true, nil +} + +func (r *Router) onMessagesGetMyStickers(ctx context.Context, req *tg.MessagesGetMyStickersRequest) (*tg.MessagesMyStickers, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Files == nil { + return &tg.MessagesMyStickers{Count: 0, Sets: []tg.StickerSetCoveredClass{}}, nil + } + limit := 50 + var offsetID int64 + if req != nil { + if req.OffsetID < 0 { + return nil, offsetInvalidErr() + } + if req.Limit < 0 || req.Limit > domain.MaxCreatedStickerSets { + return nil, limitInvalidErr() + } + if req.Limit > 0 { + limit = req.Limit + } + offsetID = req.OffsetID + } + sets, total, err := r.deps.Files.ListCreatedStickerSets(ctx, userID, offsetID, limit) + if err != nil { + return nil, internalErr() + } + covered := make([]tg.StickerSetCoveredClass, 0, len(sets)) + for _, set := range sets { + set.Creator = true + covered = append(covered, &tg.StickerSetNoCovered{Set: tgStickerSet(set)}) + } + return &tg.MessagesMyStickers{Count: total, Sets: covered}, nil +} + +func (r *Router) resolveInstallableStickerSet(ctx context.Context, input tg.InputStickerSetClass) (domain.StickerSet, []domain.Document, error) { + ref, ok := stickerSetRefFromInput(input) + if !ok || (ref.Kind != domain.StickerSetRefByID && ref.Kind != domain.StickerSetRefByShortName) { + return domain.StickerSet{}, nil, stickersetInvalidErr() + } + if r.deps.Files == nil { + return domain.StickerSet{}, nil, stickersetInvalidErr() + } + set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, ref) + if err != nil { + return domain.StickerSet{}, nil, internalErr() + } + if !found || set.ID == 0 { + return domain.StickerSet{}, nil, stickersetInvalidErr() + } + if ref.Kind == domain.StickerSetRefByID && set.AccessHash != ref.AccessHash { + return domain.StickerSet{}, nil, stickersetInvalidErr() + } + return set, docs, nil +} + +func userStickerSetKind(set domain.StickerSet) domain.StickerSetKind { + switch { + case set.Kind == domain.StickerSetKindMasks || set.Masks: + return domain.StickerSetKindMasks + case set.Kind == domain.StickerSetKindEmoji || set.Emojis: + return domain.StickerSetKindEmoji + default: + return domain.StickerSetKindStickers + } +} + +func stickerSetKindFromFlags(masks, emojis bool) domain.StickerSetKind { + switch { + case masks: + return domain.StickerSetKindMasks + case emojis: + return domain.StickerSetKindEmoji + default: + return domain.StickerSetKindStickers + } +} + +func uniqueNonZeroInt64s(in []int64, max int) []int64 { + if max <= 0 { + max = len(in) + } + out := make([]int64, 0, len(in)) + seen := make(map[int64]struct{}, len(in)) + for _, v := range in { + if v == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + if len(out) >= max { + break + } + } + return out +} + +func (r *Router) pushStickerSetsUpdate(ctx context.Context, userID int64, kind domain.StickerSetKind) { + update := &tg.UpdateStickerSets{} + switch kind { + case domain.StickerSetKindMasks: + update.SetMasks(true) + case domain.StickerSetKindEmoji: + update.SetEmojis(true) + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + }) +} + +func (r *Router) pushStickerSetsOrderUpdate(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64) { + update := &tg.UpdateStickerSetsOrder{Order: append([]int64(nil), order...)} + switch kind { + case domain.StickerSetKindMasks: + update.SetMasks(true) + case domain.StickerSetKindEmoji: + update.SetEmojis(true) + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + }) +} diff --git a/internal/rpc/messages_sticker_sets_rpc_test.go b/internal/rpc/messages_sticker_sets_rpc_test.go new file mode 100644 index 00000000..896e6533 --- /dev/null +++ b/internal/rpc/messages_sticker_sets_rpc_test.go @@ -0,0 +1,306 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func userStickerSetRouter(t *testing.T) (*Router, *memory.PasswordStore, *captureSessions) { + t.Helper() + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: { + {ID: 10, AccessHash: 100, ShortName: "funny", Title: "Funny", Kind: domain.StickerSetKindStickers, Count: 1, Hash: 7, Installed: true, InstalledDate: 1, DocumentIDs: []int64{101}}, + {ID: 20, AccessHash: 200, ShortName: "work", Title: "Work", Kind: domain.StickerSetKindStickers, Count: 1, Hash: 8, Installed: true, InstalledDate: 1, DocumentIDs: []int64{102}}, + }, + domain.StickerSetKindEmoji: { + {ID: 30, AccessHash: 300, ShortName: "emoji_fun", Title: "Emoji Fun", Kind: domain.StickerSetKindEmoji, Emojis: true, Count: 1, Hash: 9, Installed: true, InstalledDate: 1, DocumentIDs: []int64{101}}, + }, + }, + } + passwordStore := memory.NewPasswordStore() + sessions := &captureSessions{} + router := New(Config{}, Deps{ + Account: appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)), + Files: files, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + return router, passwordStore, sessions +} + +func TestMessagesInstallStickerSetPersistsAndPushesUpdate(t *testing.T) { + r, store, sessions := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + out, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"}, + }) + if err != nil { + t.Fatalf("install sticker set: %v", err) + } + if _, ok := out.(*tg.MessagesStickerSetInstallResultSuccess); !ok { + t.Fatalf("install result = %T, want *tg.MessagesStickerSetInstallResultSuccess", out) + } + + items, total, err := store.ListUserStickerSets(ctx, 1000000001, domain.StickerSetKindStickers, nil, 0, 10) + if err != nil { + t.Fatalf("list installed sets: %v", err) + } + if total != 1 || len(items) != 1 || items[0].StickerSetID != 10 || items[0].Archived { + t.Fatalf("installed sets = total %d items %+v, want one active set 10", total, items) + } + assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil) +} + +func TestMessagesInstallStickerSetUpdatesAllStickersProjection(t *testing.T) { + r, _, _ := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + before, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("get all stickers before install: %v", err) + } + if full, ok := before.(*tg.MessagesAllStickers); ok && len(full.Sets) != 0 { + t.Fatalf("all stickers before install = %+v, want empty non-default catalog", full.Sets) + } + if _, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"}, + }); err != nil { + t.Fatalf("install sticker set: %v", err) + } + + after, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("get all stickers after install: %v", err) + } + full, ok := after.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("get all stickers after install = %T, want *tg.MessagesAllStickers", after) + } + if len(full.Sets) != 1 || full.Sets[0].ID != 10 || full.Sets[0].InstalledDate == 0 { + t.Fatalf("all stickers after install = %+v, want installed set 10", full.Sets) + } + + if ok, err := r.onMessagesUninstallStickerSet(ctx, &tg.InputStickerSetID{ID: 10, AccessHash: 100}); err != nil || !ok { + t.Fatalf("uninstall sticker set = %v %v", ok, err) + } + afterUninstall, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("get all stickers after uninstall: %v", err) + } + if full, ok := afterUninstall.(*tg.MessagesAllStickers); ok && len(full.Sets) != 0 { + t.Fatalf("all stickers after uninstall = %+v, want empty", full.Sets) + } +} + +func TestMessagesEmptyViewerStickerSetsInvalidateOldClientHash(t *testing.T) { + r, _, _ := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + stickers, err := r.onMessagesGetAllStickers(ctx, 3041827464193675523) + if err != nil { + t.Fatalf("get all stickers with old hash: %v", err) + } + full, ok := stickers.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("get all stickers with old hash = %T, want full empty list", stickers) + } + if full.Hash != 0 || len(full.Sets) != 0 { + t.Fatalf("empty sticker list = hash %d sets %+v, want hash 0 with no sets", full.Hash, full.Sets) + } + + emoji, err := r.onMessagesGetEmojiStickers(ctx, 7254637046733671932) + if err != nil { + t.Fatalf("get emoji stickers with old hash: %v", err) + } + emojiFull, ok := emoji.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("get emoji stickers with old hash = %T, want full empty list", emoji) + } + if emojiFull.Hash != 0 || len(emojiFull.Sets) != 0 { + t.Fatalf("empty emoji list = hash %d sets %+v, want hash 0 with no sets", emojiFull.Hash, emojiFull.Sets) + } +} + +func TestMessagesGetStickerSetUsesViewerInstallState(t *testing.T) { + r, _, _ := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + before, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"}, + }) + if err != nil { + t.Fatalf("get sticker set before install: %v", err) + } + full, ok := before.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("get sticker set before install = %T, want *tg.MessagesStickerSet", before) + } + if full.Set.InstalledDate != 0 { + t.Fatalf("preview installed_date before install = %d, want 0", full.Set.InstalledDate) + } + + if _, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"}, + }); err != nil { + t.Fatalf("install sticker set: %v", err) + } + after, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"}, + }) + if err != nil { + t.Fatalf("get sticker set after install: %v", err) + } + full, ok = after.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("get sticker set after install = %T, want *tg.MessagesStickerSet", after) + } + if full.Set.InstalledDate == 0 { + t.Fatalf("preview installed_date after install = 0, want viewer install date") + } +} + +func TestMessagesInstallStickerSetRejectsBadAccessHash(t *testing.T) { + r, store, sessions := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + out, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{ + Stickerset: &tg.InputStickerSetID{ID: 10, AccessHash: 999}, + }) + if out != nil || !tgerr.Is(err, "STICKERSET_INVALID") { + t.Fatalf("install with bad access hash = %T %v, want STICKERSET_INVALID", out, err) + } + items, total, err := store.ListUserStickerSets(ctx, 1000000001, domain.StickerSetKindStickers, nil, 0, 10) + if err != nil { + t.Fatalf("list installed sets: %v", err) + } + if total != 0 || len(items) != 0 { + t.Fatalf("installed after rejected install = total %d items %+v, want empty", total, items) + } + if push := sessions.lastUserPush(); push != nil { + t.Fatalf("push after rejected install = %T, want nil", push) + } +} + +func TestMessagesReorderAndToggleStickerSets(t *testing.T) { + r, store, sessions := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + for _, shortName := range []string{"funny", "work"} { + if _, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{Stickerset: &tg.InputStickerSetShortName{ShortName: shortName}}); err != nil { + t.Fatalf("install %s: %v", shortName, err) + } + } + if ok, err := r.onMessagesReorderStickerSets(ctx, &tg.MessagesReorderStickerSetsRequest{Order: []int64{20, 10, 20, 0}}); err != nil || !ok { + t.Fatalf("reorder = %v %v", ok, err) + } + if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindStickers, nil); len(got) != 2 || got[0] != 20 || got[1] != 10 { + t.Fatalf("installed order = %v, want [20 10]", got) + } + assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, []int64{20, 10}) + + if ok, err := r.onMessagesToggleStickerSets(ctx, &tg.MessagesToggleStickerSetsRequest{ + Stickersets: []tg.InputStickerSetClass{&tg.InputStickerSetID{ID: 20, AccessHash: 200}}, + Uninstall: true, + }); err != nil || !ok { + t.Fatalf("toggle uninstall = %v %v", ok, err) + } + if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != 10 { + t.Fatalf("installed after toggle uninstall = %v, want [10]", got) + } + assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil) +} + +func TestMessagesToggleEmojiStickerSetsUsesEmojiUpdateFlag(t *testing.T) { + r, store, sessions := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + if ok, err := r.onMessagesToggleStickerSets(ctx, &tg.MessagesToggleStickerSetsRequest{ + Stickersets: []tg.InputStickerSetClass{&tg.InputStickerSetShortName{ShortName: "emoji_fun"}}, + }); err != nil || !ok { + t.Fatalf("toggle emoji install = %v %v", ok, err) + } + if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindEmoji, nil); len(got) != 1 || got[0] != 30 { + t.Fatalf("emoji installed sets = %v, want [30]", got) + } + assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindEmoji, nil) +} + +func TestMessagesGetMyStickersEmptyUntilCreatorStore(t *testing.T) { + r, _, _ := userStickerSetRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + out, err := r.onMessagesGetMyStickers(ctx, &tg.MessagesGetMyStickersRequest{Limit: 50}) + if err != nil { + t.Fatalf("get my stickers: %v", err) + } + if out.Count != 0 || len(out.Sets) != 0 { + t.Fatalf("get my stickers = count %d sets %d, want empty creator-owned page", out.Count, len(out.Sets)) + } + out, err = r.onMessagesGetMyStickers(ctx, &tg.MessagesGetMyStickersRequest{Limit: domain.MaxInstalledStickerSets + 1}) + if out != nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("get my stickers over limit = %T %v, want LIMIT_INVALID", out, err) + } +} + +func installedStickerSetIDs(t *testing.T, store *memory.PasswordStore, ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool) []int64 { + t.Helper() + items, _, err := store.ListUserStickerSets(ctx, userID, kind, archived, 0, 10) + if err != nil { + t.Fatalf("list installed sticker sets: %v", err) + } + out := make([]int64, 0, len(items)) + for _, item := range items { + out = append(out, item.StickerSetID) + } + return out +} + +func assertStickerSetsUpdate(t *testing.T, push any, kind domain.StickerSetKind, order []int64) { + t.Helper() + updates, ok := push.(*tg.Updates) + if !ok { + t.Fatalf("push = %T, want *tg.Updates", push) + } + if len(updates.Updates) != 1 { + t.Fatalf("push updates = %d, want 1", len(updates.Updates)) + } + if order != nil { + update, ok := updates.Updates[0].(*tg.UpdateStickerSetsOrder) + if !ok { + t.Fatalf("push update = %T, want *tg.UpdateStickerSetsOrder", updates.Updates[0]) + } + if len(update.Order) != len(order) { + t.Fatalf("order update = %v, want %v", update.Order, order) + } + for i := range order { + if update.Order[i] != order[i] { + t.Fatalf("order update = %v, want %v", update.Order, order) + } + } + if update.Masks != (kind == domain.StickerSetKindMasks) || update.Emojis != (kind == domain.StickerSetKindEmoji) { + t.Fatalf("order flags masks=%v emojis=%v for kind %s", update.Masks, update.Emojis, kind) + } + return + } + update, ok := updates.Updates[0].(*tg.UpdateStickerSets) + if !ok { + t.Fatalf("push update = %T, want *tg.UpdateStickerSets", updates.Updates[0]) + } + if update.Masks != (kind == domain.StickerSetKindMasks) || update.Emojis != (kind == domain.StickerSetKindEmoji) { + t.Fatalf("update flags masks=%v emojis=%v for kind %s", update.Masks, update.Emojis, kind) + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index de1c89a3..27996c8f 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -169,6 +169,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { r.registerUpdates(d) r.registerAccount(d) r.registerMessages(d) + r.registerStickers(d) r.registerChannels(d) r.registerUpload(d) r.registerPhotos(d) diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go index cd3ac292..9cb6fe4a 100644 --- a/internal/rpc/send_media_test.go +++ b/internal/rpc/send_media_test.go @@ -3,6 +3,8 @@ package rpc import ( "context" "errors" + "sort" + "strings" "testing" "github.com/gotd/td/clock" @@ -87,6 +89,9 @@ func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Docum func (f *fakeFiles) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { for _, sets := range f.sets { for _, set := range sets { + if set.Deleted { + continue + } match := false switch ref.Kind { case domain.StickerSetRefByID: @@ -114,6 +119,411 @@ func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKin sets := f.sets[kind] return append([]domain.StickerSet(nil), sets...), nil } +func (f *fakeFiles) CheckStickerSetShortName(_ context.Context, shortName string) (bool, error) { + if !validTestStickerShortName(shortName) { + return false, domain.ErrStickerSetShortNameInvalid + } + for _, sets := range f.sets { + for _, set := range sets { + if set.ShortName != "" && strings.EqualFold(set.ShortName, shortName) && !set.Deleted { + return false, nil + } + } + } + return true, nil +} +func validTestStickerShortName(shortName string) bool { + shortName = strings.ToLower(strings.TrimSpace(shortName)) + if len(shortName) < domain.MinStickerSetShortNameLen || len(shortName) > domain.MaxStickerSetShortNameLen { + return false + } + for i := 0; i < len(shortName); i++ { + ch := shortName[i] + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9' && i > 0) || (ch == '_' && i > 0 && i < len(shortName)-1) { + continue + } + return false + } + return true +} +func (f *fakeFiles) SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error) { + base := strings.ToLower(strings.TrimSpace(title)) + base = strings.ReplaceAll(base, " ", "_") + if base == "" { + base = "stickers" + } + if len(base) < domain.MinStickerSetShortNameLen { + base += "_pack" + } + if len(base) > domain.MaxStickerSetShortNameLen { + base = strings.Trim(base[:domain.MaxStickerSetShortNameLen], "_") + } + candidates := []string{base, base + "_pack", base + "_2"} + for _, c := range candidates { + if ok, err := f.CheckStickerSetShortName(ctx, c); err != nil { + continue + } else if ok { + return c, nil + } + } + return "", domain.ErrStickerSetShortNameOccupied +} +func (f *fakeFiles) CreateStickerSet(_ context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) { + if f.sets == nil { + f.sets = map[domain.StickerSetKind][]domain.StickerSet{} + } + if f.docs == nil { + f.docs = map[int64]domain.Document{} + } + if strings.TrimSpace(req.Title) == "" { + return domain.StickerSet{}, nil, domain.ErrStickerSetTitleInvalid + } + if len(req.Items) == 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty + } + shortName := strings.ToLower(strings.TrimSpace(req.ShortName)) + if shortName == "" { + shortName = "created_pack" + } + if ok, err := f.CheckStickerSetShortName(context.Background(), shortName); err != nil { + return domain.StickerSet{}, nil, err + } else if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied + } + kind := req.Kind + if kind == "" { + kind = domain.StickerSetKindStickers + } + docIDs := make([]int64, 0, len(req.Items)) + packs := []domain.StickerPack{} + keywords := []domain.StickerKeyword{} + docs := make([]domain.Document, 0, len(req.Items)) + for _, item := range req.Items { + doc, ok := f.docs[item.DocumentID] + if !ok || doc.AccessHash != item.DocumentAccessHash || !doc.IsStickerSetMaterial() { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + if strings.TrimSpace(item.Emoji) == "" { + return domain.StickerSet{}, nil, domain.ErrStickerSetEmojiInvalid + } + docIDs = append(docIDs, item.DocumentID) + packs = append(packs, domain.StickerPack{Emoticon: item.Emoji, DocumentIDs: []int64{item.DocumentID}}) + if item.Keywords != "" { + keywords = append(keywords, domain.StickerKeyword{DocumentID: item.DocumentID, Keywords: []string{strings.TrimSpace(item.Keywords)}}) + } + doc.Attributes = []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: item.Emoji, StickerSetID: 9000, StickerSetAccessHash: 9001}} + if kind == domain.StickerSetKindEmoji { + doc.Attributes[0].Kind = domain.DocAttrCustomEmoji + doc.Attributes[0].TextColor = req.TextColor + } + f.docs[item.DocumentID] = doc + docs = append(docs, doc) + } + set := domain.StickerSet{ + ID: 9000 + int64(len(f.sets[kind])), + AccessHash: 9001 + int64(len(f.sets[kind])), + ShortName: shortName, + Title: req.Title, + Kind: kind, + Emojis: kind == domain.StickerSetKindEmoji, + Masks: kind == domain.StickerSetKindMasks, + TextColor: kind == domain.StickerSetKindEmoji && req.TextColor, + Creator: true, + CreatorUserID: req.CreatorUserID, + Count: len(docIDs), + Hash: 77 + len(f.sets[kind]), + DocumentIDs: docIDs, + Packs: packs, + Keywords: keywords, + } + f.sets[kind] = append(f.sets[kind], set) + return set, docs, nil +} +func (f *fakeFiles) ListCreatedStickerSets(_ context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) { + var all []domain.StickerSet + for _, sets := range f.sets { + for _, set := range sets { + if set.CreatorUserID == userID && !set.Deleted { + set.Creator = true + all = append(all, set) + } + } + } + sort.Slice(all, func(i, j int) bool { return all[i].ID > all[j].ID }) + total := len(all) + if offsetID != 0 { + filtered := all[:0] + for _, set := range all { + if set.ID < offsetID { + filtered = append(filtered, set) + } + } + all = filtered + } + if limit > 0 && len(all) > limit { + all = all[:limit] + } + return all, total, nil +} +func (f *fakeFiles) AddStickerToSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) { + kind, idx, ok := f.fakeStickerSetIndex(ref) + if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + set := f.sets[kind][idx] + if set.CreatorUserID != actorUserID { + return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned + } + doc, ok := f.docs[item.DocumentID] + if !ok || doc.AccessHash != item.DocumentAccessHash || !doc.IsStickerSetMaterial() { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + if setID, _, ok := doc.StickerSetRef(); ok && setID != 0 && setID != set.ID { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + if fakeContainsInt64(set.DocumentIDs, doc.ID) { + return set, f.fakeStickerSetDocs(set), nil + } + emoji := strings.TrimSpace(item.Emoji) + if emoji == "" { + return domain.StickerSet{}, nil, domain.ErrStickerSetEmojiInvalid + } + doc = fakeAttachStickerSet(doc, set, emoji) + f.docs[doc.ID] = doc + set.DocumentIDs = append(set.DocumentIDs, doc.ID) + set.Count = len(set.DocumentIDs) + set.Packs = fakeAddStickerPackDoc(set.Packs, emoji, doc.ID) + if kw := strings.TrimSpace(item.Keywords); kw != "" { + set.Keywords = fakeUpsertStickerKeyword(set.Keywords, domain.StickerKeyword{DocumentID: doc.ID, Keywords: []string{kw}}) + } + set.Hash++ + f.sets[kind][idx] = set + return set, f.fakeStickerSetDocs(set), nil +} +func (f *fakeFiles) RemoveStickerFromSet(_ context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) { + doc, ok := f.docs[documentID] + if !ok || doc.AccessHash != accessHash || !doc.IsStickerLike() { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + setID, setAccessHash, ok := doc.StickerSetRef() + if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + kind, idx, ok := f.fakeStickerSetIndex(domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash}) + if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + set := f.sets[kind][idx] + if set.CreatorUserID != actorUserID { + return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned + } + pos := fakeIndexInt64(set.DocumentIDs, documentID) + if pos < 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + set.DocumentIDs = append(append([]int64(nil), set.DocumentIDs[:pos]...), set.DocumentIDs[pos+1:]...) + set.Count = len(set.DocumentIDs) + set.Packs = fakeRemoveStickerPackDoc(set.Packs, documentID) + set.Keywords = fakeRemoveStickerKeyword(set.Keywords, documentID) + set.Hash++ + doc = fakeDetachStickerSet(doc) + f.docs[doc.ID] = doc + f.sets[kind][idx] = set + return set, f.fakeStickerSetDocs(set), nil +} +func (f *fakeFiles) ChangeStickerPosition(_ context.Context, actorUserID int64, documentID int64, accessHash int64, position int) (domain.StickerSet, []domain.Document, error) { + doc, ok := f.docs[documentID] + if !ok || doc.AccessHash != accessHash || !doc.IsStickerLike() { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + setID, setAccessHash, ok := doc.StickerSetRef() + if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + kind, idx, ok := f.fakeStickerSetIndex(domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash}) + if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + set := f.sets[kind][idx] + if set.CreatorUserID != actorUserID { + return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned + } + from := fakeIndexInt64(set.DocumentIDs, documentID) + if from < 0 { + return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + } + if position < 0 || position >= len(set.DocumentIDs) { + return domain.StickerSet{}, nil, domain.ErrStickerSetPositionInvalid + } + set.DocumentIDs = fakeMoveInt64(set.DocumentIDs, from, position) + set.Hash++ + f.sets[kind][idx] = set + return set, f.fakeStickerSetDocs(set), nil +} +func (f *fakeFiles) RenameStickerSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef, title string) (domain.StickerSet, []domain.Document, error) { + kind, idx, ok := f.fakeStickerSetIndex(ref) + if !ok { + return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid + } + set := f.sets[kind][idx] + if set.CreatorUserID != actorUserID { + return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned + } + title = strings.TrimSpace(title) + if title == "" { + return domain.StickerSet{}, nil, domain.ErrStickerSetTitleInvalid + } + set.Title = title + set.Hash++ + f.sets[kind][idx] = set + return set, f.fakeStickerSetDocs(set), nil +} +func (f *fakeFiles) DeleteStickerSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSetKind, error) { + kind, idx, ok := f.fakeStickerSetIndex(ref) + if !ok { + return "", domain.ErrStickerSetInvalid + } + set := f.sets[kind][idx] + if set.CreatorUserID != actorUserID { + return "", domain.ErrStickerSetNotOwned + } + set.Deleted = true + f.sets[kind][idx] = set + return kind, nil +} +func (f *fakeFiles) fakeStickerSetIndex(ref domain.StickerSetRef) (domain.StickerSetKind, int, bool) { + for kind, sets := range f.sets { + for idx, set := range sets { + if set.Deleted { + continue + } + switch ref.Kind { + case domain.StickerSetRefByID: + if set.ID == ref.ID && (ref.AccessHash == 0 || set.AccessHash == ref.AccessHash) { + return kind, idx, true + } + case domain.StickerSetRefByShortName: + if strings.EqualFold(set.ShortName, ref.ShortName) { + return kind, idx, true + } + case domain.StickerSetRefBySystem: + if set.SystemKey == ref.SystemKey { + return kind, idx, true + } + } + } + } + return "", 0, false +} +func (f *fakeFiles) fakeStickerSetDocs(set domain.StickerSet) []domain.Document { + out := make([]domain.Document, 0, len(set.DocumentIDs)) + for _, id := range set.DocumentIDs { + if doc, ok := f.docs[id]; ok { + out = append(out, doc) + } + } + return out +} +func fakeAttachStickerSet(doc domain.Document, set domain.StickerSet, emoji string) domain.Document { + want := domain.DocAttrSticker + if set.Kind == domain.StickerSetKindEmoji || set.Emojis { + want = domain.DocAttrCustomEmoji + } + attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...) + replaced := false + for i := range attrs { + if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji { + continue + } + attrs[i].Kind = want + attrs[i].Alt = emoji + attrs[i].StickerSetID = set.ID + attrs[i].StickerSetAccessHash = set.AccessHash + attrs[i].TextColor = set.TextColor + replaced = true + break + } + if !replaced { + attrs = append(attrs, domain.DocumentAttribute{Kind: want, Alt: emoji, StickerSetID: set.ID, StickerSetAccessHash: set.AccessHash, TextColor: set.TextColor}) + } + doc.Attributes = attrs + return doc +} +func fakeDetachStickerSet(doc domain.Document) domain.Document { + attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...) + for i := range attrs { + if attrs[i].Kind == domain.DocAttrSticker || attrs[i].Kind == domain.DocAttrCustomEmoji { + attrs[i].StickerSetID = 0 + attrs[i].StickerSetAccessHash = 0 + attrs[i].TextColor = false + break + } + } + doc.Attributes = attrs + return doc +} +func fakeAddStickerPackDoc(packs []domain.StickerPack, emoji string, documentID int64) []domain.StickerPack { + out := append([]domain.StickerPack(nil), packs...) + for i := range out { + out[i].DocumentIDs = append([]int64(nil), out[i].DocumentIDs...) + if out[i].Emoticon == emoji { + if !fakeContainsInt64(out[i].DocumentIDs, documentID) { + out[i].DocumentIDs = append(out[i].DocumentIDs, documentID) + } + return out + } + } + return append(out, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{documentID}}) +} +func fakeRemoveStickerPackDoc(packs []domain.StickerPack, documentID int64) []domain.StickerPack { + out := make([]domain.StickerPack, 0, len(packs)) + for _, pack := range packs { + ids := make([]int64, 0, len(pack.DocumentIDs)) + for _, id := range pack.DocumentIDs { + if id != documentID { + ids = append(ids, id) + } + } + if len(ids) != 0 { + out = append(out, domain.StickerPack{Emoticon: pack.Emoticon, DocumentIDs: ids}) + } + } + return out +} +func fakeUpsertStickerKeyword(in []domain.StickerKeyword, keyword domain.StickerKeyword) []domain.StickerKeyword { + out := fakeRemoveStickerKeyword(in, keyword.DocumentID) + return append(out, keyword) +} +func fakeRemoveStickerKeyword(in []domain.StickerKeyword, documentID int64) []domain.StickerKeyword { + out := make([]domain.StickerKeyword, 0, len(in)) + for _, kw := range in { + if kw.DocumentID != documentID { + out = append(out, kw) + } + } + return out +} +func fakeContainsInt64(in []int64, value int64) bool { + return fakeIndexInt64(in, value) >= 0 +} +func fakeIndexInt64(in []int64, value int64) int { + for i, v := range in { + if v == value { + return i + } + } + return -1 +} +func fakeMoveInt64(in []int64, from, to int) []int64 { + out := append([]int64(nil), in...) + value := out[from] + out = append(out[:from], out[from+1:]...) + if to >= len(out) { + return append(out, value) + } + out = append(out[:to], append([]int64{value}, out[to:]...)...) + return out +} func (f *fakeFiles) CreatePhotoFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) { photo := domain.Photo{ID: 777, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}} return f.putPhoto(photo), nil diff --git a/internal/rpc/sticker_catalog_cache.go b/internal/rpc/sticker_catalog_cache.go index ed15b149..c9d8299f 100644 --- a/internal/rpc/sticker_catalog_cache.go +++ b/internal/rpc/sticker_catalog_cache.go @@ -52,3 +52,10 @@ func (r *Router) stickerCatalogSets(ctx context.Context, kind domain.StickerSetK } return sets } + +func (r *Router) invalidateStickerCatalog(kind domain.StickerSetKind) { + if r.stickerCatalog == nil || r.stickerCatalog.cache == nil { + return + } + r.stickerCatalog.cache.Invalidate(kind) +} diff --git a/internal/rpc/sticker_link_smoke_test.go b/internal/rpc/sticker_link_smoke_test.go new file mode 100644 index 00000000..898bc194 --- /dev/null +++ b/internal/rpc/sticker_link_smoke_test.go @@ -0,0 +1,330 @@ +package rpc + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + botsapp "telesrv/internal/app/bots" + appmessages "telesrv/internal/app/messages" + apppolls "telesrv/internal/app/polls" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "telesrv/internal/web/stickerlinks" +) + +func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550009002", FirstName: "Bob"}) + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + pollStore := memory.NewPollStore() + messageStore.AttachPollStore(pollStore) + passwordStore := memory.NewPasswordStore() + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 101: { + ID: 101, + AccessHash: 1101, + DCID: 2, + MimeType: "image/webp", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}, + }, + }, + photos: map[int64]domain.Photo{}, + sets: map[domain.StickerSetKind][]domain.StickerSet{}, + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Account: appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)), + Users: appusers.NewService(userStore), + Messages: appmessages.NewService(messageStore, dialogStore), + Files: files, + Polls: apppolls.NewService(pollStore), + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onStickersCreateStickerSet(WithUserID(ctx, alice.ID), &tg.StickersCreateStickerSetRequest{ + UserID: &tg.InputUserSelf{}, + Title: "Alice Fresh Pack", + ShortName: "alice_fresh_pack", + Stickers: []tg.InputStickerSetItem{{ + Document: &tg.InputDocument{ID: 101, AccessHash: 1101}, + Emoji: "🙂", + Keywords: "fresh", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + createdFull, ok := created.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("created = %T, want *tg.MessagesStickerSet", created) + } + + web := stickerlinks.NewHandler(files, "https://telesrv.net") + rr := httptest.NewRecorder() + web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_fresh_pack", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("sticker link status = %d body=%q, want 200", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"https://telesrv.net/addstickers/alice_fresh_pack", "telesrv://addstickers?set=alice_fresh_pack", "tg://addstickers?set=alice_fresh_pack"} { + if !strings.Contains(body, want) { + t.Fatalf("sticker link body missing %q:\n%s", want, body) + } + } + if strings.Contains(body, `window.location.href = "tg://`) { + t.Fatalf("sticker link must auto-open telesrv://, not tg://:\n%s", body) + } + + preview, err := r.onMessagesGetStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_fresh_pack"}, + Hash: 0, + }) + if err != nil { + t.Fatalf("bob preview sticker set: %v", err) + } + previewFull, ok := preview.(*tg.MessagesStickerSet) + if !ok || previewFull.Set.ID != createdFull.Set.ID || len(previewFull.Documents) != 1 { + t.Fatalf("preview = %T %+v, want created set with one document", preview, preview) + } + + if _, err := r.onMessagesInstallStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesInstallStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_fresh_pack"}, + }); err != nil { + t.Fatalf("bob install sticker set: %v", err) + } + if got := installedStickerSetIDs(t, passwordStore, ctx, bob.ID, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != createdFull.Set.ID { + t.Fatalf("bob installed sets = %v, want [%d]", got, createdFull.Set.ID) + } + + if _, err := r.onMessagesSendMedia(WithUserID(ctx, bob.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 101, AccessHash: 1101}}, + RandomID: 7001, + }); err != nil { + t.Fatalf("bob send sticker: %v", err) + } + + historyReq := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Limit: 10, + } + var raw bin.Buffer + if err := historyReq.Encode(&raw); err != nil { + t.Fatalf("encode history request: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, alice.ID), [8]byte{}, 0, &raw) + if err != nil { + t.Fatalf("alice get history: %v", err) + } + box, ok := enc.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("history response = %T, want *tg.MessagesMessagesBox", enc) + } + messages, ok := box.Messages.(*tg.MessagesMessages) + if !ok { + t.Fatalf("history payload = %T, want *tg.MessagesMessages", box.Messages) + } + if len(messages.Messages) != 1 { + t.Fatalf("history messages = %d, want 1", len(messages.Messages)) + } + msg, ok := messages.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("history message = %T, want *tg.Message", messages.Messages[0]) + } + media, ok := msg.Media.(*tg.MessageMediaDocument) + if !ok { + t.Fatalf("history media = %T, want *tg.MessageMediaDocument", msg.Media) + } + if got := tgDocumentID(media.Document); got != 101 { + t.Fatalf("history document id = %d, want 101", got) + } +} + +func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550009101", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009102", FirstName: "Bob"}) + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + pollStore := memory.NewPollStore() + messageStore.AttachPollStore(pollStore) + passwordStore := memory.NewPasswordStore() + accountService := appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)) + botStore := memory.NewBotStore(userStore) + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 401: { + ID: 401, + AccessHash: 4401, + DCID: 2, + MimeType: "image/webp", + Size: 4096, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "alice.webp"}}, + }, + }, + photos: map[int64]domain.Photo{}, + sets: map[domain.StickerSetKind][]domain.StickerSet{}, + } + botsService := botsapp.NewService(userStore, botStore, messageStore, + botsapp.WithStickerSetCreator(files), + botsapp.WithUserStickerSets(accountService)) + messagesService := appmessages.NewService(messageStore, dialogStore, + appmessages.WithBotResponder(botsService)) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Account: accountService, + Users: appusers.NewService(userStore), + Messages: messagesService, + Files: files, + Polls: apppolls.NewService(pollStore), + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + botsService.SetRouterHooks(r) + + sendStickersBotText(t, r, alice, "/newpack", 9101) + waitForStickersReply(t, messageStore, alice.ID, "sticker pack") + sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102) + waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON") + sendStickersBotDocument(t, r, alice, 401, 4401, 9103) + waitForStickersReply(t, messageStore, alice.ID, "emoji") + sendStickersBotText(t, r, alice, "🙂", 9104) + waitForStickersReply(t, messageStore, alice.ID, "Added") + sendStickersBotText(t, r, alice, "/publish", 9105) + waitForStickersReply(t, messageStore, alice.ID, "short name") + sendStickersBotText(t, r, alice, "alice_bot_pack", 9106) + waitForStickersReply(t, messageStore, alice.ID, "https://telesrv.net/addstickers/alice_bot_pack") + + created := files.sets[domain.StickerSetKindStickers] + if len(created) != 1 || created[0].ShortName != "alice_bot_pack" || created[0].CreatorUserID != alice.ID { + t.Fatalf("created sets = %+v, want Alice alice_bot_pack", created) + } + setID := created[0].ID + if got := installedStickerSetIDs(t, passwordStore, ctx, alice.ID, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != setID { + t.Fatalf("alice installed sets = %v, want [%d]", got, setID) + } + if got := installedStickerSetIDs(t, passwordStore, ctx, bob.ID, domain.StickerSetKindStickers, nil); len(got) != 0 { + t.Fatalf("bob installed sets before link = %v, want empty", got) + } + if got := allStickerSetIDs(t, r, WithUserID(ctx, bob.ID), domain.StickerSetKindStickers); len(got) != 0 { + t.Fatalf("bob getAllStickers before install = %v, want empty", got) + } + + web := stickerlinks.NewHandler(files, "https://telesrv.net") + rr := httptest.NewRecorder() + web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_bot_pack", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "https://telesrv.net/addstickers/alice_bot_pack") { + t.Fatalf("sticker bot link response = %d %q", rr.Code, rr.Body.String()) + } + preview, err := r.onMessagesGetStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_bot_pack"}, + }) + if err != nil { + t.Fatalf("bob preview bot-created sticker set: %v", err) + } + previewFull, ok := preview.(*tg.MessagesStickerSet) + if !ok || previewFull.Set.ID != setID || previewFull.Set.InstalledDate != 0 { + t.Fatalf("bob preview = %T %+v, want uninstalled created set", preview, preview) + } + if _, err := r.onMessagesInstallStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesInstallStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_bot_pack"}, + }); err != nil { + t.Fatalf("bob install bot-created sticker set: %v", err) + } + if got := installedStickerSetIDs(t, passwordStore, ctx, bob.ID, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != setID { + t.Fatalf("bob installed sets after link = %v, want [%d]", got, setID) + } + if got := allStickerSetIDs(t, r, WithUserID(ctx, alice.ID), domain.StickerSetKindStickers); len(got) != 1 || got[0] != setID { + t.Fatalf("alice getAllStickers = %v, want [%d]", got, setID) + } + if got := allStickerSetIDs(t, r, WithUserID(ctx, bob.ID), domain.StickerSetKindStickers); len(got) != 1 || got[0] != setID { + t.Fatalf("bob getAllStickers after install = %v, want [%d]", got, setID) + } + if got := allStickerSetIDs(t, r, WithUserID(ctx, bob.ID), domain.StickerSetKindEmoji); len(got) != 0 { + t.Fatalf("bob getEmojiStickers = %v, want empty", got) + } +} + +func sendStickersBotText(t *testing.T, r *Router, user domain.User, text string, randomID int64) { + t.Helper() + if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), user.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: domain.StickersBotUserID, AccessHash: domain.StickersBotAccessHash}, + Message: text, + RandomID: randomID, + }); err != nil { + t.Fatalf("send @Stickers text %q: %v", text, err) + } +} + +func sendStickersBotDocument(t *testing.T, r *Router, user domain.User, docID, accessHash, randomID int64) { + t.Helper() + if _, err := r.onMessagesSendMedia(WithUserID(context.Background(), user.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: domain.StickersBotUserID, AccessHash: domain.StickersBotAccessHash}, + Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: docID, AccessHash: accessHash}}, + RandomID: randomID, + }); err != nil { + t.Fatalf("send @Stickers document %d: %v", docID, err) + } +} + +func waitForStickersReply(t *testing.T, messages *memory.MessageStore, userID int64, want string) string { + t.Helper() + deadline := time.Now().Add(time.Second) + for { + list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID}, + Limit: 100, + }) + if err != nil { + t.Fatalf("list @Stickers history: %v", err) + } + for _, msg := range list.Messages { + if msg.From.ID == domain.StickersBotUserID && strings.Contains(msg.Body, want) { + return msg.Body + } + } + if time.Now().After(deadline) { + t.Fatalf("no @Stickers reply containing %q; history=%+v", want, list.Messages) + } + time.Sleep(5 * time.Millisecond) + } +} + +func allStickerSetIDs(t *testing.T, r *Router, ctx context.Context, kind domain.StickerSetKind) []int64 { + t.Helper() + var ( + out tg.MessagesAllStickersClass + err error + ) + if kind == domain.StickerSetKindEmoji { + out, err = r.onMessagesGetEmojiStickers(ctx, 0) + } else { + out, err = r.onMessagesGetAllStickers(ctx, 0) + } + if err != nil { + t.Fatalf("get sticker sets for kind %s: %v", kind, err) + } + full, ok := out.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("get sticker sets for kind %s = %T, want *tg.MessagesAllStickers", kind, out) + } + ids := make([]int64, 0, len(full.Sets)) + for _, set := range full.Sets { + ids = append(ids, set.ID) + } + return ids +} diff --git a/internal/rpc/stickers.go b/internal/rpc/stickers.go index b022b35a..3a694750 100644 --- a/internal/rpc/stickers.go +++ b/internal/rpc/stickers.go @@ -90,6 +90,10 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe if fallbackSet, fallbackDocs, fallbackFound, fallbackErr := r.resolvePlaceholderStickerSet(ctx, ref); fallbackErr != nil { return nil, internalErr() } else if fallbackFound { + fallbackSet, fallbackErr = r.stickerSetWithViewerInstallState(ctx, fallbackSet) + if fallbackErr != nil { + return nil, fallbackErr + } if r.log != nil { r.log.Debug("getStickerSet placeholder fallback", zap.String("short_name", ref.ShortName), @@ -109,6 +113,10 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe if req.Hash != 0 && req.Hash == set.Hash { return &tg.MessagesStickerSetNotModified{}, nil } + set, err = r.stickerSetWithViewerInstallState(ctx, set) + if err != nil { + return nil, err + } return tgMessagesStickerSet(set, docs), nil } @@ -191,8 +199,14 @@ func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain if r.deps.Files == nil { return messagesAllStickersEmpty(hash), nil } - // perf:从目录缓存读集(TTL 内 hash 命中不打 PG)。 - sets := r.stickerCatalogSets(ctx, kind) + sets, handled, err := r.installedStickerSetsForViewer(ctx, kind) + if err != nil { + return nil, err + } + if !handled { + // 兼容无 per-user 安装态的测试/旧内存路径:从目录缓存读全局 installed 标志。 + sets = installedGlobalStickerSets(r.stickerCatalogSets(ctx, kind)) + } if len(sets) == 0 { return messagesAllStickersEmpty(hash), nil } @@ -203,6 +217,107 @@ func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain return &tg.MessagesAllStickers{Hash: catalogHash, Sets: tgStickerSets(sets)}, nil } +func (r *Router) installedStickerSetsForViewer(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, bool, error) { + svc, ok := r.userStickerSetSvc() + if !ok { + return nil, false, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID == 0 { + if err != nil { + return nil, true, internalErr() + } + return nil, true, nil + } + userSets, _, err := svc.ListUserStickerSets(ctx, userID, kind, nil, 0, domain.MaxInstalledStickerSets) + if err != nil { + return nil, true, internalErr() + } + out := make([]domain.StickerSet, 0, len(userSets)) + for _, item := range userSets { + if item.Archived || item.StickerSetID == 0 { + continue + } + set, _, found, err := r.deps.Files.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: item.StickerSetID}) + if err != nil { + return nil, true, internalErr() + } + if !found || set.ID == 0 || set.Deleted || userStickerSetKind(set) != kind { + continue + } + set = stickerSetWithoutViewerInstallState(set) + out = append(out, stickerSetWithViewerInstallItem(set, item)) + } + return out, true, nil +} + +func (r *Router) stickerSetsWithViewerInstallState(ctx context.Context, kind domain.StickerSetKind, sets []domain.StickerSet) ([]domain.StickerSet, error) { + out := make([]domain.StickerSet, 0, len(sets)) + byID := make(map[int64]int, len(sets)) + for _, set := range sets { + set = stickerSetWithoutViewerInstallState(set) + byID[set.ID] = len(out) + out = append(out, set) + } + svc, ok := r.userStickerSetSvc() + if !ok { + return out, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return out, nil + } + userSets, _, err := svc.ListUserStickerSets(ctx, userID, kind, nil, 0, domain.MaxInstalledStickerSets) + if err != nil { + return nil, internalErr() + } + for _, item := range userSets { + i, ok := byID[item.StickerSetID] + if !ok { + continue + } + out[i] = stickerSetWithViewerInstallItem(out[i], item) + } + return out, nil +} + +func (r *Router) stickerSetWithViewerInstallState(ctx context.Context, set domain.StickerSet) (domain.StickerSet, error) { + sets, err := r.stickerSetsWithViewerInstallState(ctx, userStickerSetKind(set), []domain.StickerSet{set}) + if err != nil { + return domain.StickerSet{}, err + } + if len(sets) == 0 { + return stickerSetWithoutViewerInstallState(set), nil + } + return sets[0], nil +} + +func stickerSetWithoutViewerInstallState(set domain.StickerSet) domain.StickerSet { + set.Installed = false + set.InstalledDate = 0 + return set +} + +func stickerSetWithViewerInstallItem(set domain.StickerSet, item domain.UserStickerSet) domain.StickerSet { + set.Installed = true + set.Archived = item.Archived + set.InstalledDate = item.InstalledDate + return set +} + +func installedGlobalStickerSets(sets []domain.StickerSet) []domain.StickerSet { + out := make([]domain.StickerSet, 0, len(sets)) + for _, set := range sets { + if set.Installed && !set.Archived { + out = append(out, set) + } + } + return out +} + // featuredCoversPerSet 限制每个 featured 集解析的封面贴纸数量(trending 预览用)。 const featuredCoversPerSet = 5 @@ -233,7 +348,12 @@ func (r *Router) featuredStickersForKind(ctx context.Context, hash int64, kind d if len(visible) == 0 { return messagesFeaturedStickersEmpty(hash), nil } - catalogHash := stickerSetsCatalogHash(visible) + var err error + visible, err = r.stickerSetsWithViewerInstallState(ctx, kind, visible) + if err != nil { + return nil, err + } + catalogHash := featuredStickerSetsHash(visible) if hash != 0 && hash == catalogHash { // 关键 perf 短路:目录未变直接返回,不解析任何封面文档。 return &tg.MessagesFeaturedStickersNotModified{Count: len(visible)}, nil @@ -333,6 +453,20 @@ func stickerSetsCatalogHash(sets []domain.StickerSet) int64 { return int64(tdesktopCountHash(values)) } +func featuredStickerSetsHash(sets []domain.StickerSet) int64 { + values := make([]int64, 0, len(sets)) + for _, set := range sets { + if set.ID == 0 { + return 0 + } + if set.Archived { + continue + } + values = append(values, set.ID) + } + return int64(tdesktopCountHash(values)) +} + func boolHashValue(v bool) int64 { if v { return 1 diff --git a/internal/rpc/stickers_creator.go b/internal/rpc/stickers_creator.go new file mode 100644 index 00000000..9a25ffb4 --- /dev/null +++ b/internal/rpc/stickers_creator.go @@ -0,0 +1,351 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) registerStickers(d *tg.ServerDispatcher) { + d.OnStickersCreateStickerSet(r.onStickersCreateStickerSet) + d.OnStickersCheckShortName(r.onStickersCheckShortName) + d.OnStickersSuggestShortName(r.onStickersSuggestShortName) + d.OnStickersAddStickerToSet(r.onStickersAddStickerToSet) + d.OnStickersRemoveStickerFromSet(r.onStickersRemoveStickerFromSet) + d.OnStickersChangeStickerPosition(r.onStickersChangeStickerPosition) + d.OnStickersRenameStickerSet(r.onStickersRenameStickerSet) + d.OnStickersDeleteStickerSet(r.onStickersDeleteStickerSet) +} + +func (r *Router) onStickersCreateStickerSet(ctx context.Context, req *tg.StickersCreateStickerSetRequest) (tg.MessagesStickerSetClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Files == nil { + return nil, internalErr() + } + if req.Masks && req.Emojis { + return nil, packTypeInvalidErr() + } + userID, err := r.stickerSetCreatorUserID(ctx, req.UserID) + if err != nil { + return nil, err + } + items := make([]domain.StickerSetItemInput, 0, len(req.Stickers)) + for _, item := range req.Stickers { + id, accessHash, ok := inputDocumentRef(item.Document) + if !ok { + return nil, stickerFileInvalidErr() + } + items = append(items, domain.StickerSetItemInput{ + DocumentID: id, + DocumentAccessHash: accessHash, + Emoji: item.Emoji, + Keywords: item.Keywords, + }) + } + thumbID, thumbAccessHash, ok := inputDocumentRef(req.Thumb) + if req.Thumb != nil && !ok { + return nil, stickerFileInvalidErr() + } + kind := domain.StickerSetKindStickers + if req.Emojis { + kind = domain.StickerSetKindEmoji + } else if req.Masks { + kind = domain.StickerSetKindMasks + } + set, docs, err := r.deps.Files.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: userID, + Title: req.Title, + ShortName: req.ShortName, + Kind: kind, + TextColor: req.TextColor, + ThumbDocumentID: thumbID, + ThumbAccessHash: thumbAccessHash, + Items: items, + Software: req.Software, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, stickerSetCreateErr(err) + } + if svc, ok := r.userStickerSetSvc(); ok { + if err := svc.InstallUserStickerSet(ctx, userID, set.ID, userStickerSetKind(set), false, int(r.clock.Now().Unix())); err != nil { + return nil, internalErr() + } + set.Installed = true + set.InstalledDate = int(r.clock.Now().Unix()) + } + r.invalidateStickerCatalog(userStickerSetKind(set)) + r.pushStickerSetsUpdate(ctx, userID, userStickerSetKind(set)) + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onStickersCheckShortName(ctx context.Context, shortName string) (bool, error) { + if r.deps.Files == nil { + return false, internalErr() + } + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + available, err := r.deps.Files.CheckStickerSetShortName(ctx, shortName) + if err != nil { + return false, stickerSetShortNameCheckErr(err) + } + return available, nil +} + +func (r *Router) onStickersSuggestShortName(ctx context.Context, title string) (*tg.StickersSuggestedShortName, error) { + if r.deps.Files == nil { + return nil, internalErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + shortName, err := r.deps.Files.SuggestStickerSetShortName(ctx, title, userID) + if err != nil { + return nil, stickerSetSuggestShortNameErr(err) + } + return &tg.StickersSuggestedShortName{ShortName: shortName}, nil +} + +func (r *Router) onStickersAddStickerToSet(ctx context.Context, req *tg.StickersAddStickerToSetRequest) (tg.MessagesStickerSetClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, err := r.stickerSetActorUserID(ctx) + if err != nil { + return nil, err + } + ref, ok := stickerSetRefFromInput(req.Stickerset) + if !ok { + return nil, stickersetInvalidErr() + } + documentID, accessHash, ok := inputDocumentRef(req.Sticker.Document) + if !ok { + return nil, stickerFileInvalidErr() + } + set, docs, err := r.deps.Files.AddStickerToSet(ctx, userID, ref, domain.StickerSetItemInput{ + DocumentID: documentID, + DocumentAccessHash: accessHash, + Emoji: req.Sticker.Emoji, + Keywords: req.Sticker.Keywords, + }) + if err != nil { + return nil, stickerSetManagementErr(err) + } + r.notifyStickerSetMutated(ctx, userID, set) + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onStickersRemoveStickerFromSet(ctx context.Context, input tg.InputDocumentClass) (tg.MessagesStickerSetClass, error) { + userID, err := r.stickerSetActorUserID(ctx) + if err != nil { + return nil, err + } + documentID, accessHash, ok := inputDocumentRef(input) + if !ok { + return nil, stickerFileInvalidErr() + } + set, docs, err := r.deps.Files.RemoveStickerFromSet(ctx, userID, documentID, accessHash) + if err != nil { + return nil, stickerSetManagementErr(err) + } + r.notifyStickerSetMutated(ctx, userID, set) + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onStickersChangeStickerPosition(ctx context.Context, req *tg.StickersChangeStickerPositionRequest) (tg.MessagesStickerSetClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, err := r.stickerSetActorUserID(ctx) + if err != nil { + return nil, err + } + documentID, accessHash, ok := inputDocumentRef(req.Sticker) + if !ok { + return nil, stickerFileInvalidErr() + } + set, docs, err := r.deps.Files.ChangeStickerPosition(ctx, userID, documentID, accessHash, req.Position) + if err != nil { + return nil, stickerSetManagementErr(err) + } + r.notifyStickerSetMutated(ctx, userID, set) + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onStickersRenameStickerSet(ctx context.Context, req *tg.StickersRenameStickerSetRequest) (tg.MessagesStickerSetClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, err := r.stickerSetActorUserID(ctx) + if err != nil { + return nil, err + } + ref, ok := stickerSetRefFromInput(req.Stickerset) + if !ok { + return nil, stickersetInvalidErr() + } + set, docs, err := r.deps.Files.RenameStickerSet(ctx, userID, ref, req.Title) + if err != nil { + return nil, stickerSetManagementErr(err) + } + r.notifyStickerSetMutated(ctx, userID, set) + return tgMessagesStickerSet(set, docs), nil +} + +func (r *Router) onStickersDeleteStickerSet(ctx context.Context, input tg.InputStickerSetClass) (bool, error) { + userID, err := r.stickerSetActorUserID(ctx) + if err != nil { + return false, err + } + ref, ok := stickerSetRefFromInput(input) + if !ok { + return false, stickersetInvalidErr() + } + kind, err := r.deps.Files.DeleteStickerSet(ctx, userID, ref) + if err != nil { + return false, stickerSetManagementErr(err) + } + r.invalidateStickerCatalog(kind) + r.pushStickerSetsUpdate(ctx, userID, kind) + return true, nil +} + +func (r *Router) stickerSetActorUserID(ctx context.Context) (int64, error) { + if r.deps.Files == nil { + return 0, internalErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, internalErr() + } + return userID, nil +} + +func (r *Router) notifyStickerSetMutated(ctx context.Context, userID int64, set domain.StickerSet) { + kind := userStickerSetKind(set) + r.invalidateStickerCatalog(kind) + r.pushStickerSetsUpdate(ctx, userID, kind) +} + +func (r *Router) stickerSetCreatorUserID(ctx context.Context, input tg.InputUserClass) (int64, error) { + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, internalErr() + } + if r.deps.Users == nil { + switch v := input.(type) { + case *tg.InputUserSelf: + return currentUserID, nil + case *tg.InputUser: + if v != nil && v.UserID == currentUserID { + return currentUserID, nil + } + } + return 0, userIDInvalidErr() + } + user, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return 0, internalErr() + } + if !found || user.ID != currentUserID { + return 0, userIDInvalidErr() + } + return currentUserID, nil +} + +func inputDocumentRef(input tg.InputDocumentClass) (int64, int64, bool) { + doc, ok := input.(*tg.InputDocument) + if !ok || doc == nil || doc.ID == 0 || doc.AccessHash == 0 { + return 0, 0, false + } + return doc.ID, doc.AccessHash, true +} + +func packShortNameInvalidErr() error { return tgerr400("PACK_SHORT_NAME_INVALID") } +func packShortNameOccupiedErr() error { return tgerr400("PACK_SHORT_NAME_OCCUPIED") } +func packTitleInvalidErr() error { return tgerr400("PACK_TITLE_INVALID") } +func packTypeInvalidErr() error { return tgerr400("PACK_TYPE_INVALID") } +func stickersEmptyErr() error { return tgerr400("STICKERS_EMPTY") } +func stickersTooMuchErr() error { return tgerr400("STICKERS_TOO_MUCH") } +func stickerEmojiInvalidErr() error { return tgerr400("STICKER_EMOJI_INVALID") } +func stickerFileInvalidErr() error { return tgerr400("STICKER_FILE_INVALID") } +func shortNameInvalidErr() error { return tgerr400("SHORT_NAME_INVALID") } +func titleInvalidErr() error { return tgerr400("TITLE_INVALID") } +func positionInvalidErr() error { return tgerr400("POSITION_INVALID") } + +func stickerSetCreateErr(err error) error { + switch { + case errors.Is(err, domain.ErrStickerSetTitleInvalid): + return packTitleInvalidErr() + case errors.Is(err, domain.ErrStickerSetShortNameInvalid): + return packShortNameInvalidErr() + case errors.Is(err, domain.ErrStickerSetShortNameOccupied): + return packShortNameOccupiedErr() + case errors.Is(err, domain.ErrStickerSetTypeInvalid): + return packTypeInvalidErr() + case errors.Is(err, domain.ErrStickerSetEmpty): + return stickersEmptyErr() + case errors.Is(err, domain.ErrStickerSetTooMuch): + return stickersTooMuchErr() + case errors.Is(err, domain.ErrStickerSetEmojiInvalid): + return stickerEmojiInvalidErr() + case errors.Is(err, domain.ErrStickerSetFileInvalid), errors.Is(err, domain.ErrDocumentInvalid): + return stickerFileInvalidErr() + case errors.Is(err, domain.ErrStickerSetCreatorInvalid): + return userIDInvalidErr() + default: + return internalErr() + } +} + +func stickerSetShortNameCheckErr(err error) error { + switch { + case errors.Is(err, domain.ErrStickerSetShortNameInvalid): + return shortNameInvalidErr() + default: + return internalErr() + } +} + +func stickerSetSuggestShortNameErr(err error) error { + switch { + case errors.Is(err, domain.ErrStickerSetTitleInvalid): + return titleInvalidErr() + case errors.Is(err, domain.ErrStickerSetShortNameOccupied): + return packShortNameOccupiedErr() + case errors.Is(err, domain.ErrStickerSetCreatorInvalid): + return userIDInvalidErr() + default: + return internalErr() + } +} + +func stickerSetManagementErr(err error) error { + switch { + case errors.Is(err, domain.ErrStickerSetTitleInvalid): + return packTitleInvalidErr() + case errors.Is(err, domain.ErrStickerSetEmpty): + return stickersEmptyErr() + case errors.Is(err, domain.ErrStickerSetTooMuch): + return stickersTooMuchErr() + case errors.Is(err, domain.ErrStickerSetEmojiInvalid): + return stickerEmojiInvalidErr() + case errors.Is(err, domain.ErrStickerSetFileInvalid), errors.Is(err, domain.ErrDocumentInvalid): + return stickerFileInvalidErr() + case errors.Is(err, domain.ErrStickerSetCreatorInvalid): + return userIDInvalidErr() + case errors.Is(err, domain.ErrStickerSetPositionInvalid): + return positionInvalidErr() + case errors.Is(err, domain.ErrStickerSetInvalid), errors.Is(err, domain.ErrStickerSetNotOwned): + return stickersetInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/stickers_creator_rpc_test.go b/internal/rpc/stickers_creator_rpc_test.go new file mode 100644 index 00000000..1eb271d5 --- /dev/null +++ b/internal/rpc/stickers_creator_rpc_test.go @@ -0,0 +1,253 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func stickerCreatorRouter(t *testing.T) (*Router, *fakeFiles, *memory.PasswordStore, *captureSessions) { + t.Helper() + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 103: {ID: 103, AccessHash: 13, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[domain.StickerSetKind][]domain.StickerSet{}, + } + passwordStore := memory.NewPasswordStore() + sessions := &captureSessions{} + router := New(Config{}, Deps{ + Account: appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)), + Files: files, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + return router, files, passwordStore, sessions +} + +func TestStickersCreateStickerSetInstallsAndInvalidatesCatalog(t *testing.T) { + r, _, store, sessions := stickerCreatorRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + before, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("get all before create: %v", err) + } + if full, ok := before.(*tg.MessagesAllStickers); ok && len(full.Sets) != 0 { + t.Fatalf("all stickers before create = %+v, want empty", full.Sets) + } + + out, err := r.onStickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{ + UserID: &tg.InputUserSelf{}, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Stickers: []tg.InputStickerSetItem{{ + Document: &tg.InputDocument{ID: 101, AccessHash: 11}, + Emoji: "🙂", + Keywords: "fresh,happy", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + full, ok := out.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("create result = %T, want *tg.MessagesStickerSet", out) + } + if full.Set.ShortName != "fresh_pack" || !full.Set.Creator || full.Set.InstalledDate == 0 { + t.Fatalf("created set = %+v, want creator installed fresh_pack", full.Set) + } + if len(full.Packs) != 1 || len(full.Keywords) != 1 || len(full.Documents) != 1 { + t.Fatalf("created payload packs=%d keywords=%d docs=%d, want 1/1/1", len(full.Packs), len(full.Keywords), len(full.Documents)) + } + if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != full.Set.ID { + t.Fatalf("installed created set ids = %v, want [%d]", got, full.Set.ID) + } + assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil) + + owned, err := r.onMessagesGetMyStickers(ctx, &tg.MessagesGetMyStickersRequest{Limit: 10}) + if err != nil { + t.Fatalf("get my stickers: %v", err) + } + if owned.Count != 1 || len(owned.Sets) != 1 { + t.Fatalf("my stickers = count %d sets %d, want one created set", owned.Count, len(owned.Sets)) + } + + after, err := r.onMessagesGetAllStickers(ctx, 0) + if err != nil { + t.Fatalf("get all after create: %v", err) + } + all, ok := after.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("all after create = %T, want *tg.MessagesAllStickers", after) + } + if len(all.Sets) != 1 || all.Sets[0].ID != full.Set.ID { + t.Fatalf("all after create = %+v, want created set", all.Sets) + } + + available, err := r.onStickersCheckShortName(ctx, "fresh_pack") + if err != nil { + t.Fatalf("check short name: %v", err) + } + if available { + t.Fatalf("fresh_pack available = true, want false after create") + } +} + +func TestStickersCreateStickerSetRejectsBadDocumentAccessHash(t *testing.T) { + r, _, _, _ := stickerCreatorRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + out, err := r.onStickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{ + UserID: &tg.InputUserSelf{}, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Stickers: []tg.InputStickerSetItem{{ + Document: &tg.InputDocument{ID: 101, AccessHash: 999}, + Emoji: "🙂", + }}, + }) + if out != nil || !tgerr.Is(err, "STICKER_FILE_INVALID") { + t.Fatalf("create with bad document hash = %T %v, want STICKER_FILE_INVALID", out, err) + } +} + +func TestStickersSuggestAndCheckShortNameValidation(t *testing.T) { + r, _, _, _ := stickerCreatorRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + suggested, err := r.onStickersSuggestShortName(ctx, "Fresh Pack") + if err != nil { + t.Fatalf("suggest short name: %v", err) + } + if suggested.ShortName == "" { + t.Fatalf("suggested short name empty") + } + if ok, err := r.onStickersCheckShortName(ctx, "bad!"); ok || !tgerr.Is(err, "SHORT_NAME_INVALID") { + t.Fatalf("check invalid short name = %v %v, want SHORT_NAME_INVALID", ok, err) + } +} + +func TestStickersManageCreatedStickerSetRPCs(t *testing.T) { + r, files, _, sessions := stickerCreatorRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + created, err := r.onStickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{ + UserID: &tg.InputUserSelf{}, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Stickers: []tg.InputStickerSetItem{{ + Document: &tg.InputDocument{ID: 101, AccessHash: 11}, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + full := created.(*tg.MessagesStickerSet) + setInput := &tg.InputStickerSetID{ID: full.Set.ID, AccessHash: full.Set.AccessHash} + + added, err := r.onStickersAddStickerToSet(ctx, &tg.StickersAddStickerToSetRequest{ + Stickerset: setInput, + Sticker: tg.InputStickerSetItem{ + Document: &tg.InputDocument{ID: 102, AccessHash: 12}, + Emoji: "😄", + Keywords: "smile", + }, + }) + if err != nil { + t.Fatalf("add sticker: %v", err) + } + addedFull := added.(*tg.MessagesStickerSet) + if addedFull.Set.Count != 2 || len(addedFull.Documents) != 2 || len(addedFull.Keywords) != 1 { + t.Fatalf("after add count=%d docs=%d keywords=%d, want 2/2/1", addedFull.Set.Count, len(addedFull.Documents), len(addedFull.Keywords)) + } + assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil) + + moved, err := r.onStickersChangeStickerPosition(ctx, &tg.StickersChangeStickerPositionRequest{ + Sticker: &tg.InputDocument{ID: 102, AccessHash: 12}, + Position: 0, + }) + if err != nil { + t.Fatalf("change sticker position: %v", err) + } + movedFull := moved.(*tg.MessagesStickerSet) + if len(movedFull.Documents) != 2 || tgDocumentID(movedFull.Documents[0]) != 102 { + t.Fatalf("documents after move = %+v, want doc 102 first", movedFull.Documents) + } + + renamed, err := r.onStickersRenameStickerSet(ctx, &tg.StickersRenameStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "fresh_pack"}, + Title: "Renamed Pack", + }) + if err != nil { + t.Fatalf("rename sticker set: %v", err) + } + renamedFull := renamed.(*tg.MessagesStickerSet) + if renamedFull.Set.Title != "Renamed Pack" { + t.Fatalf("renamed title = %q, want Renamed Pack", renamedFull.Set.Title) + } + + removed, err := r.onStickersRemoveStickerFromSet(ctx, &tg.InputDocument{ID: 102, AccessHash: 12}) + if err != nil { + t.Fatalf("remove sticker: %v", err) + } + removedFull := removed.(*tg.MessagesStickerSet) + if removedFull.Set.Count != 1 || len(removedFull.Documents) != 1 || tgDocumentID(removedFull.Documents[0]) != 101 { + t.Fatalf("after remove count=%d docs=%+v, want only doc 101", removedFull.Set.Count, removedFull.Documents) + } + + ok, err := r.onStickersDeleteStickerSet(ctx, setInput) + if err != nil || !ok { + t.Fatalf("delete sticker set = %v %v, want true nil", ok, err) + } + if _, _, found, err := files.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}); err != nil || found { + t.Fatalf("resolve deleted set = found %v err %v, want miss", found, err) + } +} + +func TestStickersManageCreatedStickerSetRejectsNonCreator(t *testing.T) { + r, _, _, _ := stickerCreatorRouter(t) + ownerCtx := WithUserID(context.Background(), 1000000001) + + created, err := r.onStickersCreateStickerSet(ownerCtx, &tg.StickersCreateStickerSetRequest{ + UserID: &tg.InputUserSelf{}, + Title: "Fresh Pack", + ShortName: "fresh_pack", + Stickers: []tg.InputStickerSetItem{{ + Document: &tg.InputDocument{ID: 101, AccessHash: 11}, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + full := created.(*tg.MessagesStickerSet) + otherCtx := WithUserID(context.Background(), 1000000002) + out, err := r.onStickersAddStickerToSet(otherCtx, &tg.StickersAddStickerToSetRequest{ + Stickerset: &tg.InputStickerSetID{ID: full.Set.ID, AccessHash: full.Set.AccessHash}, + Sticker: tg.InputStickerSetItem{ + Document: &tg.InputDocument{ID: 102, AccessHash: 12}, + Emoji: "😄", + }, + }) + if out != nil || !tgerr.Is(err, "STICKERSET_INVALID") { + t.Fatalf("non-creator add = %T %v, want STICKERSET_INVALID", out, err) + } +} + +func tgDocumentID(doc tg.DocumentClass) int64 { + if d, ok := doc.(*tg.Document); ok && d != nil { + return d.ID + } + return 0 +} diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go index a779e0c2..ada1888d 100644 --- a/internal/rpc/stickers_test.go +++ b/internal/rpc/stickers_test.go @@ -26,6 +26,21 @@ func TestStickerSetsCatalogHashMatchesTDesktopFormula(t *testing.T) { } } +func TestFeaturedStickerSetsHashMatchesTDesktopFormula(t *testing.T) { + sets := []domain.StickerSet{ + {ID: 10, Hash: 123}, + {ID: 11, Hash: 456}, + } + got := featuredStickerSetsHash(sets) + const want int64 = 365072220181 + if got != want { + t.Fatalf("featuredStickerSetsHash() = %d, want %d", got, want) + } + if catalog := stickerSetsCatalogHash(sets); catalog == got { + t.Fatalf("test fixture no longer distinguishes featured hash from catalog hash: %d", got) + } +} + func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) { ctx := context.Background() files := &fakeFiles{ diff --git a/internal/store/account.go b/internal/store/account.go index bc2b3da8..21b27666 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -49,6 +49,17 @@ type StickerCollectionStore interface { ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error } +// UserStickerSetStore persists per-user installed sticker set state. +// Sticker set metadata remains in MediaStore; this store only owns account-local +// installation/archive/order facts. +type UserStickerSetStore interface { + InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error + UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error + SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error + ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error + ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) +} + // SavedMusicStore persists one account's ordered profile music list. type SavedMusicStore interface { SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error diff --git a/internal/store/media.go b/internal/store/media.go index 8050da68..f79b37be 100644 --- a/internal/store/media.go +++ b/internal/store/media.go @@ -44,10 +44,15 @@ type MediaStore interface { // 贴纸集 / 可用 reaction。 PutStickerSet(ctx context.Context, set domain.StickerSet) error + CreateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error + UpdateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error + DeleteStickerSet(ctx context.Context, setID int64, creatorUserID int64) error GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) + ListStickerSetsByCreator(ctx context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) + StickerSetShortNameAvailable(ctx context.Context, shortName string) (bool, error) CountStickerSets(ctx context.Context) (int, error) PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) diff --git a/internal/store/memory/bot.go b/internal/store/memory/bot.go index 5d534037..6e00fdbd 100644 --- a/internal/store/memory/bot.go +++ b/internal/store/memory/bot.go @@ -12,7 +12,7 @@ import ( // BotStore 是 store.BotStore 的内存实现。bot 的 users 行经注入的 UserStore 创建, // 与 postgres 实现(单事务建 users+bots 两行)保持可见性一致。 -// 内置 BotFather 的 bots 行预置(token 为空 = 不可登录),对齐迁移 0090 种子。 +// 内置 service bots 的 bots 行预置(token 为空 = 不可登录),对齐迁移种子。 type BotStore struct { mu sync.RWMutex users *UserStore @@ -59,7 +59,13 @@ func NewBotStore(users *UserStore) *BotStore { emojiPerms: make(map[[2]int64]bool), customMethod: make(map[string]domain.BotWebViewCustomMethodQuery), } - s.byID[domain.BotFatherUserID] = domain.BotProfile{ + s.byID[domain.BotFatherUserID] = botFatherSeedProfile() + s.byID[domain.StickersBotUserID] = stickersSeedProfile() + return s +} + +func botFatherSeedProfile() domain.BotProfile { + return domain.BotProfile{ BotUserID: domain.BotFatherUserID, OwnerUserID: domain.BotFatherUserID, Description: "BotFather is the one bot to rule them all. Use it to create new bot accounts and manage your existing bots.", @@ -72,7 +78,25 @@ func NewBotStore(users *UserStore) *BotStore { {Command: "help", Description: "show help"}, }, } - return s +} + +func stickersSeedProfile() domain.BotProfile { + return domain.BotProfile{ + BotUserID: domain.StickersBotUserID, + OwnerUserID: domain.StickersBotUserID, + Description: "Create custom sticker and emoji packs for telesrv.", + Commands: []domain.BotCommand{ + {Command: "start", Description: "start the sticker pack assistant"}, + {Command: "help", Description: "show help"}, + {Command: "newpack", Description: "create a sticker pack"}, + {Command: "newemoji", Description: "create a custom emoji pack"}, + {Command: "addsticker", Description: "add to one of your packs"}, + {Command: "delsticker", Description: "remove one item from a pack"}, + {Command: "publish", Description: "publish the current pack"}, + {Command: "cancel", Description: "cancel the current operation"}, + {Command: "packs", Description: "list your created packs"}, + }, + } } func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) { diff --git a/internal/store/memory/password.go b/internal/store/memory/password.go index 55d5714a..1b01e0ac 100644 --- a/internal/store/memory/password.go +++ b/internal/store/memory/password.go @@ -2,6 +2,7 @@ package memory import ( "context" + "sort" "sync" "telesrv/internal/domain" ) @@ -14,6 +15,7 @@ type PasswordStore struct { accountSettings map[int64]domain.AccountSettings notifySettings map[notifySettingsKey]domain.PeerNotifySettings stickerCollections map[stickerCollectionKey][]domain.StickerCollectionItem + userStickerSets map[int64]map[int64]domain.UserStickerSet savedMusic map[int64][]domain.Document businessProfiles map[int64]domain.BusinessProfile businessChatLinks map[string]domain.BusinessChatLink @@ -48,6 +50,7 @@ func NewPasswordStore() *PasswordStore { accountSettings: make(map[int64]domain.AccountSettings), notifySettings: make(map[notifySettingsKey]domain.PeerNotifySettings), stickerCollections: make(map[stickerCollectionKey][]domain.StickerCollectionItem), + userStickerSets: make(map[int64]map[int64]domain.UserStickerSet), savedMusic: make(map[int64][]domain.Document), businessProfiles: make(map[int64]domain.BusinessProfile), businessChatLinks: make(map[string]domain.BusinessChatLink), @@ -234,6 +237,113 @@ func (s *PasswordStore) ClearStickerCollection(_ context.Context, userID int64, return nil } +func (s *PasswordStore) InstallUserStickerSet(_ context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error { + if userID == 0 || setID == 0 { + return domain.ErrStickerInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + sets := s.userStickerSets[userID] + if sets == nil { + sets = make(map[int64]domain.UserStickerSet) + s.userStickerSets[userID] = sets + } + order := int64(installedDate) << 32 + sets[setID] = domain.UserStickerSet{ + OwnerUserID: userID, + StickerSetID: setID, + Kind: kind, + Archived: archived, + InstalledDate: installedDate, + OrderValue: order, + } + return nil +} + +func (s *PasswordStore) UninstallUserStickerSet(_ context.Context, userID int64, setID int64) error { + s.mu.Lock() + if sets := s.userStickerSets[userID]; sets != nil { + delete(sets, setID) + } + s.mu.Unlock() + return nil +} + +func (s *PasswordStore) SetUserStickerSetArchived(_ context.Context, userID int64, setID int64, archived bool, now int) error { + s.mu.Lock() + if sets := s.userStickerSets[userID]; sets != nil { + if item, ok := sets[setID]; ok { + item.Archived = archived + if !archived && now > 0 { + item.OrderValue = int64(now) << 32 + } + sets[setID] = item + } + } + s.mu.Unlock() + return nil +} + +func (s *PasswordStore) ReorderUserStickerSets(_ context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error { + s.mu.Lock() + defer s.mu.Unlock() + sets := s.userStickerSets[userID] + if sets == nil { + return nil + } + orderValue := int64(now) << 32 + for _, id := range order { + item, ok := sets[id] + if !ok || item.Kind != kind { + continue + } + item.OrderValue = orderValue + sets[id] = item + orderValue-- + } + return nil +} + +func (s *PasswordStore) ListUserStickerSets(_ context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) { + if limit <= 0 || limit > domain.MaxInstalledStickerSets { + limit = domain.MaxInstalledStickerSets + } + s.mu.RLock() + raw := s.userStickerSets[userID] + items := make([]domain.UserStickerSet, 0, len(raw)) + for _, item := range raw { + if item.Kind != kind { + continue + } + if archived != nil && item.Archived != *archived { + continue + } + items = append(items, item) + } + s.mu.RUnlock() + sort.SliceStable(items, func(i, j int) bool { + if items[i].OrderValue == items[j].OrderValue { + return items[i].StickerSetID > items[j].StickerSetID + } + return items[i].OrderValue > items[j].OrderValue + }) + total := len(items) + if offsetID != 0 { + start := 0 + for i, item := range items { + if item.StickerSetID == offsetID { + start = i + 1 + break + } + } + items = items[start:] + } + if len(items) > limit { + items = items[:limit] + } + return append([]domain.UserStickerSet(nil), items...), total, nil +} + func (s *PasswordStore) ListNotifyExceptions(_ context.Context, ownerUserID int64) ([]domain.NotifyException, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go index 192e7ec8..a836633f 100644 --- a/internal/store/memory/users.go +++ b/internal/store/memory/users.go @@ -15,11 +15,11 @@ type UserStore struct { nextID int64 } -// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather)预置进表, -// 与 postgres 的迁移种子(0005 / 0090)保持双 store 行为一致。 +// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers) +// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。 func NewUserStore() *UserStore { s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase} - for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID} { + for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID} { if u, ok := domain.SystemUserByID(id); ok { s.byID[u.ID] = u } diff --git a/internal/store/postgres/account.go b/internal/store/postgres/account.go index ff30cb15..5a526fc1 100644 --- a/internal/store/postgres/account.go +++ b/internal/store/postgres/account.go @@ -559,6 +559,125 @@ func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64 return nil } +func (s *PasswordStore) InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error { + if userID == 0 || setID == 0 { + return domain.ErrStickerInvalid + } + orderValue := int64(installedDate) << 32 + _, err := s.db.Exec(ctx, ` +INSERT INTO user_sticker_sets (owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, now()) +ON CONFLICT (owner_user_id, sticker_set_id) DO UPDATE SET + set_kind = EXCLUDED.set_kind, + archived = EXCLUDED.archived, + installed_date = CASE + WHEN user_sticker_sets.installed_date = 0 THEN EXCLUDED.installed_date + ELSE user_sticker_sets.installed_date + END, + order_value = EXCLUDED.order_value, + updated_at = now()`, + userID, setID, string(kind), archived, installedDate, orderValue) + if err != nil { + return fmt.Errorf("install user sticker set: %w", err) + } + return nil +} + +func (s *PasswordStore) UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error { + if _, err := s.db.Exec(ctx, ` +DELETE FROM user_sticker_sets +WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID); err != nil { + return fmt.Errorf("uninstall user sticker set: %w", err) + } + return nil +} + +func (s *PasswordStore) SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error { + orderValue := int64(now) << 32 + _, err := s.db.Exec(ctx, ` +UPDATE user_sticker_sets +SET archived = $3, + order_value = CASE WHEN $3::boolean = false AND $4::bigint > 0 THEN $4::bigint ELSE order_value END, + updated_at = now() +WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID, archived, orderValue) + if err != nil { + return fmt.Errorf("set user sticker set archived: %w", err) + } + return nil +} + +func (s *PasswordStore) ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error { + if len(order) == 0 { + return nil + } + return withTx(ctx, s.db, "reorder user sticker sets", func(tx pgx.Tx) error { + orderValue := int64(now) << 32 + for _, id := range order { + if _, err := tx.Exec(ctx, ` +UPDATE user_sticker_sets +SET order_value = $4, updated_at = now() +WHERE owner_user_id = $1 AND set_kind = $2 AND sticker_set_id = $3`, + userID, string(kind), id, orderValue); err != nil { + return fmt.Errorf("update user sticker set order: %w", err) + } + orderValue-- + } + return nil + }) +} + +func (s *PasswordStore) ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) { + if userID == 0 { + return nil, 0, nil + } + if limit <= 0 || limit > domain.MaxInstalledStickerSets { + limit = domain.MaxInstalledStickerSets + } + var archivedFilter any + if archived != nil { + archivedFilter = *archived + } + rows, err := s.db.Query(ctx, ` +WITH ordered AS ( + SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, + ROW_NUMBER() OVER (ORDER BY order_value DESC, sticker_set_id DESC) AS rn, + COUNT(*) OVER () AS total + FROM user_sticker_sets + WHERE owner_user_id = $1 + AND set_kind = $2 + AND ($3::boolean IS NULL OR archived = $3::boolean) +), +page AS ( + SELECT COALESCE((SELECT rn FROM ordered WHERE sticker_set_id = $4), 0) AS offset_rn +) +SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, total +FROM ordered, page +WHERE ordered.rn > page.offset_rn +ORDER BY ordered.rn ASC +LIMIT $5`, userID, string(kind), archivedFilter, offsetID, limit) + if err != nil { + return nil, 0, fmt.Errorf("list user sticker sets: %w", err) + } + defer rows.Close() + out := make([]domain.UserStickerSet, 0, limit) + total := 0 + for rows.Next() { + var ( + item domain.UserStickerSet + kindText string + ) + if err := rows.Scan(&item.OwnerUserID, &item.StickerSetID, &kindText, &item.Archived, &item.InstalledDate, &item.OrderValue, &total); err != nil { + return nil, 0, fmt.Errorf("scan user sticker set: %w", err) + } + item.Kind = domain.StickerSetKind(kindText) + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate user sticker sets: %w", err) + } + return out, total, nil +} + func nullableBool(v *bool) any { if v == nil { return nil diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go index e17355b3..0b5dcb16 100644 --- a/internal/store/postgres/media.go +++ b/internal/store/postgres/media.go @@ -8,7 +8,9 @@ import ( "sync" "time" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" @@ -237,15 +239,27 @@ ON CONFLICT (key) DO UPDATE SET // ---- 文档 ---- func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error { - attrs, err := jsonArrayOrEmpty(doc.Attributes) + params, err := putDocumentParams(doc) if err != nil { return err } + if err := s.q.PutDocument(ctx, params); err != nil { + return err + } + s.documents.put(doc.ID, doc) + return nil +} + +func putDocumentParams(doc domain.Document) (sqlcgen.PutDocumentParams, error) { + attrs, err := jsonArrayOrEmpty(doc.Attributes) + if err != nil { + return sqlcgen.PutDocumentParams{}, err + } thumbs, err := jsonArrayOrEmpty(doc.Thumbs) if err != nil { - return err + return sqlcgen.PutDocumentParams{}, err } - if err := s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{ + return sqlcgen.PutDocumentParams{ ID: doc.ID, AccessHash: doc.AccessHash, FileReference: bytesOrEmpty(doc.FileReference), @@ -255,11 +269,7 @@ func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error DcID: int32(doc.DCID), AttributesJson: attrs, ThumbsJson: thumbs, - }); err != nil { - return err - } - s.documents.put(doc.ID, doc) - return nil + }, nil } func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { @@ -559,53 +569,399 @@ func (s *MediaStore) PutStickerSet(ctx context.Context, set domain.StickerSet) e }) } -func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { - row, err := s.q.GetStickerSetByID(ctx, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.StickerSet{}, false, nil +func (s *MediaStore) CreateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error { + err := withTx(ctx, s.db, "create sticker set", func(tx pgx.Tx) error { + if err := insertStickerSet(ctx, tx, set); err != nil { + return err } - return domain.StickerSet{}, false, err + qtx := s.q.WithTx(tx) + for _, doc := range docs { + params, err := putDocumentParams(doc) + if err != nil { + return err + } + if err := qtx.PutDocument(ctx, params); err != nil { + return err + } + } + return nil + }) + if err != nil { + if stickerSetShortNameConflict(err) { + return domain.ErrStickerSetShortNameOccupied + } + return err } - return stickerSetFromRow(row) + for _, doc := range docs { + s.documents.put(doc.ID, doc) + } + return nil +} + +func (s *MediaStore) UpdateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error { + err := withTx(ctx, s.db, "update sticker set", func(tx pgx.Tx) error { + if err := updateStickerSet(ctx, tx, set); err != nil { + return err + } + qtx := s.q.WithTx(tx) + for _, doc := range docs { + params, err := putDocumentParams(doc) + if err != nil { + return err + } + if err := qtx.PutDocument(ctx, params); err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + for _, doc := range docs { + s.documents.put(doc.ID, doc) + } + return nil +} + +func (s *MediaStore) DeleteStickerSet(ctx context.Context, setID int64, creatorUserID int64) error { + return withTx(ctx, s.db, "delete sticker set", func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` +UPDATE sticker_sets +SET deleted = true, updated_at = now() +WHERE id = $1 + AND creator_user_id = $2 + AND deleted = false`, setID, creatorUserID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrStickerSetInvalid + } + _, err = tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID) + return err + }) +} + +func insertStickerSet(ctx context.Context, db sqlcgen.DBTX, set domain.StickerSet) error { + thumbs, err := jsonArrayOrEmpty(set.Thumbs) + if err != nil { + return err + } + docIDs, err := jsonArrayOrEmpty(set.DocumentIDs) + if err != nil { + return err + } + packs, err := jsonArrayOrEmpty(set.Packs) + if err != nil { + return err + } + keywords, err := jsonArrayOrEmpty(set.Keywords) + if err != nil { + return err + } + kind := string(set.Kind) + if kind == "" { + kind = string(domain.StickerSetKindStickers) + } + _, err = db.Exec(ctx, ` +INSERT INTO sticker_sets ( + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, text_color, creator_user_id, + installed, archived, deleted, installed_date, + thumb_document_id, thumbs, thumb_dc_id, thumb_version, + document_ids, packs, keywords, sort_order, system_key, software, updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20::jsonb, $21, $22, + $23::jsonb, $24::jsonb, $25::jsonb, $26, $27, $28, now() +)`, + set.ID, set.AccessHash, set.ShortName, set.Title, set.Count, set.Hash, kind, + set.Official, set.Animated, set.Videos, set.Emojis, set.Masks, set.TextColor, set.CreatorUserID, + set.Installed, set.Archived, set.Deleted, set.InstalledDate, + set.ThumbDocumentID, thumbs, set.ThumbDCID, set.ThumbVersion, + docIDs, packs, keywords, set.SortOrder, set.SystemKey, set.Software, + ) + return err +} + +func updateStickerSet(ctx context.Context, db sqlcgen.DBTX, set domain.StickerSet) error { + thumbs, err := jsonArrayOrEmpty(set.Thumbs) + if err != nil { + return err + } + docIDs, err := jsonArrayOrEmpty(set.DocumentIDs) + if err != nil { + return err + } + packs, err := jsonArrayOrEmpty(set.Packs) + if err != nil { + return err + } + keywords, err := jsonArrayOrEmpty(set.Keywords) + if err != nil { + return err + } + kind := string(set.Kind) + if kind == "" { + kind = string(domain.StickerSetKindStickers) + } + tag, err := db.Exec(ctx, ` +UPDATE sticker_sets +SET title = $3, + count = $4, + hash = $5, + set_kind = $6, + official = $7, + animated = $8, + videos = $9, + emojis = $10, + masks = $11, + text_color = $12, + installed = $13, + archived = $14, + installed_date = $15, + thumb_document_id = $16, + thumbs = $17::jsonb, + thumb_dc_id = $18, + thumb_version = $19, + document_ids = $20::jsonb, + packs = $21::jsonb, + keywords = $22::jsonb, + sort_order = $23, + software = $24, + updated_at = now() +WHERE id = $1 + AND access_hash = $2 + AND deleted = false`, + set.ID, set.AccessHash, + set.Title, set.Count, set.Hash, kind, + set.Official, set.Animated, set.Videos, set.Emojis, set.Masks, set.TextColor, + set.Installed, set.Archived, set.InstalledDate, + set.ThumbDocumentID, thumbs, set.ThumbDCID, set.ThumbVersion, + docIDs, packs, keywords, set.SortOrder, set.Software, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return domain.ErrStickerSetInvalid + } + return nil +} + +func stickerSetShortNameConflict(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != pgerrcode.UniqueViolation { + return false + } + switch pgErr.ConstraintName { + case "sticker_sets_short_name_lower_idx", "sticker_sets_short_name_idx": + return true + default: + return false + } +} + +func (s *MediaStore) GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) { + return queryStickerSet(ctx, s.db, "id = $1", id) } func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) { - row, err := s.q.GetStickerSetByShortName(ctx, shortName) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.StickerSet{}, false, nil - } - return domain.StickerSet{}, false, err - } - return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) + return queryStickerSet(ctx, s.db, "lower(short_name) = lower($1)", shortName) } func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) { - row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.StickerSet{}, false, nil - } - return domain.StickerSet{}, false, err - } - return stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(row)) + return queryStickerSet(ctx, s.db, "system_key = $1", systemKey) } func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { - rows, err := s.q.ListStickerSetsByKind(ctx, string(kind)) + rows, err := s.db.Query(ctx, stickerSetSelectSQL+` +WHERE set_kind = $1 AND deleted = false +ORDER BY sort_order ASC, id ASC`, string(kind)) if err != nil { return nil, err } - out := make([]domain.StickerSet, 0, len(rows)) - for _, r := range rows { - set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r)) + defer rows.Close() + out := []domain.StickerSet{} + for rows.Next() { + set, err := scanStickerSet(rows) if err != nil { return nil, err } out = append(out, set) } - return out, nil + return out, rows.Err() +} + +func (s *MediaStore) ListStickerSetsByCreator(ctx context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) { + if limit <= 0 { + limit = domain.MaxCreatedStickerSets + } + if limit > domain.MaxCreatedStickerSets { + limit = domain.MaxCreatedStickerSets + } + var total int + if err := s.db.QueryRow(ctx, ` +SELECT count(*)::int +FROM sticker_sets +WHERE creator_user_id = $1 AND deleted = false`, creatorUserID).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := s.db.Query(ctx, stickerSetSelectSQL+` +WHERE creator_user_id = $1 + AND deleted = false + AND ($2::bigint = 0 OR id < $2::bigint) +ORDER BY id DESC +LIMIT $3`, creatorUserID, offsetID, limit) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []domain.StickerSet{} + for rows.Next() { + set, err := scanStickerSet(rows) + if err != nil { + return nil, 0, err + } + set.Creator = true + out = append(out, set) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return out, total, nil +} + +func (s *MediaStore) StickerSetShortNameAvailable(ctx context.Context, shortName string) (bool, error) { + var exists bool + if err := s.db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM sticker_sets + WHERE lower(short_name) = lower($1) + AND short_name <> '' + AND deleted = false +)`, shortName).Scan(&exists); err != nil { + return false, err + } + return !exists, nil +} + +const stickerSetSelectSQL = ` +SELECT + id, access_hash, short_name, title, count, hash, set_kind, + official, animated, videos, emojis, masks, installed, archived, installed_date, + thumb_document_id, thumbs::text AS thumbs_json, thumb_dc_id, thumb_version, + document_ids::text AS document_ids_json, packs::text AS packs_json, sort_order, system_key, + creator_user_id, text_color, deleted, software, keywords::text AS keywords_json +FROM sticker_sets +` + +type stickerSetScanner interface { + Scan(dest ...any) error +} + +func queryStickerSet(ctx context.Context, db sqlcgen.DBTX, predicate string, args ...any) (domain.StickerSet, bool, error) { + row := db.QueryRow(ctx, stickerSetSelectSQL+"WHERE "+predicate+" AND deleted = false LIMIT 1", args...) + set, err := scanStickerSet(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StickerSet{}, false, nil + } + return domain.StickerSet{}, false, err + } + return set, true, nil +} + +func scanStickerSet(row stickerSetScanner) (domain.StickerSet, error) { + var ( + id int64 + accessHash int64 + shortName string + title string + count int32 + hash int32 + kind string + official bool + animated bool + videos bool + emojis bool + masks bool + installed bool + archived bool + installedDate int32 + thumbDocumentID int64 + thumbsJSON string + thumbDCID int32 + thumbVersion int32 + docIDsJSON string + packsJSON string + sortOrder int32 + systemKey string + creatorUserID int64 + textColor bool + deleted bool + software string + keywordsJSON string + ) + if err := row.Scan( + &id, &accessHash, &shortName, &title, &count, &hash, &kind, + &official, &animated, &videos, &emojis, &masks, &installed, &archived, &installedDate, + &thumbDocumentID, &thumbsJSON, &thumbDCID, &thumbVersion, + &docIDsJSON, &packsJSON, &sortOrder, &systemKey, + &creatorUserID, &textColor, &deleted, &software, &keywordsJSON, + ); err != nil { + return domain.StickerSet{}, err + } + thumbs, err := decodePhotoSizes(thumbsJSON) + if err != nil { + return domain.StickerSet{}, err + } + docIDs, err := decodeInt64Slice(docIDsJSON) + if err != nil { + return domain.StickerSet{}, err + } + packs, err := decodeStickerPacks(packsJSON) + if err != nil { + return domain.StickerSet{}, err + } + keywords, err := decodeStickerKeywords(keywordsJSON) + if err != nil { + return domain.StickerSet{}, err + } + return domain.StickerSet{ + ID: id, + AccessHash: accessHash, + ShortName: shortName, + Title: title, + Count: int(count), + Hash: int(hash), + Kind: domain.StickerSetKind(kind), + Official: official, + Animated: animated, + Videos: videos, + Emojis: emojis, + Masks: masks, + TextColor: textColor, + CreatorUserID: creatorUserID, + Installed: installed, + Archived: archived, + Deleted: deleted, + InstalledDate: int(installedDate), + ThumbDocumentID: thumbDocumentID, + Thumbs: thumbs, + ThumbDCID: int(thumbDCID), + ThumbVersion: int(thumbVersion), + DocumentIDs: docIDs, + Packs: packs, + Keywords: keywords, + SortOrder: int(sortOrder), + SystemKey: systemKey, + Software: software, + }, nil } func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) { diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go index 27f0f7b4..57b371ad 100644 --- a/internal/store/postgres/media_codec.go +++ b/internal/store/postgres/media_codec.go @@ -136,3 +136,14 @@ func decodeStickerPacks(s string) ([]domain.StickerPack, error) { } return out, nil } + +func decodeStickerKeywords(s string) ([]domain.StickerKeyword, error) { + if s == "" || s == "[]" || s == "null" { + return nil, nil + } + var out []domain.StickerKeyword + if err := json.Unmarshal([]byte(s), &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/web/stickerlinks/server.go b/internal/web/stickerlinks/server.go new file mode 100644 index 00000000..31321e5d --- /dev/null +++ b/internal/web/stickerlinks/server.go @@ -0,0 +1,283 @@ +package stickerlinks + +import ( + "context" + "errors" + "fmt" + "html/template" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const defaultPublicBaseURL = "https://telesrv.net" + +type Config struct { + Addr string + PublicBaseURL string +} + +type Resolver interface { + ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) +} + +func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logger) (*http.Server, error) { + addr := strings.TrimSpace(cfg.Addr) + if addr == "" { + return nil, nil + } + if resolver == nil { + return nil, fmt.Errorf("sticker links resolver is nil") + } + if logger == nil { + logger = zap.NewNop() + } + handler := NewHandler(resolver, cfg.PublicBaseURL) + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + go func() { + logger.Info("Sticker 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)) + } + }() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + return srv, nil +} + +func NewHandler(resolver Resolver, publicBaseURL string) http.Handler { + h := &handler{ + resolver: resolver, + publicBaseURL: normalizePublicBaseURL(publicBaseURL), + } + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", h.healthz) + mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) + mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) + return mux +} + +type handler struct { + resolver Resolver + publicBaseURL string +} + +func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) +} + +func (h *handler) addStickers(w http.ResponseWriter, r *http.Request) { + h.serveSet(w, r, "addstickers") +} + +func (h *handler) addEmoji(w http.ResponseWriter, r *http.Request) { + h.serveSet(w, r, "addemoji") +} + +func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) { + shortName := strings.TrimSpace(r.PathValue("shortName")) + if !validShortNamePath(shortName) { + http.NotFound(w, r) + return + } + set, docs, found, err := h.resolver.ResolveStickerSet(r.Context(), domain.StickerSetRef{ + Kind: domain.StickerSetRefByShortName, + ShortName: shortName, + }) + if err != nil { + http.Error(w, "sticker set lookup failed", http.StatusInternalServerError) + return + } + if !found || set.Deleted { + http.NotFound(w, r) + return + } + canonicalKind := linkKind(set) + if canonicalKind != pathKind { + http.Redirect(w, r, h.setURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect) + return + } + count := set.Count + if count == 0 { + count = len(docs) + } + app := appURL(canonicalKind, 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), + AppURL: template.URL(app), + LegacyTgURL: template.URL(legacyTgURL(canonicalKind, set.ShortName)), + } + 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 sticker set page failed", http.StatusInternalServerError) + } +} + +func (h *handler) setURL(kind, shortName string) string { + return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(shortName) +} + +func normalizePublicBaseURL(raw string) string { + raw = strings.TrimRight(strings.TrimSpace(raw), "/") + if raw == "" { + return defaultPublicBaseURL + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return defaultPublicBaseURL + } + u.Path = strings.TrimRight(u.Path, "/") + u.RawQuery = "" + u.Fragment = "" + return strings.TrimRight(u.String(), "/") +} + +func validShortNamePath(shortName string) bool { + if shortName == "" || len(shortName) > 64 { + return false + } + for _, r := range shortName { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '_' || r == '.': + default: + return false + } + } + return true +} + +func linkKind(set domain.StickerSet) string { + if set.Kind == domain.StickerSetKindEmoji || set.Emojis { + return "addemoji" + } + return "addstickers" +} + +func fallbackTitle(set domain.StickerSet) string { + if title := strings.TrimSpace(set.Title); title != "" { + return title + } + return set.ShortName +} + +func kindLabel(set domain.StickerSet) string { + switch { + case set.Kind == domain.StickerSetKindEmoji || set.Emojis: + return "custom emoji set" + case set.Kind == domain.StickerSetKindMasks || set.Masks: + return "mask set" + default: + return "sticker set" + } +} + +func itemNoun(set domain.StickerSet, count int) string { + if set.Kind == domain.StickerSetKindEmoji || set.Emojis { + if count == 1 { + return "custom emoji" + } + return "custom emoji" + } + if count == 1 { + return "sticker" + } + return "stickers" +} + +func appURL(kind, shortName string) string { + return "telesrv://" + kind + "?set=" + url.QueryEscape(shortName) +} + +func legacyTgURL(kind, shortName string) string { + return "tg://" + kind + "?set=" + url.QueryEscape(shortName) +} + +type pageData struct { + Title string + ShortName string + Count int + KindLabel string + ItemNoun string + CanonicalURL string + AppURL template.URL + LegacyTgURL template.URL + AppURLJS template.JS +} + +var landingTemplate = template.Must(template.New("landing").Parse(` + + + + + {{.Title}} - telesrv + + + + + + + + +
+

{{.KindLabel}}

+

{{.Title}}

+

@{{.ShortName}} · {{.Count}} {{.ItemNoun}}

+

Open in telesrv

+

This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.

+

Old test clients only: open with tg://

+

{{.CanonicalURL}}

+
+ + + +`)) diff --git a/internal/web/stickerlinks/server_test.go b/internal/web/stickerlinks/server_test.go new file mode 100644 index 00000000..8871ff03 --- /dev/null +++ b/internal/web/stickerlinks/server_test.go @@ -0,0 +1,149 @@ +package stickerlinks + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "telesrv/internal/domain" +) + +func TestHandlerServesStickerSetLandingPage(t *testing.T) { + resolver := fakeResolver{ + "fresh_pack": { + ID: 10, + ShortName: "fresh_pack", + Title: "Fresh Pack", + Count: 2, + Kind: domain.StickerSetKindStickers, + }, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil) + + NewHandler(resolver, "https://telesrv.net/").ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "Fresh Pack", + "https://telesrv.net/addstickers/fresh_pack", + "telesrv://addstickers?set=fresh_pack", + "tg://addstickers?set=fresh_pack", + "Files are still fetched by the app through MTProto.", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q:\n%s", want, body) + } + } + if strings.Contains(body, `window.location.href = "tg://`) { + t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", body) + } + if strings.Contains(body, "/upload/getFile") { + t.Fatalf("landing page should not expose media download paths:\n%s", body) + } +} + +func TestHandlerServesEmojiLandingPage(t *testing.T) { + resolver := fakeResolver{ + "emoji_pack": { + ID: 11, + ShortName: "emoji_pack", + Title: "Emoji Pack", + Count: 1, + Kind: domain.StickerSetKindEmoji, + Emojis: true, + }, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/addemoji/emoji_pack", nil) + + NewHandler(resolver, "https://example.test/base").ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "custom emoji set", + "https://example.test/base/addemoji/emoji_pack", + "telesrv://addemoji?set=emoji_pack", + "tg://addemoji?set=emoji_pack", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q:\n%s", want, body) + } + } +} + +func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) { + resolver := fakeResolver{ + "emoji_pack": { + ID: 11, + ShortName: "emoji_pack", + Title: "Emoji Pack", + Kind: domain.StickerSetKindEmoji, + Emojis: true, + }, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/addstickers/emoji_pack", nil) + + NewHandler(resolver, "https://telesrv.net").ServeHTTP(rr, req) + + if rr.Code != http.StatusPermanentRedirect { + t.Fatalf("status = %d, want 308; body=%s", rr.Code, rr.Body.String()) + } + if got, want := rr.Header().Get("Location"), "https://telesrv.net/addemoji/emoji_pack"; got != want { + t.Fatalf("Location = %q, want %q", got, want) + } +} + +func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) { + handler := NewHandler(fakeResolver{}, "https://telesrv.net") + for _, path := range []string{ + "/addstickers/missing_pack", + "/addstickers/bad-name", + "/addemoji/%E4%B8%AD%E6%96%87", + } { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("%s status = %d, want 404", path, rr.Code) + } + } +} + +func TestHandlerLookupErrorIsInternalServerError(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil) + + NewHandler(errorResolver{}, "https://telesrv.net").ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rr.Code) + } +} + +type fakeResolver map[string]domain.StickerSet + +func (f fakeResolver) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + set, ok := f[ref.ShortName] + if !ok { + return domain.StickerSet{}, nil, false, nil + } + docs := make([]domain.Document, set.Count) + return set, docs, true, nil +} + +type errorResolver struct{} + +func (errorResolver) ResolveStickerSet(context.Context, domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + return domain.StickerSet{}, nil, false, errors.New("boom") +}