package telegram import ( "sort" "strconv" "strings" "dev.narayana.im/narayana/telegabber/xmpp/gateway" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/zelenin/go-tdlib/client" ) // defaultActiveEmojiReactions seeds Client.activeEmojiReactions until the // first live updateActiveEmojiReactions arrives (normally within the // initial post-authorization update burst, so this seed's exposure window // is brief). There's no versioned schema for this set, so treat it as a // best-effort snapshot, not a ceiling: a chat's own restricted list // (ChatAvailableReactionsSome) always wins over it, and a Telegram-side // rejection is still the final authority regardless of what's in this // list. Compiled from the public list at // https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9 // (Telegram's pre-custom-emoji "expanded" reaction set, ~2022). var defaultActiveEmojiReactions = []string{ "๐Ÿ‘", "๐Ÿ‘Ž", "โค", "๐Ÿ”ฅ", "๐Ÿฅฐ", "๐Ÿ‘", "๐Ÿ˜", "๐Ÿค”", "๐Ÿคฏ", "๐Ÿ˜ฑ", "๐Ÿคฌ", "๐Ÿ˜ข", "๐ŸŽ‰", "๐Ÿคฉ", "๐Ÿคฎ", "๐Ÿ’ฉ", "๐Ÿ™", "๐Ÿ‘Œ", "๐Ÿ•Š", "๐Ÿคก", "๐Ÿฅฑ", "๐Ÿฅด", "๐Ÿ˜", "๐Ÿณ", "โค๏ธโ€๐Ÿ”ฅ", "๐ŸŒš", "๐ŸŒญ", "๐Ÿ’ฏ", "๐Ÿคฃ", "โšก", "๐ŸŒ", "๐Ÿ†", "๐Ÿ’”", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ“", "๐Ÿพ", "๐Ÿ’‹", "๐Ÿ–•", "๐Ÿ˜ˆ", "๐Ÿ˜ด", "๐Ÿ˜ญ", "๐Ÿค“", "๐Ÿ‘ป", "๐Ÿ‘จโ€๐Ÿ’ป", "๐Ÿ‘€", "๐ŸŽƒ", "๐Ÿ™ˆ", "๐Ÿ˜‡", "๐Ÿ˜จ", "๐Ÿค", "โœ", "๐Ÿค—", "๐Ÿซก", "๐ŸŽ…", "๐ŸŽ„", "โ˜ƒ", "๐Ÿ’…", "๐Ÿคช", "๐Ÿ—ฟ", "๐Ÿ†’", "๐Ÿ’˜", "๐Ÿ™‰", "๐Ÿฆ„", "๐Ÿ˜˜", "๐Ÿ’Š", "๐Ÿ™Š", "๐Ÿ˜Ž", "๐Ÿ‘พ", "๐Ÿคทโ€โ™‚๏ธ", "๐Ÿคท", "๐Ÿคทโ€โ™€๏ธ", "๐Ÿ˜ก", } func newDefaultActiveEmojiReactions() map[string]bool { m := make(map[string]bool, len(defaultActiveEmojiReactions)) for _, e := range defaultActiveEmojiReactions { m[e] = true } return m } // updateActiveEmojiReactions replaces the cached global reaction set with // TDlib's authoritative, current one. func (c *Client) updateActiveEmojiReactions(update *client.UpdateActiveEmojiReactions) { m := make(map[string]bool, len(update.Emojis)) for _, e := range update.Emojis { m[e] = true } c.activeEmojiReactionsLock.Lock() c.activeEmojiReactions = m c.activeEmojiReactionsLock.Unlock() } // chatAllowsReaction reports whether emoji is a plain-text reaction chat // currently accepts: chat's own restricted list if Telegram has set one // (ChatAvailableReactionsSome), otherwise the global active set. func (c *Client) chatAllowsReaction(chat *client.Chat, emoji string) bool { if chat != nil { if some, ok := chat.AvailableReactions.(*client.ChatAvailableReactionsSome); ok { for _, rt := range some.Reactions { if e, ok := rt.(*client.ReactionTypeEmoji); ok && e.Emoji == emoji { return true } } return false } } c.activeEmojiReactionsLock.Lock() defer c.activeEmojiReactionsLock.Unlock() return c.activeEmojiReactions[emoji] } // paidReactionFallback stands in for ReactionTypePaid, which has no // emoji field of its own - Telegram's UI name for it ("Star reaction"), // not a protocol value. // // TODO: ReactionTypePaid doesn't exist in the go-tdlib revision this // project currently pins (predates Telegram's Stars-reactions feature) - // wire up the case below once that's bumped. const paidReactionFallback = "โญ" // resolveReactionEmoji returns rt's XEP-0444 plain-text equivalent, or // ok=false if it has none. func (c *Client) resolveReactionEmoji(rt client.ReactionType) (emoji string, ok bool) { switch v := rt.(type) { case *client.ReactionTypeEmoji: return v.Emoji, true case *client.ReactionTypeCustomEmoji: return c.customEmojiFallback(int64(v.CustomEmojiId)) // case *client.ReactionTypePaid: // return paidReactionFallback, true default: return "", false } } func (c *Client) customEmojiFallback(customEmojiId int64) (string, bool) { c.customEmojiFallbacksLock.Lock() emoji, cached := c.customEmojiFallbacks[customEmojiId] c.customEmojiFallbacksLock.Unlock() if cached { return emoji, true } stickers, err := c.client.GetCustomEmojiStickers(&client.GetCustomEmojiStickersRequest{ CustomEmojiIds: []client.JsonInt64{client.JsonInt64(customEmojiId)}, }) if err != nil || len(stickers.Stickers) == 0 { return "", false } emoji = stickers.Stickers[0].Emoji if emoji == "" { return "", false } c.customEmojiFallbacksLock.Lock() c.customEmojiFallbacks[customEmojiId] = emoji c.customEmojiFallbacksLock.Unlock() return emoji, true } // ReactionRestrictions reports chatId's allowed emoji and its per-message, // per-user reaction cap, for XEP-0444's disco#info restricted-reactions // form (urn:xmpp:reactions:0:restrictions). ok is false only if chatId // can't be resolved to a chat. // // TDlib doesn't expose the per-user cap as a queryable field; 1 for // non-Premium and 3 for Premium accounts matches Telegram's own // description of the feature (telegram.org/blog/infinite-reactions-statuses). func (c *Client) ReactionRestrictions(chatId int64) (allowlist []string, maxPerUser int, ok bool) { chat, _, err := c.GetContactByID(chatId, nil, true) if err != nil || chat == nil { return nil, 0, false } if some, isSome := chat.AvailableReactions.(*client.ChatAvailableReactionsSome); isSome { for _, rt := range some.Reactions { if e, isEmoji := rt.(*client.ReactionTypeEmoji); isEmoji { allowlist = append(allowlist, e.Emoji) } } } else { c.activeEmojiReactionsLock.Lock() allowlist = mapKeys(c.activeEmojiReactions) c.activeEmojiReactionsLock.Unlock() } sort.Strings(allowlist) maxPerUser = 1 if c.me != nil && c.me.IsPremium { maxPerUser = 3 } return allowlist, maxPerUser, true } // reactionKey identifies one reactor's signature slot in lastPeerReactions. // SenderId is 0 for the peer side of a 1:1 chat (there's only one possible // non-self reactor there); in groups/channels it's the Telegram user id of // a named occupant, or ChatId itself for the pooled anonymous bucket (see // collectGroupReactions). type reactionKey struct { ChatId int64 MessageId int64 SenderId int64 } // updateMessageInteractionInfo relays a Telegram reaction change to XMPP, // restricted to resources that disco'd support for urn:xmpp:reactions:0 // (GetReactionCapableFullJids / GetMUCReactionCapableFullJids) - anything // else is dropped rather than falling back to a flood of plaintext // notices for every reaction toggle. func (c *Client) updateMessageInteractionInfo(update *client.UpdateMessageInteractionInfo) { if c.Session.IsChatIgnored(update.ChatId) { return } chat, _, err := c.GetContactByID(update.ChatId, nil, true) if err != nil || chat == nil { return } isMUC := c.Session.MUC && c.IsGroup(chat) xmppId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, update.ChatId, update.MessageId) if err != nil { xmppId = strconv.FormatInt(update.MessageId, 10) } if !isMUC { selfEmojis, peerEmojis := c.splitPMReactions(update.InteractionInfo) // Own reaction state can change from a different Telegram client, // which has no other way to reach XMPP - relayed as a carbon // (privileged component injection) since a component can't claim // from= otherwise. Carbons fan out via the // real server, so unlike the peer relay below this can't be // filtered to reaction-capable resources only. if c.me != nil && c.peerReactionsChanged(update.ChatId, update.MessageId, c.me.Id, selfEmojis) { gateway.SendMessage(c.jid, gateway.CHATNODE(update.ChatId), c.xmpp, gateway.SMReactions(&gateway.Reactions{Id: xmppId, Reactions: selfEmojis}), gateway.SMIsCarbon(true), ) } if jids := c.GetReactionCapableFullJids(); len(jids) > 0 && c.peerReactionsChanged(update.ChatId, update.MessageId, 0, peerEmojis) { from := gateway.CHATNODE(update.ChatId) for _, jid := range jids { gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, peerEmojis, false) } } return } jids := c.GetMUCReactionCapableFullJids(update.ChatId) if len(jids) == 0 { return } perOccupant, anonymousEmojis := c.collectGroupReactions(update.ChatId, update.MessageId, update.InteractionInfo) mucJid := gateway.MUCJID(update.ChatId) for senderId, senderEmojis := range perOccupant { if !c.peerReactionsChanged(update.ChatId, update.MessageId, senderId, senderEmojis) { continue } if !c.assureMUCOccupant(update.ChatId, senderId, &client.MessageSenderUser{UserId: senderId}, chat) { continue } from := mucJid + "/" + c.GetMUCNickname(senderId) for _, jid := range jids { gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, senderEmojis, true) } } // Anonymous reactions (broadcast channel posts, or a group's // "anonymous admin" reacting as the group) can't be attributed to a // person, so they're pooled under the chat's own identity instead - // same convention regular messages already use for anonymous-admin // authorship (see getMessageSenderId/GetMUCNickname). if c.peerReactionsChanged(update.ChatId, update.MessageId, update.ChatId, anonymousEmojis) { anonymousSender := &client.MessageSenderChat{ChatId: update.ChatId} if c.assureMUCOccupant(update.ChatId, update.ChatId, anonymousSender, chat) { from := mucJid + "/" + c.GetMUCNickname(update.ChatId) for _, jid := range jids { gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, anonymousEmojis, true) } } } } // splitPMReactions splits a 1:1 message's reactions into self's own // current set and the peer's - IsChosen only means self is among a given // emoji's reactors, not that they're the only one (TotalCount still // includes the peer if both picked the same emoji). func (c *Client) splitPMReactions(info *client.MessageInteractionInfo) (selfEmojis, peerEmojis []string) { if info == nil { return nil, nil } for _, r := range info.Reactions { emoji, ok := c.resolveReactionEmoji(r.Type) if !ok { continue } peerCount := r.TotalCount if r.IsChosen { peerCount-- selfEmojis = append(selfEmojis, emoji) } if peerCount > 0 { peerEmojis = append(peerEmojis, emoji) } } return selfEmojis, peerEmojis } // SendDelayedMUCReactions synthesizes XEP-0444 MAM-result stanzas for // message's current reactors, one call site down from // SendDelayedMUCMessage in a MAM replay loop. Unlike live relay it // doesn't call assureMUCOccupant, matching SendDelayedMUCMessage's own // MAM-replay convention of not establishing presence for a historical // sender; toJid only receives anything if its resource disco'd support // for urn:xmpp:reactions:0. func (c *Client) SendDelayedMUCReactions(chatId int64, message *client.Message, toJid string, mamQueryId string) { _, resource, ok := gateway.SplitJID(toJid) if !ok || !c.resourceSupportsReactions(resource) { return } perOccupant, anonymousEmojis := c.collectGroupReactions(chatId, message.Id, message.InteractionInfo) if len(perOccupant) == 0 && len(anonymousEmojis) == 0 { return } xmppId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, chatId, message.Id) if err != nil { xmppId = strconv.FormatInt(message.Id, 10) } mucJid := gateway.MUCJID(chatId) strId := strconv.FormatInt(message.Id, 10) timestamp := int64(message.Date) for senderId, senderEmojis := range perOccupant { from := mucJid + "/" + c.GetMUCNickname(senderId) entryId := "r" + strId + "-" + strconv.FormatInt(senderId, 10) gateway.SendMAMReactionMessage(toJid, from, c.xmpp, xmppId, senderEmojis, entryId, timestamp, mucJid, mamQueryId) } if len(anonymousEmojis) > 0 { from := mucJid + "/" + c.GetMUCNickname(chatId) gateway.SendMAMReactionMessage(toJid, from, c.xmpp, xmppId, anonymousEmojis, "r"+strId+"-a", timestamp, mucJid, mamQueryId) } } // collectGroupReactions expands a message's aggregate per-emoji reaction // counts into per-reactor emoji sets, since a XEP-0444 stanza carries one // sender's complete reaction set, not one emoji at a time. // // TDlib only samples up to 3 recent reactors per emoji for free // (MessageReaction.RecentSenderIds); beyond that, getMessageAddedReactions // is called to enumerate the rest, up to gateway.MaxReactionsPerEmoji - // remaining reactors past that cap are silently dropped (not queued, not // substituted with a placeholder) to bound the number of XMPP stanzas a // single popular reaction can generate. Anonymous reactors are exempt // from the cap since they all collapse into the one pooled bucket // regardless of count. func (c *Client) collectGroupReactions(chatId, messageId int64, info *client.MessageInteractionInfo) (perOccupant map[int64][]string, anonymousEmojis []string) { if info == nil { return nil, nil } limit := int(gateway.MaxReactionsPerEmoji) if limit <= 0 { limit = 10 } apiLimit := limit if apiLimit > 100 { apiLimit = 100 } perOccupant = make(map[int64][]string) sawAnonymous := make(map[string]bool) for _, r := range info.Reactions { emoji, ok := c.resolveReactionEmoji(r.Type) if !ok { continue // paid reactions have no plain-text XEP-0444 equivalent } var senders []client.MessageSender var enumerated bool if int(r.TotalCount) > len(r.RecentSenderIds) { added, err := c.client.GetMessageAddedReactions(&client.GetMessageAddedReactionsRequest{ ChatId: chatId, MessageId: messageId, ReactionType: r.Type, Limit: int32(apiLimit), }) if err == nil { enumerated = true for _, a := range added.Reactions { senders = append(senders, a.SenderId) } } } if !enumerated { senders = r.RecentSenderIds } named := 0 for _, sender := range senders { switch sender.MessageSenderType() { case client.TypeMessageSenderUser: if named >= limit { continue } userId := sender.(*client.MessageSenderUser).UserId perOccupant[userId] = append(perOccupant[userId], emoji) named++ case client.TypeMessageSenderChat: if !sawAnonymous[emoji] { sawAnonymous[emoji] = true anonymousEmojis = append(anonymousEmojis, emoji) } } } } return perOccupant, anonymousEmojis } // peerReactionsChanged dedups against the last announced signature for // key. UpdateMessageInteractionInfo also fires for reasons unrelated to a // reaction change (e.g. view/forward counts), which would otherwise // re-announce the same state on every such update. func (c *Client) peerReactionsChanged(chatId, messageId, senderId int64, emojis []string) bool { sorted := append([]string(nil), emojis...) sort.Strings(sorted) sig := strings.Join(sorted, "\x00") key := reactionKey{ChatId: chatId, MessageId: messageId, SenderId: senderId} c.lastPeerReactionsLock.Lock() defer c.lastPeerReactionsLock.Unlock() prev, known := c.lastPeerReactions[key] if prev == sig { return false } if sig == "" && !known { // Not caching this keeps a later first real reaction correctly // triggering below, instead of "no prior entry" being confused // with "was reacted to, now cleared". return false } c.lastPeerReactions[key] = sig return true } // ownReactions returns chatId/messageId's currently self-chosen plain // emoji - deliberately not resolveReactionEmoji: removing a custom/paid // reaction needs its exact original type, not its fallback text, so // treating one as already-applied here would make it unremovable below. func (c *Client) ownReactions(chatId, messageId int64) (map[string]bool, error) { message, err := c.client.GetMessage(&client.GetMessageRequest{ ChatId: chatId, MessageId: messageId, }) if err != nil { return nil, err } current := make(map[string]bool) if message.InteractionInfo != nil { for _, r := range message.InteractionInfo.Reactions { if !r.IsChosen { continue } if emoji, isEmoji := r.Type.(*client.ReactionTypeEmoji); isEmoji { current[emoji.Emoji] = true } } } return current, nil } func mapKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // SetOwnMessageReactions reconciles the real user's chosen reactions on a // Telegram message with the complete set announced by an incoming // XEP-0444 stanza (always a full replacement, never a diff) - // diffing against TDlib's own reaction state since Telegram's per-user // reaction API is add/remove, not "set the following list" (TDlib's // setMessageReactions is bot-only). // // Per XEP-0444 S3.3, an invalid payload is rejected wholesale, before // touching Telegram (rejected then holds every requested emoji). A // rejection from Telegram's own API past that point is applied per-emoji // instead, not reverted - there's no atomic "set reactions" call // available to a non-bot account. func (c *Client) SetOwnMessageReactions(chatId, messageId int64, emojis []string) (applied []string, rejected []string) { current, err := c.ownReactions(chatId, messageId) if err != nil { log.Error(errors.Wrap(err, "Failed to fetch message to reconcile reactions")) return nil, nil } chat, _, _ := c.GetContactByID(chatId, nil, true) for _, emoji := range emojis { if !c.chatAllowsReaction(chat, emoji) { log.Warnf("Reaction update for %d/%d rejected wholesale: %q not in chat's allowed set", chatId, messageId, emoji) return mapKeys(current), emojis } } wanted := make(map[string]bool, len(emojis)) for _, e := range emojis { wanted[e] = true } for emoji := range wanted { if current[emoji] { continue } if _, err := c.client.AddMessageReaction(&client.AddMessageReactionRequest{ ChatId: chatId, MessageId: messageId, ReactionType: &client.ReactionTypeEmoji{Emoji: emoji}, UpdateRecentReactions: true, }); err != nil { log.Warn(errors.Wrapf(err, "Reaction %q rejected", emoji)) rejected = append(rejected, emoji) continue } current[emoji] = true } for emoji := range current { if wanted[emoji] { continue } if _, err := c.client.RemoveMessageReaction(&client.RemoveMessageReactionRequest{ ChatId: chatId, MessageId: messageId, ReactionType: &client.ReactionTypeEmoji{Emoji: emoji}, }); err != nil { log.Error(errors.Wrap(err, "Failed to remove reaction")) continue } delete(current, emoji) } return mapKeys(current), rejected }