backfill: implement

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
This commit is contained in:
Sumner Evans
2024-08-20 12:41:27 -06:00
parent 7e2d9bbc4e
commit 56f83315ed
9 changed files with 429 additions and 218 deletions
+163
View File
@@ -0,0 +1,163 @@
package connector
import (
"context"
"fmt"
"slices"
"time"
"github.com/gotd/td/tg"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
)
func (t *TelegramClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
log := zerolog.Ctx(ctx).With().
Str("method", "FetchMessages").
Logger()
ctx = log.WithContext(ctx)
peer, err := t.inputPeerForPortalID(ctx, fetchParams.Portal.ID)
if err != nil {
return nil, err
}
req := tg.MessagesGetHistoryRequest{
Peer: peer,
Limit: fetchParams.Count,
}
if fetchParams.AnchorMessage != nil && !fetchParams.Forward {
req.MaxID, err = ids.ParseMessageID(fetchParams.AnchorMessage.ID)
if err != nil {
return nil, err
}
}
rawMsgs, err := t.client.API().MessagesGetHistory(ctx, &req)
if err != nil {
return nil, err
}
msgs, ok := rawMsgs.(interface{ GetMessages() []tg.MessageClass })
if !ok {
return nil, fmt.Errorf("unsupported messages type %T", rawMsgs)
}
var markRead bool // TODO implement
messages := msgs.GetMessages()
var cursor networkid.PaginationCursor
if len(messages) > 0 {
cursor = ids.MakePaginationCursorID(messages[len(messages)-1].GetID())
}
var stopAt int
if fetchParams.AnchorMessage != nil && fetchParams.Forward {
stopAt, err = ids.ParseMessageID(fetchParams.AnchorMessage.ID)
if err != nil {
return nil, err
}
}
var backfillMessages []*bridgev2.BackfillMessage
for _, msg := range messages {
// If we are doing forward backfill and we get to the anchor message,
// don't convert any more messages.
if stopAt > 0 && msg.GetID() <= stopAt {
break
}
if msg.TypeID() != tg.MessageTypeID {
log.Warn().Str("type", msg.TypeName()).Msg("skipping backfilling unsupported message type")
continue
}
message := msg.(*tg.Message)
portal, err := t.main.Bridge.GetPortalByKey(ctx, fetchParams.Portal.PortalKey)
if err != nil {
return nil, err
}
sender := t.getEventSender(message)
intent := portal.GetIntentFor(ctx, sender, t.userLogin, bridgev2.RemoteEventBackfill)
converted, err := t.convertToMatrix(ctx, portal, intent, message)
if err != nil {
return nil, err
}
reactionsList, _, customEmojis, err := t.computeReactionsList(ctx, message)
if err != nil {
return nil, err
}
backfillMessage := bridgev2.BackfillMessage{
ConvertedMessage: converted,
Sender: sender,
ID: ids.MakeMessageID(message.ID),
Timestamp: time.Unix(int64(message.Date), 0),
}
for _, reaction := range reactionsList {
peer, ok := reaction.PeerID.(*tg.PeerUser)
if !ok {
return nil, fmt.Errorf("unknown peer type %T", reaction.PeerID)
}
emojiID, emoji, err := computeEmojiAndID(reaction.Reaction, customEmojis)
if err != nil {
return nil, fmt.Errorf("failed to compute emoji and ID: %w", err)
}
backfillMessage.Reactions = append(backfillMessage.Reactions, &bridgev2.BackfillReaction{
Timestamp: time.Unix(int64(reaction.Date), 0),
Sender: bridgev2.EventSender{
IsFromMe: reaction.My,
SenderLogin: ids.MakeUserLoginID(peer.UserID),
Sender: ids.MakeUserID(peer.UserID),
},
EmojiID: emojiID,
Emoji: emoji,
})
}
backfillMessages = append(backfillMessages, &backfillMessage)
}
// They are returned with most recent message first, so reverse the order.
slices.Reverse(backfillMessages)
return &bridgev2.FetchMessagesResponse{
Messages: backfillMessages,
Cursor: cursor,
HasMore: len(messages) == fetchParams.Count,
Forward: fetchParams.Forward,
MarkRead: markRead,
}, nil
}
func (c *TelegramClient) GetBackfillMaxBatchCount(ctx context.Context, portal *bridgev2.Portal, task *database.BackfillTask) int {
log := zerolog.Ctx(ctx).With().
Str("method", "GetBackfillMaxBatchCount").
Logger()
peerType, _, err := ids.ParsePortalID(portal.ID)
if err != nil {
log.Err(err).Msg("failed to parse portal ID")
return 0
}
switch peerType {
case ids.PeerTypeUser:
return c.main.Bridge.Config.Backfill.Queue.GetOverride("user")
case ids.PeerTypeChat:
return c.main.Bridge.Config.Backfill.Queue.GetOverride("normal_group")
case ids.PeerTypeChannel:
if portal.Metadata.(*PortalMetadata).IsSuperGroup {
return c.main.Bridge.Config.Backfill.Queue.GetOverride("supergroup")
} else {
return c.main.Bridge.Config.Backfill.Queue.GetOverride("channel")
}
default:
log.Error().Str("peer_type", string(peerType)).Msg("unknown peer type")
return 0
}
}
+27 -15
View File
@@ -16,8 +16,9 @@ import (
func (t *TelegramClient) getDMChatInfo(ctx context.Context, userID int64) (*bridgev2.ChatInfo, error) {
chatInfo := bridgev2.ChatInfo{
Type: ptr.Ptr(database.RoomTypeDM),
Members: &bridgev2.ChatMemberList{IsFull: true},
Type: ptr.Ptr(database.RoomTypeDM),
Members: &bridgev2.ChatMemberList{IsFull: true},
CanBackfill: true,
}
accessHash, found, err := t.ScopedStore.GetUserAccessHash(ctx, userID)
if err != nil {
@@ -57,25 +58,36 @@ func (t *TelegramClient) getGroupChatInfo(ctx context.Context, fullChat *tg.Mess
return nil, false, err
}
var name *string
var isBroadcastChannel, isMegagroup bool
for _, c := range fullChat.GetChats() {
if c.GetID() == chatID {
switch chat := c.(type) {
case *tg.Chat:
name = &chat.Title
case *tg.Channel:
name = &chat.Title
isBroadcastChannel = chat.Broadcast
isMegagroup = chat.Megagroup
}
break
}
}
chatInfo := bridgev2.ChatInfo{
Name: name,
Type: ptr.Ptr(database.RoomTypeGroupDM), // TODO Is this correct for channels?
Members: &bridgev2.ChatMemberList{
IsFull: true,
Members: []bridgev2.ChatMember{{EventSender: t.mySender()}},
},
}
var isBroadcastChannel bool
for _, c := range fullChat.GetChats() {
if c.GetID() == chatID {
switch chat := c.(type) {
case *tg.Chat:
chatInfo.Name = &chat.Title
case *tg.Channel:
chatInfo.Name = &chat.Title
isBroadcastChannel = chat.Broadcast
}
break
}
CanBackfill: true,
ExtraUpdates: func(ctx context.Context, p *bridgev2.Portal) bool {
meta := p.Metadata.(*PortalMetadata)
changed := meta.IsSuperGroup != isMegagroup
meta.IsSuperGroup = isMegagroup
return changed
},
}
if ttl, ok := fullChat.FullChat.GetTTLPeriod(); ok {
+9 -7
View File
@@ -48,13 +48,15 @@ type TelegramClient struct {
}
var (
_ bridgev2.NetworkAPI = (*TelegramClient)(nil)
_ bridgev2.EditHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.ReactionHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.TypingHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.NetworkAPI = (*TelegramClient)(nil)
_ bridgev2.EditHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.ReactionHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.TypingHandlingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.BackfillingNetworkAPI = (*TelegramClient)(nil)
_ bridgev2.BackfillingNetworkAPIWithLimits = (*TelegramClient)(nil)
// _ bridgev2.IdentifierResolvingNetworkAPI = (*TelegramClient)(nil)
// _ bridgev2.GroupCreatingNetworkAPI = (*TelegramClient)(nil)
// _ bridgev2.ContactListingNetworkAPI = (*TelegramClient)(nil)
+5 -1
View File
@@ -87,7 +87,7 @@ func (tg *TelegramConnector) ValidateConfig() error {
func (tg *TelegramConnector) GetDBMetaTypes() database.MetaTypes {
return database.MetaTypes{
Ghost: func() any { return &GhostMetadata{} },
Portal: nil,
Portal: func() any { return &PortalMetadata{} },
Message: func() any { return &MessageMetadata{} },
Reaction: nil,
UserLogin: func() any { return &UserLoginMetadata{} },
@@ -99,6 +99,10 @@ type GhostMetadata struct {
IsBot bool `json:"is_bot,omitempty"`
}
type PortalMetadata struct {
IsSuperGroup bool `json:"is_supergroup,omitempty"`
}
type MessageMetadata struct {
ContentHash []byte `json:"content_hash,omitempty"`
ContentURI id.ContentURIString `json:"content_uri,omitempty"`
+4
View File
@@ -30,6 +30,10 @@ func MakeMessageID(messageID int) networkid.MessageID {
return networkid.MessageID(strconv.Itoa(messageID))
}
func MakePaginationCursorID(messageID int) networkid.PaginationCursor {
return networkid.PaginationCursor(strconv.Itoa(messageID))
}
func ParseMessageID(messageID networkid.MessageID) (int, error) {
return strconv.Atoi(string(messageID))
}
+193
View File
@@ -0,0 +1,193 @@
package connector
import (
"context"
"fmt"
"time"
"github.com/gotd/td/tg"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
)
func (t *TelegramClient) computeReactionsList(ctx context.Context, msg *tg.Message) (reactions []tg.MessagePeerReaction, isFull bool, customEmojis map[networkid.EmojiID]string, err error) {
log := zerolog.Ctx(ctx).With().Str("fn", "computeReactionsList").Logger()
if _, set := msg.GetReactions(); !set {
return
}
var totalCount int
for _, r := range msg.Reactions.Results {
totalCount += r.Count
}
reactionsList := msg.Reactions.RecentReactions
if totalCount > 0 && len(reactionsList) == 0 && !msg.Reactions.CanSeeList {
// We don't know who reacted in a channel, so we can't bridge it properly either
log.Warn().Msg("Can't see reaction list in channel")
return
}
// TODO
// if self.peer_type == "channel" and not self.megagroup:
// # This should never happen with the previous if
// self.log.warning(f"Can see reaction list in channel ({data!s})")
// # return
if len(reactionsList) < totalCount {
if user, ok := msg.PeerID.(*tg.PeerUser); ok {
reactionsList = splitDMReactionCounts(msg.Reactions.Results, user.UserID, t.telegramUserID)
// TODO
// } else if t.isBot {
// // Can't fetch exact reaction senders as a bot
// return
// TODO should calls to this be limited?
} else if peer, err := t.inputPeerForPortalID(ctx, ids.MakePortalKey(msg.PeerID, t.loginID).ID); err != nil {
return nil, false, nil, fmt.Errorf("failed to get input peer: %w", err)
} else {
reactions, err := t.client.API().MessagesGetMessageReactionsList(ctx, &tg.MessagesGetMessageReactionsListRequest{
Peer: peer, ID: msg.ID, Limit: 100,
})
if err != nil {
return nil, false, nil, fmt.Errorf("failed to get reactions list: %w", err)
}
reactionsList = reactions.Reactions
}
}
var customEmojiIDs []int64
for _, reaction := range reactionsList {
if e, ok := reaction.Reaction.(*tg.ReactionCustomEmoji); ok {
customEmojiIDs = append(customEmojiIDs, e.DocumentID)
} else if reaction.Reaction.TypeID() != tg.ReactionEmojiTypeID {
return nil, false, nil, fmt.Errorf("unsupported reaction type %T", reaction.Reaction)
}
}
customEmojis, err = t.transferEmojisToMatrix(ctx, customEmojiIDs)
return reactionsList, len(reactionsList) == totalCount, customEmojis, err
}
func computeEmojiAndID(reaction tg.ReactionClass, customEmojis map[networkid.EmojiID]string) (emojiID networkid.EmojiID, emoji string, err error) {
if r, ok := reaction.(*tg.ReactionCustomEmoji); ok {
emojiID = ids.MakeEmojiIDFromDocumentID(r.DocumentID)
emoji = customEmojis[emojiID]
} else if r, ok := reaction.(*tg.ReactionEmoji); ok {
emojiID = ids.MakeEmojiIDFromEmoticon(r.Emoticon)
emoji = r.Emoticon
} else {
return "", "", fmt.Errorf("invalid reaction type %T", reaction)
}
return
}
func (t *TelegramClient) handleTelegramReactions(ctx context.Context, msg *tg.Message) {
log := zerolog.Ctx(ctx).With().
Str("handler", "handle_telegram_reactions").
Int("message_id", msg.ID).
Logger()
dbMsg, err := t.main.Bridge.DB.Message.GetFirstPartByID(ctx, t.loginID, ids.MakeMessageID(msg.ID))
if err != nil {
log.Err(err).Msg("failed to get message from database")
return
} else if dbMsg == nil {
log.Warn().Msg("message not found in database")
return
}
reactionsList, isFull, customEmojis, err := t.computeReactionsList(ctx, msg)
if err != nil {
log.Err(err).Msg("failed to compute reactions list")
return
}
users := map[networkid.UserID]*bridgev2.ReactionSyncUser{}
for _, reaction := range reactionsList {
peer, ok := reaction.PeerID.(*tg.PeerUser)
if !ok {
log.Error().Type("peer_id", reaction.PeerID).Msg("unknown peer type")
return
}
userID := ids.MakeUserID(peer.UserID)
reactionLimit, err := t.getReactionLimit(ctx, userID)
if err != nil {
reactionLimit = 1
log.Err(err).Int64("id", peer.UserID).Msg("failed to get reaction limit")
}
if _, ok := users[userID]; !ok {
users[userID] = &bridgev2.ReactionSyncUser{HasAllReactions: isFull, MaxCount: reactionLimit}
}
emojiID, emoji, err := computeEmojiAndID(reaction.Reaction, customEmojis)
if err != nil {
log.Err(err).Msg("failed to compute emoji and ID")
return
}
users[userID].Reactions = append(users[userID].Reactions, &bridgev2.BackfillReaction{
Timestamp: time.Unix(int64(reaction.Date), 0),
Sender: bridgev2.EventSender{
IsFromMe: reaction.My,
SenderLogin: ids.MakeUserLoginID(peer.UserID),
Sender: userID,
},
EmojiID: emojiID,
Emoji: emoji,
})
}
t.main.Bridge.QueueRemoteEvent(t.userLogin, &simplevent.ReactionSync{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventReactionSync,
LogContext: func(c zerolog.Context) zerolog.Context {
return c.Int("message_id", msg.ID)
},
PortalKey: dbMsg.Room,
},
TargetMessage: dbMsg.ID,
Reactions: &bridgev2.ReactionSyncData{Users: users, HasAllUsers: isFull},
})
}
func splitDMReactionCounts(res []tg.ReactionCount, theirUserID, myUserID int64) (reactions []tg.MessagePeerReaction) {
for _, item := range res {
if item.Count == 2 || item.ChosenOrder > 0 {
reactions = append(reactions, tg.MessagePeerReaction{
Reaction: item.Reaction,
PeerID: &tg.PeerUser{UserID: myUserID},
})
}
if item.Count == 2 {
reactions = append(reactions, tg.MessagePeerReaction{
Reaction: item.Reaction,
PeerID: &tg.PeerUser{UserID: theirUserID},
})
}
}
return
}
func (t *TelegramClient) getReactionLimit(ctx context.Context, sender networkid.UserID) (limit int, err error) {
config, err := t.getAppConfigCached(ctx)
if err != nil {
return 0, err
}
ghost, err := t.main.Bridge.GetGhostByID(ctx, sender)
if err != nil {
return 0, err
}
if ghost.Metadata.(*GhostMetadata).IsPremium {
return int(config["reactions_user_max_premium"].(float64)), nil
} else {
return int(config["reactions_user_max_default"].(float64)), nil
}
}
-170
View File
@@ -359,140 +359,6 @@ func (t *TelegramClient) onOwnReadReceipt(portalKey networkid.PortalKey, maxID i
return nil
}
func (t *TelegramClient) handleTelegramReactions(ctx context.Context, msg *tg.Message) {
log := zerolog.Ctx(ctx).With().
Str("handler", "handle_telegram_reactions").
Int("message_id", msg.ID).
Logger()
if _, set := msg.GetReactions(); !set {
log.Debug().Msg("no reactions set on message")
return
}
var totalCount int
for _, r := range msg.Reactions.Results {
totalCount += r.Count
}
reactionsList := msg.Reactions.RecentReactions
if totalCount > 0 && len(reactionsList) == 0 && !msg.Reactions.CanSeeList {
// We don't know who reacted in a channel, so we can't bridge it properly either
log.Warn().Msg("Can't see reaction list in channel")
return
}
// TODO
// if self.peer_type == "channel" and not self.megagroup:
// # This should never happen with the previous if
// self.log.warning(f"Can see reaction list in channel ({data!s})")
// # return
dbMsg, err := t.main.Bridge.DB.Message.GetFirstPartByID(ctx, t.loginID, ids.MakeMessageID(msg.ID))
if err != nil {
log.Err(err).Msg("failed to get message from database")
return
} else if dbMsg == nil {
log.Warn().Msg("no message found in database")
return
}
if len(reactionsList) < totalCount {
if user, ok := msg.PeerID.(*tg.PeerUser); ok {
reactionsList = splitDMReactionCounts(msg.Reactions.Results, user.UserID, t.telegramUserID)
// TODO
// } else if t.isBot {
// // Can't fetch exact reaction senders as a bot
// return
// TODO should calls to this be limited?
} else if peer, err := t.inputPeerForPortalID(ctx, ids.MakePortalKey(msg.PeerID, t.loginID).ID); err != nil {
log.Err(err).Msg("failed to get input peer")
return
} else {
reactions, err := t.client.API().MessagesGetMessageReactionsList(ctx, &tg.MessagesGetMessageReactionsListRequest{
Peer: peer, ID: msg.ID, Limit: 100,
})
if err != nil {
log.Err(err).Msg("failed to get reactions list")
return
}
reactionsList = reactions.Reactions
}
}
var customEmojiIDs []int64
for _, reaction := range reactionsList {
if e, ok := reaction.Reaction.(*tg.ReactionCustomEmoji); ok {
customEmojiIDs = append(customEmojiIDs, e.DocumentID)
} else if reaction.Reaction.TypeID() != tg.ReactionEmojiTypeID {
log.Error().Type("reaction", reaction.Reaction).Msg("unknown reaction type")
return
}
}
customEmojis, err := t.transferEmojisToMatrix(ctx, customEmojiIDs)
if err != nil {
log.Err(err).Msg("failed to transfer emojis")
return
}
isFull := len(reactionsList) == totalCount
users := map[networkid.UserID]*bridgev2.ReactionSyncUser{}
for _, reaction := range reactionsList {
peer, ok := reaction.PeerID.(*tg.PeerUser)
if !ok {
log.Error().Type("peer_id", reaction.PeerID).Msg("unknown peer type")
return
}
userID := ids.MakeUserID(peer.UserID)
reactionLimit, err := t.getReactionLimit(ctx, userID)
if err != nil {
reactionLimit = 1
log.Err(err).Int64("id", peer.UserID).Msg("failed to get reaction limit")
}
if _, ok := users[userID]; !ok {
users[userID] = &bridgev2.ReactionSyncUser{HasAllReactions: isFull, MaxCount: reactionLimit}
}
var emojiID networkid.EmojiID
var emoji string
if r, ok := reaction.Reaction.(*tg.ReactionCustomEmoji); ok {
emojiID = ids.MakeEmojiIDFromDocumentID(r.DocumentID)
emoji = customEmojis[emojiID]
} else if r, ok := reaction.Reaction.(*tg.ReactionEmoji); ok {
emojiID = ids.MakeEmojiIDFromEmoticon(r.Emoticon)
emoji = r.Emoticon
} else {
log.Error().Type("reaction_type", reaction.Reaction).Msg("invalid reaction type")
return
}
users[userID].Reactions = append(users[userID].Reactions, &bridgev2.BackfillReaction{
Timestamp: time.Unix(int64(reaction.Date), 0),
Sender: bridgev2.EventSender{
IsFromMe: reaction.My,
SenderLogin: ids.MakeUserLoginID(peer.UserID),
Sender: userID,
},
EmojiID: emojiID,
Emoji: emoji,
})
}
t.main.Bridge.QueueRemoteEvent(t.userLogin, &simplevent.ReactionSync{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventReactionSync,
LogContext: func(c zerolog.Context) zerolog.Context {
return c.Int("message_id", msg.ID)
},
PortalKey: dbMsg.Room,
},
TargetMessage: dbMsg.ID,
Reactions: &bridgev2.ReactionSyncData{Users: users, HasAllUsers: isFull},
})
}
func (t *TelegramClient) inputPeerForPortalID(ctx context.Context, portalID networkid.PortalID) (tg.InputPeerClass, error) {
peerType, id, err := ids.ParsePortalID(portalID)
if err != nil {
@@ -522,25 +388,6 @@ func (t *TelegramClient) inputPeerForPortalID(ctx context.Context, portalID netw
}
}
func splitDMReactionCounts(res []tg.ReactionCount, theirUserID, myUserID int64) (reactions []tg.MessagePeerReaction) {
for _, item := range res {
if item.Count == 2 || item.ChosenOrder > 0 {
reactions = append(reactions, tg.MessagePeerReaction{
Reaction: item.Reaction,
PeerID: &tg.PeerUser{UserID: myUserID},
})
}
if item.Count == 2 {
reactions = append(reactions, tg.MessagePeerReaction{
Reaction: item.Reaction,
PeerID: &tg.PeerUser{UserID: theirUserID},
})
}
}
return
}
func (t *TelegramClient) getAppConfigCached(ctx context.Context) (map[string]any, error) {
if t.appConfig == nil {
cfg, err := t.client.API().HelpGetAppConfig(ctx, t.appConfigHash)
@@ -564,23 +411,6 @@ func (t *TelegramClient) getAppConfigCached(ctx context.Context) (map[string]any
return t.appConfig, nil
}
func (t *TelegramClient) getReactionLimit(ctx context.Context, sender networkid.UserID) (limit int, err error) {
config, err := t.getAppConfigCached(ctx)
if err != nil {
return 0, err
}
ghost, err := t.main.Bridge.GetGhostByID(ctx, sender)
if err != nil {
return 0, err
}
if ghost.Metadata.(*GhostMetadata).IsPremium {
return int(config["reactions_user_max_premium"].(float64)), nil
} else {
return int(config["reactions_user_max_default"].(float64)), nil
}
}
func (t *TelegramClient) transferEmojisToMatrix(ctx context.Context, customEmojiIDs []int64) (result map[networkid.EmojiID]string, err error) {
result, customEmojiIDs = emojis.ConvertKnownEmojis(customEmojiIDs)