feat: sync built-in sticker bot

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

View file

@ -7,6 +7,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
@ -69,47 +70,61 @@ type botReply struct {
// HandlesBot 报告该收件人是否为内置应答 botmessages.BotResponder 实现)。
func (s *Service) HandlesBot(botUserID int64) bool {
return s != nil && botUserID == domain.BotFatherUserID
return s != nil && (botUserID == domain.BotFatherUserID || botUserID == domain.StickersBotUserID)
}
// OnPrivateMessage 处理投递给内置 bot 的私聊消息messages.BotResponder 实现)。
// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC
// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。
func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) {
if s == nil || s.messages == nil || botUserID != domain.BotFatherUserID {
if s == nil || s.messages == nil || !s.HandlesBot(botUserID) {
return
}
userID := msg.From.ID
if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID {
return
}
go s.respondAsBotFather(userID, msg.Body)
switch botUserID {
case domain.BotFatherUserID:
go s.respondAsBotFather(userID, msg.Body)
case domain.StickersBotUserID:
go s.respondAsStickers(userID, msg)
}
}
// respondAsBotFather 生成并写入 BotFather 回复OnPrivateMessage 在 goroutine 内调用)。
// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序,
// 不同用户并发不受影响。ctx 用 Background脱离已返回的用户 RPC限较长超时。
func (s *Service) respondAsBotFather(userID int64, body string) {
mu := &s.replyLocks[uint64(userID)%replyLockStripes]
mu := s.serviceBotReplyLock(domain.BotFatherUserID, userID)
mu.Lock()
defer mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
reply := s.handleBotFather(ctx, userID, body)
if reply.Text == "" {
s.sendServiceBotReply(ctx, domain.BotFatherUserID, userID, reply)
}
func (s *Service) serviceBotReplyLock(botUserID, userID int64) *sync.Mutex {
key := uint64(userID) ^ (uint64(botUserID) * 11400714819323198485)
return &s.replyLocks[key%replyLockStripes]
}
func (s *Service) sendServiceBotReply(ctx context.Context, botUserID, userID int64, reply botReply) {
if s == nil || s.messages == nil || reply.Text == "" {
return
}
blocked := false
if s.blocker != nil {
if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil {
s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err))
if b, err := s.blocker.IsBlocked(ctx, userID, botUserID); err != nil {
s.log.Warn("service bot: check block", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
} else {
blocked = b
}
}
if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: domain.BotFatherUserID,
SenderUserID: botUserID,
RecipientUserID: userID,
RandomID: s.botReplyRandomID(),
Message: reply.Text,
@ -117,12 +132,12 @@ func (s *Service) respondAsBotFather(userID int64, body string) {
Date: int(s.now().Unix()),
RecipientBlocked: blocked,
}); err != nil {
s.log.Error("botfather: send reply", zap.Int64("user_id", userID), zap.Error(err))
s.log.Error("service bot: send reply", zap.Int64("bot_user_id", botUserID), zap.Int64("user_id", userID), zap.Error(err))
}
}
// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。
// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用
// 所有服务 bot 回复按各自 sender 命名空间唯一——用
// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。
func (s *Service) botReplyRandomID() int64 {
if v, err := randomInt64(); err == nil && v != 0 {

View file

@ -411,3 +411,5 @@ func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int
c.pushedCommandsTo = botUserID
c.pushedCommands = append([]domain.BotCommand(nil), commands...)
}
func (c *captureRevoker) PushStickerSetsChanged(context.Context, int64, domain.StickerSetKind) {}

View file

@ -29,15 +29,31 @@ type publicChannelUsernameResolver interface {
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
}
type stickerSetCreator interface {
CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error)
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error)
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error)
}
type userStickerSetInstaller interface {
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
}
// RouterHooks 是 rpc 层回调router 创建后经 SetRouterHooks 延迟注入,打破
// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现):
// router↔bots 的构造循环;两个能力都依赖 TL/连接层边界,不能在 app 层实现):
// - RevokeBotSessionstoken revoke 后撤销 bot 的全部已登录 session
// authorization + 强制断连)。
// - PushBotCommandsChanged命令变更后给在线相关用户推 updateBotCommands
// (无 pts 的 ephemeral update离线用户靠 bot_info_version bump 兜底)。
// - PushStickerSetsChanged@Stickers 发布后给 creator 当前在线 session 推
// updateStickerSets离线端靠持久化 install 状态 + 下次 getAllStickers 兜底。
type RouterHooks interface {
RevokeBotSessions(ctx context.Context, botUserID int64) error
PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand)
PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind)
}
// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、
@ -51,6 +67,8 @@ type Service struct {
messages store.MessageStore
blocker blockChecker
channels publicChannelUsernameResolver
stickers stickerSetCreator
installer userStickerSetInstaller
hooks RouterHooks
userCache store.UserCache
cache *botProfileCache
@ -114,6 +132,24 @@ func WithUserCache(c store.UserCache) Option {
}
}
// WithStickerSetCreator 注入 sticker set 创建/查询能力,供内置 @Stickers bot 使用。
func WithStickerSetCreator(c stickerSetCreator) Option {
return func(s *Service) {
if c != nil {
s.stickers = c
}
}
}
// WithUserStickerSets 注入 per-user sticker set 安装状态写入能力。
func WithUserStickerSets(c userStickerSetInstaller) Option {
return func(s *Service) {
if c != nil {
s.installer = c
}
}
}
// invalidateUserCache 在 bot 的 users 行变更(含 version bump后清缓存。
// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。
func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) {

View file

@ -0,0 +1,793 @@
package bots
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const (
stickersBotCmdNewPack = "newpack"
stickersBotCmdNewEmoji = "newemoji"
stickersBotCmdPublish = "publish"
stickersBotCmdPacks = "packs"
stickersBotCmdAdd = "addsticker"
stickersBotCmdDel = "delsticker"
stickersBotStepSet = "set"
stickersBotStepTitle = "title"
stickersBotStepDocument = "document"
stickersBotStepEmoji = "emoji"
stickersBotStepShortName = "short_name"
stickersBotDraftKind = "kind"
stickersBotDraftTitle = "title"
stickersBotDraftSetID = "set_id"
stickersBotDraftSetAccessHash = "set_access_hash"
stickersBotDraftSetShortName = "set_short_name"
stickersBotDraftSetTitle = "set_title"
stickersBotDraftItems = "items"
stickersBotDraftPendingDocID = "pending_doc_id"
stickersBotDraftPendingDocHash = "pending_doc_access_hash"
stickersBotCreateSoftware = "telesrv-stickers-bot"
stickersBotCreatedListPageLimit = 20
)
const stickersBotHelpText = `I can help you create sticker and custom emoji packs for telesrv.
Send /newpack to create a sticker pack.
Send /newemoji to create a custom emoji pack.
Send /addsticker to add an item to one of your packs.
Send /delsticker to remove an item from one of your packs.
Send a sticker/custom emoji, or upload a TGS, Lottie JSON, WebP, WebM, or MP4 file as a document. Send /publish when your pack is ready, then choose a short name for the link.
/packs - list your created packs
/cancel - cancel the current operation
/help - show this message`
var stickersBotGlobalCommands = map[string]bool{
"start": true, "help": true, "cancel": true,
stickersBotCmdNewPack: true, stickersBotCmdNewEmoji: true,
stickersBotCmdPublish: true, stickersBotCmdPacks: true,
stickersBotCmdAdd: true, stickersBotCmdDel: true,
}
type stickersBotDraftItem struct {
DocumentID int64 `json:"document_id"`
DocumentAccessHash int64 `json:"document_access_hash"`
Emoji string `json:"emoji"`
}
func (s *Service) respondAsStickers(userID int64, msg domain.Message) {
mu := s.serviceBotReplyLock(domain.StickersBotUserID, userID)
mu.Lock()
defer mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
reply := s.handleStickers(ctx, userID, msg)
s.sendServiceBotReply(ctx, domain.StickersBotUserID, userID, reply)
}
func (s *Service) handleStickers(ctx context.Context, userID int64, msg domain.Message) botReply {
text := strings.TrimSpace(msg.Body)
state, found, err := s.bots.GetBotChatState(ctx, domain.StickersBotUserID, userID)
if err != nil {
s.log.Error("stickersbot: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if cmd, ok := parseBotCommand(text); ok {
if stickersBotGlobalCommands[cmd] {
return s.handleStickersCommand(ctx, userID, cmd, state, found)
}
return botReply{Text: "Unrecognized command. Send /help for a list of commands."}
}
if !found {
if stickersBotDocument(msg) != nil {
return botReply{Text: "Start a pack first with /newpack or /newemoji."}
}
return botReply{Text: "Send /newpack to create a sticker pack or /newemoji to create a custom emoji pack."}
}
switch state.Step {
case stickersBotStepSet:
if text == "" {
return stickersBotStepPrompt(state)
}
return s.handleStickersSet(ctx, state, text)
case stickersBotStepTitle:
if text == "" {
return stickersBotStepPrompt(state)
}
return s.handleStickersTitle(ctx, state, text)
case stickersBotStepDocument:
switch state.Command {
case stickersBotCmdAdd:
return s.handleStickersAddDocument(ctx, state, msg)
case stickersBotCmdDel:
return s.handleStickersDeleteDocument(ctx, state, msg)
}
return s.handleStickersDocument(ctx, state, msg)
case stickersBotStepEmoji:
if text == "" {
return stickersBotStepPrompt(state)
}
if state.Command == stickersBotCmdAdd {
return s.handleStickersAddEmoji(ctx, state, text)
}
return s.handleStickersEmoji(ctx, state, text)
case stickersBotStepShortName:
if text == "" {
return stickersBotStepPrompt(state)
}
return s.handleStickersShortName(ctx, state, text)
default:
if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID); err != nil {
s.log.Error("stickersbot: delete corrupt state", zap.Int64("user_id", userID), zap.Error(err))
}
return botReply{Text: "Something went wrong, I forgot what we were doing. Send /newpack or /newemoji to start again."}
}
}
func (s *Service) handleStickersCommand(ctx context.Context, userID int64, cmd string, state domain.BotChatState, found bool) botReply {
switch cmd {
case "start":
_ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID)
return botReply{Text: stickersBotHelpText}
case "help":
return botReply{Text: stickersBotHelpText}
case "cancel":
if !found {
return botReply{Text: "No active pack to cancel."}
}
if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, userID); err != nil {
s.log.Error("stickersbot: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Cancelled. Send /newpack or /newemoji when you are ready."}
case stickersBotCmdNewPack:
return s.startStickersFlow(ctx, userID, stickersBotCmdNewPack, domain.StickerSetKindStickers)
case stickersBotCmdNewEmoji:
return s.startStickersFlow(ctx, userID, stickersBotCmdNewEmoji, domain.StickerSetKindEmoji)
case stickersBotCmdAdd:
return s.startStickersEditFlow(ctx, userID, stickersBotCmdAdd)
case stickersBotCmdDel:
return s.startStickersEditFlow(ctx, userID, stickersBotCmdDel)
case stickersBotCmdPublish:
if !found || !stickersBotCreateCommand(state.Command) {
return botReply{Text: "Start a pack first with /newpack or /newemoji."}
}
if len(stickersBotDraftItemsFromState(state)) == 0 {
return botReply{Text: "Add at least one sticker material document before publishing."}
}
state.Step = stickersBotStepShortName
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save publish state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Choose a short name for this pack. It will be used in the public link, for example: my_fun_pack"}
case stickersBotCmdPacks:
return s.listStickersBotPacks(ctx, userID)
default:
return botReply{Text: "Unrecognized command. Send /help for a list of commands."}
}
}
func (s *Service) startStickersEditFlow(ctx context.Context, userID int64, cmd string) botReply {
state := domain.BotChatState{
BotUserID: domain.StickersBotUserID,
UserID: userID,
Command: cmd,
Step: stickersBotStepSet,
Draft: map[string]string{},
}
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("stickersbot: save edit state", zap.Int64("user_id", userID), zap.String("command", cmd), zap.Error(err))
return internalReply()
}
if cmd == stickersBotCmdDel {
return botReply{Text: "Send the short name or telesrv link of the pack you want to edit. Use /packs to see your packs."}
}
return botReply{Text: "Send the short name or telesrv link of the pack you want to add to. Use /packs to see your packs."}
}
func (s *Service) startStickersFlow(ctx context.Context, userID int64, cmd string, kind domain.StickerSetKind) botReply {
state := domain.BotChatState{
BotUserID: domain.StickersBotUserID,
UserID: userID,
Command: cmd,
Step: stickersBotStepTitle,
Draft: map[string]string{
stickersBotDraftKind: string(kind),
},
}
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("stickersbot: save chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if kind == domain.StickerSetKindEmoji {
return botReply{Text: "Alright, a new custom emoji pack. Send me a title for it."}
}
return botReply{Text: "Alright, a new sticker pack. Send me a title for it."}
}
func (s *Service) handleStickersSet(ctx context.Context, state domain.BotChatState, raw string) botReply {
if s.stickers == nil {
return botReply{Text: "Sticker pack editing is not available right now."}
}
shortName := normalizeStickersBotShortName(raw)
if shortName == "" || strings.HasPrefix(shortName, "/") {
return botReply{Text: "Send the pack short name or telesrv link. Use /packs to list your packs, or /cancel."}
}
set, _, found, err := s.stickers.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName})
if err != nil {
s.log.Error("stickersbot: resolve edit set", zap.Int64("user_id", state.UserID), zap.String("short_name", shortName), zap.Error(err))
return internalReply()
}
if !found || set.Deleted || set.ID == 0 {
return botReply{Text: "I couldn't find that pack. Send a short name from /packs, or /cancel."}
}
if set.CreatorUserID != state.UserID {
return botReply{Text: "I can only edit packs created by you. Send one of your pack links, or /cancel."}
}
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[stickersBotDraftSetID] = strconv.FormatInt(set.ID, 10)
state.Draft[stickersBotDraftSetAccessHash] = strconv.FormatInt(set.AccessHash, 10)
state.Draft[stickersBotDraftSetShortName] = set.ShortName
state.Draft[stickersBotDraftSetTitle] = set.Title
state.Draft[stickersBotDraftKind] = string(stickersBotSetKind(set))
state.Step = stickersBotStepDocument
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save edit set", zap.Int64("user_id", state.UserID), zap.Int64("set_id", set.ID), zap.Error(err))
return internalReply()
}
if state.Command == stickersBotCmdDel {
return botReply{Text: fmt.Sprintf("Selected %s. Send the sticker or custom emoji from this pack that you want to remove.", stickersBotSetTitleFromState(state))}
}
return botReply{Text: fmt.Sprintf("Selected %s. Now send the sticker material document to add.", stickersBotSetTitleFromState(state))}
}
func (s *Service) handleStickersTitle(ctx context.Context, state domain.BotChatState, title string) botReply {
title = strings.TrimSpace(title)
if title == "" || utf8.RuneCountInString(title) > domain.MaxStickerSetTitleLen {
return botReply{Text: fmt.Sprintf("The title must be 1-%d characters. Send another title or /cancel.", domain.MaxStickerSetTitleLen)}
}
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[stickersBotDraftTitle] = title
if _, ok := state.Draft[stickersBotDraftKind]; !ok {
state.Draft[stickersBotDraftKind] = string(stickersBotKindFromState(state))
}
state.Step = stickersBotStepDocument
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save title", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Good. Now send me a sticker, custom emoji, or a TGS/Lottie JSON/WebP/WebM/MP4 document to add to this pack."}
}
func (s *Service) handleStickersDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
doc := stickersBotDocument(msg)
if doc == nil || doc.ID == 0 || doc.AccessHash == 0 || !doc.IsStickerSetMaterial() {
return botReply{Text: "Please send a sticker, custom emoji, TGS, Lottie JSON, or WebP document. WebM/MP4 must include video metadata. /cancel to stop."}
}
if len(stickersBotDraftItemsFromState(state)) >= domain.MaxStickerSetItems {
return botReply{Text: fmt.Sprintf("This pack already has the maximum of %d items. Send /publish to finish.", domain.MaxStickerSetItems)}
}
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[stickersBotDraftPendingDocID] = strconv.FormatInt(doc.ID, 10)
state.Draft[stickersBotDraftPendingDocHash] = strconv.FormatInt(doc.AccessHash, 10)
state.Step = stickersBotStepEmoji
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save document", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Now send the emoji that should be associated with this item."}
}
func (s *Service) handleStickersAddDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
doc := stickersBotDocument(msg)
if doc == nil || doc.ID == 0 || doc.AccessHash == 0 || !doc.IsStickerSetMaterial() {
return botReply{Text: "Please send a sticker, custom emoji, TGS, Lottie JSON, or WebP document to add. WebM/MP4 must include video metadata. /cancel to stop."}
}
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[stickersBotDraftPendingDocID] = strconv.FormatInt(doc.ID, 10)
state.Draft[stickersBotDraftPendingDocHash] = strconv.FormatInt(doc.AccessHash, 10)
state.Step = stickersBotStepEmoji
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save add document", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Now send the emoji that should be associated with this added item."}
}
func (s *Service) handleStickersEmoji(ctx context.Context, state domain.BotChatState, emoji string) botReply {
emoji = strings.TrimSpace(emoji)
if !validStickersBotEmoji(emoji) {
return botReply{Text: "That doesn't look like a valid emoji. Send an emoji like 🙂, or /cancel."}
}
docID, docHash := pendingStickersBotDocument(state)
if docID == 0 || docHash == 0 {
state.Step = stickersBotStepDocument
delete(state.Draft, stickersBotDraftPendingDocID)
delete(state.Draft, stickersBotDraftPendingDocHash)
_ = s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state))
return botReply{Text: "I lost the document for this item. Please send it again."}
}
items := stickersBotDraftItemsFromState(state)
for _, item := range items {
if item.DocumentID == docID {
state.Step = stickersBotStepDocument
delete(state.Draft, stickersBotDraftPendingDocID)
delete(state.Draft, stickersBotDraftPendingDocHash)
_ = s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state))
return botReply{Text: "That document is already in this pack. Send another document or /publish."}
}
}
items = append(items, stickersBotDraftItem{DocumentID: docID, DocumentAccessHash: docHash, Emoji: emoji})
if err := setStickersBotDraftItems(&state, items); err != nil {
s.log.Error("stickersbot: encode items", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
delete(state.Draft, stickersBotDraftPendingDocID)
delete(state.Draft, stickersBotDraftPendingDocHash)
state.Step = stickersBotStepDocument
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save emoji", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: fmt.Sprintf("Added. This pack has %d item(s). Send another sticker material document, or /publish when ready.", len(items))}
}
func (s *Service) handleStickersAddEmoji(ctx context.Context, state domain.BotChatState, emoji string) botReply {
if s.stickers == nil {
return botReply{Text: "Sticker pack editing is not available right now."}
}
emoji = strings.TrimSpace(emoji)
if !validStickersBotEmoji(emoji) {
return botReply{Text: "That doesn't look like a valid emoji. Send an emoji like 🙂, or /cancel."}
}
docID, docHash := pendingStickersBotDocument(state)
if docID == 0 || docHash == 0 {
state.Step = stickersBotStepDocument
delete(state.Draft, stickersBotDraftPendingDocID)
delete(state.Draft, stickersBotDraftPendingDocHash)
_ = s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state))
return botReply{Text: "I lost the document for this item. Please send it again."}
}
ref, ok := stickersBotSetRefFromState(state)
if !ok {
_ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID)
return botReply{Text: "I lost the pack selection. Send /addsticker to start again."}
}
set, _, found, err := s.stickers.ResolveStickerSet(ctx, ref)
if err != nil {
s.log.Error("stickersbot: resolve add set", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
if !found || set.Deleted || set.CreatorUserID != state.UserID {
_ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID)
return botReply{Text: "That pack is no longer available. Send /addsticker to start again."}
}
if containsInt64(set.DocumentIDs, docID) {
state.Step = stickersBotStepDocument
delete(state.Draft, stickersBotDraftPendingDocID)
delete(state.Draft, stickersBotDraftPendingDocHash)
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save duplicate add state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "That document is already in this pack. Send another sticker material document, or /cancel."}
}
set, _, err = s.stickers.AddStickerToSet(ctx, state.UserID, ref, domain.StickerSetItemInput{
DocumentID: docID,
DocumentAccessHash: docHash,
Emoji: emoji,
})
if err != nil {
return s.stickersBotEditError(state.UserID, err)
}
if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID); err != nil {
s.log.Error("stickersbot: delete add state", zap.Int64("user_id", state.UserID), zap.Error(err))
}
if s.hooks != nil {
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
}
return botReply{Text: fmt.Sprintf("Done. Added to %s.\n\n%s", stickersBotSetTitle(set), stickersBotPublicURL(set))}
}
func (s *Service) handleStickersDeleteDocument(ctx context.Context, state domain.BotChatState, msg domain.Message) botReply {
if s.stickers == nil {
return botReply{Text: "Sticker pack editing is not available right now."}
}
doc, err := s.stickersBotDocumentForDelete(ctx, msg)
if err != nil {
s.log.Error("stickersbot: load delete document", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
if doc == nil || doc.ID == 0 || doc.AccessHash == 0 || !doc.IsStickerLike() {
return botReply{Text: "Please send the sticker or custom emoji from the selected pack that you want to remove, or /cancel."}
}
setID, setHash, ok := stickersBotSetIdentityFromState(state)
if !ok {
_ = s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID)
return botReply{Text: "I lost the pack selection. Send /delsticker to start again."}
}
docSetID, docSetHash, docHasSet := doc.StickerSetRef()
if !docHasSet || docSetID != setID || docSetHash != setHash {
return botReply{Text: "That sticker is not from the selected pack. Send a sticker from that pack, or /cancel."}
}
set, _, err := s.stickers.RemoveStickerFromSet(ctx, state.UserID, doc.ID, doc.AccessHash)
if err != nil {
return s.stickersBotEditError(state.UserID, err)
}
if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID); err != nil {
s.log.Error("stickersbot: delete remove state", zap.Int64("user_id", state.UserID), zap.Error(err))
}
if s.hooks != nil {
s.hooks.PushStickerSetsChanged(ctx, state.UserID, stickersBotSetKind(set))
}
return botReply{Text: fmt.Sprintf("Done. Removed from %s.\n\n%s", stickersBotSetTitle(set), stickersBotPublicURL(set))}
}
func (s *Service) handleStickersShortName(ctx context.Context, state domain.BotChatState, raw string) botReply {
if s.stickers == nil || s.installer == nil {
return botReply{Text: "Sticker pack creation is not available right now. Please try again later."}
}
shortName := normalizeStickersBotShortName(raw)
items := stickersBotDraftItemsFromState(state)
if len(items) == 0 {
state.Step = stickersBotStepDocument
if err := s.bots.UpsertBotChatState(ctx, cloneStickersBotState(state)); err != nil {
s.log.Error("stickersbot: save empty publish state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
return botReply{Text: "Add at least one sticker material document before publishing."}
}
kind := stickersBotKindFromState(state)
set, _, err := s.stickers.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: state.UserID,
Title: state.Draft[stickersBotDraftTitle],
ShortName: shortName,
Kind: kind,
Items: stickersBotCreateItems(items),
Software: stickersBotCreateSoftware,
Date: int(s.now().Unix()),
})
if err != nil {
return s.stickersBotCreateError(state.UserID, err)
}
installKind := stickersBotSetKind(set)
if err := s.installer.InstallUserStickerSet(ctx, state.UserID, set.ID, installKind, false, int(s.now().Unix())); err != nil {
s.log.Error("stickersbot: install created set", zap.Int64("user_id", state.UserID), zap.Int64("set_id", set.ID), zap.Error(err))
return internalReply()
}
if err := s.bots.DeleteBotChatState(ctx, domain.StickersBotUserID, state.UserID); err != nil {
s.log.Error("stickersbot: delete published state", zap.Int64("user_id", state.UserID), zap.Error(err))
}
if s.hooks != nil {
s.hooks.PushStickerSetsChanged(ctx, state.UserID, installKind)
}
return botReply{Text: "Done. Your pack is published and installed.\n\n" + stickersBotPublicURL(set)}
}
func (s *Service) stickersBotCreateError(userID int64, err error) botReply {
switch {
case errors.Is(err, domain.ErrStickerSetShortNameInvalid):
return botReply{Text: "That short name is invalid. Use 5-32 lowercase letters, digits or underscores, starting with a letter."}
case errors.Is(err, domain.ErrStickerSetShortNameOccupied):
return botReply{Text: "That short name is already taken. Please send another one."}
case errors.Is(err, domain.ErrStickerSetTitleInvalid):
return botReply{Text: "The title is invalid. Send /cancel and start again."}
case errors.Is(err, domain.ErrStickerSetEmpty):
return botReply{Text: "Add at least one sticker material document before publishing."}
case errors.Is(err, domain.ErrStickerSetEmojiInvalid):
return botReply{Text: "One of the emoji values is invalid. Send /cancel and start again."}
case errors.Is(err, domain.ErrStickerSetFileInvalid):
return botReply{Text: "One of the documents is no longer a valid sticker material file. Send /cancel and start again."}
default:
s.log.Error("stickersbot: create sticker set", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
}
func (s *Service) stickersBotEditError(userID int64, err error) botReply {
switch {
case errors.Is(err, domain.ErrStickerSetInvalid):
return botReply{Text: "That pack is no longer available. Send /packs and try again."}
case errors.Is(err, domain.ErrStickerSetNotOwned):
return botReply{Text: "I can only edit packs created by you."}
case errors.Is(err, domain.ErrStickerSetTooMuch):
return botReply{Text: fmt.Sprintf("That pack already has the maximum of %d items.", domain.MaxStickerSetItems)}
case errors.Is(err, domain.ErrStickerSetEmpty):
return botReply{Text: "A pack must keep at least one item. Add another item before removing this one."}
case errors.Is(err, domain.ErrStickerSetEmojiInvalid):
return botReply{Text: "That doesn't look like a valid emoji. Send /addsticker and try again."}
case errors.Is(err, domain.ErrStickerSetFileInvalid):
return botReply{Text: "That document is not a valid sticker material or is not in the selected pack. Send the command again and try another document."}
default:
s.log.Error("stickersbot: edit sticker set", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
}
func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botReply {
if s.stickers == nil {
return botReply{Text: "Sticker pack listing is not available right now."}
}
sets, total, err := s.stickers.ListCreatedStickerSets(ctx, userID, 0, stickersBotCreatedListPageLimit)
if err != nil {
s.log.Error("stickersbot: list packs", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if len(sets) == 0 {
return botReply{Text: "You don't have any packs yet. Use /newpack or /newemoji to create one."}
}
lines := make([]string, 0, len(sets)+1)
lines = append(lines, "Your packs:")
for _, set := range sets {
lines = append(lines, fmt.Sprintf("%s - %s", set.Title, stickersBotPublicURL(set)))
}
if total > len(sets) {
lines = append(lines, fmt.Sprintf("Showing %d of %d.", len(sets), total))
}
lines = append(lines, "Use /addsticker to add to a pack, or /delsticker to remove one item.")
return botReply{Text: strings.Join(lines, "\n")}
}
func stickersBotStepPrompt(state domain.BotChatState) botReply {
switch state.Step {
case stickersBotStepSet:
return botReply{Text: "Send the pack short name or telesrv link, or /cancel."}
case stickersBotStepTitle:
return botReply{Text: "Send a title for this pack, or /cancel."}
case stickersBotStepDocument:
switch state.Command {
case stickersBotCmdAdd:
return botReply{Text: "Send a sticker material document to add, or /cancel."}
case stickersBotCmdDel:
return botReply{Text: "Send the sticker or custom emoji from the selected pack to remove, or /cancel."}
}
return botReply{Text: "Send a sticker material document, or /publish if the pack already has items."}
case stickersBotStepEmoji:
return botReply{Text: "Send the emoji for the last document, or /cancel."}
case stickersBotStepShortName:
return botReply{Text: "Send a short name for the public link, or /cancel."}
default:
return botReply{Text: "Send /help for a list of commands."}
}
}
func stickersBotDocument(msg domain.Message) *domain.Document {
if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindDocument || msg.Media.Document == nil {
return nil
}
doc := *msg.Media.Document
return &doc
}
func (s *Service) stickersBotDocumentForDelete(ctx context.Context, msg domain.Message) (*domain.Document, error) {
if doc := stickersBotDocument(msg); doc != nil {
return doc, nil
}
for _, entity := range msg.Entities {
if entity.Type != domain.MessageEntityCustomEmoji || entity.DocumentID == 0 {
continue
}
docs, err := s.stickers.GetDocuments(ctx, []int64{entity.DocumentID})
if err != nil {
return nil, err
}
for _, doc := range docs {
if doc.ID == entity.DocumentID {
doc := doc
return &doc, nil
}
}
return nil, nil
}
return nil, nil
}
func stickersBotKindFromState(state domain.BotChatState) domain.StickerSetKind {
if state.Draft != nil {
switch domain.StickerSetKind(state.Draft[stickersBotDraftKind]) {
case domain.StickerSetKindEmoji:
return domain.StickerSetKindEmoji
case domain.StickerSetKindMasks:
return domain.StickerSetKindMasks
}
}
if state.Command == stickersBotCmdNewEmoji {
return domain.StickerSetKindEmoji
}
return domain.StickerSetKindStickers
}
func stickersBotCreateCommand(command string) bool {
return command == stickersBotCmdNewPack || command == stickersBotCmdNewEmoji
}
func stickersBotSetKind(set domain.StickerSet) domain.StickerSetKind {
switch {
case set.Kind == domain.StickerSetKindMasks || set.Masks:
return domain.StickerSetKindMasks
case set.Kind == domain.StickerSetKindEmoji || set.Emojis:
return domain.StickerSetKindEmoji
default:
return domain.StickerSetKindStickers
}
}
func stickersBotSetTitle(set domain.StickerSet) string {
title := strings.TrimSpace(set.Title)
if title == "" {
title = set.ShortName
}
if title == "" {
return "the pack"
}
return title
}
func stickersBotSetTitleFromState(state domain.BotChatState) string {
if state.Draft != nil {
if title := strings.TrimSpace(state.Draft[stickersBotDraftSetTitle]); title != "" {
return title
}
if shortName := strings.TrimSpace(state.Draft[stickersBotDraftSetShortName]); shortName != "" {
return shortName
}
}
return "the pack"
}
func stickersBotSetRefFromState(state domain.BotChatState) (domain.StickerSetRef, bool) {
id, accessHash, ok := stickersBotSetIdentityFromState(state)
if ok {
return domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: id, AccessHash: accessHash}, true
}
if state.Draft != nil {
if shortName := strings.TrimSpace(state.Draft[stickersBotDraftSetShortName]); shortName != "" {
return domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName}, true
}
}
return domain.StickerSetRef{}, false
}
func stickersBotSetIdentityFromState(state domain.BotChatState) (int64, int64, bool) {
if state.Draft == nil {
return 0, 0, false
}
id, _ := strconv.ParseInt(state.Draft[stickersBotDraftSetID], 10, 64)
accessHash, _ := strconv.ParseInt(state.Draft[stickersBotDraftSetAccessHash], 10, 64)
return id, accessHash, id != 0 && accessHash != 0
}
func stickersBotDraftItemsFromState(state domain.BotChatState) []stickersBotDraftItem {
if state.Draft == nil || strings.TrimSpace(state.Draft[stickersBotDraftItems]) == "" {
return nil
}
var items []stickersBotDraftItem
if err := json.Unmarshal([]byte(state.Draft[stickersBotDraftItems]), &items); err != nil {
return nil
}
return items
}
func setStickersBotDraftItems(state *domain.BotChatState, items []stickersBotDraftItem) error {
if state.Draft == nil {
state.Draft = map[string]string{}
}
raw, err := json.Marshal(items)
if err != nil {
return err
}
state.Draft[stickersBotDraftItems] = string(raw)
return nil
}
func pendingStickersBotDocument(state domain.BotChatState) (int64, int64) {
if state.Draft == nil {
return 0, 0
}
id, _ := strconv.ParseInt(state.Draft[stickersBotDraftPendingDocID], 10, 64)
accessHash, _ := strconv.ParseInt(state.Draft[stickersBotDraftPendingDocHash], 10, 64)
return id, accessHash
}
func stickersBotCreateItems(items []stickersBotDraftItem) []domain.StickerSetItemInput {
out := make([]domain.StickerSetItemInput, 0, len(items))
for _, item := range items {
out = append(out, domain.StickerSetItemInput{
DocumentID: item.DocumentID,
DocumentAccessHash: item.DocumentAccessHash,
Emoji: item.Emoji,
})
}
return out
}
func containsInt64(values []int64, want int64) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func cloneStickersBotState(state domain.BotChatState) domain.BotChatState {
out := state
if state.Draft != nil {
out.Draft = make(map[string]string, len(state.Draft))
for k, v := range state.Draft {
out.Draft[k] = v
}
}
return out
}
func normalizeStickersBotShortName(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "https://telesrv.net/addstickers/")
raw = strings.TrimPrefix(raw, "https://telesrv.net/addemoji/")
raw = strings.TrimPrefix(raw, "telesrv://addstickers?set=")
raw = strings.TrimPrefix(raw, "telesrv://addemoji?set=")
raw = strings.TrimPrefix(raw, "tg://addstickers?set=")
raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
return strings.ToLower(strings.Trim(raw, " /"))
}
func validStickersBotEmoji(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "/") || utf8.RuneCountInString(raw) > 64 {
return false
}
hasEmoji := false
for _, r := range raw {
switch {
case r == 0x200D || r == 0xFE0E || r == 0xFE0F:
continue
case r >= 0x1F3FB && r <= 0x1F3FF:
continue
case r == 0x20E3:
hasEmoji = true
case r == '#' || r == '*' || (r >= '0' && r <= '9'):
continue
case r == 0x00A9 || r == 0x00AE || r == 0x3030 || r == 0x303D || r == 0x3297 || r == 0x3299:
hasEmoji = true
case r >= 0x2600 && r <= 0x27BF:
hasEmoji = true
case r >= 0x1F000 && r <= 0x1FAFF:
hasEmoji = true
default:
return false
}
}
return hasEmoji
}
func stickersBotPublicURL(set domain.StickerSet) string {
part := "addstickers"
if stickersBotSetKind(set) == domain.StickerSetKindEmoji {
part = "addemoji"
}
return "https://telesrv.net/" + part + "/" + set.ShortName
}

View file

@ -0,0 +1,624 @@
package bots
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func sendTextToStickers(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string {
t.Helper()
return sendMessageToStickers(t, svc, messages, owner, domain.Message{Body: text})
}
func sendDocumentToStickers(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, doc domain.Document) string {
t.Helper()
return sendMessageToStickers(t, svc, messages, owner, domain.Message{
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &doc,
},
})
}
func sendMessageToStickers(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, msg domain.Message) string {
t.Helper()
msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}
msg.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID}
svc.respondAsStickers(owner.ID, msg)
list, err := messages.ListByUser(context.Background(), owner.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID},
Limit: 100,
})
if err != nil {
t.Fatalf("list stickers history: %v", err)
}
var latest domain.Message
for _, msg := range list.Messages {
if msg.From.ID == domain.StickersBotUserID && msg.ID > latest.ID {
latest = msg
}
}
if latest.ID == 0 {
t.Fatalf("no Stickers reply after message %+v", msg)
}
return latest.Body
}
func newStickersBotTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore, *stickersBotFakeCreator, *stickersBotFakeInstaller) {
t.Helper()
users := memory.NewUserStore()
bots := memory.NewBotStore(users)
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
creator := &stickersBotFakeCreator{}
installer := &stickersBotFakeInstaller{}
svc := NewService(users, bots, messages, WithStickerSetCreator(creator), WithUserStickerSets(installer))
return svc, users, bots, messages, creator, installer
}
func stickerBotTestDocument(id, accessHash int64, attr domain.DocumentAttributeKind) domain.Document {
return domain.Document{
ID: id,
AccessHash: accessHash,
DCID: 2,
MimeType: "image/webp",
Attributes: []domain.DocumentAttribute{{Kind: attr}},
}
}
func stickerBotUploadDocument(id, accessHash int64, mimeType, fileName string) domain.Document {
return domain.Document{
ID: id,
AccessHash: accessHash,
DCID: 2,
MimeType: mimeType,
Size: 4096,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: fileName}},
}
}
func stickerBotSetDocument(id, accessHash, setID, setAccessHash int64) domain.Document {
return domain.Document{
ID: id,
AccessHash: accessHash,
DCID: 2,
MimeType: "image/webp",
Attributes: []domain.DocumentAttribute{{
Kind: domain.DocAttrSticker,
StickerSetID: setID,
StickerSetAccessHash: setAccessHash,
}},
}
}
func stickerBotSetCustomEmojiDocument(id, accessHash, setID, setAccessHash int64) domain.Document {
return domain.Document{
ID: id,
AccessHash: accessHash,
DCID: 2,
MimeType: "application/x-tgsticker",
Attributes: []domain.DocumentAttribute{{
Kind: domain.DocAttrCustomEmoji,
StickerSetID: setID,
StickerSetAccessHash: setAccessHash,
}},
}
}
func TestStickersBotSystemSeedStartAndCancel(t *testing.T) {
svc, users, bots, messages, _, _ := newStickersBotTestService(t)
owner := newOwner(t, users, "+3000")
ctx := context.Background()
if !svc.HandlesBot(domain.BotFatherUserID) || !svc.HandlesBot(domain.StickersBotUserID) {
t.Fatal("service should handle BotFather and Stickers")
}
u, found, err := users.ByUsername(ctx, "Stickers")
if err != nil || !found {
t.Fatalf("@Stickers user not seeded: found=%v err=%v", found, err)
}
if u.ID != domain.StickersBotUserID || !u.Bot || u.BotInfoVersion < 1 {
t.Fatalf("@Stickers user = %+v, want seeded bot", u)
}
profile, found, err := bots.GetBot(ctx, domain.StickersBotUserID)
if err != nil || !found {
t.Fatalf("@Stickers profile not seeded: found=%v err=%v", found, err)
}
if !botCommandExists(profile.Commands, "newpack") || !botCommandExists(profile.Commands, "newemoji") ||
!botCommandExists(profile.Commands, "publish") || !botCommandExists(profile.Commands, "addsticker") ||
!botCommandExists(profile.Commands, "delsticker") {
t.Fatalf("@Stickers commands = %+v, want newpack/newemoji/publish/addsticker/delsticker", profile.Commands)
}
if reply := sendTextToStickers(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newpack") || !strings.Contains(reply, "/newemoji") || !strings.Contains(reply, "/addsticker") {
t.Fatalf("/start reply = %q, want help text", reply)
}
sendTextToStickers(t, svc, messages, owner, "/newpack")
if reply := sendTextToStickers(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "Cancelled") {
t.Fatalf("/cancel reply = %q, want cancelled", reply)
}
if _, found, _ := bots.GetBotChatState(ctx, domain.StickersBotUserID, owner.ID); found {
t.Fatal("stickers bot state still present after /cancel")
}
}
func TestStickersBotNewPackNoMaterialAndInvalidEmoji(t *testing.T) {
svc, users, _, messages, creator, _ := newStickersBotTestService(t)
owner := newOwner(t, users, "+3001")
if reply := sendTextToStickers(t, svc, messages, owner, "/newpack"); !strings.Contains(reply, "sticker pack") {
t.Fatalf("/newpack reply = %q, want sticker pack prompt", reply)
}
if reply := sendTextToStickers(t, svc, messages, owner, "My Pack"); !strings.Contains(reply, "Lottie JSON") {
t.Fatalf("title reply = %q, want document prompt", reply)
}
if reply := sendTextToStickers(t, svc, messages, owner, "/publish"); !strings.Contains(reply, "Add at least one") {
t.Fatalf("empty publish reply = %q, want no material notice", reply)
}
if reply := sendTextToStickers(t, svc, messages, owner, "not a document"); !strings.Contains(reply, "WebM/MP4 must include video metadata") {
t.Fatalf("text in document step reply = %q, want material prompt", reply)
}
if reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotTestDocument(101, 1101, domain.DocAttrSticker)); !strings.Contains(reply, "emoji") {
t.Fatalf("document reply = %q, want emoji prompt", reply)
}
if reply := sendTextToStickers(t, svc, messages, owner, "notemoji"); !strings.Contains(reply, "valid emoji") {
t.Fatalf("invalid emoji reply = %q, want emoji validation", reply)
}
if len(creator.created) != 0 {
t.Fatalf("creator called before valid publish: %+v", creator.created)
}
}
func TestStickersBotPublishStickerPack(t *testing.T) {
svc, users, bots, messages, creator, installer := newStickersBotTestService(t)
hooks := &stickersBotHookRecorder{}
svc.SetRouterHooks(hooks)
owner := newOwner(t, users, "+3002")
sendTextToStickers(t, svc, messages, owner, "/newpack")
sendTextToStickers(t, svc, messages, owner, "Fresh Pack")
sendDocumentToStickers(t, svc, messages, owner, stickerBotTestDocument(201, 2201, domain.DocAttrSticker))
sendTextToStickers(t, svc, messages, owner, "🙂")
sendTextToStickers(t, svc, messages, owner, "/publish")
reply := sendTextToStickers(t, svc, messages, owner, "fresh_pack")
if !strings.Contains(reply, "https://telesrv.net/addstickers/fresh_pack") {
t.Fatalf("publish reply = %q, want addstickers link", reply)
}
if len(creator.created) != 1 {
t.Fatalf("created requests = %d, want 1", len(creator.created))
}
req := creator.created[0]
if req.CreatorUserID != owner.ID || req.Title != "Fresh Pack" || req.ShortName != "fresh_pack" || req.Kind != domain.StickerSetKindStickers {
t.Fatalf("create request = %+v, want owner/title/short/kind", req)
}
if len(req.Items) != 1 || req.Items[0].DocumentID != 201 || req.Items[0].DocumentAccessHash != 2201 || req.Items[0].Emoji != "🙂" {
t.Fatalf("create items = %+v, want doc+emoji", req.Items)
}
if len(installer.installs) != 1 || installer.installs[0].userID != owner.ID || installer.installs[0].setID != creator.sets[0].ID {
t.Fatalf("installs = %+v, want creator install", installer.installs)
}
if hooks.userID != owner.ID || hooks.kind != domain.StickerSetKindStickers {
t.Fatalf("sticker update hook = user %d kind %q, want creator stickers", hooks.userID, hooks.kind)
}
if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found {
t.Fatal("stickers bot state still present after publish")
}
}
func TestStickersBotPublishUploadedTGS(t *testing.T) {
svc, users, _, messages, creator, installer := newStickersBotTestService(t)
owner := newOwner(t, users, "+3004")
sendTextToStickers(t, svc, messages, owner, "/newemoji")
sendTextToStickers(t, svc, messages, owner, "Local Emoji")
reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotUploadDocument(401, 4401, "application/octet-stream", "wave.tgs"))
if !strings.Contains(reply, "emoji") {
t.Fatalf("uploaded tgs reply = %q, want emoji prompt", reply)
}
sendTextToStickers(t, svc, messages, owner, "👋")
sendTextToStickers(t, svc, messages, owner, "/publish")
reply = sendTextToStickers(t, svc, messages, owner, "local_emoji")
if !strings.Contains(reply, "https://telesrv.net/addemoji/local_emoji") {
t.Fatalf("publish uploaded tgs reply = %q, want addemoji link", reply)
}
if len(creator.created) != 1 {
t.Fatalf("created requests = %d, want 1", len(creator.created))
}
req := creator.created[0]
if req.Kind != domain.StickerSetKindEmoji || len(req.Items) != 1 || req.Items[0].DocumentID != 401 || req.Items[0].DocumentAccessHash != 4401 {
t.Fatalf("create request = %+v, want uploaded tgs item in emoji pack", req)
}
if len(installer.installs) != 1 || installer.installs[0].kind != domain.StickerSetKindEmoji {
t.Fatalf("installs = %+v, want emoji install", installer.installs)
}
}
func TestStickersBotPublishUploadedLottieJSON(t *testing.T) {
svc, users, _, messages, creator, installer := newStickersBotTestService(t)
owner := newOwner(t, users, "+3005")
sendTextToStickers(t, svc, messages, owner, "/newpack")
sendTextToStickers(t, svc, messages, owner, "Lottie Pack")
reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotUploadDocument(402, 4402, "application/json", "wave.json"))
if !strings.Contains(reply, "emoji") {
t.Fatalf("uploaded lottie reply = %q, want emoji prompt", reply)
}
sendTextToStickers(t, svc, messages, owner, "👋")
sendTextToStickers(t, svc, messages, owner, "/publish")
reply = sendTextToStickers(t, svc, messages, owner, "lottie_pack")
if !strings.Contains(reply, "https://telesrv.net/addstickers/lottie_pack") {
t.Fatalf("publish uploaded lottie reply = %q, want addstickers link", reply)
}
if len(creator.created) != 1 {
t.Fatalf("created requests = %d, want 1", len(creator.created))
}
req := creator.created[0]
if req.Kind != domain.StickerSetKindStickers || len(req.Items) != 1 || req.Items[0].DocumentID != 402 || req.Items[0].DocumentAccessHash != 4402 {
t.Fatalf("create request = %+v, want uploaded lottie item in sticker pack", req)
}
if len(installer.installs) != 1 || installer.installs[0].kind != domain.StickerSetKindStickers {
t.Fatalf("installs = %+v, want sticker install", installer.installs)
}
}
func TestStickersBotPublishCustomEmojiPack(t *testing.T) {
svc, users, _, messages, creator, installer := newStickersBotTestService(t)
owner := newOwner(t, users, "+3003")
sendTextToStickers(t, svc, messages, owner, "/newemoji")
sendTextToStickers(t, svc, messages, owner, "Emoji Pack")
sendDocumentToStickers(t, svc, messages, owner, stickerBotTestDocument(301, 3301, domain.DocAttrCustomEmoji))
sendTextToStickers(t, svc, messages, owner, "🔥")
sendTextToStickers(t, svc, messages, owner, "/publish")
reply := sendTextToStickers(t, svc, messages, owner, "emoji_pack")
if !strings.Contains(reply, "https://telesrv.net/addemoji/emoji_pack") {
t.Fatalf("publish emoji reply = %q, want addemoji link", reply)
}
if len(creator.created) != 1 || creator.created[0].Kind != domain.StickerSetKindEmoji {
t.Fatalf("created emoji requests = %+v, want kind emoji", creator.created)
}
if len(installer.installs) != 1 || installer.installs[0].kind != domain.StickerSetKindEmoji {
t.Fatalf("emoji installs = %+v, want emoji kind install", installer.installs)
}
}
func TestStickersBotAddStickerToExistingPack(t *testing.T) {
svc, users, bots, messages, manager, _ := newStickersBotTestService(t)
hooks := &stickersBotHookRecorder{}
svc.SetRouterHooks(hooks)
owner := newOwner(t, users, "+3006")
manager.sets = append(manager.sets, domain.StickerSet{
ID: 7100,
AccessHash: 8100,
ShortName: "fresh_pack",
Title: "Fresh Pack",
Kind: domain.StickerSetKindStickers,
Creator: true,
CreatorUserID: owner.ID,
Count: 1,
DocumentIDs: []int64{501},
})
if reply := sendTextToStickers(t, svc, messages, owner, "/addsticker"); !strings.Contains(reply, "short name") {
t.Fatalf("/addsticker reply = %q, want short name prompt", reply)
}
if reply := sendTextToStickers(t, svc, messages, owner, "https://telesrv.net/addstickers/fresh_pack"); !strings.Contains(reply, "Selected Fresh Pack") {
t.Fatalf("select pack reply = %q, want selected pack", reply)
}
if reply := sendDocumentToStickers(t, svc, messages, owner, stickerBotUploadDocument(502, 5502, "application/json", "new.json")); !strings.Contains(reply, "emoji") {
t.Fatalf("add document reply = %q, want emoji prompt", reply)
}
reply := sendTextToStickers(t, svc, messages, owner, "😄")
if !strings.Contains(reply, "Done. Added to Fresh Pack") || !strings.Contains(reply, "https://telesrv.net/addstickers/fresh_pack") {
t.Fatalf("add final reply = %q, want done link", reply)
}
if len(manager.adds) != 1 {
t.Fatalf("adds = %+v, want one add", manager.adds)
}
add := manager.adds[0]
if add.userID != owner.ID || add.ref.ID != 7100 || add.item.DocumentID != 502 || add.item.DocumentAccessHash != 5502 || add.item.Emoji != "😄" {
t.Fatalf("add call = %+v, want owner/set/doc/emoji", add)
}
if hooks.userID != owner.ID || hooks.kind != domain.StickerSetKindStickers {
t.Fatalf("hook = user %d kind %q, want owner stickers", hooks.userID, hooks.kind)
}
if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found {
t.Fatal("stickers bot state still present after add")
}
}
func TestStickersBotAddStickerDuplicateStaysInFlow(t *testing.T) {
svc, users, bots, messages, manager, _ := newStickersBotTestService(t)
owner := newOwner(t, users, "+3008")
manager.sets = append(manager.sets, domain.StickerSet{
ID: 7300,
AccessHash: 8300,
ShortName: "fresh_pack",
Title: "Fresh Pack",
Kind: domain.StickerSetKindStickers,
CreatorUserID: owner.ID,
Count: 1,
DocumentIDs: []int64{501},
})
sendTextToStickers(t, svc, messages, owner, "/addsticker")
sendTextToStickers(t, svc, messages, owner, "fresh_pack")
sendDocumentToStickers(t, svc, messages, owner, stickerBotSetDocument(501, 5501, 7300, 8300))
reply := sendTextToStickers(t, svc, messages, owner, "😄")
if !strings.Contains(reply, "already in this pack") {
t.Fatalf("duplicate add reply = %q, want already-in-pack notice", reply)
}
if len(manager.adds) != 0 {
t.Fatalf("adds = %+v, want no manager add for duplicate", manager.adds)
}
state, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID)
if !found || state.Step != stickersBotStepDocument {
t.Fatalf("state after duplicate = %+v found=%v, want document step", state, found)
}
}
func TestStickersBotDeleteStickerFromExistingPack(t *testing.T) {
svc, users, bots, messages, manager, _ := newStickersBotTestService(t)
hooks := &stickersBotHookRecorder{}
svc.SetRouterHooks(hooks)
owner := newOwner(t, users, "+3007")
manager.sets = append(manager.sets, domain.StickerSet{
ID: 7200,
AccessHash: 8200,
ShortName: "old_pack",
Title: "Old Pack",
Kind: domain.StickerSetKindStickers,
Creator: true,
CreatorUserID: owner.ID,
Count: 2,
DocumentIDs: []int64{601, 602},
})
sendTextToStickers(t, svc, messages, owner, "/delsticker")
if reply := sendTextToStickers(t, svc, messages, owner, "old_pack"); !strings.Contains(reply, "Selected Old Pack") {
t.Fatalf("select delete pack reply = %q, want selected pack", reply)
}
doc := stickerBotSetDocument(601, 6601, 7200, 8200)
reply := sendDocumentToStickers(t, svc, messages, owner, doc)
if !strings.Contains(reply, "Done. Removed from Old Pack") || !strings.Contains(reply, "https://telesrv.net/addstickers/old_pack") {
t.Fatalf("delete final reply = %q, want done link", reply)
}
if len(manager.removes) != 1 || manager.removes[0].documentID != 601 || manager.removes[0].accessHash != 6601 {
t.Fatalf("removes = %+v, want document 601", manager.removes)
}
if hooks.userID != owner.ID || hooks.kind != domain.StickerSetKindStickers {
t.Fatalf("hook = user %d kind %q, want owner stickers", hooks.userID, hooks.kind)
}
if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found {
t.Fatal("stickers bot state still present after delete")
}
}
func TestStickersBotDeleteCustomEmojiEntityFromExistingPack(t *testing.T) {
svc, users, bots, messages, manager, _ := newStickersBotTestService(t)
owner := newOwner(t, users, "+3009")
manager.sets = append(manager.sets, domain.StickerSet{
ID: 7400,
AccessHash: 8400,
ShortName: "emoji_pack",
Title: "Emoji Pack",
Kind: domain.StickerSetKindEmoji,
Emojis: true,
CreatorUserID: owner.ID,
Count: 2,
DocumentIDs: []int64{701, 702},
})
manager.docs = map[int64]domain.Document{
701: stickerBotSetCustomEmojiDocument(701, 7701, 7400, 8400),
}
sendTextToStickers(t, svc, messages, owner, "/delsticker")
sendTextToStickers(t, svc, messages, owner, "emoji_pack")
reply := sendMessageToStickers(t, svc, messages, owner, domain.Message{
Body: "🔥",
Entities: []domain.MessageEntity{{
Type: domain.MessageEntityCustomEmoji,
Offset: 0,
Length: 2,
DocumentID: 701,
}},
})
if !strings.Contains(reply, "Done. Removed from Emoji Pack") || !strings.Contains(reply, "https://telesrv.net/addemoji/emoji_pack") {
t.Fatalf("delete custom emoji reply = %q, want done emoji link", reply)
}
if len(manager.removes) != 1 || manager.removes[0].documentID != 701 || manager.removes[0].accessHash != 7701 {
t.Fatalf("removes = %+v, want custom emoji document 701", manager.removes)
}
if _, found, _ := bots.GetBotChatState(context.Background(), domain.StickersBotUserID, owner.ID); found {
t.Fatal("stickers bot state still present after custom emoji delete")
}
}
func botCommandExists(commands []domain.BotCommand, want string) bool {
for _, c := range commands {
if c.Command == want {
return true
}
}
return false
}
type stickersBotFakeCreator struct {
created []domain.CreateStickerSetRequest
sets []domain.StickerSet
docs map[int64]domain.Document
adds []stickersBotAdd
removes []stickersBotRemove
}
type stickersBotAdd struct {
userID int64
ref domain.StickerSetRef
item domain.StickerSetItemInput
}
type stickersBotRemove struct {
userID int64
documentID int64
accessHash int64
}
func (f *stickersBotFakeCreator) CreateStickerSet(_ context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
f.created = append(f.created, req)
docIDs := make([]int64, 0, len(req.Items))
for _, item := range req.Items {
docIDs = append(docIDs, item.DocumentID)
}
kind := req.Kind
if kind == "" {
kind = domain.StickerSetKindStickers
}
set := domain.StickerSet{
ID: 7000 + int64(len(f.sets)),
AccessHash: 8000 + int64(len(f.sets)),
ShortName: strings.ToLower(strings.TrimSpace(req.ShortName)),
Title: req.Title,
Kind: kind,
Emojis: kind == domain.StickerSetKindEmoji,
Creator: true,
CreatorUserID: req.CreatorUserID,
Count: len(docIDs),
DocumentIDs: docIDs,
}
f.sets = append(f.sets, set)
return set, nil, nil
}
func (f *stickersBotFakeCreator) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
idx := f.indexSet(ref)
if idx < 0 {
return domain.StickerSet{}, nil, false, nil
}
return f.sets[idx], nil, true, nil
}
func (f *stickersBotFakeCreator) ListCreatedStickerSets(_ context.Context, userID int64, _ int64, limit int) ([]domain.StickerSet, int, error) {
var out []domain.StickerSet
for _, set := range f.sets {
if set.CreatorUserID == userID {
out = append(out, set)
}
}
total := len(out)
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, total, nil
}
func (f *stickersBotFakeCreator) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
out := make([]domain.Document, 0, len(ids))
for _, id := range ids {
if doc, ok := f.docs[id]; ok {
out = append(out, doc)
}
}
return out, nil
}
func (f *stickersBotFakeCreator) AddStickerToSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) {
f.adds = append(f.adds, stickersBotAdd{userID: actorUserID, ref: ref, item: item})
idx := f.indexSet(ref)
if idx < 0 || f.sets[idx].CreatorUserID != actorUserID {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
set := f.sets[idx]
if !stickersBotTestContainsInt64(set.DocumentIDs, item.DocumentID) {
set.DocumentIDs = append(set.DocumentIDs, item.DocumentID)
set.Count = len(set.DocumentIDs)
}
f.sets[idx] = set
return set, nil, nil
}
func (f *stickersBotFakeCreator) RemoveStickerFromSet(_ context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) {
f.removes = append(f.removes, stickersBotRemove{userID: actorUserID, documentID: documentID, accessHash: accessHash})
for i, set := range f.sets {
if set.CreatorUserID != actorUserID {
continue
}
for idx, id := range set.DocumentIDs {
if id != documentID {
continue
}
set.DocumentIDs = append(append([]int64(nil), set.DocumentIDs[:idx]...), set.DocumentIDs[idx+1:]...)
set.Count = len(set.DocumentIDs)
f.sets[i] = set
return set, nil, nil
}
}
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
func (f *stickersBotFakeCreator) indexSet(ref domain.StickerSetRef) int {
for i, set := range f.sets {
switch ref.Kind {
case domain.StickerSetRefByID:
if set.ID == ref.ID && (ref.AccessHash == 0 || set.AccessHash == ref.AccessHash) {
return i
}
case domain.StickerSetRefByShortName:
if strings.EqualFold(set.ShortName, ref.ShortName) {
return i
}
}
}
return -1
}
func stickersBotTestContainsInt64(values []int64, want int64) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
type stickersBotFakeInstaller struct {
installs []stickersBotInstall
}
type stickersBotInstall struct {
userID int64
setID int64
kind domain.StickerSetKind
}
func (f *stickersBotFakeInstaller) InstallUserStickerSet(_ context.Context, userID int64, setID int64, kind domain.StickerSetKind, _ bool, _ int) error {
f.installs = append(f.installs, stickersBotInstall{userID: userID, setID: setID, kind: kind})
return nil
}
type stickersBotHookRecorder struct {
userID int64
kind domain.StickerSetKind
}
func (h *stickersBotHookRecorder) RevokeBotSessions(context.Context, int64) error {
return nil
}
func (h *stickersBotHookRecorder) PushBotCommandsChanged(context.Context, int64, []domain.BotCommand) {
}
func (h *stickersBotHookRecorder) PushStickerSetsChanged(_ context.Context, userID int64, kind domain.StickerSetKind) {
h.userID = userID
h.kind = kind
}