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