telegabber/telegram/reactions.go
Bohdan Horbeshko 484743946a Reactions
2026-08-21 18:44:57 -04:00

523 lines
18 KiB
Go

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=<the real user's JID> 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 <reactions/> 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 <reactions/> 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
}