From 086cff72445c0c0a06729902e1a1bf2e887024cc Mon Sep 17 00:00:00 2001 From: Karmanyaah Malhotra Date: Thu, 1 Apr 2021 20:00:27 -0400 Subject: [PATCH] Recovered portal.go contents --- output.go | Bin 46550 -> 0 bytes portal.go | 1520 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1519 insertions(+), 1 deletion(-) delete mode 100644 output.go diff --git a/output.go b/output.go deleted file mode 100644 index 5ba23919280c140b53f5a0dc941ea5152d054872..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46550 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 z 0 { + bridge.portalsByMXID[portal.MXID] = portal + } + return portal +} + +func (portal *Portal) GetUsers() []*User { + return nil +} + +func (bridge *Bridge) NewManualPortal(key database.PortalKey) *Portal { + portal := &Portal{ + Portal: bridge.DB.Portal.New(), + bridge: bridge, + log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", key)), + + recentlyHandled: make([]string, recentlyHandledLength), + + messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), + } + portal.Key = key + go portal.handleMessageLoop() + return portal +} + +func (bridge *Bridge) NewPortal(dbPortal *database.Portal) *Portal { + portal := &Portal{ + Portal: dbPortal, + bridge: bridge, + log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)), + + recentlyHandled: make([]string, recentlyHandledLength), + + messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer), + } + go portal.handleMessageLoop() + return portal +} + +const recentlyHandledLength = 100 + +type PortalMessage struct { + chat string + group bool + source *User + data *groupme.Message + timestamp uint64 +} + +type Portal struct { + *database.Portal + + bridge *Bridge + log log.Logger + + roomCreateLock sync.Mutex + + recentlyHandled []string + recentlyHandledLock sync.Mutex + recentlyHandledIndex uint8 + + backfillLock sync.Mutex + backfilling bool + lastMessageTs uint64 + + privateChatBackfillInvitePuppet func() + + messages chan PortalMessage + + isPrivate *bool + hasRelaybot *bool +} + +const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes + +func (portal *Portal) handleMessageLoop() { + for msg := range portal.messages { + if len(portal.MXID) == 0 { + if msg.timestamp+MaxMessageAgeToCreatePortal < uint64(time.Now().Unix()) { + portal.log.Debugln("Not creating portal room for incoming message as the message is too old.") + continue + } + portal.log.Debugln("Creating Matrix room from incoming message") + err := portal.CreateMatrixRoom(msg.source) + if err != nil { + portal.log.Errorln("Failed to create portal room:", err) + return + } + } + portal.backfillLock.Lock() + portal.handleMessage(msg) + portal.backfillLock.Unlock() + } +} + +func (portal *Portal) handleMessage(msg PortalMessage) { + if len(portal.MXID) == 0 { + portal.log.Warnln("handleMessage called even though portal.MXID is empty") + return + } + portal.HandleTextMessage(msg.source, msg.data) + portal.handleReaction(msg.data.ID.String(), msg.data.FavoritedBy) +} + +func (portal *Portal) isRecentlyHandled(id groupme.ID) bool { + idStr := id.String() + for i := recentlyHandledLength - 1; i >= 0; i-- { + if portal.recentlyHandled[i] == idStr { + return true + } + } + return false +} + +func (portal *Portal) isDuplicate(id groupme.ID) bool { + msg := portal.bridge.DB.Message.GetByJID(portal.Key, id.String()) + if msg != nil { + return true + } + + return false +} + +func init() { +} + +func (portal *Portal) markHandled(source *User, message *groupme.Message, mxid id.EventID) { + msg := portal.bridge.DB.Message.New() + msg.Chat = portal.Key + msg.JID = message.ID.String() + msg.MXID = mxid + msg.Timestamp = uint64(message.CreatedAt.ToTime().Unix()) + if message.UserID.String() == source.JID { + msg.Sender = source.JID + } else if portal.IsPrivateChat() { + msg.Sender = portal.Key.JID + } else { + msg.Sender = message.ID.String() + if len(msg.Sender) == 0 { + println("AAAAAAAAAAAAAAAAAAAAAAAAAAIDK") + msg.Sender = message.SenderID.String() + } + } + msg.Content = &groupmeExt.Message{Message: *message} + msg.Insert() + + portal.recentlyHandledLock.Lock() + portal.recentlyHandled[0] = "" //FIFO queue being implemented here //TODO: is this efficent + portal.recentlyHandled = portal.recentlyHandled[1:] + portal.recentlyHandled = append(portal.recentlyHandled, message.ID.String()) + portal.recentlyHandledLock.Unlock() +} + +func (portal *Portal) getMessageIntent(user *User, info *groupme.Message) *appservice.IntentAPI { + if info.UserID.String() == user.GetJID() { //from me + return portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal) //TODO why is this + } else if portal.IsPrivateChat() { + return portal.MainIntent() + } else if len(info.UserID.String()) == 0 { + // if len(info.Source.GetParticipant()) != 0 { + // info.SenderJid = info.Source.GetParticipant() + // } else { + // return nil + // } + println("TODO weird uid stuff") + } + return portal.bridge.GetPuppetByJID(info.UserID.String()).IntentFor(portal) +} + +func (portal *Portal) getReactionIntent(jid types.GroupMeID) *appservice.IntentAPI { + return portal.bridge.GetPuppetByJID(jid).IntentFor(portal) +} + +func (portal *Portal) startHandling(source *User, info *groupme.Message) *appservice.IntentAPI { + // TODO these should all be trace logs + if portal.lastMessageTs > uint64(info.CreatedAt.ToTime().Unix()+1) { + portal.log.Debugfln("Not handling %s: message is older (%d) than last bridge message (%d)", info.ID, info.CreatedAt, portal.lastMessageTs) + } else if portal.isRecentlyHandled(info.ID) { + portal.log.Debugfln("Not handling %s: message was recently handled", info.ID) + } else if portal.isDuplicate(info.ID) { + portal.log.Debugfln("Not handling %s: message is duplicate", info.ID) + } else if info.System { + portal.log.Debugfln("Not handling %s: message is from system: %s", info.ID, info.Text) + } else { + portal.lastMessageTs = uint64(info.CreatedAt.ToTime().Unix()) + intent := portal.getMessageIntent(source, info) + if intent != nil { + portal.log.Debugfln("Starting handling of %s (ts: %d)", info.ID, info.CreatedAt) + } else { + portal.log.Debugfln("Not handling %s: sender is not known") + } + return intent + } + return nil +} + +func (portal *Portal) finishHandling(source *User, message *groupme.Message, mxid id.EventID) { + portal.markHandled(source, message, mxid) + portal.sendDeliveryReceipt(mxid) + portal.log.Debugln("Handled message", message.ID.String(), "->", mxid) +} + +func (portal *Portal) SyncParticipants(metadata *groupme.Group) { + changed := false + levels, err := portal.MainIntent().PowerLevels(portal.MXID) + if err != nil { + levels = portal.GetBasePowerLevels() + changed = true + } + participantMap := make(map[string]bool) + for _, participant := range metadata.Members { + participantMap[participant.UserID.String()] = true + user := portal.bridge.GetUserByJID(participant.UserID.String()) + portal.userMXIDAction(user, portal.ensureMXIDInvited) + + puppet := portal.bridge.GetPuppetByJID(participant.UserID.String()) + err := puppet.IntentFor(portal).EnsureJoined(portal.MXID) + if err != nil { + portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.ID.String(), portal.MXID, err) + } + + expectedLevel := 0 + // if participant.IsSuperAdmin { + // expectedLevel = 95 + // } else if participant.IsAdmin { + // expectedLevel = 50 + // } + changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed + if user != nil { + changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed + } + go puppet.Sync(nil, *participant) //why nil whynot + } + if changed { + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) + } + } + members, err := portal.MainIntent().JoinedMembers(portal.MXID) + if err != nil { + portal.log.Warnln("Failed to get member list:", err) + } else { + for member := range members.Joined { + jid, ok := portal.bridge.ParsePuppetMXID(member) + if ok { + _, shouldBePresent := participantMap[jid] + if !shouldBePresent { + _, err := portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{ + UserID: member, + Reason: "User had left this WhatsApp chat", + }) + if err != nil { + portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err) + } + } + } + } + } +} + +func (portal *Portal) UpdateAvatar(user *User, avatar string, updateInfo bool) bool { + // if len(avatar) == 0 { + // var err error + // avatar, err = user.Conn.GetProfilePicThumb(portal.Key.JID) + // if err != nil { + // portal.log.Errorln(err) + // return false + // } + // } + //TODO: duplicated code from puppet.UpdateAvatar + if len(avatar) == 0 { + if len(portal.Avatar) == 0 { + return false + } + err := portal.MainIntent().SetAvatarURL(id.ContentURI{}) + if err != nil { + portal.log.Warnln("Failed to remove avatar:", err) + } + portal.AvatarURL = types.ContentURI{} + portal.Avatar = avatar + return true + } + + if portal.Avatar == avatar { + return false + } + + //TODO check its actually groupme? + response, err := http.Get(avatar + ".large") + if err != nil { + portal.log.Warnln("Failed to download avatar:", err) + return false + } + defer response.Body.Close() + + image, err := ioutil.ReadAll(response.Body) + if err != nil { + portal.log.Warnln("Failed to read downloaded avatar:", err) + return false + } + + mime := response.Header.Get("Content-Type") + if len(mime) == 0 { + mime = http.DetectContentType(image) + } + resp, err := portal.MainIntent().UploadBytes(image, mime) + if err != nil { + portal.log.Warnln("Failed to upload avatar:", err) + return false + } + + portal.AvatarURL = types.ContentURI{resp.ContentURI} + if len(portal.MXID) > 0 { + _, err = portal.MainIntent().SetRoomAvatar(portal.MXID, resp.ContentURI) + if err != nil { + portal.log.Warnln("Failed to set room topic:", err) + return false + } + } + portal.Avatar = avatar + if updateInfo { + portal.UpdateBridgeInfo() + } + return true +} + +func (portal *Portal) UpdateName(name string, setBy types.GroupMeID, updateInfo bool) bool { + if portal.Name != name { + intent := portal.MainIntent() + if len(setBy) > 0 { + intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) + } + _, err := intent.SetRoomName(portal.MXID, name) + if err == nil { + portal.Name = name + if updateInfo { + portal.UpdateBridgeInfo() + } + return true + } + portal.log.Warnln("Failed to set room name:", err) + } + return false +} + +func (portal *Portal) UpdateTopic(topic string, setBy types.GroupMeID, updateInfo bool) bool { + if portal.Topic != topic { + intent := portal.MainIntent() + if len(setBy) > 0 { + intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal) + } + _, err := intent.SetRoomTopic(portal.MXID, topic) + if err == nil { + portal.Topic = topic + if updateInfo { + portal.UpdateBridgeInfo() + } + return true + } + portal.log.Warnln("Failed to set room topic:", err) + } + return false +} + +func (portal *Portal) UpdateMetadata(user *User) bool { + if portal.IsPrivateChat() { + return false + } + group, err := user.Client.ShowGroup(context.TODO(), groupme.ID(strings.Replace(portal.Key.JID, groupmeExt.NewUserSuffix, "", 1))) + if err != nil { + portal.log.Errorln(err) + return false + } + // if metadata.Status != 0 { + // 401: access denied + // 404: group does (no longer) exist + // 500: ??? happens with status@broadcast + + // TODO: update the room, e.g. change priority level + // to send messages to moderator + //return false + // } + + portal.SyncParticipants(group) + update := false + update = portal.UpdateName(group.Name, "", false) || update + update = portal.UpdateTopic(group.Description, "", false) || update + + // portal.RestrictMessageSending(metadata.Announce) + + return update +} + +func (portal *Portal) userMXIDAction(user *User, fn func(mxid id.UserID)) { + if user == nil { + return + } + + if user == portal.bridge.Relaybot { + for _, mxid := range portal.bridge.Config.Bridge.Relaybot.InviteUsers { + fn(mxid) + } + } else { + fn(user.MXID) + } +} + +func (portal *Portal) ensureMXIDInvited(mxid id.UserID) { + err := portal.MainIntent().EnsureInvited(portal.MXID, mxid) + if err != nil { + portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", mxid, portal.MXID, err) + } +} + +func (portal *Portal) ensureUserInvited(user *User) { + portal.userMXIDAction(user, portal.ensureMXIDInvited) + + customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) + if customPuppet != nil && customPuppet.CustomIntent() != nil { + _ = customPuppet.CustomIntent().EnsureJoined(portal.MXID) + } +} + +func (portal *Portal) Sync(user *User, group groupme.Group) { + portal.log.Infoln("Syncing portal for", user.MXID) + + if user.IsRelaybot { + yes := true + portal.hasRelaybot = &yes + } + + err := user.Conn.SubscribeToGroup(context.TODO(), group.ID, user.Token) + if err != nil { + portal.log.Errorln("Subscribing failed, live metadata updates won't work", err) + } + + if len(portal.MXID) == 0 { + if !portal.IsPrivateChat() { + portal.Name = group.Name + } + err := portal.CreateMatrixRoom(user) + if err != nil { + portal.log.Errorln("Failed to create portal room:", err) + return + } + } else { + portal.ensureUserInvited(user) + } + + if portal.IsPrivateChat() { + return + } + + update := false + update = portal.UpdateMetadata(user) || update + update = portal.UpdateAvatar(user, group.ImageURL, false) || update + + if update { + portal.Update() + portal.UpdateBridgeInfo() + } +} + +func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent { + anyone := 0 + nope := 99 + invite := 50 + if portal.bridge.Config.Bridge.AllowUserInvite { + invite = 0 + } + return &event.PowerLevelsEventContent{ + UsersDefault: anyone, + EventsDefault: anyone, + RedactPtr: &anyone, + StateDefaultPtr: &nope, + BanPtr: &nope, + InvitePtr: &invite, + Users: map[id.UserID]int{ + portal.MainIntent().UserID: 100, + }, + Events: map[string]int{ + event.StateRoomName.Type: anyone, + event.StateRoomAvatar.Type: anyone, + event.StateTopic.Type: anyone, + }, + } +} + +func (portal *Portal) ChangeAdminStatus(jids []string, setAdmin bool) { + levels, err := portal.MainIntent().PowerLevels(portal.MXID) + if err != nil { + levels = portal.GetBasePowerLevels() + } + newLevel := 0 + if setAdmin { + newLevel = 50 + } + changed := false + for _, jid := range jids { + puppet := portal.bridge.GetPuppetByJID(jid) + changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed + + user := portal.bridge.GetUserByJID(jid) + if user != nil { + changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed + } + } + if changed { + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) + } + } +} + +func (portal *Portal) RestrictMessageSending(restrict bool) { + levels, err := portal.MainIntent().PowerLevels(portal.MXID) + if err != nil { + levels = portal.GetBasePowerLevels() + } + + newLevel := 0 + if restrict { + newLevel = 50 + } + + if levels.EventsDefault == newLevel { + return + } + + levels.EventsDefault = newLevel + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) + } +} + +func (portal *Portal) RestrictMetadataChanges(restrict bool) { + levels, err := portal.MainIntent().PowerLevels(portal.MXID) + if err != nil { + levels = portal.GetBasePowerLevels() + } + newLevel := 0 + if restrict { + newLevel = 50 + } + changed := false + changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed + changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed + changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed + if changed { + _, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels) + if err != nil { + portal.log.Errorln("Failed to change power levels:", err) + } + } +} + +func (portal *Portal) BackfillHistory(user *User, lastMessageTime uint64) error { + if !portal.bridge.Config.Bridge.RecoverHistory { + return nil + } + + endBackfill := portal.beginBackfill() + defer endBackfill() + + lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key) + if lastMessage == nil { + return nil + } + if lastMessage.Timestamp >= lastMessageTime { + portal.log.Debugln("Not backfilling: no new messages") + return nil + } + + lastMessageID := lastMessage.JID + lastMessageFromMe := lastMessage.Sender == user.JID + portal.log.Infoln("Backfilling history since", lastMessageID, "for", user.MXID) + for len(lastMessageID) > 0 { + portal.log.Debugln("Fetching 50 messages of history after", lastMessageID) + messages, err := user.Client.LoadMessagesAfter(portal.Key.JID, lastMessageID, lastMessageFromMe, 50) + if err != nil { + return err + } + // messages, ok := resp.Content.([]interface{}) + if len(messages) == 0 { + portal.log.Debugfln("Didn't get more messages to backfill (resp.Content is %T)", messages) + break + } + + portal.handleHistory(user, messages) + + lastMessageProto := messages[len(messages)-1] + lastMessageID = lastMessageProto.ID.String() + lastMessageFromMe = lastMessageProto.UserID.String() == user.JID + } + portal.log.Infoln("Backfilling finished") + return nil +} + +func (portal *Portal) beginBackfill() func() { + portal.backfillLock.Lock() + portal.backfilling = true + var privateChatPuppetInvited bool + var privateChatPuppet *Puppet + if portal.IsPrivateChat() && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling && portal.Key.JID != portal.Key.Receiver { + privateChatPuppet = portal.bridge.GetPuppetByJID(portal.Key.Receiver) + portal.privateChatBackfillInvitePuppet = func() { + if privateChatPuppetInvited { + return + } + privateChatPuppetInvited = true + _, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: privateChatPuppet.MXID}) + _ = privateChatPuppet.DefaultIntent().EnsureJoined(portal.MXID) + } + } + return func() { + portal.backfilling = false + portal.privateChatBackfillInvitePuppet = nil + portal.backfillLock.Unlock() + if privateChatPuppet != nil && privateChatPuppetInvited { + _, _ = privateChatPuppet.DefaultIntent().LeaveRoom(portal.MXID) + } + } +} + +func (portal *Portal) disableNotifications(user *User) { + if !portal.bridge.Config.Bridge.HistoryDisableNotifs { + return + } + puppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) + if puppet == nil || puppet.customIntent == nil { + return + } + portal.log.Debugfln("Disabling notifications for %s for backfilling", user.MXID) + ruleID := fmt.Sprintf("net.maunium.silence_while_backfilling.%s", portal.MXID) + err := puppet.customIntent.PutPushRule("global", pushrules.OverrideRule, ruleID, &mautrix.ReqPutPushRule{ + Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, + Conditions: []pushrules.PushCondition{{ + Kind: pushrules.KindEventMatch, + Key: "room_id", + Pattern: string(portal.MXID), + }}, + }) + if err != nil { + portal.log.Warnfln("Failed to disable notifications for %s while backfilling: %v", user.MXID, err) + } +} + +func (portal *Portal) enableNotifications(user *User) { + if !portal.bridge.Config.Bridge.HistoryDisableNotifs { + return + } + puppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) + if puppet == nil || puppet.customIntent == nil { + return + } + portal.log.Debugfln("Re-enabling notifications for %s after backfilling", user.MXID) + ruleID := fmt.Sprintf("net.maunium.silence_while_backfilling.%s", portal.MXID) + err := puppet.customIntent.DeletePushRule("global", pushrules.OverrideRule, ruleID) + if err != nil { + portal.log.Warnfln("Failed to re-enable notifications for %s after backfilling: %v", user.MXID, err) + } +} + +func (portal *Portal) FillInitialHistory(user *User) error { + if portal.bridge.Config.Bridge.InitialHistoryFill == 0 { + return nil + } + endBackfill := portal.beginBackfill() + defer endBackfill() + if portal.privateChatBackfillInvitePuppet != nil { + portal.privateChatBackfillInvitePuppet() + } + + n := portal.bridge.Config.Bridge.InitialHistoryFill + portal.log.Infoln("Filling initial history, maximum", n, "messages") + var messages []*groupme.Message + before := "" + chunkNum := 1 + for n > 0 { + count := 50 + if n < count { + count = n + } + portal.log.Debugfln("Fetching chunk %d (%d messages / %d cap) before message %s", chunkNum, count, n, before) + chunk, err := user.Client.LoadMessagesBefore(portal.Key.JID, before, count) + if err != nil { + return err + } + if len(chunk) == 0 { + portal.log.Infoln("Chunk empty, starting handling of loaded messages") + break + } + + //reverses chunk to ascending order (oldest first) + i := 0 + j := len(chunk) - 1 + for i < j { + chunk[i], chunk[j] = chunk[j], chunk[i] + i++ + j-- + } + + messages = append(chunk, messages...) + + portal.log.Debugfln("Fetched chunk and received %d messages", len(chunk)) + + n -= len(chunk) + before = chunk[0].ID.String() + if len(before) == 0 { + portal.log.Infoln("No message ID for first message, starting handling of loaded messages") + break + } + } + portal.disableNotifications(user) + portal.handleHistory(user, messages) + portal.enableNotifications(user) + portal.log.Infoln("Initial history fill complete") + return nil +} + +func (portal *Portal) handleHistory(user *User, messages []*groupme.Message) { + portal.log.Infoln("Handling", len(messages), "messages of history") + for _, message := range messages { + // data, ok := rawMessage.(*groupme.Message) + // if !ok { + // portal.log.Warnln("Unexpected non-WebMessageInfo item in history response:", rawMessage) + // continue + // } + // data := whatsapp.ParseProtoMessage(message) + // if data == nil || data == whatsapp.ErrMessageTypeNotImplemented { + // st := message.GetMessageStubType() + // // Ignore some types that are known to fail + // if st == waProto.WebMessageInfo_CALL_MISSED_VOICE || st == waProto.WebMessageInfo_CALL_MISSED_VIDEO || + // st == waProto.WebMessageInfo_CALL_MISSED_GROUP_VOICE || st == waProto.WebMessageInfo_CALL_MISSED_GROUP_VIDEO { + // continue + // } + // portal.log.Warnln("Message", message.GetKey().GetId(), "failed to parse during backfilling") + // continue + // } + if portal.privateChatBackfillInvitePuppet != nil && message.UserID.String() == user.JID && portal.IsPrivateChat() { + portal.privateChatBackfillInvitePuppet() + } + portal.handleMessage(PortalMessage{portal.Key.JID, portal.Key.JID == portal.Key.Receiver, user, message, uint64(message.CreatedAt.ToTime().Unix())}) + } +} + +type BridgeInfoSection struct { + ID string `json:"id"` + DisplayName string `json:"displayname,omitempty"` + AvatarURL id.ContentURIString `json:"avatar_url,omitempty"` + ExternalURL string `json:"external_url,omitempty"` +} + +type BridgeInfoContent struct { + BridgeBot id.UserID `json:"bridgebot"` + Creator id.UserID `json:"creator,omitempty"` + Protocol BridgeInfoSection `json:"protocol"` + Network *BridgeInfoSection `json:"network,omitempty"` + Channel BridgeInfoSection `json:"channel"` +} + +var ( + StateBridgeInfo = event.Type{Type: "m.bridge", Class: event.StateEventType} + StateHalfShotBridgeInfo = event.Type{Type: "uk.half-shot.bridge", Class: event.StateEventType} +) + +func (portal *Portal) getBridgeInfo() (string, BridgeInfoContent) { + bridgeInfo := BridgeInfoContent{ + BridgeBot: portal.bridge.Bot.UserID, + Creator: portal.MainIntent().UserID, + Protocol: BridgeInfoSection{ + ID: "whatsapp", + DisplayName: "WhatsApp", + AvatarURL: id.ContentURIString(portal.bridge.Config.AppService.Bot.Avatar), + ExternalURL: "https://www.whatsapp.com/", + }, + Channel: BridgeInfoSection{ + ID: portal.Key.JID, + DisplayName: portal.Name, + AvatarURL: portal.AvatarURL.CUString(), + }, + } + bridgeInfoStateKey := fmt.Sprintf("net.maunium.whatsapp://whatsapp/%s", portal.Key.JID) + return bridgeInfoStateKey, bridgeInfo +} + +func (portal *Portal) UpdateBridgeInfo() { + if len(portal.MXID) == 0 { + portal.log.Debugln("Not updating bridge info: no Matrix room created") + return + } + portal.log.Debugln("Updating bridge info...") + stateKey, content := portal.getBridgeInfo() + _, err := portal.MainIntent().SendStateEvent(portal.MXID, StateBridgeInfo, stateKey, content) + if err != nil { + portal.log.Warnln("Failed to update m.bridge:", err) + } + _, err = portal.MainIntent().SendStateEvent(portal.MXID, StateHalfShotBridgeInfo, stateKey, content) + if err != nil { + portal.log.Warnln("Failed to update uk.half-shot.bridge:", err) + } +} + +func (portal *Portal) CreateMatrixRoom(user *User) error { + portal.roomCreateLock.Lock() + defer portal.roomCreateLock.Unlock() + if len(portal.MXID) > 0 { + return nil + } + + intent := portal.MainIntent() + if err := intent.EnsureRegistered(); err != nil { + return err + } + + portal.log.Infoln("Creating Matrix room. Info source:", user.MXID) + + var metadata *groupme.Group + if portal.IsPrivateChat() { + puppet := portal.bridge.GetPuppetByJID(portal.Key.JID) + if portal.bridge.Config.Bridge.PrivateChatPortalMeta { + portal.Name = puppet.Displayname + portal.AvatarURL = puppet.AvatarURL + portal.Avatar = puppet.Avatar + } else { + portal.Name = "" + } + portal.Topic = "WhatsApp private chat" + // } else if portal.IsStatusBroadcastRoom() { + // portal.Name = "WhatsApp Status Broadcast" + // portal.Topic = "WhatsApp status updates from your contacts" + } else { + var err error + metadata, err = user.Client.ShowGroup(context.TODO(), groupme.ID(portal.Key.JID)) + if err == nil { + portal.Name = metadata.Name + portal.Topic = metadata.Description + } + portal.UpdateAvatar(user, metadata.ImageURL, false) + } + + bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo() + + initialState := []*event.Event{{ + Type: event.StatePowerLevels, + Content: event.Content{ + Parsed: portal.GetBasePowerLevels(), + }, + }, { + Type: StateBridgeInfo, + Content: event.Content{Parsed: bridgeInfo}, + StateKey: &bridgeInfoStateKey, + }, { + // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec + Type: StateHalfShotBridgeInfo, + Content: event.Content{Parsed: bridgeInfo}, + StateKey: &bridgeInfoStateKey, + }} + if !portal.AvatarURL.IsEmpty() { + initialState = append(initialState, &event.Event{ + Type: event.StateRoomAvatar, + Content: event.Content{ + Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL.ContentURI}, + }, + }) + } + + invite := []id.UserID{user.MXID} + if user.IsRelaybot { + invite = portal.bridge.Config.Bridge.Relaybot.InviteUsers + } + + if portal.bridge.Config.Bridge.Encryption.Default { + initialState = append(initialState, &event.Event{ + Type: event.StateEncryption, + Content: event.Content{ + Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1}, + }, + }) + portal.Encrypted = true + if portal.IsPrivateChat() { + invite = append(invite, portal.bridge.Bot.UserID) + } + } + + resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{ + Visibility: "private", + Name: portal.Name, + Topic: portal.Topic, + Invite: invite, + Preset: "private_chat", + IsDirect: portal.IsPrivateChat(), + InitialState: initialState, + }) + if err != nil { + return err + } + portal.MXID = resp.RoomID + portal.Update() + portal.bridge.portalsLock.Lock() + portal.bridge.portalsByMXID[portal.MXID] = portal + portal.bridge.portalsLock.Unlock() + + // We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here. + for _, user := range invite { + portal.bridge.StateStore.SetMembership(portal.MXID, user, event.MembershipInvite) + } + + if metadata != nil { + portal.SyncParticipants(metadata) + // if metadata.Announce { + // portal.RestrictMessageSending(metadata.Announce) + // } + } else { + customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) + if customPuppet != nil && customPuppet.CustomIntent() != nil { + _ = customPuppet.CustomIntent().EnsureJoined(portal.MXID) + } + } + user.addPortalToCommunity(portal) + if portal.IsPrivateChat() { + puppet := user.bridge.GetPuppetByJID(portal.Key.JID) + user.addPuppetToCommunity(puppet) + + if portal.bridge.Config.Bridge.Encryption.Default { + err = portal.bridge.Bot.EnsureJoined(portal.MXID) + if err != nil { + portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err) + } + } + + user.UpdateDirectChats(map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}}) + } + err = portal.FillInitialHistory(user) + if err != nil { + portal.log.Errorln("Failed to fill history:", err) + } + return nil +} + +func (portal *Portal) IsPrivateChat() bool { + if portal.isPrivate == nil { + val := strings.HasSuffix(portal.Key.JID, whatsappExt.NewUserSuffix) + portal.isPrivate = &val + } + return *portal.isPrivate +} + +func (portal *Portal) HasRelaybot() bool { + if portal.bridge.Relaybot == nil { + return false + } else if portal.hasRelaybot == nil { + val := portal.bridge.Relaybot.IsInPortal(portal.Key) + portal.hasRelaybot = &val + } + return *portal.hasRelaybot +} + +func (portal *Portal) IsStatusBroadcastRoom() bool { + return portal.Key.JID == "status@broadcast" +} + +func (portal *Portal) MainIntent() *appservice.IntentAPI { + if portal.IsPrivateChat() { + return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent() + } + return portal.bridge.Bot +} + +func (portal *Portal) SetReply(content *event.MessageEventContent, info whatsapp.ContextInfo) { + if len(info.QuotedMessageID) == 0 { + return + } + message := portal.bridge.DB.Message.GetByJID(portal.Key, info.QuotedMessageID) + if message != nil { + evt, err := portal.MainIntent().GetEvent(portal.MXID, message.MXID) + if err != nil { + portal.log.Warnln("Failed to get reply target:", err) + return + } + if evt.Type == event.EventEncrypted { + _ = evt.Content.ParseRaw(evt.Type) + decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt) + if err != nil { + portal.log.Warnln("Failed to decrypt reply target:", err) + } else { + evt = decryptedEvt + } + } + _ = evt.Content.ParseRaw(evt.Type) + content.SetReply(evt) + } + return +} + +func (portal *Portal) HandleMessageRevoke(user *User, message whatsappExt.MessageRevocation) { + msg := portal.bridge.DB.Message.GetByJID(portal.Key, message.Id) + if msg == nil { + return + } + var intent *appservice.IntentAPI + if message.FromMe { + if portal.IsPrivateChat() { + intent = portal.bridge.GetPuppetByJID(user.JID).CustomIntent() + } else { + intent = portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal) + } + } else if len(message.Participant) > 0 { + intent = portal.bridge.GetPuppetByJID(message.Participant).IntentFor(portal) + } + if intent == nil { + intent = portal.MainIntent() + } + _, err := intent.RedactEvent(portal.MXID, msg.MXID) + if err != nil { + portal.log.Errorln("Failed to redact %s: %v", msg.JID, err) + return + } + msg.Delete() +} + +//func (portal *Portal) HandleFakeMessage(_ *User, message FakeMessage) { +// if portal.isRecentlyHandled(message.ID) { +// return +// } +// +// content := event.MessageEventContent{ +// MsgType: event.MsgNotice, +// Body: message.Text, +// } +// if message.Alert { +// content.MsgType = event.MsgText +// } +// _, err := portal.sendMainIntentMessage(content) +// if err != nil { +// portal.log.Errorfln("Failed to handle fake message %s: %v", message.ID, err) +// return +// } +// +// portal.recentlyHandledLock.Lock() +// index := portal.recentlyHandledIndex +// portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength +// portal.recentlyHandledLock.Unlock() +// portal.recentlyHandled[index] = message.ID +//} + +func (portal *Portal) sendMainIntentMessage(content interface{}) (*mautrix.RespSendEvent, error) { + return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, 0) +} + +const MessageSendRetries = 5 +const MediaUploadRetries = 5 +const BadGatewaySleep = 5 * time.Second + +func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { + return portal.sendMessageWithRetry(intent, eventType, content, timestamp, MessageSendRetries) +} +func (portal *Portal) sendReaction(intent *appservice.IntentAPI, eventID id.EventID, reaction string) (*mautrix.RespSendEvent, error) { + return portal.sendMessage(intent, event.EventReaction, &event.ReactionEventContent{ + RelatesTo: event.RelatesTo{ + EventID: eventID, + Type: event.RelAnnotation, + Key: reaction, + }, + }, time.Now().Unix()) +} + +func isGatewayError(err error) bool { + if err == nil { + return false + } + var httpErr mautrix.HTTPError + return errors.As(err, &httpErr) && (httpErr.IsStatus(http.StatusBadGateway) || httpErr.IsStatus(http.StatusGatewayTimeout)) +} + +func (portal *Portal) sendMessageWithRetry(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64, retries int) (*mautrix.RespSendEvent, error) { + for ; ; retries-- { + resp, err := portal.sendMessageDirect(intent, eventType, content, timestamp) + if retries > 0 && isGatewayError(err) { + portal.log.Warnfln("Got gateway error trying to send message, retrying in %d seconds", int(BadGatewaySleep.Seconds())) + time.Sleep(BadGatewaySleep) + + } else { + return resp, err + } + } +} + +func (portal *Portal) sendMessageDirect(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { + wrappedContent := event.Content{Parsed: content} + if timestamp != 0 && intent.IsCustomPuppet { + wrappedContent.Raw = map[string]interface{}{ + "net.maunium.whatsapp.puppet": intent.IsCustomPuppet, + } + } + if portal.Encrypted && portal.bridge.Crypto != nil { + encrypted, err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, wrappedContent) + if err != nil { + return nil, fmt.Errorf("failed to encrypt event: %w", err) + } + eventType = event.EventEncrypted + wrappedContent.Parsed = encrypted + } + if timestamp == 0 { + return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) + } else { + return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp*1000) //milliseconds + } +} + +func (portal *Portal) handleAttachment(intent *appservice.IntentAPI, attachment *groupme.Attachment, source *User, message *groupme.Message) (msg *event.MessageEventContent, sendText bool, err error) { + sendText = true + switch attachment.Type { + case "image": + imgData, mime, err := groupmeExt.DownloadImage(attachment.URL) + if err != nil { + return nil, true, fmt.Errorf("failed to load media info: %w", err) + } + + var width, height int + if strings.HasPrefix(mime, "image/") { + cfg, _, _ := image.DecodeConfig(bytes.NewReader(*imgData)) + width, height = cfg.Width, cfg.Height + } + data, uploadMimeType, file := portal.encryptFile(*imgData, mime) + + uploaded, err := portal.uploadWithRetry(intent, data, uploadMimeType, MediaUploadRetries) + if err != nil { + if errors.Is(err, mautrix.MTooLarge) { + err = errors.New("homeserver rejected too large file") + } else if httpErr := err.(mautrix.HTTPError); httpErr.IsStatus(413) { + err = errors.New("proxy rejected too large file") + } else { + err = fmt.Errorf("failed to upload media: %w", err) + } + return nil, true, err + } + attachmentUrl, _ := url.Parse(attachment.URL) + urlParts := strings.Split(attachmentUrl.Path, ".") + var fname1, fname2 string + if len(urlParts) == 2 { + fname1, fname2 = urlParts[1], urlParts[0] + } else if len(urlParts) > 2 { + fname1, fname2 = urlParts[2], urlParts[1] + } //TODO abstract groupme url parsing in groupmeExt + fname := fmt.Sprintf("%s.%s", fname1, fname2) + + content := &event.MessageEventContent{ + Body: fname, + File: file, + Info: &event.FileInfo{ + Size: len(data), + MimeType: mime, + Width: width, + Height: height, + //Duration: int(msg.length), + }, + } + if content.File != nil { + content.File.URL = uploaded.ContentURI.CUString() + } else { + content.URL = uploaded.ContentURI.CUString() + } + //TODO thumbnail since groupme supports it anyway + content.MsgType = event.MsgImage + + return content, true, nil + case "video": + vidContents, mime := groupmeExt.DownloadVideo(attachment.VideoPreviewURL, attachment.URL, source.Token) + if mime == "" { + mime = mimetype.Detect(vidContents).String() + } + + data, uploadMimeType, file := portal.encryptFile(vidContents, mime) + uploaded, err := portal.uploadWithRetry(intent, data, uploadMimeType, MediaUploadRetries) + if err != nil { + if errors.Is(err, mautrix.MTooLarge) { + err = errors.New("homeserver rejected too large file") + } else if httpErr := err.(mautrix.HTTPError); httpErr.IsStatus(413) { + err = errors.New("proxy rejected too large file") + } else { + err = fmt.Errorf("failed to upload media: %w", err) + } + return nil, true, err + } + + text := strings.Split(attachment.URL, "/") + content := &event.MessageEventContent{ + Body: text[len(text)-1], + File: file, + Info: &event.FileInfo{ + Size: len(data), + MimeType: mime, + //Width: width, + //Height: height, + //Duration: int(msg.length), + }, + } + if content.File != nil { + content.File.URL = uploaded.ContentURI.CUString() + } else { + content.URL = uploaded.ContentURI.CUString() + } + content.MsgType = event.MsgVideo + + message.Text = strings.Replace(message.Text, attachment.URL, "", 1) + return content, true, nil + case "file": + fileData, fname, fmime := groupmeExt.DownloadFile(portal.Key.JID, attachment.FileID, source.Token) + if fmime == "" { + fmime = mimetype.Detect(fileData).String() + } + data, uploadMimeType, file := portal.encryptFile(fileData, fmime) + + uploaded, err := portal.uploadWithRetry(intent, data, uploadMimeType, MediaUploadRetries) + if err != nil { + if errors.Is(err, mautrix.MTooLarge) { + err = errors.New("homeserver rejected too large file") + } else if httpErr := err.(mautrix.HTTPError); httpErr.IsStatus(413) { + err = errors.New("proxy rejected too large file") + } else { + err = fmt.Errorf("failed to upload media: %w", err) + } + return nil, true, err + } + + content := &event.MessageEventContent{ + Body: fname, + File: file, + Info: &event.FileInfo{ + Size: len(data), + MimeType: fmime, + //Width: width, + //Height: height, + //Duration: int(msg.length), + }, + } + if content.File != nil { + content.File.URL = uploaded.ContentURI.CUString() + } else { + content.URL = uploaded.ContentURI.CUString() + } + //TODO thumbnail since groupme supports it anyway + if strings.HasPrefix(fmime, "image") { + content.MsgType = event.MsgImage + } else if strings.HasPrefix(fmime, "video") { + content.MsgType = event.MsgVideo + } else { + content.MsgType = event.MsgFile + } + + return content, false, nil + case "location": + name := attachment.Name + lat, _ := strconv.ParseFloat(attachment.Latitude, 64) + lng, _ := strconv.ParseFloat(attachment.Longitude, 64) + latChar := 'N' + if lat < 0 { + latChar = 'S' + } + longChar := 'E' + if lng < 0 { + longChar = 'W' + } + formattedLoc := fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(lat), latChar, math.Abs(lng), longChar) + + content := &event.MessageEventContent{ + MsgType: event.MsgLocation, + Body: fmt.Sprintf("Location: %s\n%s", name, formattedLoc), //TODO link and stuff + GeoURI: fmt.Sprintf("geo:%.5f,%.5f", lat, lng), + } + + return content, false, nil + default: + portal.log.Warnln("Unable to handle groupme attachment type", attachment.Type) + return nil, true, fmt.Errorf("Unable to handle groupme attachment type %s", attachment.Type) + } + return nil, true, errors.New("Unknown type") +} +func (portal *Portal) HandleMediaMessage(source *User, msg mediaMessage) { + // intent := portal.startHandling(source, msg.info) + // if intent == nil { + // return + // } + // + // data, err := msg.download() + // if err == whatsapp.ErrMediaDownloadFailedWith404 || err == whatsapp.ErrMediaDownloadFailedWith410 { + // portal.log.Warnfln("Failed to download media for %s: %v. Calling LoadMediaInfo and retrying download...", msg.info.Id, err) + // _, err = source.Conn.LoadMediaInfo(msg.info.RemoteJid, msg.info.Id, msg.info.FromMe) + // if err != nil { + // portal.sendMediaBridgeFailure(source, intent, msg.info, fmt.Errorf("failed to load media info: %w", err)) + // return + // } + // data, err = msg.download() + // } + // if err == whatsapp.ErrNoURLPresent { + // portal.log.Debugfln("No URL present error for media message %s, ignoring...", msg.info.Id) + // return + // } else if err != nil { + // portal.sendMediaBridgeFailure(source, intent, msg.info, err) + // return + // } + // + // var width, height int + // if strings.HasPrefix(msg.mimeType, "image/") { + // cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) + // width, height = cfg.Width, cfg.Height + // } + // + // data, uploadMimeType, file := portal.encryptFile(data, msg.mimeType) + // + // uploaded, err := portal.uploadWithRetry(intent, data, uploadMimeType, MediaUploadRetries) + // if err != nil { + // if errors.Is(err, mautrix.MTooLarge) { + // portal.sendMediaBridgeFailure(source, intent, msg.info, errors.New("homeserver rejected too large file")) + // } else if httpErr := err.(mautrix.HTTPError); httpErr.IsStatus(413) { + // portal.sendMediaBridgeFailure(source, intent, msg.info, errors.New("proxy rejected too large file")) + // } else { + // portal.sendMediaBridgeFailure(source, intent, msg.info, fmt.Errorf("failed to upload media: %w", err)) + // } + // return + // } + // + // if msg.fileName == "" { + // mimeClass := strings.Split(msg.mimeType, "/")[0] + // switch mimeClass { + // case "application": + // msg.fileName = "file" + // default: + // msg.fileName = mimeClass + // } + // + // exts, _ := mime.ExtensionsByType(msg.mimeType) + // if exts != nil && len(exts) > 0 { + // msg.fileName += exts[0] + // } + // } + // + // content := &event.MessageEventContent{ + // Body: msg.fileName, + // File: file, + // Info: &event.FileInfo{ + // Size: len(data), + // MimeType: msg.mimeType, + // Width: width, + // Height: height, + // Duration: int(msg.length), + // }, + // } + // if content.File != nil { + // content.File.URL = uploaded.ContentURI.CUString() + // } else { + // content.URL = uploaded.ContentURI.CUString() + // } + // portal.SetReply(content, msg.context) + // + // if msg.thumbnail != nil && portal.bridge.Config.Bridge.WhatsappThumbnail { + // thumbnailMime := http.DetectContentType(msg.thumbnail) + // thumbnailCfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.thumbnail)) + // thumbnailSize := len(msg.thumbnail) + // thumbnail, thumbnailUploadMime, thumbnailFile := portal.encryptFile(msg.thumbnail, thumbnailMime) + // uploadedThumbnail, err := intent.UploadBytes(thumbnail, thumbnailUploadMime) + // if err != nil { + // portal.log.Warnfln("Failed to upload thumbnail for %s: %v", msg.info.Id, err) + // } else if uploadedThumbnail != nil { + // if thumbnailFile != nil { + // thumbnailFile.URL = uploadedThumbnail.ContentURI.CUString() + // content.Info.ThumbnailFile = thumbnailFile + // } else { + // content.Info.ThumbnailURL = uploadedThumbnail.ContentURI.CUString() + // } + // content.Info.ThumbnailInfo = &event.FileInfo{ + // Size: thumbnailSize, + // Width: thumbnailCfg.Width, + // Height: thumbnailCfg.Height, + // MimeType: thumbnailMime, + // } + // } + // } + // + // switch strings.ToLower(strings.Split(msg.mimeType, "/")[0]) { + // case "image": + // if !msg.sendAsSticker { + // content.MsgType = event.MsgImage + // } + // case "video": + // content.MsgType = event.MsgVideo + // case "audio": + // content.MsgType = event.MsgAudio + // default: + // content.MsgType = event.MsgFile + // } + // + // _, _ = intent.UserTyping(portal.MXID, false, 0) + // ts := int64(msg.info.Timestamp * 1000) + // eventType := event.EventMessage + // if msg.sendAsSticker { + // eventType = event.EventSticker + // } + // resp, err := portal.sendMessage(intent, eventType, content, ts) + // if err != nil { + // portal.log.Errorfln("Failed to handle message %s: %v", msg.info.Id, err) + // return + // } + // + // if len(msg.caption) > 0 { + // captionContent := &event.MessageEventContent{ + // Body: msg.caption, + // MsgType: event.MsgNotice, + // } + // + // portal.bridge.Formatter.ParseWhatsApp(captionContent, msg.context.MentionedJID) + // + // _, err := portal.sendMessage(intent, event.EventMessage, captionContent, ts) + // if err != nil { + // portal.log.Warnfln("Failed to handle caption of message %s: %v", msg.info.Id, err) + // } + // // TODO store caption mxid? + // } + // + // portal.finishHandling(source, msg.info.Source, resp.EventID) +} + +func (portal *Portal) HandleTextMessage(source *User, message *groupme.Message) { + intent := portal.startHandling(source, message) + if intent == nil { + return + } + + sendText := true + var sentID id.EventID + for _, a := range message.Attachments { + msg, text, err := portal.handleAttachment(intent, a, source, message) + + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", "TODOID", err) + portal.sendMediaBridgeFailure(source, intent, *message, err) + continue + } + if msg == nil { + continue + } + resp, err := portal.sendMessage(intent, event.EventMessage, msg, message.CreatedAt.ToTime().Unix()) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", "TODOID", err) + portal.sendMediaBridgeFailure(source, intent, *message, err) + continue + } + sentID = resp.EventID + + sendText = sendText && text + } + + // portal.bridge.Formatter.ParseWhatsApp(content, message.ContextInfo.MentionedJID) + // portal.SetReply(content, message.ContextInfo) + //TODO: mentions + content := &event.MessageEventContent{ + Body: message.Text, + MsgType: event.MsgText, + } + + _, _ = intent.UserTyping(portal.MXID, false, 0) + if sendText { + resp, err := portal.sendMessage(intent, event.EventMessage, content, message.CreatedAt.ToTime().Unix()) + if err != nil { + portal.log.Errorfln("Failed to handle message %s: %v", message.ID, err) + return + } + sentID = resp.EventID + + } + portal.finishHandling(source, message, sentID) +} + +func (portal *Portal) handleReaction(msgID types.GroupMeID, ppl []types.GroupMeID) { + reactions := portal.bridge.DB.Reaction.GetByJID(msgID) newLikes := newReactions(reactions, ppl) removeLikes := oldReactions(reactions, ppl)