feat: sync built-in sticker bot
This commit is contained in:
parent
7096625e13
commit
6867d201ed
60 changed files with 7063 additions and 144 deletions
|
|
@ -6,8 +6,8 @@
|
||||||
//
|
//
|
||||||
// 用法:
|
// 用法:
|
||||||
//
|
//
|
||||||
// go run ./cmd/botcheck -token "<bot_id>:<secret>" # 仅登录自检
|
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" # 仅登录自检
|
||||||
// go run ./cmd/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
|
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
|
||||||
//
|
//
|
||||||
// 连接生产 telesrv(obfuscated TCP)靠 DCOption.TCPObfuscatedOnly=true,
|
// 连接生产 telesrv(obfuscated TCP)靠 DCOption.TCPObfuscatedOnly=true,
|
||||||
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。
|
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
//
|
//
|
||||||
// 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条):
|
// 用法示例(向超级群定时轮流发文本/图片/图文,每 15s 一条):
|
||||||
//
|
//
|
||||||
// go run ./cmd/botdemo \
|
// go run ./cmd/bots/botdemo \
|
||||||
// -token "<bot_id>:<secret>" \
|
// -token "<bot_id>:<secret>" \
|
||||||
// -chat <channel_id> -chat-hash <access_hash> \
|
// -chat <channel_id> -chat-hash <access_hash> \
|
||||||
// -interval 15s -mode rotate
|
// -interval 15s -mode rotate
|
||||||
|
|
@ -60,6 +60,7 @@ import (
|
||||||
"telesrv/internal/store/postgres"
|
"telesrv/internal/store/postgres"
|
||||||
"telesrv/internal/store/redisstore"
|
"telesrv/internal/store/redisstore"
|
||||||
"telesrv/internal/turnsrv"
|
"telesrv/internal/turnsrv"
|
||||||
|
"telesrv/internal/web/stickerlinks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -405,11 +406,22 @@ func run(logger *zap.Logger) error {
|
||||||
// userCache 与 users 服务共享同一实例:bot 元数据写入(version bump)后必须
|
// userCache 与 users 服务共享同一实例:bot 元数据写入(version bump)后必须
|
||||||
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
|
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
|
||||||
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
|
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,
|
botsService := botsapp.NewService(userStore, botStore, messageStore,
|
||||||
botsapp.WithLogger(logger.Named("bots")),
|
botsapp.WithLogger(logger.Named("bots")),
|
||||||
botsapp.WithBlockChecker(contactStore),
|
botsapp.WithBlockChecker(contactStore),
|
||||||
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
botsapp.WithPublicChannelUsernameResolver(channelStore),
|
||||||
botsapp.WithUserCache(userCache))
|
botsapp.WithUserCache(userCache),
|
||||||
|
botsapp.WithStickerSetCreator(filesService),
|
||||||
|
botsapp.WithUserStickerSets(accountService))
|
||||||
groupCallStore := postgres.NewGroupCallStore(pool)
|
groupCallStore := postgres.NewGroupCallStore(pool)
|
||||||
groupCallsService := groupcallsapp.NewService(groupCallStore)
|
groupCallsService := groupcallsapp.NewService(groupCallStore)
|
||||||
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
// 群通话媒体面:内嵌 pion SFU(M1+)。SFU 的 liveness reporter 把媒体面存活
|
||||||
|
|
@ -517,14 +529,6 @@ func run(logger *zap.Logger) error {
|
||||||
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
|
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))
|
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")))
|
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||||
router := rpc.New(rpc.Config{
|
router := rpc.New(rpc.Config{
|
||||||
DC: cfg.DC,
|
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 {
|
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)
|
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{
|
srv := mtprotoedge.New(mtprotoedge.Options{
|
||||||
Logger: logger.Named("mtprotoedge"),
|
Logger: logger.Named("mtprotoedge"),
|
||||||
|
|
|
||||||
1
deploy/migrations/0041_user_sticker_sets.down.sql
Normal file
1
deploy/migrations/0041_user_sticker_sets.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS public.user_sticker_sets;
|
||||||
14
deploy/migrations/0041_user_sticker_sets.up.sql
Normal file
14
deploy/migrations/0041_user_sticker_sets.up.sql
Normal 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);
|
||||||
14
deploy/migrations/0042_sticker_set_creator.down.sql
Normal file
14
deploy/migrations/0042_sticker_set_creator.down.sql
Normal 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;
|
||||||
17
deploy/migrations/0042_sticker_set_creator.up.sql
Normal file
17
deploy/migrations/0042_sticker_set_creator.up.sql
Normal 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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
UPDATE sticker_sets
|
||||||
|
SET installed = false,
|
||||||
|
installed_date = 0
|
||||||
|
WHERE installed = true
|
||||||
|
OR installed_date <> 0;
|
||||||
14
deploy/migrations/0044_stickers_service_bot.down.sql
Normal file
14
deploy/migrations/0044_stickers_service_bot.down.sql
Normal 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;
|
||||||
74
deploy/migrations/0044_stickers_service_bot.up.sql
Normal file
74
deploy/migrations/0044_stickers_service_bot.up.sql
Normal 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;
|
||||||
12
deploy/migrations/0045_stickers_bot_edit_commands.down.sql
Normal file
12
deploy/migrations/0045_stickers_bot_edit_commands.down.sql
Normal 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;
|
||||||
19
deploy/migrations/0045_stickers_bot_edit_commands.up.sql
Normal file
19
deploy/migrations/0045_stickers_bot_edit_commands.up.sql
Normal 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;
|
||||||
|
|
@ -20,13 +20,14 @@ const (
|
||||||
|
|
||||||
// Service 提供账号安全配置查询。
|
// Service 提供账号安全配置查询。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
passwords store.PasswordStore
|
passwords store.PasswordStore
|
||||||
reactions store.AccountReactionSettingsStore
|
reactions store.AccountReactionSettingsStore
|
||||||
settings store.AccountSettingsStore
|
settings store.AccountSettingsStore
|
||||||
notify store.NotifySettingsStore
|
notify store.NotifySettingsStore
|
||||||
stickers store.StickerCollectionStore
|
stickers store.StickerCollectionStore
|
||||||
savedMusic store.SavedMusicStore
|
stickerSets store.UserStickerSetStore
|
||||||
business store.BusinessAutomationStore
|
savedMusic store.SavedMusicStore
|
||||||
|
business store.BusinessAutomationStore
|
||||||
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
||||||
users store.UserStore
|
users store.UserStore
|
||||||
}
|
}
|
||||||
|
|
@ -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 列表持久化。
|
// WithSavedMusic 注入账号级 profile music 列表持久化。
|
||||||
func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption {
|
func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption {
|
||||||
return func(s *Service) {
|
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)
|
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 {
|
func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings {
|
||||||
defaults := domain.DefaultAccountReactionSettings()
|
defaults := domain.DefaultAccountReactionSettings()
|
||||||
settings.Notify = normalizeNotifySettings(settings.Notify)
|
settings.Notify = normalizeNotifySettings(settings.Notify)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
@ -69,47 +70,61 @@ type botReply struct {
|
||||||
|
|
||||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||||
func (s *Service) HandlesBot(botUserID int64) bool {
|
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 实现)。
|
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||||
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
|
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
|
||||||
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
|
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
|
||||||
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
|
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
|
return
|
||||||
}
|
}
|
||||||
userID := msg.From.ID
|
userID := msg.From.ID
|
||||||
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
|
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
|
||||||
return
|
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 内调用)。
|
// respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。
|
||||||
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
|
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
|
||||||
// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。
|
// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。
|
||||||
func (s *Service) respondAsBotFather(userID int64, body string) {
|
func (s *Service) respondAsBotFather(userID int64, body string) {
|
||||||
mu := &s.replyLocks[uint64(userID)%replyLockStripes]
|
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
reply := s.handleBotFather(ctx, userID, body)
|
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
|
return
|
||||||
}
|
}
|
||||||
blocked := false
|
blocked := false
|
||||||
if s.blocker != nil {
|
if s.blocker != nil {
|
||||||
if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil {
|
if b, err := s.blocker.IsBlocked(ctx, userID, botUserID); err != nil {
|
||||||
s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err))
|
s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
blocked = b
|
blocked = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||||
SenderUserID: domain.BotFatherUserID,
|
SenderUserID: botUserID,
|
||||||
RecipientUserID: userID,
|
RecipientUserID: userID,
|
||||||
RandomID: s.botReplyRandomID(),
|
RandomID: s.botReplyRandomID(),
|
||||||
Message: reply.Text,
|
Message: reply.Text,
|
||||||
|
|
@ -117,12 +132,12 @@ func (s *Service) respondAsBotFather(userID int64, body string) {
|
||||||
Date: int(s.now().Unix()),
|
Date: int(s.now().Unix()),
|
||||||
RecipientBlocked: blocked,
|
RecipientBlocked: blocked,
|
||||||
}); err != nil {
|
}); 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) 唯一索引)。
|
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
|
||||||
// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用
|
// 所有服务 bot 回复按各自 sender 命名空间唯一——用
|
||||||
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
|
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
|
||||||
func (s *Service) botReplyRandomID() int64 {
|
func (s *Service) botReplyRandomID() int64 {
|
||||||
if v, err := randomInt64(); err == nil && v != 0 {
|
if v, err := randomInt64(); err == nil && v != 0 {
|
||||||
|
|
|
||||||
|
|
@ -411,3 +411,5 @@ func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int
|
||||||
c.pushedCommandsTo = botUserID
|
c.pushedCommandsTo = botUserID
|
||||||
c.pushedCommands = append([]domain.BotCommand(nil), commands...)
|
c.pushedCommands = append([]domain.BotCommand(nil), commands...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *captureRevoker) PushStickerSetsChanged(context.Context, int64, domain.StickerSetKind) {}
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,31 @@ type publicChannelUsernameResolver interface {
|
||||||
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
|
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 延迟注入,打破
|
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
|
||||||
// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现):
|
// router↔bots 的构造循环;两个能力都依赖 TL/连接层边界,不能在 app 层实现):
|
||||||
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
||||||
// authorization + 强制断连)。
|
// authorization + 强制断连)。
|
||||||
// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands
|
// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands
|
||||||
// (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。
|
// (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。
|
||||||
|
// - PushStickerSetsChanged:@Stickers 发布后给 creator 当前在线 session 推
|
||||||
|
// updateStickerSets;离线端靠持久化 install 状态 + 下次 getAllStickers 兜底。
|
||||||
type RouterHooks interface {
|
type RouterHooks interface {
|
||||||
RevokeBotSessions(ctx context.Context, botUserID int64) error
|
RevokeBotSessions(ctx context.Context, botUserID int64) error
|
||||||
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
|
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
|
||||||
|
PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
|
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
|
||||||
|
|
@ -51,6 +67,8 @@ type Service struct {
|
||||||
messages store.MessageStore
|
messages store.MessageStore
|
||||||
blocker blockChecker
|
blocker blockChecker
|
||||||
channels publicChannelUsernameResolver
|
channels publicChannelUsernameResolver
|
||||||
|
stickers stickerSetCreator
|
||||||
|
installer userStickerSetInstaller
|
||||||
hooks RouterHooks
|
hooks RouterHooks
|
||||||
userCache store.UserCache
|
userCache store.UserCache
|
||||||
cache *botProfileCache
|
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)后清缓存。
|
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
||||||
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
||||||
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
||||||
|
|
|
||||||
793
internal/app/bots/stickersbot.go
Normal file
793
internal/app/bots/stickersbot.go
Normal 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
|
||||||
|
}
|
||||||
624
internal/app/bots/stickersbot_test.go
Normal file
624
internal/app/bots/stickersbot_test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,19 @@ func (c *stickerSetNegativeCache) put(ref domain.StickerSetRef) {
|
||||||
c.entries[key] = time.Now().Add(c.ttl)
|
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
|
// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile
|
||||||
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
|
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
|
||||||
// reaction/头像更被大量用户重复拉)。
|
// 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 {
|
func copyStickerSet(set domain.StickerSet) domain.StickerSet {
|
||||||
set.DocumentIDs = append([]int64(nil), set.DocumentIDs...)
|
set.DocumentIDs = append([]int64(nil), set.DocumentIDs...)
|
||||||
set.Packs = append([]domain.StickerPack(nil), set.Packs...)
|
set.Packs = append([]domain.StickerPack(nil), set.Packs...)
|
||||||
for i := range set.Packs {
|
for i := range set.Packs {
|
||||||
set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...)
|
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)
|
set.Thumbs = copyPhotoSizes(set.Thumbs)
|
||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBlobMetaCacheGetPutEvict(t *testing.T) {
|
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) {
|
func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
local, err := NewLocalFS(t.TempDir())
|
local, err := NewLocalFS(t.TempDir())
|
||||||
|
|
|
||||||
|
|
@ -602,7 +602,7 @@ func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKi
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
|
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
|
||||||
return kind != domain.StickerSetKindSystem
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- JSON → domain 转换 ----
|
// ---- JSON → domain 转换 ----
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -209,17 +210,63 @@ func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet)
|
||||||
f.sets[set.ID] = set
|
f.sets[set.ID] = set
|
||||||
return nil
|
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) {
|
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
s, ok := f.sets[id]
|
s, ok := f.sets[id]
|
||||||
|
if ok && s.Deleted {
|
||||||
|
return domain.StickerSet{}, false, nil
|
||||||
|
}
|
||||||
return s, ok, nil
|
return s, ok, nil
|
||||||
}
|
}
|
||||||
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
|
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
for _, s := range f.sets {
|
for _, s := range f.sets {
|
||||||
if s.ShortName == name {
|
if strings.EqualFold(s.ShortName, name) && !s.Deleted {
|
||||||
return s, true, nil
|
return s, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,12 +287,48 @@ func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerS
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
var out []domain.StickerSet
|
var out []domain.StickerSet
|
||||||
for _, s := range f.sets {
|
for _, s := range f.sets {
|
||||||
if s.Kind == kind {
|
if s.Kind == kind && !s.Deleted {
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
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) {
|
func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
kind domain.StickerSetKind
|
kind domain.StickerSetKind
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: true},
|
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: false},
|
||||||
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true},
|
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: false},
|
||||||
{name: "masks", kind: domain.StickerSetKindMasks, want: true},
|
{name: "masks", kind: domain.StickerSetKindMasks, want: false},
|
||||||
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
|
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|
|
||||||
|
|
@ -263,23 +263,48 @@ func (s *Service) DeleteExpiredUploadParts(ctx context.Context, before time.Time
|
||||||
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
|
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
|
||||||
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
|
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
|
||||||
type blobMetaResult struct {
|
type blobMetaResult struct {
|
||||||
blob domain.FileBlob
|
blob domain.FileBlob
|
||||||
found bool
|
found bool
|
||||||
|
cacheHit bool
|
||||||
|
cacheFilled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type blobBytesResult struct {
|
type blobBytesResult struct {
|
||||||
data []byte
|
data []byte
|
||||||
total int64
|
total int64
|
||||||
cacheable bool
|
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)
|
blob, ok := s.blobCache.get(req.LocationKey)
|
||||||
if !ok {
|
if ok {
|
||||||
|
cacheLog.metaCacheHit = true
|
||||||
|
} else {
|
||||||
// 同一 location_key 的并发首访合并成一次 PG GetFileBlob。
|
// 同一 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 {
|
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)
|
b, found, err := s.media.GetFileBlob(ctx, req.LocationKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -288,19 +313,26 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
||||||
if found {
|
if found {
|
||||||
s.blobCache.put(req.LocationKey, b)
|
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 {
|
if err != nil {
|
||||||
return domain.FileChunk{}, false, err
|
return domain.FileChunk{}, false, err
|
||||||
}
|
}
|
||||||
res := v.(blobMetaResult)
|
res := v.(blobMetaResult)
|
||||||
|
cacheLog.metaCacheHit = res.cacheHit
|
||||||
|
cacheLog.metaCacheFilled = res.cacheFilled
|
||||||
if !res.found {
|
if !res.found {
|
||||||
|
cacheLog.source = "metadata_miss"
|
||||||
return domain.FileChunk{}, false, nil
|
return domain.FileChunk{}, false, nil
|
||||||
}
|
}
|
||||||
blob = res.blob
|
blob = res.blob
|
||||||
}
|
}
|
||||||
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
|
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
|
||||||
|
cacheLog.byteCacheEligible = true
|
||||||
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
|
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
|
||||||
|
cacheLog.byteCacheHit = true
|
||||||
|
cacheLog.source = "byte_cache"
|
||||||
return domain.FileChunk{
|
return domain.FileChunk{
|
||||||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||||||
MimeType: blob.MimeType,
|
MimeType: blob.MimeType,
|
||||||
|
|
@ -308,9 +340,9 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
// 同一 object_key 的小 blob 并发首访合并成一次 backend 全量读 + 一次 byteCache 填充。
|
// 同一 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 {
|
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)
|
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -318,15 +350,24 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
||||||
}
|
}
|
||||||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||||||
s.byteCache.put(blob.ObjectKey, data)
|
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
|
return blobBytesResult{cacheable: false}, nil
|
||||||
})
|
})
|
||||||
|
cacheLog.byteSingleflight = shared
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||||
}
|
}
|
||||||
// res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。
|
// res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。
|
||||||
if res := v.(blobBytesResult); res.cacheable {
|
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{
|
return domain.FileChunk{
|
||||||
Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)),
|
Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)),
|
||||||
MimeType: blob.MimeType,
|
MimeType: blob.MimeType,
|
||||||
|
|
@ -334,6 +375,11 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
// 大小不符/超限:落到下面的按需 range 读(与原行为一致)。
|
// 大小不符/超限:落到下面的按需 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))
|
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -346,6 +392,38 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
||||||
}, true, nil
|
}, 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 {
|
func sliceBlobBytes(data []byte, offset, limit int64) []byte {
|
||||||
total := int64(len(data))
|
total := int64(len(data))
|
||||||
if offset < 0 {
|
if offset < 0 {
|
||||||
|
|
|
||||||
661
internal/app/files/sticker_creator.go
Normal file
661
internal/app/files/sticker_creator.go
Normal 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})
|
||||||
|
}
|
||||||
338
internal/app/files/sticker_creator_test.go
Normal file
338
internal/app/files/sticker_creator_test.go
Normal 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": []
|
||||||
|
}`))
|
||||||
|
}
|
||||||
343
internal/app/files/sticker_management.go
Normal file
343
internal/app/files/sticker_management.go
Normal 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
|
||||||
|
}
|
||||||
180
internal/app/files/sticker_management_test.go
Normal file
180
internal/app/files/sticker_management_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,11 @@ type Config struct {
|
||||||
AdminAPIAddr string
|
AdminAPIAddr string
|
||||||
// AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。
|
// AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。
|
||||||
AdminAPIToken string
|
AdminAPIToken string
|
||||||
|
// StickerWebAddr 是公开 sticker/custom emoji deep link 落地页监听地址;为空关闭。
|
||||||
|
// 生产应只监听 loopback,并由 nginx 将 /addstickers/ 与 /addemoji/ 反代到该地址。
|
||||||
|
StickerWebAddr string
|
||||||
|
// StickerWebPublicURL 是生成 canonical telesrv.net 链接的公开根 URL。
|
||||||
|
StickerWebPublicURL string
|
||||||
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
||||||
AdminUIAddr string
|
AdminUIAddr string
|
||||||
AdminUIPassword string
|
AdminUIPassword string
|
||||||
|
|
@ -250,17 +255,19 @@ func Load() (Config, error) {
|
||||||
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions,
|
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions,
|
||||||
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。
|
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。
|
||||||
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
|
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
|
||||||
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
|
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
|
||||||
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
|
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
|
||||||
DC: envIntOr("TELESRV_DC", 2),
|
DC: envIntOr("TELESRV_DC", 2),
|
||||||
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
|
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
|
||||||
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
|
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
|
||||||
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
|
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
|
||||||
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
|
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
|
||||||
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"),
|
StickerWebAddr: envOr("TELESRV_STICKER_WEB_ADDR", ""),
|
||||||
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
|
StickerWebPublicURL: envOr("TELESRV_STICKER_WEB_PUBLIC_URL", "https://telesrv.net"),
|
||||||
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
|
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"),
|
||||||
AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""),
|
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
|
||||||
|
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
|
||||||
|
AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""),
|
||||||
|
|
||||||
// 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker
|
// 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker
|
||||||
// Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost
|
// Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadUsesExplicitAdvertiseIP(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()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
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)
|
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +62,8 @@ TELESRV_MAPBOX_TOKEN="file-token"
|
||||||
TELESRV_POSTGRES_MAX_CONNS=77
|
TELESRV_POSTGRES_MAX_CONNS=77
|
||||||
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
|
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
|
||||||
TELESRV_CALL_RING_TIMEOUT=2m
|
TELESRV_CALL_RING_TIMEOUT=2m
|
||||||
|
TELESRV_STICKER_WEB_ADDR=127.0.0.1:2401
|
||||||
|
TELESRV_STICKER_WEB_PUBLIC_URL=https://packs.example.test
|
||||||
`)
|
`)
|
||||||
t.Setenv("TELESRV_CONFIG", path)
|
t.Setenv("TELESRV_CONFIG", path)
|
||||||
|
|
||||||
|
|
@ -81,6 +83,12 @@ TELESRV_CALL_RING_TIMEOUT=2m
|
||||||
if cfg.CallRingTimeout != 2*time.Minute {
|
if cfg.CallRingTimeout != 2*time.Minute {
|
||||||
t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout)
|
t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout)
|
||||||
}
|
}
|
||||||
|
if cfg.StickerWebAddr != "127.0.0.1:2401" {
|
||||||
|
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) {
|
func TestLoadEnvironmentOverridesConfigFile(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。
|
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。
|
||||||
// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。
|
// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。
|
||||||
//
|
//
|
||||||
|
|
@ -238,6 +243,91 @@ func (d Document) IsSticker() bool {
|
||||||
return false
|
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).
|
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
|
||||||
func (d Document) IsGif() bool {
|
func (d Document) IsGif() bool {
|
||||||
for _, attr := range d.Attributes {
|
for _, attr := range d.Attributes {
|
||||||
|
|
@ -558,6 +648,11 @@ type StickerPack struct {
|
||||||
DocumentIDs []int64 `json:"document_ids"`
|
DocumentIDs []int64 `json:"document_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StickerKeyword struct {
|
||||||
|
DocumentID int64 `json:"document_id"`
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
}
|
||||||
|
|
||||||
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
|
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
|
||||||
// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集
|
// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集
|
||||||
// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
|
// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
|
||||||
|
|
@ -576,32 +671,67 @@ const (
|
||||||
|
|
||||||
// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。
|
// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。
|
||||||
type StickerSet struct {
|
type StickerSet struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
AccessHash int64 `json:"access_hash"`
|
AccessHash int64 `json:"access_hash"`
|
||||||
ShortName string `json:"short_name"`
|
ShortName string `json:"short_name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Hash int `json:"hash"`
|
Hash int `json:"hash"`
|
||||||
Kind StickerSetKind `json:"set_kind"`
|
Kind StickerSetKind `json:"set_kind"`
|
||||||
Official bool `json:"official,omitempty"`
|
Official bool `json:"official,omitempty"`
|
||||||
Animated bool `json:"animated,omitempty"`
|
Animated bool `json:"animated,omitempty"`
|
||||||
Videos bool `json:"videos,omitempty"`
|
Videos bool `json:"videos,omitempty"`
|
||||||
Emojis bool `json:"emojis,omitempty"`
|
Emojis bool `json:"emojis,omitempty"`
|
||||||
Masks bool `json:"masks,omitempty"`
|
Masks bool `json:"masks,omitempty"`
|
||||||
Installed bool `json:"installed,omitempty"`
|
TextColor bool `json:"text_color,omitempty"`
|
||||||
Archived bool `json:"archived,omitempty"`
|
Creator bool `json:"creator,omitempty"`
|
||||||
InstalledDate int `json:"installed_date,omitempty"`
|
CreatorUserID int64 `json:"creator_user_id,omitempty"`
|
||||||
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
|
Installed bool `json:"installed,omitempty"`
|
||||||
Thumbs []PhotoSize `json:"thumbs,omitempty"`
|
Archived bool `json:"archived,omitempty"`
|
||||||
ThumbDCID int `json:"thumb_dc_id,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
ThumbVersion int `json:"thumb_version,omitempty"`
|
InstalledDate int `json:"installed_date,omitempty"`
|
||||||
DocumentIDs []int64 `json:"document_ids,omitempty"`
|
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
|
||||||
Packs []StickerPack `json:"packs,omitempty"`
|
Thumbs []PhotoSize `json:"thumbs,omitempty"`
|
||||||
SortOrder int `json:"sort_order,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 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。
|
||||||
SystemKey string `json:"system_key,omitempty"`
|
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
|
// ProfilePhotoKind distinguishes a user's real profile photo from the fallback
|
||||||
// public photo shown when privacy hides the real one.
|
// public photo shown when privacy hides the real one.
|
||||||
type ProfilePhotoKind string
|
type ProfilePhotoKind string
|
||||||
|
|
|
||||||
74
internal/domain/media_test.go
Normal file
74
internal/domain/media_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,21 @@ import "errors"
|
||||||
// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIF(faveSticker/saveGif 等)。
|
// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIF(faveSticker/saveGif 等)。
|
||||||
var ErrStickerInvalid = errors.New("sticker document invalid")
|
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 区分一个用户的几类个人贴纸集合。
|
// StickerCollectionKind 区分一个用户的几类个人贴纸集合。
|
||||||
type StickerCollectionKind string
|
type StickerCollectionKind string
|
||||||
|
|
||||||
|
|
@ -22,9 +37,11 @@ const (
|
||||||
// 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额;
|
// 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额;
|
||||||
// faved 不做 premium 分档(用户决策范围外),统一一个上界。
|
// faved 不做 premium 分档(用户决策范围外),统一一个上界。
|
||||||
const (
|
const (
|
||||||
MaxFavedStickers = 100
|
MaxFavedStickers = 100
|
||||||
MaxRecentStickers = 30
|
MaxRecentStickers = 30
|
||||||
MaxSavedGifs = 200
|
MaxSavedGifs = 200
|
||||||
|
MaxInstalledStickerSets = 200
|
||||||
|
MaxCreatedStickerSets = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxStickerCollectionItems 返回某类集合的容量上界。
|
// MaxStickerCollectionItems 返回某类集合的容量上界。
|
||||||
|
|
@ -44,3 +61,14 @@ type StickerCollectionItem struct {
|
||||||
DocumentID int64
|
DocumentID int64
|
||||||
Date int
|
Date int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserStickerSet 是某个账号安装/归档的一条 sticker set 状态。
|
||||||
|
// set 元数据、文档和 packs 仍由 media/files 模块维护;这里仅保存用户视角。
|
||||||
|
type UserStickerSet struct {
|
||||||
|
OwnerUserID int64
|
||||||
|
StickerSetID int64
|
||||||
|
Kind StickerSetKind
|
||||||
|
Archived bool
|
||||||
|
InstalledDate int
|
||||||
|
OrderValue int64
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ const (
|
||||||
BotFatherUserID int64 = 93372553
|
BotFatherUserID int64 = 93372553
|
||||||
// BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。
|
// BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。
|
||||||
BotFatherAccessHash int64 = 7421896403922962293
|
BotFatherAccessHash int64 = 7421896403922962293
|
||||||
|
|
||||||
|
// StickersBotUserID 是内置 @Stickers 账号。它是 server 内置 service bot,
|
||||||
|
// 不走外部 Bot API 进程。
|
||||||
|
StickersBotUserID int64 = 1063110917
|
||||||
|
// StickersBotAccessHash 固定不变;与 postgres 种子行双写,必须保持一致。
|
||||||
|
StickersBotAccessHash int64 = 5213187021149032991
|
||||||
)
|
)
|
||||||
|
|
||||||
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
|
// 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。
|
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
|
||||||
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
|
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
|
||||||
func SystemUserByID(id int64) (User, bool) {
|
func SystemUserByID(id int64) (User, bool) {
|
||||||
|
|
@ -44,6 +63,8 @@ func SystemUserByID(id int64) (User, bool) {
|
||||||
return OfficialSystemUser(), true
|
return OfficialSystemUser(), true
|
||||||
case BotFatherUserID:
|
case BotFatherUserID:
|
||||||
return BotFatherUser(), true
|
return BotFatherUser(), true
|
||||||
|
case StickersBotUserID:
|
||||||
|
return StickersBotUser(), true
|
||||||
}
|
}
|
||||||
return User{}, false
|
return User{}, false
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +76,7 @@ func IsSystemUserID(id int64) bool {
|
||||||
|
|
||||||
func SystemUserByPhone(phone string) (User, bool) {
|
func SystemUserByPhone(phone string) (User, bool) {
|
||||||
phone = NormalizePhone(phone)
|
phone = NormalizePhone(phone)
|
||||||
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID} {
|
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID, StickersBotUserID} {
|
||||||
u, ok := SystemUserByID(id)
|
u, ok := SystemUserByID(id)
|
||||||
if !ok || u.Phone == "" {
|
if !ok || u.Phone == "" {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// 本文件实现 app/bots 的 RouterHooks 回调:token revoke 后的 session 失效闭环,
|
// 本文件实现 app/bots 的 RouterHooks 回调:token revoke 后的 session 失效闭环,
|
||||||
// 以及命令变更后的 updateBotCommands 在线推送。Router 创建后经
|
// 命令变更后的 updateBotCommands 在线推送,以及 @Stickers 发布后的
|
||||||
|
// updateStickerSets 在线提示。Router 创建后经
|
||||||
// botsService.SetRouterHooks(router) 装配(见 cmd/telesrv/main.go)。
|
// botsService.SetRouterHooks(router) 装配(见 cmd/telesrv/main.go)。
|
||||||
|
|
||||||
// maxBotCommandsPushPeers 限制单次命令变更的推送扇出(bot 的最近 dialog peer 数)。
|
// 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)
|
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) {
|
func (r *Router) pushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if rec := recover(); rec != nil {
|
if rec := recover(); rec != nil {
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,8 @@ func tgStickerSet(set domain.StickerSet) tg.StickerSet {
|
||||||
Official: set.Official,
|
Official: set.Official,
|
||||||
Masks: set.Masks,
|
Masks: set.Masks,
|
||||||
Emojis: set.Emojis,
|
Emojis: set.Emojis,
|
||||||
|
TextColor: set.TextColor,
|
||||||
|
Creator: set.Creator,
|
||||||
ID: set.ID,
|
ID: set.ID,
|
||||||
AccessHash: set.AccessHash,
|
AccessHash: set.AccessHash,
|
||||||
Title: set.Title,
|
Title: set.Title,
|
||||||
|
|
@ -633,12 +635,23 @@ func tgStickerPacks(packs []domain.StickerPack) []tg.StickerPack {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tgStickerKeywords(keywords []domain.StickerKeyword) []tg.StickerKeyword {
|
||||||
|
out := make([]tg.StickerKeyword, 0, len(keywords))
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if kw.DocumentID == 0 || len(kw.Keywords) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, tg.StickerKeyword{DocumentID: kw.DocumentID, Keyword: append([]string(nil), kw.Keywords...)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// tgMessagesStickerSet 构造完整 messages.stickerSet(set + packs + documents)。
|
// tgMessagesStickerSet 构造完整 messages.stickerSet(set + packs + documents)。
|
||||||
func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet {
|
func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet {
|
||||||
return &tg.MessagesStickerSet{
|
return &tg.MessagesStickerSet{
|
||||||
Set: tgStickerSet(set),
|
Set: tgStickerSet(set),
|
||||||
Packs: tgStickerPacks(set.Packs),
|
Packs: tgStickerPacks(set.Packs),
|
||||||
Keywords: []tg.StickerKeyword{},
|
Keywords: tgStickerKeywords(set.Keywords),
|
||||||
Documents: tgDocuments(docs),
|
Documents: tgDocuments(docs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ func applyTgUserBotFields(out *tg.User, u domain.User) {
|
||||||
version = 1
|
version = 1
|
||||||
}
|
}
|
||||||
out.SetBotInfoVersion(version)
|
out.SetBotInfoVersion(version)
|
||||||
if u.ID != domain.BotFatherUserID {
|
if !domain.IsSystemUserID(u.ID) {
|
||||||
out.SetBotBusiness(true)
|
out.SetBotBusiness(true)
|
||||||
}
|
}
|
||||||
out.Phone = ""
|
out.Phone = ""
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,15 @@ type FilesService interface {
|
||||||
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
|
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)
|
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)
|
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)与消息媒体组装。
|
// 头像(profile photo)与消息媒体组装。
|
||||||
CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error)
|
CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error)
|
||||||
CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error)
|
CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error)
|
||||||
|
|
|
||||||
|
|
@ -517,17 +517,12 @@ func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, use
|
||||||
}
|
}
|
||||||
|
|
||||||
func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass {
|
func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass {
|
||||||
if hash != 0 {
|
return &tg.MessagesAllStickers{Hash: 0, Sets: []tg.StickerSet{}}
|
||||||
return &tg.MessagesAllStickersNotModified{}
|
|
||||||
}
|
|
||||||
return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass {
|
func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass {
|
||||||
if hash != 0 {
|
|
||||||
return &tg.MessagesFeaturedStickersNotModified{Count: 0}
|
|
||||||
}
|
|
||||||
return &tg.MessagesFeaturedStickers{
|
return &tg.MessagesFeaturedStickers{
|
||||||
|
Hash: 0,
|
||||||
Count: 0,
|
Count: 0,
|
||||||
Sets: []tg.StickerSetCoveredClass{},
|
Sets: []tg.StickerSetCoveredClass{},
|
||||||
Unread: []int64{},
|
Unread: []int64{},
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,11 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
|
||||||
d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions)
|
d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions)
|
||||||
d.OnMessagesGetAvailableEffects(r.onMessagesGetAvailableEffects)
|
d.OnMessagesGetAvailableEffects(r.onMessagesGetAvailableEffects)
|
||||||
d.OnMessagesGetStickers(r.onMessagesGetStickers)
|
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) {
|
d.OnMessagesGetArchivedStickers(func(ctx context.Context, req *tg.MessagesGetArchivedStickersRequest) (*tg.MessagesArchivedStickers, error) {
|
||||||
return &tg.MessagesArchivedStickers{
|
return &tg.MessagesArchivedStickers{
|
||||||
Count: 0,
|
Count: 0,
|
||||||
|
|
|
||||||
263
internal/rpc/messages_sticker_sets.go
Normal file
263
internal/rpc/messages_sticker_sets.go
Normal 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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
306
internal/rpc/messages_sticker_sets_rpc_test.go
Normal file
306
internal/rpc/messages_sticker_sets_rpc_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -169,6 +169,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
|
||||||
r.registerUpdates(d)
|
r.registerUpdates(d)
|
||||||
r.registerAccount(d)
|
r.registerAccount(d)
|
||||||
r.registerMessages(d)
|
r.registerMessages(d)
|
||||||
|
r.registerStickers(d)
|
||||||
r.registerChannels(d)
|
r.registerChannels(d)
|
||||||
r.registerUpload(d)
|
r.registerUpload(d)
|
||||||
r.registerPhotos(d)
|
r.registerPhotos(d)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gotd/td/clock"
|
"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) {
|
func (f *fakeFiles) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
|
||||||
for _, sets := range f.sets {
|
for _, sets := range f.sets {
|
||||||
for _, set := range sets {
|
for _, set := range sets {
|
||||||
|
if set.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
match := false
|
match := false
|
||||||
switch ref.Kind {
|
switch ref.Kind {
|
||||||
case domain.StickerSetRefByID:
|
case domain.StickerSetRefByID:
|
||||||
|
|
@ -114,6 +119,411 @@ func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKin
|
||||||
sets := f.sets[kind]
|
sets := f.sets[kind]
|
||||||
return append([]domain.StickerSet(nil), sets...), nil
|
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) {
|
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}}}
|
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
|
return f.putPhoto(photo), nil
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,10 @@ func (r *Router) stickerCatalogSets(ctx context.Context, kind domain.StickerSetK
|
||||||
}
|
}
|
||||||
return sets
|
return sets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) invalidateStickerCatalog(kind domain.StickerSetKind) {
|
||||||
|
if r.stickerCatalog == nil || r.stickerCatalog.cache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.stickerCatalog.cache.Invalidate(kind)
|
||||||
|
}
|
||||||
|
|
|
||||||
330
internal/rpc/sticker_link_smoke_test.go
Normal file
330
internal/rpc/sticker_link_smoke_test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
if fallbackSet, fallbackDocs, fallbackFound, fallbackErr := r.resolvePlaceholderStickerSet(ctx, ref); fallbackErr != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
} else if fallbackFound {
|
} else if fallbackFound {
|
||||||
|
fallbackSet, fallbackErr = r.stickerSetWithViewerInstallState(ctx, fallbackSet)
|
||||||
|
if fallbackErr != nil {
|
||||||
|
return nil, fallbackErr
|
||||||
|
}
|
||||||
if r.log != nil {
|
if r.log != nil {
|
||||||
r.log.Debug("getStickerSet placeholder fallback",
|
r.log.Debug("getStickerSet placeholder fallback",
|
||||||
zap.String("short_name", ref.ShortName),
|
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 {
|
if req.Hash != 0 && req.Hash == set.Hash {
|
||||||
return &tg.MessagesStickerSetNotModified{}, nil
|
return &tg.MessagesStickerSetNotModified{}, nil
|
||||||
}
|
}
|
||||||
|
set, err = r.stickerSetWithViewerInstallState(ctx, set)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return tgMessagesStickerSet(set, docs), nil
|
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 {
|
if r.deps.Files == nil {
|
||||||
return messagesAllStickersEmpty(hash), nil
|
return messagesAllStickersEmpty(hash), nil
|
||||||
}
|
}
|
||||||
// perf:从目录缓存读集(TTL 内 hash 命中不打 PG)。
|
sets, handled, err := r.installedStickerSetsForViewer(ctx, kind)
|
||||||
sets := r.stickerCatalogSets(ctx, kind)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
// 兼容无 per-user 安装态的测试/旧内存路径:从目录缓存读全局 installed 标志。
|
||||||
|
sets = installedGlobalStickerSets(r.stickerCatalogSets(ctx, kind))
|
||||||
|
}
|
||||||
if len(sets) == 0 {
|
if len(sets) == 0 {
|
||||||
return messagesAllStickersEmpty(hash), nil
|
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
|
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 预览用)。
|
// featuredCoversPerSet 限制每个 featured 集解析的封面贴纸数量(trending 预览用)。
|
||||||
const featuredCoversPerSet = 5
|
const featuredCoversPerSet = 5
|
||||||
|
|
||||||
|
|
@ -233,7 +348,12 @@ func (r *Router) featuredStickersForKind(ctx context.Context, hash int64, kind d
|
||||||
if len(visible) == 0 {
|
if len(visible) == 0 {
|
||||||
return messagesFeaturedStickersEmpty(hash), nil
|
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 {
|
if hash != 0 && hash == catalogHash {
|
||||||
// 关键 perf 短路:目录未变直接返回,不解析任何封面文档。
|
// 关键 perf 短路:目录未变直接返回,不解析任何封面文档。
|
||||||
return &tg.MessagesFeaturedStickersNotModified{Count: len(visible)}, nil
|
return &tg.MessagesFeaturedStickersNotModified{Count: len(visible)}, nil
|
||||||
|
|
@ -333,6 +453,20 @@ func stickerSetsCatalogHash(sets []domain.StickerSet) int64 {
|
||||||
return int64(tdesktopCountHash(values))
|
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 {
|
func boolHashValue(v bool) int64 {
|
||||||
if v {
|
if v {
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
351
internal/rpc/stickers_creator.go
Normal file
351
internal/rpc/stickers_creator.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
253
internal/rpc/stickers_creator_rpc_test.go
Normal file
253
internal/rpc/stickers_creator_rpc_test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
files := &fakeFiles{
|
files := &fakeFiles{
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,17 @@ type StickerCollectionStore interface {
|
||||||
ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error
|
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.
|
// SavedMusicStore persists one account's ordered profile music list.
|
||||||
type SavedMusicStore interface {
|
type SavedMusicStore interface {
|
||||||
SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error
|
SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,15 @@ type MediaStore interface {
|
||||||
|
|
||||||
// 贴纸集 / 可用 reaction。
|
// 贴纸集 / 可用 reaction。
|
||||||
PutStickerSet(ctx context.Context, set domain.StickerSet) error
|
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)
|
GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error)
|
||||||
GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error)
|
GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error)
|
||||||
GetStickerSetBySystemKey(ctx context.Context, systemKey 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)
|
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)
|
CountStickerSets(ctx context.Context) (int, error)
|
||||||
PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error
|
PutAvailableReaction(ctx context.Context, r domain.AvailableReaction) error
|
||||||
ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error)
|
ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
|
|
||||||
// BotStore 是 store.BotStore 的内存实现。bot 的 users 行经注入的 UserStore 创建,
|
// BotStore 是 store.BotStore 的内存实现。bot 的 users 行经注入的 UserStore 创建,
|
||||||
// 与 postgres 实现(单事务建 users+bots 两行)保持可见性一致。
|
// 与 postgres 实现(单事务建 users+bots 两行)保持可见性一致。
|
||||||
// 内置 BotFather 的 bots 行预置(token 为空 = 不可登录),对齐迁移 0090 种子。
|
// 内置 service bots 的 bots 行预置(token 为空 = 不可登录),对齐迁移种子。
|
||||||
type BotStore struct {
|
type BotStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
users *UserStore
|
users *UserStore
|
||||||
|
|
@ -59,7 +59,13 @@ func NewBotStore(users *UserStore) *BotStore {
|
||||||
emojiPerms: make(map[[2]int64]bool),
|
emojiPerms: make(map[[2]int64]bool),
|
||||||
customMethod: make(map[string]domain.BotWebViewCustomMethodQuery),
|
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,
|
BotUserID: domain.BotFatherUserID,
|
||||||
OwnerUserID: 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.",
|
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"},
|
{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) {
|
func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package memory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -14,6 +15,7 @@ type PasswordStore struct {
|
||||||
accountSettings map[int64]domain.AccountSettings
|
accountSettings map[int64]domain.AccountSettings
|
||||||
notifySettings map[notifySettingsKey]domain.PeerNotifySettings
|
notifySettings map[notifySettingsKey]domain.PeerNotifySettings
|
||||||
stickerCollections map[stickerCollectionKey][]domain.StickerCollectionItem
|
stickerCollections map[stickerCollectionKey][]domain.StickerCollectionItem
|
||||||
|
userStickerSets map[int64]map[int64]domain.UserStickerSet
|
||||||
savedMusic map[int64][]domain.Document
|
savedMusic map[int64][]domain.Document
|
||||||
businessProfiles map[int64]domain.BusinessProfile
|
businessProfiles map[int64]domain.BusinessProfile
|
||||||
businessChatLinks map[string]domain.BusinessChatLink
|
businessChatLinks map[string]domain.BusinessChatLink
|
||||||
|
|
@ -48,6 +50,7 @@ func NewPasswordStore() *PasswordStore {
|
||||||
accountSettings: make(map[int64]domain.AccountSettings),
|
accountSettings: make(map[int64]domain.AccountSettings),
|
||||||
notifySettings: make(map[notifySettingsKey]domain.PeerNotifySettings),
|
notifySettings: make(map[notifySettingsKey]domain.PeerNotifySettings),
|
||||||
stickerCollections: make(map[stickerCollectionKey][]domain.StickerCollectionItem),
|
stickerCollections: make(map[stickerCollectionKey][]domain.StickerCollectionItem),
|
||||||
|
userStickerSets: make(map[int64]map[int64]domain.UserStickerSet),
|
||||||
savedMusic: make(map[int64][]domain.Document),
|
savedMusic: make(map[int64][]domain.Document),
|
||||||
businessProfiles: make(map[int64]domain.BusinessProfile),
|
businessProfiles: make(map[int64]domain.BusinessProfile),
|
||||||
businessChatLinks: make(map[string]domain.BusinessChatLink),
|
businessChatLinks: make(map[string]domain.BusinessChatLink),
|
||||||
|
|
@ -234,6 +237,113 @@ func (s *PasswordStore) ClearStickerCollection(_ context.Context, userID int64,
|
||||||
return nil
|
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) {
|
func (s *PasswordStore) ListNotifyExceptions(_ context.Context, ownerUserID int64) ([]domain.NotifyException, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ type UserStore struct {
|
||||||
nextID int64
|
nextID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather)预置进表,
|
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers)
|
||||||
// 与 postgres 的迁移种子(0005 / 0090)保持双 store 行为一致。
|
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
|
||||||
func NewUserStore() *UserStore {
|
func NewUserStore() *UserStore {
|
||||||
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
|
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 {
|
if u, ok := domain.SystemUserByID(id); ok {
|
||||||
s.byID[u.ID] = u
|
s.byID[u.ID] = u
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,125 @@ func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64
|
||||||
return nil
|
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 {
|
func nullableBool(v *bool) any {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgerrcode"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
@ -237,15 +239,27 @@ ON CONFLICT (key) DO UPDATE SET
|
||||||
// ---- 文档 ----
|
// ---- 文档 ----
|
||||||
|
|
||||||
func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error {
|
func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error {
|
||||||
attrs, err := jsonArrayOrEmpty(doc.Attributes)
|
params, err := putDocumentParams(doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
thumbs, err := jsonArrayOrEmpty(doc.Thumbs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return sqlcgen.PutDocumentParams{}, err
|
||||||
}
|
}
|
||||||
if err := s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{
|
return sqlcgen.PutDocumentParams{
|
||||||
ID: doc.ID,
|
ID: doc.ID,
|
||||||
AccessHash: doc.AccessHash,
|
AccessHash: doc.AccessHash,
|
||||||
FileReference: bytesOrEmpty(doc.FileReference),
|
FileReference: bytesOrEmpty(doc.FileReference),
|
||||||
|
|
@ -255,11 +269,7 @@ func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error
|
||||||
DcID: int32(doc.DCID),
|
DcID: int32(doc.DCID),
|
||||||
AttributesJson: attrs,
|
AttributesJson: attrs,
|
||||||
ThumbsJson: thumbs,
|
ThumbsJson: thumbs,
|
||||||
}); err != nil {
|
}, nil
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.documents.put(doc.ID, doc)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
|
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) {
|
func (s *MediaStore) CreateStickerSet(ctx context.Context, set domain.StickerSet, docs []domain.Document) error {
|
||||||
row, err := s.q.GetStickerSetByID(ctx, id)
|
err := withTx(ctx, s.db, "create sticker set", func(tx pgx.Tx) error {
|
||||||
if err != nil {
|
if err := insertStickerSet(ctx, tx, set); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
return err
|
||||||
return domain.StickerSet{}, false, nil
|
|
||||||
}
|
}
|
||||||
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) {
|
func (s *MediaStore) GetStickerSetByShortName(ctx context.Context, shortName string) (domain.StickerSet, bool, error) {
|
||||||
row, err := s.q.GetStickerSetByShortName(ctx, shortName)
|
return queryStickerSet(ctx, s.db, "lower(short_name) = lower($1)", 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) {
|
func (s *MediaStore) GetStickerSetBySystemKey(ctx context.Context, systemKey string) (domain.StickerSet, bool, error) {
|
||||||
row, err := s.q.GetStickerSetBySystemKey(ctx, systemKey)
|
return queryStickerSet(ctx, s.db, "system_key = $1", 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaStore) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]domain.StickerSet, 0, len(rows))
|
defer rows.Close()
|
||||||
for _, r := range rows {
|
out := []domain.StickerSet{}
|
||||||
set, _, err := stickerSetFromRow(sqlcgen.GetStickerSetByIDRow(r))
|
for rows.Next() {
|
||||||
|
set, err := scanStickerSet(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, set)
|
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) {
|
func (s *MediaStore) CountStickerSets(ctx context.Context) (int, error) {
|
||||||
|
|
|
||||||
|
|
@ -136,3 +136,14 @@ func decodeStickerPacks(s string) ([]domain.StickerPack, error) {
|
||||||
}
|
}
|
||||||
return out, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
283
internal/web/stickerlinks/server.go
Normal file
283
internal/web/stickerlinks/server.go
Normal 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>
|
||||||
|
`))
|
||||||
149
internal/web/stickerlinks/server_test.go
Normal file
149
internal/web/stickerlinks/server_test.go
Normal 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")
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue