mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-10-07 16:51:47 +00:00
Reactions
This commit is contained in:
parent
a7b4eb7d88
commit
484743946a
13 changed files with 895 additions and 58 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
523
telegram/reactions.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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{})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
||||||
|
|
@ -1194,4 +1288,15 @@ func discoToCaps(disco *stanza.DiscoInfo, w io.Writer) {
|
||||||
io.WriteString(w, sep)
|
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 ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
69
xmpp/reactions.go
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue