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