From 484743946a2823c5226c43ae0f3187a6643793a3 Mon Sep 17 00:00:00 2001 From: Bohdan Horbeshko Date: Fri, 21 Aug 2026 18:44:57 -0400 Subject: [PATCH] Reactions --- config.yml.example | 1 + config/config.go | 5 + go.mod | 2 +- go.sum | 4 +- telegram/client.go | 58 ++-- telegram/handlers.go | 6 + telegram/reactions.go | 523 ++++++++++++++++++++++++++++++++++ telegram/utils.go | 60 +++- xmpp/component.go | 4 + xmpp/extensions/extensions.go | 24 ++ xmpp/gateway/gateway.go | 171 ++++++++--- xmpp/handlers.go | 26 +- xmpp/reactions.go | 69 +++++ 13 files changed, 895 insertions(+), 58 deletions(-) create mode 100644 telegram/reactions.go create mode 100644 xmpp/reactions.go diff --git a/config.yml.example b/config.yml.example index 9c49b50..56d606a 100644 --- a/config.yml.example +++ b/config.yml.example @@ -8,6 +8,7 @@ :quota: '256MB' # maximum storage size :tdlib_verbosity: 1 :mam_threshold: 7 # in days + :max_reactions_per_emoji: 10 # named reactors relayed per (message, emoji) in group/channel XEP-0444 reactions :tdlib: :datadir: './sessions/' :client: diff --git a/config/config.go b/config/config.go index 39d45df..bd86247 100644 --- a/config/config.go +++ b/config/config.go @@ -58,6 +58,11 @@ type TelegramConfig struct { // HistoryBackfillWorkers bounds how many chats backfill concurrently // (see gateway.HistoryBackfillWorkers). 0/unset falls back to 4. HistoryBackfillWorkers uint32 `yaml:":history_backfill_workers"` + + // MaxReactionsPerEmoji caps named reactors relayed per (message, + // emoji) in group/channel reactions (see gateway.MaxReactionsPerEmoji). + // 0/unset falls back to a default of 10. + MaxReactionsPerEmoji uint32 `yaml:":max_reactions_per_emoji"` } // TelegramContentConfig is for :content: subtree diff --git a/go.mod b/go.mod index d170429..9c70fa0 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( nhooyr.io/websocket v1.6.5 // indirect ) -replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4 +replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20260821210951-a027f257a96d replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 diff --git a/go.sum b/go.sum index d5a3a15..386cb84 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 h1:CWAQT74LwQne/3Po5KXDvudu3N0FBWm3XZZZhtl5j2w= dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= -dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4 h1:HQT33Zp3iRkbCiijWDo943K//wQgzoMccIP7Vb2uEfY= -dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +dev.narayana.im/narayana/go-xmpp v0.0.0-20260821210951-a027f257a96d h1:oeYqHbaAQPV4ID4X47fHOeSS5s4y4hZlog4hOsZuMkE= +dev.narayana.im/narayana/go-xmpp v0.0.0-20260821210951-a027f257a96d/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Laky-64/gologging v1.1.0 h1:iV/VgoIbImLrI3EPssOzzoyv1OQrp6t5RtDQTKzUes8= github.com/Laky-64/gologging v1.1.0/go.mod h1:Ody93tsM0OZUAsWApkfb3rg35fAvyZDAx07kNH13DhI= diff --git a/telegram/client.go b/telegram/client.go index 5245edc..840bdf9 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -166,6 +166,23 @@ type Client struct { MessageIdChanges map[int64]map[int64]*newId MessageIdChangesLock sync.Mutex + // lastPeerReactions dedups outgoing XEP-0444 announcements - see + // peerReactionsChanged in reactions.go. + lastPeerReactions map[reactionKey]string + lastPeerReactionsLock sync.Mutex + + // activeEmojiReactions is Telegram's current global built-in reaction + // set, seeded from defaultActiveEmojiReactions and kept live by + // updateActiveEmojiReactions - see chatAllowsReaction in reactions.go. + activeEmojiReactions map[string]bool + activeEmojiReactionsLock sync.Mutex + + // customEmojiFallbacks caches a custom emoji reaction's plain-emoji + // fallback (Sticker.Emoji, resolved via GetCustomEmojiStickers) - see + // resolveReactionEmoji in reactions.go. + customEmojiFallbacks map[int64]string + customEmojiFallbacksLock sync.Mutex + // guards the call trio. Connect() builds it after c.client is live; // close() tears it down before c.client is closed. Accessors return // nil when no live session. @@ -300,25 +317,28 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component } return &Client{ - parameters: ¶meters, - xmpp: component, - jid: jid, - Session: session, - resources: make(map[string]bool), - content: &conf.Content, - cache: cache.NewCache(), - outbox: make(map[string]string), - editOutbox: make(map[string]string), - pinOutbox: make(map[IntPair]chan int64), - mucCache: make(map[int64]*MUCState), - uploadingFiles: make(map[int32]string), - options: options, - DelayedStatuses: make(map[int64]*DelayedStatus), - lastMsgHashes: make(map[int64]uint64), - lastMsgIds: make(map[int64]string), - XmppClientFeatures: make(map[string]*[]string), - avatarHashes: make(map[int64]*HashedAvatar), - MessageIdChanges: make(map[int64]map[int64]*newId), + parameters: ¶meters, + xmpp: component, + jid: jid, + Session: session, + resources: make(map[string]bool), + content: &conf.Content, + cache: cache.NewCache(), + outbox: make(map[string]string), + editOutbox: make(map[string]string), + pinOutbox: make(map[IntPair]chan int64), + mucCache: make(map[int64]*MUCState), + uploadingFiles: make(map[int32]string), + options: options, + DelayedStatuses: make(map[int64]*DelayedStatus), + lastMsgHashes: make(map[int64]uint64), + lastMsgIds: make(map[int64]string), + XmppClientFeatures: make(map[string]*[]string), + avatarHashes: make(map[int64]*HashedAvatar), + MessageIdChanges: make(map[int64]map[int64]*newId), + lastPeerReactions: make(map[reactionKey]string), + activeEmojiReactions: newDefaultActiveEmojiReactions(), + customEmojiFallbacks: make(map[int64]string), locks: clientLocks{ chatMessageLocks: make(map[int64]*sync.Mutex), deliveredMessageIds: make(map[int64]int64), diff --git a/telegram/handlers.go b/telegram/handlers.go index 0c5f164..fefa261 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -165,6 +165,12 @@ func (c *Client) dispatchUpdate(update client.Type) { case client.TypeUpdateDeleteMessages: typedUpdate, _ := update.(*client.UpdateDeleteMessages) c.updateDeleteMessages(typedUpdate) + case client.TypeUpdateMessageInteractionInfo: + typedUpdate, _ := update.(*client.UpdateMessageInteractionInfo) + c.updateMessageInteractionInfo(typedUpdate) + case client.TypeUpdateActiveEmojiReactions: + typedUpdate, _ := update.(*client.UpdateActiveEmojiReactions) + c.updateActiveEmojiReactions(typedUpdate) case client.TypeUpdateAuthorizationState: typedUpdate, _ := update.(*client.UpdateAuthorizationState) c.updateAuthorizationState(typedUpdate) diff --git a/telegram/reactions.go b/telegram/reactions.go new file mode 100644 index 0000000..8077343 --- /dev/null +++ b/telegram/reactions.go @@ -0,0 +1,523 @@ +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 +} diff --git a/telegram/utils.go b/telegram/utils.go index 49ef836..b278b6f 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -30,6 +30,7 @@ import ( "github.com/soheilhy/args" "github.com/zelenin/go-tdlib/client" "golang.org/x/sys/unix" + "gosrc.io/xmpp/stanza" ) type VCardInfo struct { @@ -609,7 +610,11 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o if isMUC { newArgs = append(newArgs, gateway.SPFullFrom(gateway.MUCJID(chatID))) } else { - newArgs = append(newArgs, gateway.SPCaps(gateway.PMCapsVer())) + var restrictionsForm *stanza.Form + if allowlist, maxPerUser, ok := c.ReactionRestrictions(chatID); ok { + restrictionsForm = gateway.ReactionRestrictionsForm(allowlist, maxPerUser) + } + newArgs = append(newArgs, gateway.SPCaps(gateway.PMCapsVer(restrictionsForm))) newArgs = gateway.SPAppendFrom(newArgs, chatID) } @@ -3152,6 +3157,59 @@ func (c *Client) GetCarbonFullJids(isOutgoing bool, ignoredResource string, forc return jids } +// resourceSupportsReactions reports whether resource disco'd support for +// XEP-0444 (gateway.NSReactions). +func (c *Client) resourceSupportsReactions(resource string) bool { + c.XmppClientFeaturesLock.Lock() + features, ok := c.XmppClientFeatures[resource] + c.XmppClientFeaturesLock.Unlock() + if !ok || features == nil { + return false + } + for _, feature := range *features { + if feature == gateway.NSReactions { + return true + } + } + return false +} + +// GetReactionCapableFullJids is like GetCarbonFullJids but for reactions: +// unlike that one, there is no bare-JID fallback - an unprobed or +// non-supporting resource is just skipped, never sent a legacy plaintext +// notice instead. +func (c *Client) GetReactionCapableFullJids() []string { + var jids []string + for _, resource := range c.resourcesSnapshot() { + if c.resourceSupportsReactions(resource) { + jids = append(jids, c.jid+"/"+resource) + } + } + return jids +} + +// GetMUCReactionCapableFullJids is GetReactionCapableFullJids scoped to +// the resources actually joined to chatId's room. +func (c *Client) GetMUCReactionCapableFullJids(chatId int64) []string { + c.locks.mucCacheLock.Lock() + mucState := c.mucCache[chatId] + var resources []string + if mucState != nil { + for resource := range mucState.Resources { + resources = append(resources, resource) + } + } + c.locks.mucCacheLock.Unlock() + + var jids []string + for _, resource := range resources { + if c.resourceSupportsReactions(resource) { + jids = append(jids, c.jid+"/"+resource) + } + } + return jids +} + func (c *Client) UpdateLastChatMessageId(chatId int64, messageId string) { c.locks.lastMsgIdsLock.Lock() defer c.locks.lastMsgIdsLock.Unlock() diff --git a/xmpp/component.go b/xmpp/component.go index d5ef556..16f1bba 100644 --- a/xmpp/component.go +++ b/xmpp/component.go @@ -112,6 +112,10 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri gateway.HistoryBackfillWorkers = tc.HistoryBackfillWorkers } + if tc.MaxReactionsPerEmoji > 0 { + gateway.MaxReactionsPerEmoji = tc.MaxReactionsPerEmoji + } + options := xmpp.ComponentOptions{ TransportConfiguration: xmpp.TransportConfiguration{ Address: conf.Host + ":" + conf.Port, diff --git a/xmpp/extensions/extensions.go b/xmpp/extensions/extensions.go index ce64b35..4d777f9 100644 --- a/xmpp/extensions/extensions.go +++ b/xmpp/extensions/extensions.go @@ -499,6 +499,19 @@ type AttachTo struct { Id string `xml:"id,attr"` } +// Reactions is from XEP-0444 +type Reactions struct { + XMLName xml.Name `xml:"urn:xmpp:reactions:0 reactions"` + Id string `xml:"id,attr"` + Reactions []Reaction `xml:"urn:xmpp:reactions:0 reaction"` +} + +// Reaction is a child element of Reactions, from XEP-0444 +type Reaction struct { + XMLName xml.Name `xml:"urn:xmpp:reactions:0 reaction"` + Text string `xml:",chardata"` +} + // Namespace is a namespace! func (c PresenceNickExtension) Namespace() string { return c.XMLName.Space @@ -734,6 +747,11 @@ func (c AttachTo) Namespace() string { return c.XMLName.Space } +// Namespace is a namespace! +func (c Reactions) Namespace() string { + return c.XMLName.Space +} + // NewReplyFallback initializes a fallback range func NewReplyFallback(start uint64, end uint64) Fallback { return Fallback{ @@ -939,4 +957,10 @@ func init() { "urn:xmpp:message-attaching:1", "attach-to", }, AttachTo{}) + + // reactions + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:reactions:0", + "reactions", + }, Reactions{}) } diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index 54e383d..0c4cc15 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -33,6 +33,14 @@ type Reply struct { End uint64 } +// Reactions is a XEP-0444 reaction set: the sender's complete current +// list of emoji on the message identified by Id (always a full +// replacement, never a diff). +type Reactions struct { + Id string + Reactions []string +} + type MarkerType byte const ( @@ -61,6 +69,9 @@ const NSCommand string = "http://jabber.org/protocol/commands" // NSCaps is the XEP-0115 entity capabilities namespace const NSCaps string = "http://jabber.org/protocol/caps" +// NSReactions is the XEP-0444 message reactions namespace +const NSReactions string = "urn:xmpp:reactions:0" + // CapsNode is this software's advertised XEP-0115 node URI const CapsNode string = "https://dev.narayana.im/narayana/telegabber/" @@ -121,6 +132,12 @@ var HistoryBackfillLimit uint32 = 1000 // waiting behind it. 0 (unset) means the default of 4. var HistoryBackfillWorkers uint32 = 4 +// MaxReactionsPerEmoji caps named reactors relayed per (message, emoji) +// pair for group/channel reactions - anonymous ones are exempt, see +// telegram.updateMessageInteractionInfo. 0 (unset) means the default of +// 10; values above 100 are clamped (TDlib's own per-call ceiling). +var MaxReactionsPerEmoji uint32 = 10 + // DownloadSpaceMargin is extra free-disk-space headroom, beyond a file's // own declared size, required before a TDlib download proceeds. Download // requests are already serialized system-wide via StorageLock, so this @@ -262,6 +279,32 @@ func SendMessageMarker(to string, from string, component *xmpp.Component, marker })) } +// SendReactionMessage creates and sends a message stanza with a XEP-0444 +// reaction set (see Reactions); an empty reactionEmojis clears id's reactions. +func SendReactionMessage(to string, from string, component *xmpp.Component, id string, reactionEmojis []string, isGroupchat bool) { + sendMessageWrapper(to, from, component, SMReactions(&Reactions{ + Id: id, + Reactions: reactionEmojis, + }), SMIsGroupchat(isGroupchat)) +} + +// SendMAMReactionMessage is SendReactionMessage wrapped as a XEP-0313 MAM +// result: entryId is this result's own id (distinct from id, the +// reacted-to message's own id); timestamp stands in for a historical +// reaction timestamp, which doesn't exist (only a reaction's current +// state, not when it was added). +func SendMAMReactionMessage(to string, from string, component *xmpp.Component, id string, reactionEmojis []string, entryId string, timestamp int64, mucJID string, mamQueryId string) { + sendMessageWrapper(to, from, component, + SMReactions(&Reactions{Id: id, Reactions: reactionEmojis}), + SMId(entryId), + SMStanzaId(entryId), + SMTimestamp(timestamp), + SMMamQueryId(mamQueryId), + SMMucJID(mucJID), + SMIsGroupchat(true), + ) +} + // SendMUCInvite creates and send a MUC invitation message func SendMUCInvite(to string, from string, component *xmpp.Component, inviteFrom string) { sendMessageWrapper(to, from, component, SMInviteFrom(inviteFrom)) @@ -340,6 +383,9 @@ var SMMucUserItem = args.New() // XEP-0380 EME hint - see omemo.go's omemoStanzaExtension. var SMOMEMOEnvelope = args.New() +// SMReactions is a XEP-0444 reaction set (*Reactions) +var SMReactions = args.New() + func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args.V) { body := SMBody.Get(args) subject := SMSubject.Get(args) @@ -363,6 +409,7 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args mucJID := SMMucJID.Get(args) mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem) envelope, _ := SMOMEMOEnvelope.Get(args).(*e2ee.Envelope) + reactions, _ := SMReactions.Get(args).(*Reactions) toJid, err := stanza.NewJid(to) if err != nil { @@ -525,6 +572,18 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args if replaceId != "" { message.Extensions = append(message.Extensions, extensions.Replace{Id: replaceId}) } + if reactions != nil { + reactionExts := make([]extensions.Reaction, len(reactions.Reactions)) + for i, r := range reactions.Reactions { + reactionExts[i] = extensions.Reaction{Text: r} + } + message.Extensions = append(message.Extensions, + extensions.Reactions{Id: reactions.Id, Reactions: reactionExts}, + // XEP-0334: body-less messages are commonly excluded from + // MAM by default archiving policies, so hint explicitly. + stanza.HintStore{}, + ) + } var userExt extensions.MessageXMucUserExtension if inviteFrom != "" { userExt.Invite = &extensions.MessageXMucUserInvite{ @@ -1086,6 +1145,7 @@ func AddPMDiscoFeatures(disco *stanza.DiscoInfo) { } disco.AddFeatures(stanza.NSMsgChatMarkers) disco.AddFeatures(stanza.NSMsgReceipts) + disco.AddFeatures(NSReactions) // Jingle / JMI features so clients (e.g. Dino) treat the contact as // call-capable. Without these, JMI never fires: the client // disco-info's the caller before engaging its call state machine and @@ -1117,13 +1177,37 @@ func AddCommonDiscoFeatures(disco *stanza.DiscoInfo) { ) } +// ReactionRestrictionsForm builds the XEP-0444 disco#info restricted- +// reactions data form (urn:xmpp:reactions:0:restrictions). Shared by the +// real disco#info response and PMCapsVer so they stay identical. +func ReactionRestrictionsForm(allowlist []string, maxPerUser int) *stanza.Form { + return stanza.NewForm([]*stanza.Field{ + &stanza.Field{ + Var: "FORM_TYPE", + Type: "hidden", + ValuesList: []string{"urn:xmpp:reactions:0:restrictions"}, + }, + &stanza.Field{ + Var: "allowlist", + ValuesList: allowlist, + }, + &stanza.Field{ + Var: "max_reactions_per_user", + ValuesList: []string{strconv.Itoa(maxPerUser)}, + }, + }, "result") +} + // PMCapsVer computes the XEP-0115 ver for a 1:1 contact's disco#info. -// Not cached: if AddPMDiscoFeatures ever starts varying silently (no -// signature change), a cached ver would go stale with no compiler warning. -func PMCapsVer() string { +// forms must match that contact's live disco#info response's own Forms. +// Not otherwise cached: if AddPMDiscoFeatures ever starts varying +// silently (no signature change), a cached ver would go stale with no +// compiler warning. +func PMCapsVer(forms ...*stanza.Form) string { disco := &stanza.DiscoInfo{} AddPMDiscoFeatures(disco) AddCommonDiscoFeatures(disco) + disco.Forms = forms return DiscoVer(disco) } @@ -1158,40 +1242,61 @@ func discoToCaps(disco *stanza.DiscoInfo, w io.Writer) { io.WriteString(w, sep) } - if disco.Form == nil { - return + var forms []*stanza.Form + for _, f := range disco.Forms { + if f != nil { + forms = append(forms, f) + } } - fields := make([]*stanza.Field, len(disco.Form.Fields)) - copy(fields, disco.Form.Fields) - sort.Slice(fields, func(i, j int) bool { - a, b := fields[i], fields[j] - if a.Var == "FORM_TYPE" { - return true - } - if b.Var == "FORM_TYPE" { - return false - } - return a.Var < b.Var + // XEP-0115 S5.1 step 6: with more than one extended form, sort by + // FORM_TYPE value before processing each in turn. + sort.Slice(forms, func(i, j int) bool { + return formType(forms[i]) < formType(forms[j]) }) - for _, field := range fields { - if field.Var == "FORM_TYPE" { - if len(field.ValuesList) > 0 { - io.WriteString(w, field.ValuesList[0]) + + for _, form := range forms { + fields := make([]*stanza.Field, len(form.Fields)) + copy(fields, form.Fields) + sort.Slice(fields, func(i, j int) bool { + a, b := fields[i], fields[j] + if a.Var == "FORM_TYPE" { + return true + } + if b.Var == "FORM_TYPE" { + return false + } + return a.Var < b.Var + }) + for _, field := range fields { + if field.Var == "FORM_TYPE" { + if len(field.ValuesList) > 0 { + io.WriteString(w, field.ValuesList[0]) + io.WriteString(w, sep) + } + continue + } + io.WriteString(w, field.Var) + io.WriteString(w, sep) + + values := field.ValuesList + if len(values) > 1 { + values = append([]string(nil), values...) + sort.Strings(values) + } + for _, value := range values { + io.WriteString(w, value) io.WriteString(w, sep) } - continue - } - io.WriteString(w, field.Var) - io.WriteString(w, sep) - - values := field.ValuesList - if len(values) > 1 { - values = append([]string(nil), values...) - sort.Strings(values) - } - for _, value := range values { - io.WriteString(w, value) - io.WriteString(w, sep) } } } + +// formType returns form's FORM_TYPE field value, or "" if it has none. +func formType(form *stanza.Form) string { + for _, field := range form.Fields { + if field.Var == "FORM_TYPE" && len(field.ValuesList) > 0 { + return field.ValuesList[0] + } + } + return "" +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index d28b7cb..b078573 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -216,7 +216,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { return } - if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg)) { + if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg) || hasReactionsPayload(msg)) { log.WithFields(log.Fields{ "from": msg.From, "to": msg.To, @@ -273,14 +273,22 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { var fallback extensions.Fallback var replace extensions.Replace var attachTo extensions.AttachTo + var reactions extensions.Reactions msg.Get(&reply) msg.Get(&fallback) msg.Get(&replace) msg.Get(&attachTo) + msg.Get(&reactions) log.Debugf("reply: %#v", reply) log.Debugf("fallback: %#v", fallback) log.Debugf("replace: %#v", replace) log.Debugf("attachTo: %#v", attachTo) + log.Debugf("reactions: %#v", reactions) + + if reactions.Id != "" { + handleIncomingReaction(component, msg, session, bare, toID, isGroupchat, reactions) + return + } var replyId int64 text := msg.Body @@ -1116,6 +1124,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { "http://jabber.org/protocol/muc", "muc_persistent", "muc_hidden", + "muc_membersonly", "muc_moderated", "muc_nonanonymous", "muc_unsecured", @@ -1125,6 +1134,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { gateway.NS_MAM1, gateway.NS_MAM2, "urn:xmpp:mam:2#extended", + gateway.NSReactions, "urn:xmpp:sid:0", "vcard-temp", ) @@ -1155,7 +1165,11 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { }) } - disco.Form = stanza.NewForm(fields, "result") + disco.Forms = append(disco.Forms, stanza.NewForm(fields, "result")) + + if allowlist, maxPerUser, restrictOk := session.ReactionRestrictions(toID); restrictOk { + disco.Forms = append(disco.Forms, gateway.ReactionRestrictionsForm(allowlist, maxPerUser)) + } } } else if !toOk { disco.AddFeatures( @@ -1175,6 +1189,13 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { } } else { gateway.AddPMDiscoFeatures(disco) + if fromOk { + if session, sessionOk := sessions[bare]; sessionOk { + if allowlist, maxPerUser, restrictOk := session.ReactionRestrictions(toID); restrictOk { + disco.Forms = append(disco.Forms, gateway.ReactionRestrictionsForm(allowlist, maxPerUser)) + } + } + } } } else { gateway.AddTransportDiscoFeatures(disco) @@ -2808,6 +2829,7 @@ func handleSetQueryMAM(s xmpp.Sender, iq *stanza.IQ, query extensions.MAMQuery) queryId := ns + " " + query.GetQueryId() for _, message := range messages { session.SendDelayedMUCMessage(toID, message, iq.From, queryId) + session.SendDelayedMUCReactions(toID, message, iq.From, queryId) } rs := stanza.ResultSet{} diff --git a/xmpp/reactions.go b/xmpp/reactions.go new file mode 100644 index 0000000..801488a --- /dev/null +++ b/xmpp/reactions.go @@ -0,0 +1,69 @@ +package xmpp + +import ( + "strings" + + log "github.com/sirupsen/logrus" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" + + "dev.narayana.im/narayana/telegabber/telegram" + "dev.narayana.im/narayana/telegabber/xmpp/extensions" + "dev.narayana.im/narayana/telegabber/xmpp/gateway" +) + +// hasReactionsPayload reports whether msg carries a XEP-0444 +// element - such stanzas are body-less by design, so HandleMessage's +// body-gate (there to skip typing notifications etc.) needs this alongside +// hasOMEMOPayload to let them through. +func hasReactionsPayload(msg stanza.Message) bool { + var reactions extensions.Reactions + return msg.Get(&reactions) +} + +// handleIncomingReaction applies an incoming XEP-0444 stanza +// to Telegram, rejecting it (S3.3, type='error'/not-acceptable) if any +// requested emoji isn't allowed. bare and toID are already resolved by +// HandleMessage's caller. +func handleIncomingReaction(component *xmpp.Component, msg stanza.Message, session *telegram.Client, bare string, toID int64, isGroupchat bool, reactions extensions.Reactions) { + var msgId int64 + chatId, foundMsgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, reactions.Id) + if err == nil { + if chatId != toID { + log.Warnf("Reaction chat mismatch: %v != %v", chatId, toID) + return + } + msgId = foundMsgId + } else { + var ok bool + msgId, ok = parseMessageId(reactions.Id) + if !ok { + return + } + } + + emojis := make([]string, 0, len(reactions.Reactions)) + for _, r := range reactions.Reactions { + if r.Text != "" { + emojis = append(emojis, r.Text) + } + } + + _, rejected := session.SetOwnMessageReactions(toID, msgId, emojis) + if len(rejected) == 0 { + return + } + + var from string + if isGroupchat { + from = gateway.MUCJID(toID) + } else { + from = gateway.CHATNODE(toID) + } + gateway.SendMessage(msg.From, from, component, + gateway.SMErrorText("Reaction(s) not allowed: "+strings.Join(rejected, " ")), + gateway.SMErrorCode(406), + gateway.SMId(msg.Id), + gateway.SMIsGroupchat(isGroupchat), + ) +}