Reactions

This commit is contained in:
Bohdan Horbeshko 2026-08-21 18:44:57 -04:00
parent a7b4eb7d88
commit 484743946a
13 changed files with 895 additions and 58 deletions

View file

@ -8,6 +8,7 @@
:quota: '256MB' # maximum storage size :quota: '256MB' # maximum storage size
:tdlib_verbosity: 1 :tdlib_verbosity: 1
:mam_threshold: 7 # in days :mam_threshold: 7 # in days
:max_reactions_per_emoji: 10 # named reactors relayed per (message, emoji) in group/channel XEP-0444 reactions
:tdlib: :tdlib:
:datadir: './sessions/' :datadir: './sessions/'
:client: :client:

View file

@ -58,6 +58,11 @@ type TelegramConfig struct {
// HistoryBackfillWorkers bounds how many chats backfill concurrently // HistoryBackfillWorkers bounds how many chats backfill concurrently
// (see gateway.HistoryBackfillWorkers). 0/unset falls back to 4. // (see gateway.HistoryBackfillWorkers). 0/unset falls back to 4.
HistoryBackfillWorkers uint32 `yaml:":history_backfill_workers"` 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 // TelegramContentConfig is for :content: subtree

2
go.mod
View file

@ -71,7 +71,7 @@ require (
nhooyr.io/websocket v1.6.5 // indirect 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 replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061

4
go.sum
View file

@ -1,8 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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 h1:CWAQT74LwQne/3Po5KXDvudu3N0FBWm3XZZZhtl5j2w=
dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= 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-20260821210951-a027f257a96d h1:oeYqHbaAQPV4ID4X47fHOeSS5s4y4hZlog4hOsZuMkE=
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/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 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 h1:iV/VgoIbImLrI3EPssOzzoyv1OQrp6t5RtDQTKzUes8=
github.com/Laky-64/gologging v1.1.0/go.mod h1:Ody93tsM0OZUAsWApkfb3rg35fAvyZDAx07kNH13DhI= github.com/Laky-64/gologging v1.1.0/go.mod h1:Ody93tsM0OZUAsWApkfb3rg35fAvyZDAx07kNH13DhI=

View file

@ -166,6 +166,23 @@ type Client struct {
MessageIdChanges map[int64]map[int64]*newId MessageIdChanges map[int64]map[int64]*newId
MessageIdChangesLock sync.Mutex 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; // guards the call trio. Connect() builds it after c.client is live;
// close() tears it down before c.client is closed. Accessors return // close() tears it down before c.client is closed. Accessors return
// nil when no live session. // nil when no live session.
@ -319,6 +336,9 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component
XmppClientFeatures: make(map[string]*[]string), XmppClientFeatures: make(map[string]*[]string),
avatarHashes: make(map[int64]*HashedAvatar), avatarHashes: make(map[int64]*HashedAvatar),
MessageIdChanges: make(map[int64]map[int64]*newId), MessageIdChanges: make(map[int64]map[int64]*newId),
lastPeerReactions: make(map[reactionKey]string),
activeEmojiReactions: newDefaultActiveEmojiReactions(),
customEmojiFallbacks: make(map[int64]string),
locks: clientLocks{ locks: clientLocks{
chatMessageLocks: make(map[int64]*sync.Mutex), chatMessageLocks: make(map[int64]*sync.Mutex),
deliveredMessageIds: make(map[int64]int64), deliveredMessageIds: make(map[int64]int64),

View file

@ -165,6 +165,12 @@ func (c *Client) dispatchUpdate(update client.Type) {
case client.TypeUpdateDeleteMessages: case client.TypeUpdateDeleteMessages:
typedUpdate, _ := update.(*client.UpdateDeleteMessages) typedUpdate, _ := update.(*client.UpdateDeleteMessages)
c.updateDeleteMessages(typedUpdate) 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: case client.TypeUpdateAuthorizationState:
typedUpdate, _ := update.(*client.UpdateAuthorizationState) typedUpdate, _ := update.(*client.UpdateAuthorizationState)
c.updateAuthorizationState(typedUpdate) c.updateAuthorizationState(typedUpdate)

523
telegram/reactions.go Normal file
View file

@ -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=<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
}

View file

@ -30,6 +30,7 @@ import (
"github.com/soheilhy/args" "github.com/soheilhy/args"
"github.com/zelenin/go-tdlib/client" "github.com/zelenin/go-tdlib/client"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"gosrc.io/xmpp/stanza"
) )
type VCardInfo struct { type VCardInfo struct {
@ -609,7 +610,11 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o
if isMUC { if isMUC {
newArgs = append(newArgs, gateway.SPFullFrom(gateway.MUCJID(chatID))) newArgs = append(newArgs, gateway.SPFullFrom(gateway.MUCJID(chatID)))
} else { } 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) newArgs = gateway.SPAppendFrom(newArgs, chatID)
} }
@ -3152,6 +3157,59 @@ func (c *Client) GetCarbonFullJids(isOutgoing bool, ignoredResource string, forc
return jids 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) { func (c *Client) UpdateLastChatMessageId(chatId int64, messageId string) {
c.locks.lastMsgIdsLock.Lock() c.locks.lastMsgIdsLock.Lock()
defer c.locks.lastMsgIdsLock.Unlock() defer c.locks.lastMsgIdsLock.Unlock()

View file

@ -112,6 +112,10 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
gateway.HistoryBackfillWorkers = tc.HistoryBackfillWorkers gateway.HistoryBackfillWorkers = tc.HistoryBackfillWorkers
} }
if tc.MaxReactionsPerEmoji > 0 {
gateway.MaxReactionsPerEmoji = tc.MaxReactionsPerEmoji
}
options := xmpp.ComponentOptions{ options := xmpp.ComponentOptions{
TransportConfiguration: xmpp.TransportConfiguration{ TransportConfiguration: xmpp.TransportConfiguration{
Address: conf.Host + ":" + conf.Port, Address: conf.Host + ":" + conf.Port,

View file

@ -499,6 +499,19 @@ type AttachTo struct {
Id string `xml:"id,attr"` 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! // Namespace is a namespace!
func (c PresenceNickExtension) Namespace() string { func (c PresenceNickExtension) Namespace() string {
return c.XMLName.Space return c.XMLName.Space
@ -734,6 +747,11 @@ func (c AttachTo) Namespace() string {
return c.XMLName.Space return c.XMLName.Space
} }
// Namespace is a namespace!
func (c Reactions) Namespace() string {
return c.XMLName.Space
}
// NewReplyFallback initializes a fallback range // NewReplyFallback initializes a fallback range
func NewReplyFallback(start uint64, end uint64) Fallback { func NewReplyFallback(start uint64, end uint64) Fallback {
return Fallback{ return Fallback{
@ -939,4 +957,10 @@ func init() {
"urn:xmpp:message-attaching:1", "urn:xmpp:message-attaching:1",
"attach-to", "attach-to",
}, AttachTo{}) }, AttachTo{})
// reactions
stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{
"urn:xmpp:reactions:0",
"reactions",
}, Reactions{})
} }

View file

@ -33,6 +33,14 @@ type Reply struct {
End uint64 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 type MarkerType byte
const ( const (
@ -61,6 +69,9 @@ const NSCommand string = "http://jabber.org/protocol/commands"
// NSCaps is the XEP-0115 entity capabilities namespace // NSCaps is the XEP-0115 entity capabilities namespace
const NSCaps string = "http://jabber.org/protocol/caps" 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 // CapsNode is this software's advertised XEP-0115 node URI
const CapsNode string = "https://dev.narayana.im/narayana/telegabber/" 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. // waiting behind it. 0 (unset) means the default of 4.
var HistoryBackfillWorkers uint32 = 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 // DownloadSpaceMargin is extra free-disk-space headroom, beyond a file's
// own declared size, required before a TDlib download proceeds. Download // own declared size, required before a TDlib download proceeds. Download
// requests are already serialized system-wide via StorageLock, so this // 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 // SendMUCInvite creates and send a MUC invitation message
func SendMUCInvite(to string, from string, component *xmpp.Component, inviteFrom string) { func SendMUCInvite(to string, from string, component *xmpp.Component, inviteFrom string) {
sendMessageWrapper(to, from, component, SMInviteFrom(inviteFrom)) sendMessageWrapper(to, from, component, SMInviteFrom(inviteFrom))
@ -340,6 +383,9 @@ var SMMucUserItem = args.New()
// XEP-0380 EME hint - see omemo.go's omemoStanzaExtension. // XEP-0380 EME hint - see omemo.go's omemoStanzaExtension.
var SMOMEMOEnvelope = args.New() 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) { func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args.V) {
body := SMBody.Get(args) body := SMBody.Get(args)
subject := SMSubject.Get(args) subject := SMSubject.Get(args)
@ -363,6 +409,7 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
mucJID := SMMucJID.Get(args) mucJID := SMMucJID.Get(args)
mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem) mucUserItem, _ := SMMucUserItem.Get(args).(*MUCUserItem)
envelope, _ := SMOMEMOEnvelope.Get(args).(*e2ee.Envelope) envelope, _ := SMOMEMOEnvelope.Get(args).(*e2ee.Envelope)
reactions, _ := SMReactions.Get(args).(*Reactions)
toJid, err := stanza.NewJid(to) toJid, err := stanza.NewJid(to)
if err != nil { if err != nil {
@ -525,6 +572,18 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args
if replaceId != "" { if replaceId != "" {
message.Extensions = append(message.Extensions, extensions.Replace{Id: 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 var userExt extensions.MessageXMucUserExtension
if inviteFrom != "" { if inviteFrom != "" {
userExt.Invite = &extensions.MessageXMucUserInvite{ userExt.Invite = &extensions.MessageXMucUserInvite{
@ -1086,6 +1145,7 @@ func AddPMDiscoFeatures(disco *stanza.DiscoInfo) {
} }
disco.AddFeatures(stanza.NSMsgChatMarkers) disco.AddFeatures(stanza.NSMsgChatMarkers)
disco.AddFeatures(stanza.NSMsgReceipts) disco.AddFeatures(stanza.NSMsgReceipts)
disco.AddFeatures(NSReactions)
// Jingle / JMI features so clients (e.g. Dino) treat the contact as // Jingle / JMI features so clients (e.g. Dino) treat the contact as
// call-capable. Without these, JMI <proceed> never fires: the client // call-capable. Without these, JMI <proceed> never fires: the client
// disco-info's the caller before engaging its call state machine and // 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. // PMCapsVer computes the XEP-0115 ver for a 1:1 contact's disco#info.
// Not cached: if AddPMDiscoFeatures ever starts varying silently (no // forms must match that contact's live disco#info response's own Forms.
// signature change), a cached ver would go stale with no compiler warning. // Not otherwise cached: if AddPMDiscoFeatures ever starts varying
func PMCapsVer() string { // silently (no signature change), a cached ver would go stale with no
// compiler warning.
func PMCapsVer(forms ...*stanza.Form) string {
disco := &stanza.DiscoInfo{} disco := &stanza.DiscoInfo{}
AddPMDiscoFeatures(disco) AddPMDiscoFeatures(disco)
AddCommonDiscoFeatures(disco) AddCommonDiscoFeatures(disco)
disco.Forms = forms
return DiscoVer(disco) return DiscoVer(disco)
} }
@ -1158,11 +1242,21 @@ func discoToCaps(disco *stanza.DiscoInfo, w io.Writer) {
io.WriteString(w, sep) io.WriteString(w, sep)
} }
if disco.Form == nil { var forms []*stanza.Form
return 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) // 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 _, form := range forms {
fields := make([]*stanza.Field, len(form.Fields))
copy(fields, form.Fields)
sort.Slice(fields, func(i, j int) bool { sort.Slice(fields, func(i, j int) bool {
a, b := fields[i], fields[j] a, b := fields[i], fields[j]
if a.Var == "FORM_TYPE" { if a.Var == "FORM_TYPE" {
@ -1195,3 +1289,14 @@ func discoToCaps(disco *stanza.DiscoInfo, w io.Writer) {
} }
} }
} }
}
// 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 ""
}

View file

@ -216,7 +216,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
return return
} }
if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg)) { if msg.Type != "error" && (msg.Body != "" || hasOMEMOPayload(msg) || hasReactionsPayload(msg)) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"from": msg.From, "from": msg.From,
"to": msg.To, "to": msg.To,
@ -273,14 +273,22 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
var fallback extensions.Fallback var fallback extensions.Fallback
var replace extensions.Replace var replace extensions.Replace
var attachTo extensions.AttachTo var attachTo extensions.AttachTo
var reactions extensions.Reactions
msg.Get(&reply) msg.Get(&reply)
msg.Get(&fallback) msg.Get(&fallback)
msg.Get(&replace) msg.Get(&replace)
msg.Get(&attachTo) msg.Get(&attachTo)
msg.Get(&reactions)
log.Debugf("reply: %#v", reply) log.Debugf("reply: %#v", reply)
log.Debugf("fallback: %#v", fallback) log.Debugf("fallback: %#v", fallback)
log.Debugf("replace: %#v", replace) log.Debugf("replace: %#v", replace)
log.Debugf("attachTo: %#v", attachTo) 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 var replyId int64
text := msg.Body text := msg.Body
@ -1116,6 +1124,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
"http://jabber.org/protocol/muc", "http://jabber.org/protocol/muc",
"muc_persistent", "muc_persistent",
"muc_hidden", "muc_hidden",
"muc_membersonly",
"muc_moderated", "muc_moderated",
"muc_nonanonymous", "muc_nonanonymous",
"muc_unsecured", "muc_unsecured",
@ -1125,6 +1134,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
gateway.NS_MAM1, gateway.NS_MAM1,
gateway.NS_MAM2, gateway.NS_MAM2,
"urn:xmpp:mam:2#extended", "urn:xmpp:mam:2#extended",
gateway.NSReactions,
"urn:xmpp:sid:0", "urn:xmpp:sid:0",
"vcard-temp", "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 { } else if !toOk {
disco.AddFeatures( disco.AddFeatures(
@ -1175,6 +1189,13 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
} }
} else { } else {
gateway.AddPMDiscoFeatures(disco) 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 { } else {
gateway.AddTransportDiscoFeatures(disco) gateway.AddTransportDiscoFeatures(disco)
@ -2808,6 +2829,7 @@ func handleSetQueryMAM(s xmpp.Sender, iq *stanza.IQ, query extensions.MAMQuery)
queryId := ns + " " + query.GetQueryId() queryId := ns + " " + query.GetQueryId()
for _, message := range messages { for _, message := range messages {
session.SendDelayedMUCMessage(toID, message, iq.From, queryId) session.SendDelayedMUCMessage(toID, message, iq.From, queryId)
session.SendDelayedMUCReactions(toID, message, iq.From, queryId)
} }
rs := stanza.ResultSet{} rs := stanza.ResultSet{}

69
xmpp/reactions.go Normal file
View file

@ -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 <reactions/>
// 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 <reactions/> 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),
)
}