Ctrl K
ComunidadGuías…

internal/app/bots/stickersbot.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package bots

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"go.uber.org/zap"

	"telesrv/internal/branding"
	"telesrv/internal/domain"
	"telesrv/internal/links"
)

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
)

func stickersBotHelpText() string {
	return "I can help you create sticker and custom emoji packs for " + branding.ProductName() + ".\n\n" +
		"Send /newpack to create a sticker pack.\n" +
		"Send /newemoji to create a custom emoji pack.\n" +
		"Send /addsticker to add an item to one of your packs.\n" +
		"Send /delsticker to remove an item from one of your packs.\n\n" +
		"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.\n\n" +
		"/packs - list your created packs\n" +
		"/cancel - cancel the current operation\n" +
		"/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 " + branding.ProductName() + " link of the pack you want to edit. Use /packs to see your packs."}
	}
	return botReply{Text: "Send the short name or " + branding.ProductName() + " 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 " + branding.ProductName() + " 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), s.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), s.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" + s.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, s.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 " + branding.ProductName() + " 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, "telesrv://addstickers?set=")
	raw = strings.TrimPrefix(raw, "telesrv://addemoji?set=")
	raw = strings.TrimPrefix(raw, "tg://addstickers?set=")
	raw = strings.TrimPrefix(raw, "tg://addemoji?set=")
	if strings.Contains(raw, "://") {
		if parsed, err := url.Parse(raw); err == nil {
			query := parsed.Query()
			route := strings.Trim(parsed.Path, "/")
			if route == "" {
				route = strings.ToLower(parsed.Host)
			}
			if route == "addstickers" || route == "addemoji" {
				if shortName := query.Get("set"); shortName != "" {
					raw = shortName
				}
			}
			parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
			for i, part := range parts {
				if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) {
					raw = parts[i+1]
					break
				}
			}
		}
	}
	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 (s *Service) stickersBotPublicURL(set domain.StickerSet) string {
	part := "addstickers"
	if stickersBotSetKind(set) == domain.StickerSetKindEmoji {
		part = "addemoji"
	}
	return s.publicURL(part + "/" + set.ShortName)
}

func (s *Service) publicURL(path string) string {
	baseURL := links.DefaultPublicBaseURL
	if s != nil && s.publicBaseURL != "" {
		baseURL = s.publicBaseURL
	}
	return links.Build(baseURL, path, nil)
}