feat: sync built-in sticker bot
This commit is contained in:
parent
7096625e13
commit
6867d201ed
60 changed files with 7063 additions and 144 deletions
|
|
@ -20,13 +20,14 @@ const (
|
|||
|
||||
// Service 提供账号安全配置查询。
|
||||
type Service struct {
|
||||
passwords store.PasswordStore
|
||||
reactions store.AccountReactionSettingsStore
|
||||
settings store.AccountSettingsStore
|
||||
notify store.NotifySettingsStore
|
||||
stickers store.StickerCollectionStore
|
||||
savedMusic store.SavedMusicStore
|
||||
business store.BusinessAutomationStore
|
||||
passwords store.PasswordStore
|
||||
reactions store.AccountReactionSettingsStore
|
||||
settings store.AccountSettingsStore
|
||||
notify store.NotifySettingsStore
|
||||
stickers store.StickerCollectionStore
|
||||
stickerSets store.UserStickerSetStore
|
||||
savedMusic store.SavedMusicStore
|
||||
business store.BusinessAutomationStore
|
||||
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
||||
users store.UserStore
|
||||
}
|
||||
|
|
@ -62,6 +63,13 @@ func WithStickerCollections(stickers store.StickerCollectionStore) ServiceOption
|
|||
}
|
||||
}
|
||||
|
||||
// WithUserStickerSets 注入账号级 installed sticker set 状态持久化。
|
||||
func WithUserStickerSets(stickerSets store.UserStickerSetStore) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.stickerSets = stickerSets
|
||||
}
|
||||
}
|
||||
|
||||
// WithSavedMusic 注入账号级 profile music 列表持久化。
|
||||
func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption {
|
||||
return func(s *Service) {
|
||||
|
|
@ -749,6 +757,42 @@ func (s *Service) ClearStickerCollection(ctx context.Context, userID int64, kind
|
|||
return s.stickers.ClearStickerCollection(ctx, userID, kind)
|
||||
}
|
||||
|
||||
// InstallUserStickerSet 安装或重新激活一个贴纸集,安装态是 per-user 事实。
|
||||
func (s *Service) InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error {
|
||||
if s == nil || s.stickerSets == nil || userID == 0 || setID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.stickerSets.InstallUserStickerSet(ctx, userID, setID, kind, archived, installedDate)
|
||||
}
|
||||
|
||||
func (s *Service) UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error {
|
||||
if s == nil || s.stickerSets == nil || userID == 0 || setID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.stickerSets.UninstallUserStickerSet(ctx, userID, setID)
|
||||
}
|
||||
|
||||
func (s *Service) SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error {
|
||||
if s == nil || s.stickerSets == nil || userID == 0 || setID == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.stickerSets.SetUserStickerSetArchived(ctx, userID, setID, archived, now)
|
||||
}
|
||||
|
||||
func (s *Service) ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error {
|
||||
if s == nil || s.stickerSets == nil || userID == 0 || len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.stickerSets.ReorderUserStickerSets(ctx, userID, kind, order, now)
|
||||
}
|
||||
|
||||
func (s *Service) ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) {
|
||||
if s == nil || s.stickerSets == nil || userID == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
return s.stickerSets.ListUserStickerSets(ctx, userID, kind, archived, offsetID, limit)
|
||||
}
|
||||
|
||||
func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings {
|
||||
defaults := domain.DefaultAccountReactionSettings()
|
||||
settings.Notify = normalizeNotifySettings(settings.Notify)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -69,47 +70,61 @@ type botReply struct {
|
|||
|
||||
// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。
|
||||
func (s *Service) HandlesBot(botUserID int64) bool {
|
||||
return s != nil && botUserID == domain.BotFatherUserID
|
||||
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID)
|
||||
}
|
||||
|
||||
// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。
|
||||
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
|
||||
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
|
||||
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
|
||||
if s == nil || s.messages == nil || botUserID != domain.BotFatherUserID {
|
||||
if s == nil || s.messages == nil || !s.HandlesBot(botUserID) {
|
||||
return
|
||||
}
|
||||
userID := msg.From.ID
|
||||
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
|
||||
return
|
||||
}
|
||||
go s.respondAsBotFather(userID, msg.Body)
|
||||
switch botUserID {
|
||||
case domain.BotFatherUserID:
|
||||
go s.respondAsBotFather(userID, msg.Body)
|
||||
case domain.StickersBotUserID:
|
||||
go s.respondAsStickers(userID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。
|
||||
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
|
||||
// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。
|
||||
func (s *Service) respondAsBotFather(userID int64, body string) {
|
||||
mu := &s.replyLocks[uint64(userID)%replyLockStripes]
|
||||
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
reply := s.handleBotFather(ctx, userID, body)
|
||||
if reply.Text == "" {
|
||||
s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply)
|
||||
}
|
||||
|
||||
func (s *Service) serviceBotReplyLock(botUserID, userID int64) *sync.Mutex {
|
||||
key := uint64(userID) ^ (uint64(botUserID) * 11400714819323198485)
|
||||
return &s.replyLocks[key%replyLockStripes]
|
||||
}
|
||||
|
||||
func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int64, reply botReply) {
|
||||
if s == nil || s.messages == nil || reply.Text == "" {
|
||||
return
|
||||
}
|
||||
blocked := false
|
||||
if s.blocker != nil {
|
||||
if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil {
|
||||
s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err))
|
||||
if b, err := s.blocker.IsBlocked(ctx, userID, botUserID); err != nil {
|
||||
s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
} else {
|
||||
blocked = b
|
||||
}
|
||||
}
|
||||
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: domain.BotFatherUserID,
|
||||
SenderUserID: botUserID,
|
||||
RecipientUserID: userID,
|
||||
RandomID: s.botReplyRandomID(),
|
||||
Message: reply.Text,
|
||||
|
|
@ -117,12 +132,12 @@ func (s *Service) respondAsBotFather(userID int64, body string) {
|
|||
Date: int(s.now().Unix()),
|
||||
RecipientBlocked: blocked,
|
||||
}); err != nil {
|
||||
s.log.Error("botfather: send reply", zap.Int64("user_id", userID), zap.Error(err))
|
||||
s.log.Error("service bot: send reply", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
|
||||
// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用
|
||||
// 所有服务 bot 回复按各自 sender 命名空间唯一——用
|
||||
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
|
||||
func (s *Service) botReplyRandomID() int64 {
|
||||
if v, err := randomInt64(); err == nil && v != 0 {
|
||||
|
|
|
|||
|
|
@ -411,3 +411,5 @@ func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int
|
|||
c.pushedCommandsTo = botUserID
|
||||
c.pushedCommands = append([]domain.BotCommand(nil), commands...)
|
||||
}
|
||||
|
||||
func (c *captureRevoker) PushStickerSetsChanged(context.Context, int64, domain.StickerSetKind) {}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,31 @@ type publicChannelUsernameResolver interface {
|
|||
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
|
||||
}
|
||||
|
||||
type stickerSetCreator interface {
|
||||
CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
|
||||
ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error)
|
||||
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error)
|
||||
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
|
||||
AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
|
||||
RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error)
|
||||
}
|
||||
|
||||
type userStickerSetInstaller interface {
|
||||
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
|
||||
}
|
||||
|
||||
// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破
|
||||
// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现):
|
||||
// router↔bots 的构造循环;两个能力都依赖 TL/连接层边界,不能在 app 层实现):
|
||||
// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删
|
||||
// authorization + 强制断连)。
|
||||
// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands
|
||||
// (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。
|
||||
// - PushStickerSetsChanged:@Stickers 发布后给 creator 当前在线 session 推
|
||||
// updateStickerSets;离线端靠持久化 install 状态 + 下次 getAllStickers 兜底。
|
||||
type RouterHooks interface {
|
||||
RevokeBotSessions(ctx context.Context, botUserID int64) error
|
||||
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
|
||||
PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind)
|
||||
}
|
||||
|
||||
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
|
||||
|
|
@ -51,6 +67,8 @@ type Service struct {
|
|||
messages store.MessageStore
|
||||
blocker blockChecker
|
||||
channels publicChannelUsernameResolver
|
||||
stickers stickerSetCreator
|
||||
installer userStickerSetInstaller
|
||||
hooks RouterHooks
|
||||
userCache store.UserCache
|
||||
cache *botProfileCache
|
||||
|
|
@ -114,6 +132,24 @@ func WithUserCache(c store.UserCache) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithStickerSetCreator 注入 sticker set 创建/查询能力,供内置 @Stickers bot 使用。
|
||||
func WithStickerSetCreator(c stickerSetCreator) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.stickers = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserStickerSets 注入 per-user sticker set 安装状态写入能力。
|
||||
func WithUserStickerSets(c userStickerSetInstaller) Option {
|
||||
return func(s *Service) {
|
||||
if c != nil {
|
||||
s.installer = c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。
|
||||
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
|
||||
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
||||
func (c *stickerSetNegativeCache) delete(refs ...domain.StickerSetRef) {
|
||||
if c == nil || len(refs) == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, ref := range refs {
|
||||
if key := stickerSetRefKey(ref); key != "" {
|
||||
delete(c.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile
|
||||
// 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/
|
||||
// reaction/头像更被大量用户重复拉)。
|
||||
|
|
@ -266,12 +279,31 @@ func (c *stickerSetFullCache) put(set domain.StickerSet, docs []domain.Document)
|
|||
}
|
||||
}
|
||||
|
||||
func (c *stickerSetFullCache) delete(set domain.StickerSet) {
|
||||
if c == nil || set.ID == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.byID, set.ID)
|
||||
if set.ShortName != "" {
|
||||
delete(c.byShort, set.ShortName)
|
||||
}
|
||||
if set.SystemKey != "" {
|
||||
delete(c.bySystem, set.SystemKey)
|
||||
}
|
||||
}
|
||||
|
||||
func copyStickerSet(set domain.StickerSet) domain.StickerSet {
|
||||
set.DocumentIDs = append([]int64(nil), set.DocumentIDs...)
|
||||
set.Packs = append([]domain.StickerPack(nil), set.Packs...)
|
||||
for i := range set.Packs {
|
||||
set.Packs[i].DocumentIDs = append([]int64(nil), set.Packs[i].DocumentIDs...)
|
||||
}
|
||||
set.Keywords = append([]domain.StickerKeyword(nil), set.Keywords...)
|
||||
for i := range set.Keywords {
|
||||
set.Keywords[i].Keywords = append([]string(nil), set.Keywords[i].Keywords...)
|
||||
}
|
||||
set.Thumbs = copyPhotoSizes(set.Thumbs)
|
||||
return set
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import (
|
|||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestBlobMetaCacheGetPutEvict(t *testing.T) {
|
||||
|
|
@ -107,6 +110,58 @@ func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetFileLogsCacheHitMiss(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("local fs: %v", err)
|
||||
}
|
||||
objectKey, err := local.Put(ctx, []byte("0123456789"))
|
||||
if err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
media := newFakeMediaStore()
|
||||
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
|
||||
t.Fatalf("put blob: %v", err)
|
||||
}
|
||||
blobs := &countingBlobBackend{BlobBackend: local}
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
svc := NewService(media, blobs, 2, WithLogger(zap.New(core)))
|
||||
|
||||
if _, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:log", Offset: 0, Limit: 5}); err != nil || !ok {
|
||||
t.Fatalf("first getfile ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:log", Offset: 5, Limit: 5}); err != nil || !ok {
|
||||
t.Fatalf("second getfile ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
entries := logs.FilterMessage("upload.getFile cache").All()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("cache log entries = %d, want 2", len(entries))
|
||||
}
|
||||
first := entries[0].ContextMap()
|
||||
if first["source"] != "backend_fill_byte_cache" ||
|
||||
first["meta_cache_hit"] != false ||
|
||||
first["meta_cache_filled"] != true ||
|
||||
first["byte_cache_hit"] != false ||
|
||||
first["byte_cache_filled"] != true ||
|
||||
first["backend_read"] != true ||
|
||||
first["returned_bytes"] != int64(5) {
|
||||
t.Fatalf("first cache log = %#v, want backend fill miss", first)
|
||||
}
|
||||
second := entries[1].ContextMap()
|
||||
if second["source"] != "byte_cache" ||
|
||||
second["meta_cache_hit"] != true ||
|
||||
second["byte_cache_hit"] != true ||
|
||||
second["backend_read"] != false ||
|
||||
second["returned_bytes"] != int64(5) {
|
||||
t.Fatalf("second cache log = %#v, want byte cache hit", second)
|
||||
}
|
||||
if blobs.getRangeCalls != 1 {
|
||||
t.Fatalf("GetRange calls = %d, want only first miss to read backend", blobs.getRangeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
local, err := NewLocalFS(t.TempDir())
|
||||
|
|
|
|||
|
|
@ -602,7 +602,7 @@ func stickerSetKind(sj seedStickerSetJSON, systemKey string) domain.StickerSetKi
|
|||
}
|
||||
|
||||
func seedStickerSetInstalled(kind domain.StickerSetKind) bool {
|
||||
return kind != domain.StickerSetKindSystem
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- JSON → domain 转换 ----
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -209,17 +210,63 @@ func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet)
|
|||
f.sets[set.ID] = set
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) CreateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, existing := range f.sets {
|
||||
if existing.ShortName != "" && strings.EqualFold(existing.ShortName, set.ShortName) {
|
||||
return domain.ErrStickerSetShortNameOccupied
|
||||
}
|
||||
}
|
||||
f.sets[set.ID] = set
|
||||
if f.docs == nil {
|
||||
f.docs = map[int64]domain.Document{}
|
||||
}
|
||||
for _, doc := range docs {
|
||||
f.docs[doc.ID] = doc
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) UpdateStickerSet(_ context.Context, set domain.StickerSet, docs []domain.Document) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.sets[set.ID]; !ok {
|
||||
return domain.ErrStickerSetInvalid
|
||||
}
|
||||
f.sets[set.ID] = set
|
||||
if f.docs == nil {
|
||||
f.docs = map[int64]domain.Document{}
|
||||
}
|
||||
for _, doc := range docs {
|
||||
f.docs[doc.ID] = doc
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteStickerSet(_ context.Context, setID int64, creatorUserID int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
set, ok := f.sets[setID]
|
||||
if !ok || set.Deleted || set.CreatorUserID != creatorUserID {
|
||||
return domain.ErrStickerSetInvalid
|
||||
}
|
||||
set.Deleted = true
|
||||
f.sets[setID] = set
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetByID(_ context.Context, id int64) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s, ok := f.sets[id]
|
||||
if ok && s.Deleted {
|
||||
return domain.StickerSet{}, false, nil
|
||||
}
|
||||
return s, ok, nil
|
||||
}
|
||||
func (f *fakeMediaStore) GetStickerSetByShortName(_ context.Context, name string) (domain.StickerSet, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sets {
|
||||
if s.ShortName == name {
|
||||
if strings.EqualFold(s.ShortName, name) && !s.Deleted {
|
||||
return s, true, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -240,12 +287,48 @@ func (f *fakeMediaStore) ListStickerSets(_ context.Context, kind domain.StickerS
|
|||
defer f.mu.Unlock()
|
||||
var out []domain.StickerSet
|
||||
for _, s := range f.sets {
|
||||
if s.Kind == kind {
|
||||
if s.Kind == kind && !s.Deleted {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListStickerSetsByCreator(_ context.Context, creatorUserID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var all []domain.StickerSet
|
||||
for _, s := range f.sets {
|
||||
if s.CreatorUserID == creatorUserID && !s.Deleted {
|
||||
s.Creator = true
|
||||
all = append(all, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].ID > all[j].ID })
|
||||
total := len(all)
|
||||
if offsetID != 0 {
|
||||
filtered := all[:0]
|
||||
for _, s := range all {
|
||||
if s.ID < offsetID {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
all = filtered
|
||||
}
|
||||
if limit > 0 && len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return append([]domain.StickerSet(nil), all...), total, nil
|
||||
}
|
||||
func (f *fakeMediaStore) StickerSetShortNameAvailable(_ context.Context, shortName string) (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sets {
|
||||
if s.ShortName != "" && strings.EqualFold(s.ShortName, shortName) && !s.Deleted {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CountStickerSets(_ context.Context) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -648,15 +731,15 @@ func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSeedStickerSetInstalledFlagExcludesSystemSets(t *testing.T) {
|
||||
func TestSeedStickerSetInstalledFlagNeverMarksViewerState(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind domain.StickerSetKind
|
||||
want bool
|
||||
}{
|
||||
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: true},
|
||||
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: true},
|
||||
{name: "masks", kind: domain.StickerSetKindMasks, want: true},
|
||||
{name: "regular stickers", kind: domain.StickerSetKindStickers, want: false},
|
||||
{name: "custom emoji", kind: domain.StickerSetKindEmoji, want: false},
|
||||
{name: "masks", kind: domain.StickerSetKindMasks, want: false},
|
||||
{name: "system resources", kind: domain.StickerSetKindSystem, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
|
|||
|
|
@ -263,23 +263,48 @@ func (s *Service) DeleteExpiredUploadParts(ctx context.Context, before time.Time
|
|||
// 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker /
|
||||
// reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。
|
||||
type blobMetaResult struct {
|
||||
blob domain.FileBlob
|
||||
found bool
|
||||
blob domain.FileBlob
|
||||
found bool
|
||||
cacheHit bool
|
||||
cacheFilled bool
|
||||
}
|
||||
|
||||
type blobBytesResult struct {
|
||||
data []byte
|
||||
total int64
|
||||
cacheable bool
|
||||
data []byte
|
||||
total int64
|
||||
cacheable bool
|
||||
cacheHit bool
|
||||
cacheFilled bool
|
||||
}
|
||||
|
||||
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
|
||||
type getFileCacheLog struct {
|
||||
start time.Time
|
||||
metaCacheHit bool
|
||||
metaCacheFilled bool
|
||||
metaSingleflight bool
|
||||
byteCacheEligible bool
|
||||
byteCacheHit bool
|
||||
byteCacheFilled bool
|
||||
byteSingleflight bool
|
||||
backendRead bool
|
||||
source string
|
||||
}
|
||||
|
||||
func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (chunk domain.FileChunk, found bool, err error) {
|
||||
cacheLog := getFileCacheLog{start: time.Now(), source: "unknown"}
|
||||
var blob domain.FileBlob
|
||||
defer func() {
|
||||
s.logGetFileCache(req, blob, found, chunk, cacheLog, err)
|
||||
}()
|
||||
|
||||
blob, ok := s.blobCache.get(req.LocationKey)
|
||||
if !ok {
|
||||
if ok {
|
||||
cacheLog.metaCacheHit = true
|
||||
} else {
|
||||
// 同一 location_key 的并发首访合并成一次 PG GetFileBlob。
|
||||
v, err, _ := s.blobMetaSF.Do(req.LocationKey, func() (any, error) {
|
||||
v, err, shared := s.blobMetaSF.Do(req.LocationKey, func() (any, error) {
|
||||
if cached, ok := s.blobCache.get(req.LocationKey); ok {
|
||||
return blobMetaResult{blob: cached, found: true}, nil
|
||||
return blobMetaResult{blob: cached, found: true, cacheHit: true}, nil
|
||||
}
|
||||
b, found, err := s.media.GetFileBlob(ctx, req.LocationKey)
|
||||
if err != nil {
|
||||
|
|
@ -288,19 +313,26 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
|||
if found {
|
||||
s.blobCache.put(req.LocationKey, b)
|
||||
}
|
||||
return blobMetaResult{blob: b, found: found}, nil
|
||||
return blobMetaResult{blob: b, found: found, cacheFilled: found}, nil
|
||||
})
|
||||
cacheLog.metaSingleflight = shared
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, err
|
||||
}
|
||||
res := v.(blobMetaResult)
|
||||
cacheLog.metaCacheHit = res.cacheHit
|
||||
cacheLog.metaCacheFilled = res.cacheFilled
|
||||
if !res.found {
|
||||
cacheLog.source = "metadata_miss"
|
||||
return domain.FileChunk{}, false, nil
|
||||
}
|
||||
blob = res.blob
|
||||
}
|
||||
if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes {
|
||||
cacheLog.byteCacheEligible = true
|
||||
if data, ok := s.byteCache.get(blob.ObjectKey); ok {
|
||||
cacheLog.byteCacheHit = true
|
||||
cacheLog.source = "byte_cache"
|
||||
return domain.FileChunk{
|
||||
Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)),
|
||||
MimeType: blob.MimeType,
|
||||
|
|
@ -308,9 +340,9 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
|||
}, true, nil
|
||||
}
|
||||
// 同一 object_key 的小 blob 并发首访合并成一次 backend 全量读 + 一次 byteCache 填充。
|
||||
v, err, _ := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) {
|
||||
v, err, shared := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) {
|
||||
if cached, ok := s.byteCache.get(blob.ObjectKey); ok {
|
||||
return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true}, nil
|
||||
return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true, cacheHit: true}, nil
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1)
|
||||
if err != nil {
|
||||
|
|
@ -318,15 +350,24 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
|||
}
|
||||
if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total {
|
||||
s.byteCache.put(blob.ObjectKey, data)
|
||||
return blobBytesResult{data: data, total: total, cacheable: true}, nil
|
||||
return blobBytesResult{data: data, total: total, cacheable: true, cacheFilled: true}, nil
|
||||
}
|
||||
return blobBytesResult{cacheable: false}, nil
|
||||
})
|
||||
cacheLog.byteSingleflight = shared
|
||||
if err != nil {
|
||||
return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err)
|
||||
}
|
||||
// res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。
|
||||
if res := v.(blobBytesResult); res.cacheable {
|
||||
cacheLog.byteCacheHit = res.cacheHit
|
||||
cacheLog.byteCacheFilled = res.cacheFilled
|
||||
cacheLog.backendRead = res.cacheFilled
|
||||
if res.cacheHit {
|
||||
cacheLog.source = "byte_cache"
|
||||
} else {
|
||||
cacheLog.source = "backend_fill_byte_cache"
|
||||
}
|
||||
return domain.FileChunk{
|
||||
Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)),
|
||||
MimeType: blob.MimeType,
|
||||
|
|
@ -334,6 +375,11 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
|||
}, true, nil
|
||||
}
|
||||
// 大小不符/超限:落到下面的按需 range 读(与原行为一致)。
|
||||
cacheLog.source = "backend_range_uncacheable"
|
||||
}
|
||||
cacheLog.backendRead = true
|
||||
if cacheLog.source == "unknown" {
|
||||
cacheLog.source = "backend_range"
|
||||
}
|
||||
data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit))
|
||||
if err != nil {
|
||||
|
|
@ -346,6 +392,38 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (
|
|||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) logGetFileCache(req domain.FileDownloadRequest, blob domain.FileBlob, found bool, chunk domain.FileChunk, cacheLog getFileCacheLog, err error) {
|
||||
fields := []zap.Field{
|
||||
zap.String("location_key", req.LocationKey),
|
||||
zap.Int64("offset", req.Offset),
|
||||
zap.Int("limit", req.Limit),
|
||||
zap.Bool("found", found),
|
||||
zap.String("source", cacheLog.source),
|
||||
zap.Bool("meta_cache_hit", cacheLog.metaCacheHit),
|
||||
zap.Bool("meta_cache_filled", cacheLog.metaCacheFilled),
|
||||
zap.Bool("meta_singleflight_shared", cacheLog.metaSingleflight),
|
||||
zap.Bool("byte_cache_eligible", cacheLog.byteCacheEligible),
|
||||
zap.Bool("byte_cache_hit", cacheLog.byteCacheHit),
|
||||
zap.Bool("byte_cache_filled", cacheLog.byteCacheFilled),
|
||||
zap.Bool("byte_singleflight_shared", cacheLog.byteSingleflight),
|
||||
zap.Bool("backend_read", cacheLog.backendRead),
|
||||
zap.Int("returned_bytes", len(chunk.Bytes)),
|
||||
zap.Int64("total_bytes", chunk.Total),
|
||||
zap.Duration("dur", time.Since(cacheLog.start)),
|
||||
}
|
||||
if blob.ObjectKey != "" {
|
||||
fields = append(fields,
|
||||
zap.String("object_key", blob.ObjectKey),
|
||||
zap.Int64("blob_size", blob.Size),
|
||||
zap.String("mime_type", blob.MimeType),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
s.log.Info("upload.getFile cache", fields...)
|
||||
}
|
||||
|
||||
func sliceBlobBytes(data []byte, offset, limit int64) []byte {
|
||||
total := int64(len(data))
|
||||
if offset < 0 {
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue