feat: sync built-in sticker bot

This commit is contained in:
A 2026-07-01 21:55:55 +08:00
parent 7096625e13
commit 6867d201ed
60 changed files with 7063 additions and 144 deletions

View file

@ -6,8 +6,8 @@
//
// 用法:
//
// go run ./cmd/botcheck -token "<bot_id>:<secret>" # 仅登录自检
// go run ./cmd/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" # 仅登录自检
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
//
// 连接生产 telesrvobfuscated TCP靠 DCOption.TCPObfuscatedOnly=true
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。

View file

@ -14,7 +14,7 @@
//
// 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条):
//
// go run ./cmd/botdemo \
// go run ./cmd/bots/botdemo \
// -token "<bot_id>:<secret>" \
// -chat <channel_id> -chat-hash <access_hash> \
// -interval 15s -mode rotate

View file

@ -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 SFUM1+。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"),

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.user_sticker_sets;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
UPDATE sticker_sets
SET installed = false,
installed_date = 0
WHERE installed = true
OR installed_date <> 0;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@ -69,47 +70,61 @@ type botReply struct {
// HandlesBot 报告该收件人是否为内置应答 botmessages.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 {

View file

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

View file

@ -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 层实现):
// - RevokeBotSessionstoken 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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": []
}`))
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,21 @@ import "errors"
// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIFfaveSticker/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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - telesrv</title>
<link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Count}} {{.ItemNoun}} in this {{.KindLabel}}.">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta name="robots" content="noindex">
<style>
:root { color-scheme: light dark; font-family: Arial, Helvetica, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f7fb; color: #15202b; }
main { width: min(92vw, 460px); padding: 32px; border: 1px solid #d9e1ea; border-radius: 8px; background: #fff; box-shadow: 0 16px 48px rgba(21, 32, 43, .08); }
h1 { margin: 0 0 10px; font-size: 28px; line-height: 1.18; font-weight: 700; }
p { margin: 0 0 18px; line-height: 1.5; color: #4a5b6b; }
.meta { font-size: 14px; color: #6b7b8b; }
a.button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 18px; border-radius: 6px; background: #1677c8; color: #fff; text-decoration: none; font-weight: 700; }
a.raw { color: #1677c8; overflow-wrap: anywhere; }
@media (prefers-color-scheme: dark) {
body { background: #111820; color: #e9eef5; }
main { background: #18222d; border-color: #293849; box-shadow: none; }
p, .meta { color: #aebdca; }
a.button { background: #45a3ff; color: #06131f; }
a.raw { color: #74baff; }
}
</style>
</head>
<body>
<main>
<p class="meta">{{.KindLabel}}</p>
<h1>{{.Title}}</h1>
<p class="meta">@{{.ShortName}} · {{.Count}} {{.ItemNoun}}</p>
<p><a class="button" href="{{.AppURL}}">Open in telesrv</a></p>
<p>This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.</p>
<p class="meta">Old test clients only: <a class="raw" href="{{.LegacyTgURL}}">open with tg://</a></p>
<p class="meta"><a class="raw" href="{{.CanonicalURL}}">{{.CanonicalURL}}</a></p>
</main>
<script>
window.setTimeout(function () {
window.location.href = {{.AppURLJS}};
}, 250);
</script>
</body>
</html>
`))

View file

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