diff --git a/telegram/handlers.go b/telegram/handlers.go index fefa261..febe287 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -215,9 +216,17 @@ func (c *Client) updateUser(update *client.UpdateUser) { // check if MUC nicknames should be updated oldCacheUser, ok := c.cache.GetUser(update.User.Id) c.cache.SetUser(update.User.Id, update.User) - if ok && (oldCacheUser.FirstName != update.User.FirstName || oldCacheUser.LastName != update.User.LastName) { - newNickname := c.GetMUCNickname(update.User.Id) - c.updateMUCsNickname(update.User.Id, newNickname) + if ok { + var oldUsernames, newUsernames []string + if oldCacheUser.Usernames != nil { + oldUsernames = oldCacheUser.Usernames.ActiveUsernames + } + if update.User.Usernames != nil { + newUsernames = update.User.Usernames.ActiveUsernames + } + if oldCacheUser.FirstName != update.User.FirstName || oldCacheUser.LastName != update.User.LastName || !slices.Equal(oldUsernames, newUsernames) { + c.updateMUCsNickname(update.User.Id) + } } show, status, presenceType := c.userStatusToText(update.User.Status, update.User.Id) @@ -425,10 +434,11 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { var from string var originalFrom string var nickname string + var senderId int64 if isMUC { if messageErr == nil { - senderId := c.getMessageSenderId(message) - nickname = c.GetMUCNickname(senderId) + senderId = c.getMessageSenderId(message) + nickname = c.GetMUCNickname(update.ChatId, senderId) originalFrom = gateway.CHATJID(senderId, true) safeToSend = c.assureMUCOccupant(update.ChatId, senderId, message.SenderId, chat) @@ -461,7 +471,7 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { textContent.Text.Text, textContent.Text.Entities, markupFunction, - c, + mucMentionRetriever{c, update.ChatId}, )) id := "e"+sId @@ -510,10 +520,14 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { for _, jid := range jids { if safeToSend { + var occupantId string + if isMUC && senderId != 0 { + occupantId = gateway.CHATNODE(senderId) + } gateway.SendMessage(jid, from, c.xmpp, gateway.SMBody(body), gateway.SMId(id), gateway.SMReplaceId(replaceId), gateway.SMIsCarbon(isCarbon), gateway.SMIsGroupchat(isMUC), gateway.SMOriginalFrom(originalFrom), - gateway.SMOMEMOEnvelope(envelope), + gateway.SMOMEMOEnvelope(envelope), gateway.SMOccupantId(occupantId), ) } else { gateway.SendMUCAnnouncement(jid, from, body, nickname, id, c.xmpp) @@ -713,6 +727,7 @@ func (c *Client) updateChatPermissions(update *client.UpdateChatPermissions) { gateway.SPResource(occupant.Nickname), gateway.SPImmed(true), gateway.SPMUCJid(gateway.CHATJID(occupant.id, true)), + gateway.SPOccupantId(gateway.CHATNODE(occupant.id)), gateway.SPMUCAffiliation(affiliation), gateway.SPMUCRole(role), gateway.SPToJids(toJids), diff --git a/telegram/muc.go b/telegram/muc.go index 0470f7d..37684c1 100644 --- a/telegram/muc.go +++ b/telegram/muc.go @@ -114,8 +114,8 @@ func (lru *MUCOccupantsLRU) Set(id int64, occupant *MUCOccupant) (deleted *MUCOc lru.cutOut(oldOccupant) delete(lru.rev, oldOccupant.Nickname) - lru.rev[occupant.Nickname] = id } + lru.rev[occupant.Nickname] = id if (lru.oldest == nil) != (lru.newest == nil) { log.Fatal("MRD MUDAQ") diff --git a/telegram/reactions.go b/telegram/reactions.go index 8077343..733af0e 100644 --- a/telegram/reactions.go +++ b/telegram/reactions.go @@ -210,7 +210,7 @@ func (c *Client) updateMessageInteractionInfo(update *client.UpdateMessageIntera 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) + gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, peerEmojis) } } return @@ -231,9 +231,9 @@ func (c *Client) updateMessageInteractionInfo(update *client.UpdateMessageIntera if !c.assureMUCOccupant(update.ChatId, senderId, &client.MessageSenderUser{UserId: senderId}, chat) { continue } - from := mucJid + "/" + c.GetMUCNickname(senderId) + from := mucJid + "/" + c.GetMUCNickname(update.ChatId, senderId) for _, jid := range jids { - gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, senderEmojis, true) + gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, senderEmojis, gateway.SMIsGroupchat(true), gateway.SMOccupantId(gateway.CHATNODE(senderId))) } } @@ -245,9 +245,9 @@ func (c *Client) updateMessageInteractionInfo(update *client.UpdateMessageIntera 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) + from := mucJid + "/" + c.GetMUCNickname(update.ChatId, update.ChatId) for _, jid := range jids { - gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, anonymousEmojis, true) + gateway.SendReactionMessage(jid, from, c.xmpp, xmppId, anonymousEmojis, gateway.SMIsGroupchat(true), gateway.SMOccupantId(gateway.CHATNODE(update.ChatId))) } } } @@ -306,13 +306,21 @@ func (c *Client) SendDelayedMUCReactions(chatId int64, message *client.Message, timestamp := int64(message.Date) for senderId, senderEmojis := range perOccupant { - from := mucJid + "/" + c.GetMUCNickname(senderId) + from := mucJid + "/" + c.GetMUCNickname(chatId, senderId) entryId := "r" + strId + "-" + strconv.FormatInt(senderId, 10) - gateway.SendMAMReactionMessage(toJid, from, c.xmpp, xmppId, senderEmojis, entryId, timestamp, mucJid, mamQueryId) + gateway.SendReactionMessage(toJid, from, c.xmpp, xmppId, senderEmojis, + gateway.SMId(entryId), gateway.SMStanzaId(entryId), gateway.SMTimestamp(timestamp), + gateway.SMMamQueryId(mamQueryId), gateway.SMMucJID(mucJid), gateway.SMIsGroupchat(true), + gateway.SMOccupantId(gateway.CHATNODE(senderId)), + ) } if len(anonymousEmojis) > 0 { - from := mucJid + "/" + c.GetMUCNickname(chatId) - gateway.SendMAMReactionMessage(toJid, from, c.xmpp, xmppId, anonymousEmojis, "r"+strId+"-a", timestamp, mucJid, mamQueryId) + from := mucJid + "/" + c.GetMUCNickname(chatId, chatId) + gateway.SendReactionMessage(toJid, from, c.xmpp, xmppId, anonymousEmojis, + gateway.SMId("r"+strId+"-a"), gateway.SMStanzaId("r"+strId+"-a"), gateway.SMTimestamp(timestamp), + gateway.SMMamQueryId(mamQueryId), gateway.SMMucJID(mucJid), gateway.SMIsGroupchat(true), + gateway.SMOccupantId(gateway.CHATNODE(chatId)), + ) } } @@ -431,7 +439,24 @@ func (c *Client) ownReactions(chatId, messageId int64) (map[string]bool, error) MessageId: messageId, }) if err != nil { - return nil, err + // messageId may be a since-superseded provisional id for a message + // we ourselves just sent - TDlib assigns a temporary id + // immediately and reassigns the real one shortly after via + // UpdateMessageSendSucceeded (see updateMessageSendSucceeded's + // MessageIdChanges bookkeeping). Retry with the current id if the + // reassignment has already landed. + c.MessageIdChangesLock.Lock() + newId, newIdOk := c.MessageIdChanges[chatId][messageId] + c.MessageIdChangesLock.Unlock() + if newIdOk && newId.Id != 0 { + message, err = c.client.GetMessage(&client.GetMessageRequest{ + ChatId: chatId, + MessageId: newId.Id, + }) + } + if err != nil { + return nil, err + } } current := make(map[string]bool) diff --git a/telegram/utils.go b/telegram/utils.go index b278b6f..142c415 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -596,6 +596,7 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o gateway.SPMUCAffiliation(occupant.Affiliation), gateway.SPMUCRole(occupant.Role), gateway.SPMUCJid(chatJid), + gateway.SPOccupantId(gateway.CHATNODE(chatID)), gateway.SPToJids(toJids), ) err := c.sendPresence(newMucArgs...) @@ -723,10 +724,11 @@ func (c *Client) sendMUCStatuses(chatID int64) { func (c *Client) updateMUCOccupants(mucState *MUCState, chatID int64, members []*client.ChatMember) { sChatId := gateway.MUCNODE(chatID) myNickname := "me" - var myJid string + var myJid, myOccupantId string if c.me != nil { - myNickname = c.GetMUCNickname(c.me.Id) + myNickname = c.getMUCNicknameLocked(c.me.Id, mucState) myJid = gateway.CHATJID(c.me.Id, true) + myOccupantId = gateway.CHATNODE(c.me.Id) } myAffiliation := "member" myRole := "participant" @@ -736,7 +738,7 @@ func (c *Client) updateMUCOccupants(mucState *MUCState, chatID int64, members [] _, toJids := c.getMUCJoinedJIDs(chatID, mucState, false) for _, member := range members { - senderId, nickname, affiliation, role := c.TgMemberToMUCOccupant(member, chat) + senderId, nickname, affiliation, role := c.tgMemberToMUCOccupantLocked(member, chat, mucState) mucState.Occupants.Set(senderId, &MUCOccupant{ Nickname: nickname, Affiliation: affiliation, @@ -758,6 +760,7 @@ func (c *Client) updateMUCOccupants(mucState *MUCState, chatID int64, members [] gateway.SPMUCAffiliation(affiliation), gateway.SPMUCRole(role), gateway.SPMUCJid(gateway.CHATJID(senderId, true)), + gateway.SPOccupantId(gateway.CHATNODE(senderId)), gateway.SPToJids(toJids), ) } @@ -774,6 +777,7 @@ func (c *Client) updateMUCOccupants(mucState *MUCState, chatID int64, members [] gateway.SPMUCAffiliation(myAffiliation), gateway.SPMUCRole(myRole), gateway.SPMUCJid(myJid), + gateway.SPOccupantId(myOccupantId), gateway.SPMUCStatusCodes(selfStatusCodes), gateway.SPToJids(toJids), ) @@ -819,7 +823,7 @@ func (c *Client) addMUCOccupant(mucID int64, memberID int64, affiliation, role s return false } - nickname := c.GetMUCNickname(memberID) + nickname := c.getMUCNicknameLocked(memberID, mucState) _, toJids := c.getMUCJoinedJIDs(mucID, mucState, false) @@ -830,6 +834,7 @@ func (c *Client) addMUCOccupant(mucID int64, memberID int64, affiliation, role s gateway.SPMUCAffiliation(affiliation), gateway.SPMUCRole(role), gateway.SPMUCJid(gateway.CHATJID(memberID, true)), + gateway.SPOccupantId(gateway.CHATNODE(memberID)), gateway.SPToJids(toJids), ) @@ -857,7 +862,7 @@ func (c *Client) sendMUCSubject(chatID int64, resource string) { if err == nil { gateway.SendSubjectMessage( toJid, - mucJid + "/" + c.GetMUCNickname(c.getMessageSenderId(pin)), + mucJid + "/" + c.GetMUCNickname(chatID, c.getMessageSenderId(pin)), c.messageToText(pin, false), strconv.FormatInt(pin.Id, 10), c.xmpp, @@ -868,8 +873,37 @@ func (c *Client) sendMUCSubject(chatID int64, resource string) { } } -// GetMUCNickname generates a unique nickname for a MUC occupant -func (c *Client) GetMUCNickname(chatID int64) string { +// MyId returns the bridged Telegram account's own user id, or 0 if the +// account info isn't loaded yet. +func (c *Client) MyId() int64 { + if c.me == nil { + return 0 + } + return c.me.Id +} + +// resourcePrepBMP runs XMPP resourceprep and additionally rejects non-BMP +// characters, returning ok=false if either check fails. +func resourcePrepBMP(s string) (string, bool) { + rp, err := gateway.ResourcePrep(s) + if err != nil { + return "", false + } + for _, r := range rp { + if r > bmpCeil { + return "", false + } + } + return rp, true +} + +// buildMUCNickname formats chatID (a Telegram user or chat id) for display +// as a MUC nickname. Normally a username-less user's nickname is always +// disambiguated with a trailing "(id)" so nicknames can never collide. +// When collides is non-nil (see GetMUCNickname), that disambiguator is +// dropped in favor of the bare name, unless collides reports that the +// bare name is already claimed by a different member. +func (c *Client) buildMUCNickname(chatID int64, collides func(name string) bool) string { if chatID == 0 { if c.me != nil { chatID = c.me.Id @@ -877,22 +911,49 @@ func (c *Client) GetMUCNickname(chatID int64) string { return "me" } } - fc := c.FormatContact(chatID) - rp, err := gateway.ResourcePrep(fc) - if err == nil { - // additionally check for non-BMP characters - for _, r := range rp { - if r > bmpCeil { - err = errors.New("Non-BMP character") - break + + chat, user, err := c.GetContactByID(chatID, nil, false) + var fc string + switch { + case err != nil: + fc = "unknown contact: " + err.Error() + // user must be checked before chat: GetContactByID resolves a non-nil + // chat (the private-chat wrapper) for any positive user id too, not + // just actual groups - checking chat first would always shadow user. + case user != nil: + var usernames string + if user.Usernames != nil { + usernames = c.usernamesToString(user.Usernames.ActiveUsernames) + } + name := spaceRegex.ReplaceAllString(fmt.Sprintf("%s %s", user.FirstName, user.LastName), " ") + if collides != nil && usernames == "" { + if rp, ok := resourcePrepBMP(name); ok && !collides(rp) { + return rp } } + idOrUsername := usernames + if idOrUsername == "" { + idOrUsername = strconv.FormatInt(chatID, 10) + } + fc = fmt.Sprintf("%s (%v)", name, idOrUsername) + case chat != nil: + title := spaceRegex.ReplaceAllString(chat.Title, " ") + if collides != nil { + if rp, ok := resourcePrepBMP(title); ok && !collides(rp) { + return rp + } + } + fc = fmt.Sprintf("%s (%v)", chat.Title, chat.Id) + default: + fc = strconv.FormatInt(chatID, 10) } - if err != nil { + fc = spaceRegex.ReplaceAllString(fc, " ") + + rp, ok := resourcePrepBMP(fc) + if !ok { log.Warnf("Resourceprep for %v failed, falling back to chat ID", fc) var usernames string - _, user, _ := c.GetContactByID(chatID, nil, false) if user != nil && user.Usernames != nil { usernames = c.usernamesToString(user.Usernames.ActiveUsernames) } @@ -911,7 +972,45 @@ func (c *Client) GetMUCNickname(chatID int64) string { return rp } -func (c *Client) updateMUCsNickname(memberID int64, newNickname string) { +// getMUCNicknameLocked computes the nickname to register for memberId as +// an occupant of mucState's room, dropping the "(id)" disambiguator +// unless the bare name is already taken by someone else. mucState may be +// nil (no room context, e.g. a PM), in which case this degrades to the +// plain always-disambiguated buildMUCNickname formatting. +// achtung: assuming a locked mucState context +func (c *Client) getMUCNicknameLocked(memberId int64, mucState *MUCState) string { + // buildMUCNickname resolves a 0 memberId to c.me.Id internally (the + // "self" convention GetMUCNickname(chatId, 0) also relies on) - the + // collision check below needs that same resolved id, or self's own + // registered nickname would look like a collision with someone else's + // id 0. + resolvedId := memberId + if resolvedId == 0 && c.me != nil { + resolvedId = c.me.Id + } + + var collides func(string) bool + if mucState != nil { + collides = func(name string) bool { + id, ok := mucState.Occupants.GetIdByNickname(name) + return ok && id != resolvedId + } + } + return c.buildMUCNickname(memberId, collides) +} + +// GetMUCNickname is getMUCNicknameLocked for callers that don't already +// hold c.locks.mucCacheLock. chatId 0 (no room context) falls back to the +// plain always-disambiguated nickname. +func (c *Client) GetMUCNickname(chatId, memberId int64) string { + c.locks.mucCacheLock.Lock() + mucState := c.mucCache[chatId] + nickname := c.getMUCNicknameLocked(memberId, mucState) + c.locks.mucCacheLock.Unlock() + return nickname +} + +func (c *Client) updateMUCsNickname(memberID int64) { c.locks.mucCacheLock.Lock() defer c.locks.mucCacheLock.Unlock() @@ -919,6 +1018,11 @@ func (c *Client) updateMUCsNickname(memberID int64, newNickname string) { for mucId, state := range c.mucCache { oldOccupant, ok := state.Occupants.Get(memberID) if ok { + newNickname := c.getMUCNicknameLocked(memberID, state) + if newNickname == oldOccupant.Nickname { + continue + } + c.DelayedStatusesLock.Lock() delete(c.DelayedStatuses, mucId) c.DelayedStatusesLock.Unlock() @@ -949,6 +1053,7 @@ func (c *Client) updateMUCsNickname(memberID int64, newNickname string) { gateway.SPMUCNick(newNickname), gateway.SPMUCStatusCodes(unavailableStatusCodes), gateway.SPMUCJid(realJid), + gateway.SPOccupantId(gateway.CHATNODE(memberID)), gateway.SPToJids(toJids), ) c.sendPresence( @@ -959,6 +1064,7 @@ func (c *Client) updateMUCsNickname(memberID int64, newNickname string) { gateway.SPMUCRole(oldOccupant.Role), gateway.SPMUCStatusCodes(availableStatusCodes), gateway.SPMUCJid(realJid), + gateway.SPOccupantId(gateway.CHATNODE(memberID)), gateway.SPToJids(toJids), ) } @@ -1157,7 +1263,7 @@ func (c *Client) getMessageReply(message *client.Message, preview bool, noConten replyTo.Quote.Text.Text, replyTo.Quote.Text.Entities, c.getFormatter(), - c, + mucMentionRetriever{c, replyTo.ChatId}, ) // make the whole quote fit one line text = strings.ReplaceAll(text, "\n", " ") @@ -1187,10 +1293,11 @@ func (c *Client) getMessageReply(message *client.Message, preview bool, noConten Id: replyId, } } else if !noContent { - // it's safe to assume there's no need to pass ChatId here - // as it's needed only for pin messages which are not allowed in replies + // replyTo.ChatId, not message.ChatId: this quotes a message + // from another chat, and its mentions should resolve against + // that chat's own occupants, not the replying message's. if text == "" && replyTo.Content != nil { - text = c.messageContentToText(replyTo.Content, 0, preview) + text = c.messageContentToText(replyTo.Content, replyTo.ChatId, preview) } if text == "" { @@ -1544,7 +1651,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 photo.Caption.Text, photo.Caption.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageAudio: @@ -1556,7 +1663,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 audio.Caption.Text, audio.Caption.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageVideo: @@ -1568,7 +1675,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 video.Caption.Text, video.Caption.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageDocument: @@ -1580,7 +1687,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 document.Caption.Text, document.Caption.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageText: @@ -1592,7 +1699,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 text.Text.Text, text.Text.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageVoiceNote: @@ -1604,7 +1711,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 voice.Caption.Text, voice.Caption.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageVideoNote: @@ -1618,7 +1725,7 @@ func (c *Client) messageContentToText(content client.MessageContent, chatId int6 animation.Caption.Text, animation.Caption.Entities, markupMode, - c, + mucMentionRetriever{c, chatId}, ) } case client.TypeMessageContact: @@ -1909,7 +2016,7 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { switch message.Content.MessageContentType() { case client.TypeMessageChatJoinByLink: - c.mucOccupantRolePresence(chatId, senderId, ChatMemberStatusUnmuted, c.GetMUCNickname(senderId)) + c.mucOccupantRolePresence(chatId, senderId, ChatMemberStatusUnmuted, c.GetMUCNickname(chatId, senderId)) case client.TypeMessageChatAddMembers: addMembers, _ := message.Content.(*client.MessageChatAddMembers) for _, memberId := range addMembers.MemberUserIds { @@ -1918,11 +2025,11 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { gateway.InviteToMUC(chatId, jid, c.xmpp) } } - c.mucOccupantRolePresence(chatId, memberId, ChatMemberStatusUnmuted, c.GetMUCNickname(memberId)) + c.mucOccupantRolePresence(chatId, memberId, ChatMemberStatusUnmuted, c.GetMUCNickname(chatId, memberId)) } case client.TypeMessageChatDeleteMember: deleteMember, _ := message.Content.(*client.MessageChatDeleteMember) - c.mucOccupantRolePresence(chatId, deleteMember.UserId, ChatMemberStatusKicked, c.GetMUCNickname(deleteMember.UserId)) + c.mucOccupantRolePresence(chatId, deleteMember.UserId, ChatMemberStatusKicked, c.GetMUCNickname(chatId, deleteMember.UserId)) case client.TypeMessageBasicGroupChatCreate, client.TypeMessageSupergroupChatCreate, client.TypeMessageChatUpgradeTo: inviteChatId := chatId if message.Content.MessageContentType() == client.TypeMessageChatUpgradeTo { @@ -1938,7 +2045,7 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { safeToSend = c.assureMUCOccupant(chatId, senderId, message.SenderId, chat) - groupChatFrom = gateway.MUCJID(chatId) + "/" + c.GetMUCNickname(senderId) + groupChatFrom = gateway.MUCJID(chatId) + "/" + c.GetMUCNickname(chatId, senderId) var ok bool ok, groupChatTos = c.getMUCJoinedJIDs(chatId, nil, true) if !ok { @@ -1960,6 +2067,7 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id var jids []string var isGroupchat bool var originalFrom string + senderId := c.getMessageSenderId(message) if len(groupChatTos) == 0 { isCarbon = c.isCarbonsEnabled() && message.IsOutgoing jids = c.GetCarbonFullJids(isCarbon, "", true) @@ -1967,7 +2075,6 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id isGroupchat = true jids = groupChatTos - senderId := c.getMessageSenderId(message) if senderId != 0 { originalFrom = gateway.CHATJID(senderId, true) } @@ -2189,12 +2296,18 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id } } + var occupantId string + if isGroupchat && senderId != 0 { + occupantId = gateway.CHATNODE(senderId) + } + for _, jid := range jids { commonArgs := []args.V{ gateway.SMReply(reply), gateway.SMTimestamp(timestamp), gateway.SMIsCarbon(isCarbon), gateway.SMIsGroupchat(isGroupchat), gateway.SMRequestReceipt(c.Session.Receipts), gateway.SMOriginalFrom(originalFrom), gateway.SMMamQueryId(mamQueryId), gateway.SMMucJID(mucJID), gateway.SMMucUserItem(mucUserItem), + gateway.SMOccupantId(occupantId), } if omemoFailed { gateway.SendMessage(jid, from, c.xmpp, append(commonArgs, @@ -2237,7 +2350,7 @@ func (c *Client) SendDelayedMUCMessage(chatId int64, message *client.Message, to message, msgId, true, - gateway.MUCJID(chatId) + "/" + c.GetMUCNickname(c.getMessageSenderId(message)), + gateway.MUCJID(chatId) + "/" + c.GetMUCNickname(chatId, c.getMessageSenderId(message)), []string{toJid}, mamQueryId, ) @@ -2868,7 +2981,7 @@ func (c *Client) GetChatDescription(chat *client.Chat) string { fullInfo.Bio.Text, fullInfo.Bio.Entities, c.getFormatter(), - c, + mucMentionRetriever{c, 0}, ) } else if fullInfo.BotInfo != nil { if fullInfo.BotInfo.ShortDescription != "" { @@ -3266,8 +3379,21 @@ func (c *Client) memberStatusToAffiliationAndRole(memberStatus client.ChatMember // TgMemberToMUCMember resolves useful data to generate a MUC occupant func (c *Client) TgMemberToMUCOccupant(member *client.ChatMember, chat *client.Chat) (senderId int64, nickname, affiliation, role string) { + var mucState *MUCState + if chat != nil { + c.locks.mucCacheLock.Lock() + mucState = c.mucCache[chat.Id] + defer c.locks.mucCacheLock.Unlock() + } + return c.tgMemberToMUCOccupantLocked(member, chat, mucState) +} + +// tgMemberToMUCOccupantLocked is TgMemberToMUCOccupant for callers that +// already hold c.locks.mucCacheLock (mirrors updateMUCOccupants' locking +// convention). +func (c *Client) tgMemberToMUCOccupantLocked(member *client.ChatMember, chat *client.Chat, mucState *MUCState) (senderId int64, nickname, affiliation, role string) { senderId = c.GetSenderId(member.MemberId) - nickname = c.GetMUCNickname(senderId) + nickname = c.getMUCNicknameLocked(senderId, mucState) affiliation, role = c.memberStatusToAffiliationAndRole(member.Status, chat) return } @@ -3299,12 +3425,14 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, p var originalFrom string var from string + var occupantId string if isMUC { senderId := c.getMessageSenderId(message) if senderId != 0 { originalFrom = gateway.CHATJID(senderId, true) + occupantId = gateway.CHATNODE(senderId) } - from = mucJid + "/" + c.GetMUCNickname(senderId) + from = mucJid + "/" + c.GetMUCNickname(chatID, senderId) } else { from = sChatId } @@ -3349,7 +3477,7 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, p gateway.SendMessage(to, from, c.xmpp, gateway.SMBody(body), gateway.SMId(sId), gateway.SMReply(reply), gateway.SMIsGroupchat(isMUC), gateway.SMOriginalFrom(originalFrom), - gateway.SMOMEMOEnvelope(envelope), + gateway.SMOMEMOEnvelope(envelope), gateway.SMOccupantId(occupantId), ) } } else { @@ -3518,17 +3646,19 @@ func (c *Client) leaveChat(chatID int64) error { // achtung: assuming a locked mucState context func (c *Client) kickMeFromMUC(chatID int64, statusCodes []uint16, destroy bool, mucState *MUCState) error { - var myJid string + var myJid, myOccupantId string if c.me != nil { myJid = gateway.CHATJID(c.me.Id, true) + myOccupantId = gateway.CHATNODE(c.me.Id) } _, toJids := c.getMUCJoinedJIDs(chatID, mucState, mucState == nil) args := []args.V{ gateway.SPFrom(gateway.MUCNODE(chatID)), - gateway.SPResource(c.GetMUCNickname(0)), + gateway.SPResource(c.getMUCNicknameLocked(0, mucState)), gateway.SPMUCAffiliation("none"), gateway.SPMUCRole("none"), gateway.SPMUCJid(myJid), + gateway.SPOccupantId(myOccupantId), gateway.SPMUCStatusCodes(statusCodes), gateway.SPToJids(toJids), } @@ -3562,6 +3692,7 @@ func (c *Client) kickStaleOccupant(chatID int64, deleted *MUCOccupant, mucState gateway.SPMUCAffiliation(deleted.Affiliation), gateway.SPMUCRole(deleted.Role), gateway.SPMUCStatusCodes([]uint16{307, 333}), + gateway.SPOccupantId(gateway.CHATNODE(deleted.id)), ) } @@ -3719,6 +3850,7 @@ func (c *Client) mucOccupantRolePresence(chatID, userID int64, status ChatMember gateway.SPResource(nickname), gateway.SPImmed(true), gateway.SPMUCJid(gateway.CHATJID(userID, true)), + gateway.SPOccupantId(gateway.CHATNODE(userID)), gateway.SPToJids(toJids), } @@ -3827,16 +3959,29 @@ func (c *Client) getChatMemberStatus(status client.ChatMemberStatus) ChatMemberS return ChatMemberStatusIllegal } -// GetMUCNicknameByUsername implement the MentionRetriever interface for message formatters -func (c *Client) GetMUCNicknameByUsername(username string) (string, error) { - chat, err := c.client.SearchPublicChat(&client.SearchPublicChatRequest{ +// mucMentionRetriever implements formatter.MentionRetriever bound to a +// single chat, so an @mention inside one of that chat's messages renders +// with the same nickname shown in its occupant list (see GetMUCNickname). +// chatId 0 (no particular room in scope) falls back to the plain +// always-disambiguated nickname. +type mucMentionRetriever struct { + c *Client + chatId int64 +} + +func (m mucMentionRetriever) GetMUCNickname(id int64) string { + return m.c.GetMUCNickname(m.chatId, id) +} + +func (m mucMentionRetriever) GetMUCNicknameByUsername(username string) (string, error) { + chat, err := m.c.client.SearchPublicChat(&client.SearchPublicChatRequest{ Username: username, }) if err != nil { return "", err } - return c.GetMUCNickname(chat.Id), nil + return m.c.GetMUCNickname(m.chatId, chat.Id), nil } // FindMessageByTime retrieves the closest message before the given timestamp diff --git a/xmpp/extensions/extensions.go b/xmpp/extensions/extensions.go index c17ccd5..c97ae8b 100644 --- a/xmpp/extensions/extensions.go +++ b/xmpp/extensions/extensions.go @@ -333,6 +333,12 @@ type MessageOriginId struct { Id string `xml:"id,attr"` } +// OccupantId is from XEP-0421, carried on both MUC messages and presence +type OccupantId struct { + XMLName xml.Name `xml:"urn:xmpp:occupant-id:0 occupant-id"` + Id string `xml:"id,attr"` +} + // EmptySubject is a dummy for MUCs to circumvent omitempty. Not registered as it would conflict with Subject field type EmptySubject struct { XMLName xml.Name `xml:"subject"` @@ -889,6 +895,18 @@ func init() { "origin-id", }, MessageOriginId{}) + // occupant id (message) + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:occupant-id:0", + "occupant-id", + }, OccupantId{}) + + // occupant id (presence) + stanza.TypeRegistry.MapExtension(stanza.PKTPresence, xml.Name{ + "urn:xmpp:occupant-id:0", + "occupant-id", + }, OccupantId{}) + // muc admin query stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ "http://jabber.org/protocol/muc#admin", diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index b192555..43e6022 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -72,6 +72,9 @@ const NSCaps string = "http://jabber.org/protocol/caps" // NSReactions is the XEP-0444 message reactions namespace const NSReactions string = "urn:xmpp:reactions:0" +// NSOccupantId is the XEP-0421 occupant id namespace +const NSOccupantId string = "urn:xmpp:occupant-id:0" + // CapsNode is this software's advertised XEP-0115 node URI const CapsNode string = "https://dev.narayana.im/narayana/telegabber/" @@ -281,28 +284,11 @@ 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{ +func SendReactionMessage(to string, from string, component *xmpp.Component, id string, reactionEmojis []string, args ...args.V) { + sendMessageWrapper(to, from, component, append(args, 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 @@ -366,6 +352,9 @@ var SMInviteFrom = args.NewString() // SMStanzaId is a XEP-0359 stanza id var SMStanzaId = args.NewString() +// SMOccupantId is a XEP-0421 occupant id (a stable identifier for a MUC occupant) +var SMOccupantId = args.NewString() + // SMStatusCode is a XEP-0045 muc#user status code var SMStatusCode = args.NewInt64() @@ -404,6 +393,7 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args errorCode := SMErrorCode.Get(args) inviteFrom := SMInviteFrom.Get(args) stanzaId := SMStanzaId.Get(args) + occupantId := SMOccupantId.Get(args) statusCode := SMStatusCode.Get(args) mamQueryId := SMMamQueryId.Get(args) mucJID := SMMucJID.Get(args) @@ -619,6 +609,11 @@ func sendMessageWrapper(to, from string, component *xmpp.Component, args ...args }) } } + if occupantId != "" { + message.Extensions = append(message.Extensions, extensions.OccupantId{ + Id: occupantId, + }) + } if isCarbon { carbonMessage := extensions.ClientMessage{ @@ -807,6 +802,9 @@ var SPMUCNick = args.NewString() // SPMUCJid is a real jid of a MUC member var SPMUCJid = args.NewString() +// SPOccupantId is a XEP-0421 occupant id (a stable identifier for a MUC occupant) +var SPOccupantId = args.NewString() + // SPMUCStatusCodes is a set of XEP-0045 MUC status codes var SPMUCStatusCodes = args.New() @@ -883,6 +881,14 @@ func newPresence(bareJid string, to string, args ...args.V) stanza.Presence { }) } } + if SPOccupantId.IsSet(args) { + occupantId := SPOccupantId.Get(args) + if occupantId != "" { + presence.Extensions = append(presence.Extensions, extensions.OccupantId{ + Id: occupantId, + }) + } + } if SPMUCAffiliation.IsSet(args) { affiliation := SPMUCAffiliation.Get(args) if affiliation != "" { diff --git a/xmpp/handlers.go b/xmpp/handlers.go index 511f853..cce78e8 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -298,13 +298,17 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { var replyId int64 text := msg.Body - // OMEMO decrypt hook - personal-chat pseudo-JIDs only (true - // MUC/XEP-0045 groupchat OMEMO is out of scope entirely, not - // deferred - see the plan doc). A successful decrypt is itself - // the auto-upgrade signal for this chat (enablement trigger 2); - // the account-wide config toggle (trigger 1) is a separate, - // not-yet-wired-up piece (M4). - if !isGroupchat { + // OMEMO decrypt hook - personal-chat pseudo-JIDs only, XEP-0045 + // groupchat OMEMO is out of scope entirely. A successful + // decrypt marks the chat OMEMO-active. Groupchat OMEMO is + // bounced rather than silently dropped, so the sender gets a + // clear signal instead of treating it as delivered. + if isGroupchat { + if hasOMEMOPayload(msg) { + gateway.SendErrorMessageWithBody(msg.From, msg.To, msg.Body, "OMEMO encryption is not supported in this room", msg.Id, 406, true, component) + return + } + } else { if backend, backendOk := gateway.E2EE.Backend(); backendOk { env, hasEnv, decErr := decodeOMEMOEnvelope(msg) if decErr != nil { @@ -444,14 +448,14 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { tgMessage, msg.Id, false, - msg.To + "/" + session.GetMUCNickname(session.GetSenderId(tgMessage.SenderId)), + msg.To + "/" + session.GetMUCNickname(toID, session.GetSenderId(tgMessage.SenderId)), []string{msg.From}, "", ) } } else if isCommand && isGroupchat && session.Session.MUC { // pong outgoing commands back to groupchats - gateway.SendMessage(msg.From, msg.To+"/"+session.GetMUCNickname(0), component, gateway.SMBody(text), gateway.SMIsGroupchat(isGroupchat)) + gateway.SendMessage(msg.From, msg.To+"/"+session.GetMUCNickname(toID, 0), component, gateway.SMBody(text), gateway.SMIsGroupchat(isGroupchat), gateway.SMOccupantId(gateway.CHATNODE(session.MyId()))) } else { /* // if a message failed to edit on Telegram side, match new XMPP ID with old Telegram ID anyway @@ -903,6 +907,7 @@ func handleMUCUnavailable(component *xmpp.Component, p stanza.Presence, session gateway.SPMUCAffiliation("member"), gateway.SPMUCRole("none"), gateway.SPMUCJid(p.From), + gateway.SPOccupantId(gateway.CHATNODE(session.MyId())), gateway.SPMUCStatusCodes([]uint16{110}), ) } @@ -1129,7 +1134,6 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { "http://jabber.org/protocol/muc", "muc_persistent", "muc_hidden", - "muc_membersonly", "muc_moderated", "muc_nonanonymous", "muc_unsecured", @@ -1142,6 +1146,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { gateway.NSReactions, "urn:xmpp:sid:0", "vcard-temp", + gateway.NSOccupantId, ) fields := []*stanza.Field{ &stanza.Field{ @@ -1215,7 +1220,7 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { chat, _, err := session.GetContactByID(toID, nil, true) if err == nil && session.IsGroup(chat) { disco.SetNode(di.Node) - disco.AddIdentity(session.GetMUCNickname(0), "conference", "text") + disco.AddIdentity(session.GetMUCNickname(toID, 0), "conference", "text") } } } @@ -1340,7 +1345,7 @@ func handleGetQueryRegister(s xmpp.Sender, iq *stanza.IQ) { } defer gateway.ResumableSend(component, answer) - _, toOk, toIsGroup := toToID(iq.To) + toID, toOk, toIsGroup := toToID(iq.To) bare, _, ok := gateway.SplitJID(iq.From) var session *telegram.Client @@ -1353,7 +1358,7 @@ func handleGetQueryRegister(s xmpp.Sender, iq *stanza.IQ) { if toIsGroup { nickname := "me" if sessionOk { - nickname = session.GetMUCNickname(0) + nickname = session.GetMUCNickname(toID, 0) } answer.Payload = extensions.QueryRegister{ Instructions: "MUC username is static", @@ -2317,7 +2322,7 @@ func handleSetQueryMucAdmin(s xmpp.Sender, iq *stanza.IQ, query *extensions.Quer return } - nick := session.GetMUCNickname(userID) + nick := session.GetMUCNickname(toID, userID) var status telegram.ChatMemberStatus