From 1eb210c2498ed2fa8d4b98f301903e5ff152b270 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 5 Mar 2022 21:22:31 +0200 Subject: [PATCH] Add support for bridging reactions --- CHANGELOG.md | 23 +++- ROADMAP.md | 2 + config/bridge.go | 1 - config/upgrade.go | 1 - database/database.go | 13 +- database/message.go | 37 +++-- database/reaction.go | 106 +++++++++++++++ database/upgrades/2022-03-05-reactions.go | 39 ++++++ database/upgrades/upgrades.go | 2 +- example-config.yaml | 6 +- historysync.go | 9 +- matrix.go | 16 ++- portal.go | 158 ++++++++++++++++++++-- provisioning.go | 2 +- user.go | 2 +- 15 files changed, 363 insertions(+), 54 deletions(-) create mode 100644 database/reaction.go create mode 100644 database/upgrades/2022-03-05-reactions.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e73e6..607403c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,31 @@ -# unreleased +# v0.3.0 (unreleased) +* Added reaction bridging in both directions. * Added automatic sending of hidden messages to primary device to prevent false-positive disconnection warnings if there have been no messages sent or received in >12 days. * Added proper error message when WhatsApp rejects the connection due to the bridge being out of date. -* Added experimental provisioning API to list contacts and start DMs. +* Added experimental provisioning API to list contacts/groups, start DMs and + open group portals. Note that these APIs are subject to change at any time. +* Added option to always send "active" delivery receipts (two gray ticks), even + if presence bridging is disabled. By default, WhatsApp web only sends those + receipts when it's in the foreground (i.e. showing online status). +* Added option to send online presence on typing notifications (thanks to + [@abmantis] in [#452]). This can be used to enable incoming typing + notifications without enabling Matrix presence (WhatsApp only sends typing + notifications if you're online). * Exposed maximum database connection idle time and lifetime options. +* Fixed syncing group topics. To get topics into existing portals on Matrix, + you can use `!wa sync groups`. +* Fixed sticker events on Matrix including a redundant `msgtype` field. +* Disabled file logging in Docker image by default. + * To enable it, mount a directory for the logs that's writable for the user + inside the container (1337 by default), then point the bridge at it using + the `logging` -> `directory` field, and finally set `file_name_format` to + something non-empty (the default is `{{.Date}}-{{.Index}}.log`). + +[#452]: https://github.com/mautrix/whatsapp/pull/452 # v0.2.4 (2022-02-16) diff --git a/ROADMAP.md b/ROADMAP.md index 18caacf..96f5483 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,6 +7,7 @@ * [x] Media/files * [x] Replies * [x] Message redactions + * [x] Reactions * [x] Presence * [x] Typing notifications * [x] Read receipts @@ -34,6 +35,7 @@ * [x] Status broadcast * [ ] Broadcast list (not currently supported on WhatsApp web) * [x] Message deletions + * [x] Reactions * [x] Avatars * [ ] Presence * [x] Typing notifications diff --git a/config/bridge.go b/config/bridge.go index bf4fad9..7ffe458 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -38,7 +38,6 @@ type BridgeConfig struct { PortalMessageBuffer int `yaml:"portal_message_buffer"` CallStartNotices bool `yaml:"call_start_notices"` IdentityChangeNotices bool `yaml:"identity_change_notices"` - ReactionNotices bool `yaml:"reaction_notices"` HistorySync struct { CreatePortals bool `yaml:"create_portals"` diff --git a/config/upgrade.go b/config/upgrade.go index da4b6bf..0381ef2 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -75,7 +75,6 @@ func (helper *UpgradeHelper) doUpgrade() { helper.Copy(Int, "bridge", "portal_message_buffer") helper.Copy(Bool, "bridge", "call_start_notices") helper.Copy(Bool, "bridge", "identity_change_notices") - helper.Copy(Bool, "bridge", "reaction_notices") helper.Copy(Bool, "bridge", "history_sync", "create_portals") helper.Copy(Int, "bridge", "history_sync", "max_age") helper.Copy(Bool, "bridge", "history_sync", "backfill") diff --git a/database/database.go b/database/database.go index 400bdf3..67c0912 100644 --- a/database/database.go +++ b/database/database.go @@ -40,10 +40,11 @@ type Database struct { log log.Logger dialect string - User *UserQuery - Portal *PortalQuery - Puppet *PuppetQuery - Message *MessageQuery + User *UserQuery + Portal *PortalQuery + Puppet *PuppetQuery + Message *MessageQuery + Reaction *ReactionQuery DisappearingMessage *DisappearingMessageQuery } @@ -75,6 +76,10 @@ func New(cfg config.DatabaseConfig, baseLog log.Logger) (*Database, error) { db: db, log: db.log.Sub("Message"), } + db.Reaction = &ReactionQuery{ + db: db, + log: db.log.Sub("Reaction"), + } db.DisappearingMessage = &DisappearingMessageQuery{ db: db, log: db.log.Sub("DisappearingMessage"), diff --git a/database/message.go b/database/message.go index a73b4a1..e782d9f 100644 --- a/database/message.go +++ b/database/message.go @@ -43,27 +43,27 @@ func (mq *MessageQuery) New() *Message { const ( getAllMessagesQuery = ` - SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid FROM message + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid FROM message WHERE chat_jid=$1 AND chat_receiver=$2 ` getMessageByJIDQuery = ` - SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid FROM message + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3 ` getMessageByMXIDQuery = ` - SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid FROM message + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid FROM message WHERE mxid=$1 ` getLastMessageInChatQuery = ` - SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid FROM message + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1 ` getFirstMessageInChatQuery = ` - SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid FROM message + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND sent=true ORDER BY timestamp ASC LIMIT 1 ` getMessagesBetweenQuery = ` - SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid FROM message + SELECT chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid FROM message WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp>$3 AND timestamp<=$4 AND sent=true ORDER BY timestamp ASC ` ) @@ -130,6 +130,15 @@ const ( MsgErrMediaNotFound MessageErrorType = "media_not_found" ) +type MessageType string + +const ( + MsgUnknown MessageType = "" + MsgFake MessageType = "fake" + MsgNormal MessageType = "message" + MsgReaction MessageType = "reaction" +) + type Message struct { db *Database log log.Logger @@ -140,8 +149,9 @@ type Message struct { Sender types.JID Timestamp time.Time Sent bool + Type MessageType + Error MessageErrorType - Error MessageErrorType BroadcastListJID types.JID } @@ -155,7 +165,7 @@ func (msg *Message) IsFakeJID() bool { func (msg *Message) Scan(row Scannable) *Message { var ts int64 - err := row.Scan(&msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &ts, &msg.Sent, &msg.Error, &msg.BroadcastListJID) + err := row.Scan(&msg.Chat.JID, &msg.Chat.Receiver, &msg.JID, &msg.MXID, &msg.Sender, &ts, &msg.Sent, &msg.Type, &msg.Error, &msg.BroadcastListJID) if err != nil { if !errors.Is(err, sql.ErrNoRows) { msg.log.Errorln("Database scan failed:", err) @@ -175,9 +185,9 @@ func (msg *Message) Insert() { sender = "" } _, err := msg.db.Exec(`INSERT INTO message - (chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, sender, msg.Timestamp.Unix(), msg.Sent, msg.Error, msg.BroadcastListJID) + (chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + msg.Chat.JID, msg.Chat.Receiver, msg.JID, msg.MXID, sender, msg.Timestamp.Unix(), msg.Sent, msg.Type, msg.Error, msg.BroadcastListJID) if err != nil { msg.log.Warnfln("Failed to insert %s@%s: %v", msg.Chat, msg.JID, err) } @@ -192,10 +202,11 @@ func (msg *Message) MarkSent(ts time.Time) { } } -func (msg *Message) UpdateMXID(mxid id.EventID, newError MessageErrorType) { +func (msg *Message) UpdateMXID(mxid id.EventID, newType MessageType, newError MessageErrorType) { msg.MXID = mxid + msg.Type = newType msg.Error = newError - _, err := msg.db.Exec("UPDATE message SET mxid=$1, error=$2 WHERE chat_jid=$3 AND chat_receiver=$4 AND jid=$5", mxid, newError, msg.Chat.JID, msg.Chat.Receiver, msg.JID) + _, err := msg.db.Exec("UPDATE message SET mxid=$1, type=$2, error=$3 WHERE chat_jid=$4 AND chat_receiver=$5 AND jid=$6", mxid, newType, newError, msg.Chat.JID, msg.Chat.Receiver, msg.JID) if err != nil { msg.log.Warnfln("Failed to update %s@%s: %v", msg.Chat, msg.JID, err) } diff --git a/database/reaction.go b/database/reaction.go new file mode 100644 index 0000000..497b0dd --- /dev/null +++ b/database/reaction.go @@ -0,0 +1,106 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2022 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package database + +import ( + "database/sql" + "errors" + + log "maunium.net/go/maulogger/v2" + + "maunium.net/go/mautrix/id" + + "go.mau.fi/whatsmeow/types" +) + +type ReactionQuery struct { + db *Database + log log.Logger +} + +func (rq *ReactionQuery) New() *Reaction { + return &Reaction{ + db: rq.db, + log: rq.log, + } +} + +const ( + getReactionByTargetJIDQuery = ` + SELECT chat_jid, chat_receiver, target_jid, sender, mxid, jid FROM reaction + WHERE chat_jid=$1 AND chat_receiver=$2 AND target_jid=$3 AND sender=$4 + ` + getReactionByMXIDQuery = ` + SELECT chat_jid, chat_receiver, target_jid, sender, mxid, jid FROM reaction + WHERE mxid=$1 + ` + upsertReactionQuery = ` + INSERT INTO reaction (chat_jid, chat_receiver, target_jid, sender, mxid, jid) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (chat_jid, chat_receiver, target_jid, sender) + DO UPDATE SET mxid=excluded.mxid, jid=excluded.jid + ` +) + +func (rq *ReactionQuery) GetByTargetJID(chat PortalKey, jid types.MessageID, sender types.JID) *Reaction { + return rq.maybeScan(rq.db.QueryRow(getReactionByTargetJIDQuery, chat.JID, chat.Receiver, jid, sender.ToNonAD())) +} + +func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction { + return rq.maybeScan(rq.db.QueryRow(getReactionByMXIDQuery, mxid)) +} + +func (rq *ReactionQuery) maybeScan(row *sql.Row) *Reaction { + if row == nil { + return nil + } + return rq.New().Scan(row) +} + +type Reaction struct { + db *Database + log log.Logger + + Chat PortalKey + TargetJID types.MessageID + Sender types.JID + MXID id.EventID + JID types.MessageID +} + +func (reaction *Reaction) Scan(row Scannable) *Reaction { + err := row.Scan(&reaction.Chat.JID, &reaction.Chat.Receiver, &reaction.TargetJID, &reaction.Sender, &reaction.MXID, &reaction.JID) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + reaction.log.Errorln("Database scan failed:", err) + } + return nil + } + return reaction +} + +func (reaction *Reaction) Upsert() { + reaction.Sender = reaction.Sender.ToNonAD() + _, err := reaction.db.Exec(upsertReactionQuery, reaction.Chat.JID, reaction.Chat.Receiver, reaction.TargetJID, reaction.Sender, reaction.MXID, reaction.JID) + if err != nil { + reaction.log.Warnfln("Failed to upsert reaction to %s@%s by %s: %v", reaction.Chat, reaction.TargetJID, reaction.Sender, err) + } +} + +func (reaction *Reaction) GetTarget() *Message { + return reaction.db.Message.GetByJID(reaction.Chat, reaction.TargetJID) +} diff --git a/database/upgrades/2022-03-05-reactions.go b/database/upgrades/2022-03-05-reactions.go new file mode 100644 index 0000000..2e13e04 --- /dev/null +++ b/database/upgrades/2022-03-05-reactions.go @@ -0,0 +1,39 @@ +package upgrades + +import "database/sql" + +func init() { + upgrades[38] = upgrade{"Add support for reactions", func(tx *sql.Tx, ctx context) error { + _, err := tx.Exec(`ALTER TABLE message ADD COLUMN type TEXT NOT NULL DEFAULT 'message'`) + if err != nil { + return err + } + if ctx.dialect == Postgres { + _, err = tx.Exec("ALTER TABLE message ALTER COLUMN type DROP DEFAULT") + if err != nil { + return err + } + } + _, err = tx.Exec("UPDATE message SET type='' WHERE error='decryption_failed'") + if err != nil { + return err + } + _, err = tx.Exec("UPDATE message SET type='fake' WHERE jid LIKE 'FAKE::%' OR mxid LIKE 'net.maunium.whatsapp.fake::%' OR jid=mxid") + if err != nil { + return err + } + _, err = tx.Exec(`CREATE TABLE reaction ( + chat_jid TEXT, + chat_receiver TEXT, + target_jid TEXT, + sender TEXT, + mxid TEXT NOT NULL, + jid TEXT NOT NULL, + PRIMARY KEY (chat_jid, chat_receiver, target_jid, sender), + CONSTRAINT target_message_fkey FOREIGN KEY (chat_jid, chat_receiver, target_jid) + REFERENCES message(chat_jid, chat_receiver, jid) + ON DELETE CASCADE ON UPDATE CASCADE + )`) + return err + }} +} diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go index c64df4d..d32639c 100644 --- a/database/upgrades/upgrades.go +++ b/database/upgrades/upgrades.go @@ -40,7 +40,7 @@ type upgrade struct { fn upgradeFunc } -const NumberOfUpgrades = 38 +const NumberOfUpgrades = 39 var upgrades [NumberOfUpgrades]upgrade diff --git a/example-config.yaml b/example-config.yaml index 34fa68f..a100037 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -107,8 +107,6 @@ bridge: call_start_notices: true # Should another user's cryptographic identity changing send a message to Matrix? identity_change_notices: false - # Should a "reactions not yet supported" warning be sent to the Matrix room when a user reacts to a message? - reaction_notices: true portal_message_buffer: 128 # Settings for handling history sync payloads. These settings only apply right after login, # because the phone only sends the history sync data once, and there's no way to re-request it @@ -147,8 +145,8 @@ bridge: # Existing users won't be affected when these are changed. default_bridge_receipts: true default_bridge_presence: true - # Send the presence as "available" to whatsapp when users start typing on a portal. - # This works as a workaround for homeservers that do not support presence, and allows + # Send the presence as "available" to whatsapp when users start typing on a portal. + # This works as a workaround for homeservers that do not support presence, and allows # users to see when the whatsapp user on the other side is typing during a conversation. send_presence_on_typing: false # Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp) diff --git a/historysync.go b/historysync.go index 4cc2c6c..5ddd9e1 100644 --- a/historysync.go +++ b/historysync.go @@ -48,6 +48,7 @@ type portalToBackfill struct { type wrappedInfo struct { *types.MessageInfo + Type database.MessageType Error database.MessageErrorType } @@ -503,10 +504,10 @@ func (portal *Portal) appendBatchEvents(converted *ConvertedMessage, info *types return err } *eventsArray = append(*eventsArray, mainEvt, captionEvt) - *infoArray = append(*infoArray, &wrappedInfo{info, converted.Error}, nil) + *infoArray = append(*infoArray, &wrappedInfo{info, database.MsgNormal, converted.Error}, nil) } else { *eventsArray = append(*eventsArray, mainEvt) - *infoArray = append(*infoArray, &wrappedInfo{info, converted.Error}) + *infoArray = append(*infoArray, &wrappedInfo{info, database.MsgNormal, converted.Error}) } if converted.MultiEvent != nil { for _, subEvtContent := range converted.MultiEvent { @@ -562,13 +563,13 @@ func (portal *Portal) finishBatch(eventIDs []id.EventID, infos []*wrappedInfo) { } else if info, ok := infoMap[types.MessageID(msgID)]; !ok { portal.log.Warnfln("Didn't find info of message %s (event %s) to register it in the database", msgID, eventID) } else { - portal.markHandled(nil, info.MessageInfo, eventID, true, false, info.Error) + portal.markHandled(nil, info.MessageInfo, eventID, true, false, info.Type, info.Error) } } } else { for i := 0; i < len(infos); i++ { if infos[i] != nil { - portal.markHandled(nil, infos[i].MessageInfo, eventIDs[i], true, false, infos[i].Error) + portal.markHandled(nil, infos[i].MessageInfo, eventIDs[i], true, false, infos[i].Type, infos[i].Error) } } portal.log.Infofln("Successfully sent %d events", len(eventIDs)) diff --git a/matrix.go b/matrix.go index 2c9fdec..e8c9657 100644 --- a/matrix.go +++ b/matrix.go @@ -471,22 +471,24 @@ func (mx *MatrixHandler) HandleReaction(evt *event.Event) { } user := mx.bridge.GetUserByMXID(evt.Sender) - if user == nil || !user.RelayWhitelisted { + if user == nil || !user.Whitelisted || !user.IsLoggedIn() { return } portal := mx.bridge.GetPortalByMXID(evt.RoomID) - if portal == nil || (!user.Whitelisted && !portal.HasRelaybot()) { + if portal == nil { return } + content := evt.Content.AsReaction() if content.RelatesTo.Key == "click to retry" || strings.HasPrefix(content.RelatesTo.Key, "\u267b") { // ♻️ portal.requestMediaRetry(user, content.RelatesTo.EventID) - } else if mx.bridge.Config.Bridge.ReactionNotices { - _, _ = portal.sendMainIntentMessage(&event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("\u26a0 Reactions are not yet supported by WhatsApp."), - }) + } else { + if portal.IsPrivateChat() && user.JID.User != portal.Key.Receiver.User { + // One user can only react once, so we don't use the relay user for reactions + return + } + portal.HandleMatrixReaction(user, evt) } } diff --git a/portal.go b/portal.go index 6adefe2..86227b9 100644 --- a/portal.go +++ b/portal.go @@ -554,7 +554,7 @@ func (portal *Portal) handleUndecryptableMessage(source *User, evt *events.Undec if err != nil { portal.log.Errorln("Failed to send decryption error of %s to Matrix: %v", evt.Info.ID, err) } - portal.finishHandling(nil, &evt.Info, resp.EventID, database.MsgErrDecryptionFailed) + portal.finishHandling(nil, &evt.Info, resp.EventID, database.MsgUnknown, database.MsgErrDecryptionFailed) } func (portal *Portal) handleFakeMessage(msg fakeMessage) { @@ -587,7 +587,7 @@ func (portal *Portal) handleFakeMessage(msg fakeMessage) { MessageSource: types.MessageSource{ Sender: msg.Sender, }, - }, resp.EventID, database.MsgNoError) + }, resp.EventID, database.MsgFake, database.MsgNoError) } } @@ -662,15 +662,17 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) { } } if len(eventID) != 0 { - portal.finishHandling(existingMsg, &evt.Info, eventID, converted.Error) + portal.finishHandling(existingMsg, &evt.Info, eventID, database.MsgNormal, converted.Error) } + } else if msgType == "reaction" { + portal.HandleMessageReaction(intent, source, &evt.Info, evt.Message.GetReactionMessage(), existingMsg) } else if msgType == "revoke" { portal.HandleMessageRevoke(source, &evt.Info, evt.Message.GetProtocolMessage().GetKey()) if existingMsg != nil { _, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ Reason: "The undecryptable message was actually the deletion of another message", }) - existingMsg.UpdateMXID("net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgNoError) + existingMsg.UpdateMXID("net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgFake, database.MsgNoError) } } else { portal.log.Warnfln("Unhandled message: %+v (%s)", evt.Info, msgType) @@ -678,7 +680,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) { _, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ Reason: "The undecryptable message contained an unsupported message type", }) - existingMsg.UpdateMXID("net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgNoError) + existingMsg.UpdateMXID("net.maunium.whatsapp.fake::"+existingMsg.MXID, database.MsgFake, database.MsgNoError) } return } @@ -696,7 +698,7 @@ func (portal *Portal) isRecentlyHandled(id types.MessageID, error database.Messa return false } -func (portal *Portal) markHandled(msg *database.Message, info *types.MessageInfo, mxid id.EventID, isSent, recent bool, error database.MessageErrorType) *database.Message { +func (portal *Portal) markHandled(msg *database.Message, info *types.MessageInfo, mxid id.EventID, isSent, recent bool, msgType database.MessageType, error database.MessageErrorType) *database.Message { if msg == nil { msg = portal.bridge.DB.Message.New() msg.Chat = portal.Key @@ -705,13 +707,14 @@ func (portal *Portal) markHandled(msg *database.Message, info *types.MessageInfo msg.Timestamp = info.Timestamp msg.Sender = info.Sender msg.Sent = isSent + msg.Type = msgType msg.Error = error if info.IsIncomingBroadcast() { msg.BroadcastListJID = info.Chat } msg.Insert() } else { - msg.UpdateMXID(mxid, error) + msg.UpdateMXID(mxid, msgType, error) } if recent { @@ -740,8 +743,8 @@ func (portal *Portal) getMessageIntent(user *User, info *types.MessageInfo) *app return portal.getMessagePuppet(user, info).IntentFor(portal) } -func (portal *Portal) finishHandling(existing *database.Message, message *types.MessageInfo, mxid id.EventID, error database.MessageErrorType) { - portal.markHandled(existing, message, mxid, true, true, error) +func (portal *Portal) finishHandling(existing *database.Message, message *types.MessageInfo, mxid id.EventID, msgType database.MessageType, error database.MessageErrorType) { + portal.markHandled(existing, message, mxid, true, true, msgType, error) portal.sendDeliveryReceipt(mxid) var suffix string if error == database.MsgErrDecryptionFailed { @@ -749,7 +752,7 @@ func (portal *Portal) finishHandling(existing *database.Message, message *types. } else if error == database.MsgErrMediaNotFound { suffix = "(media not found notice)" } - portal.log.Debugln("Handled message", message.ID, "->", mxid, suffix) + portal.log.Debugfln("Handled message %s (%s) -> %s %s", message.ID, msgType, mxid, suffix) } func (portal *Portal) kickExtraUsers(participantMap map[types.JID]bool) { @@ -1417,6 +1420,43 @@ func (portal *Portal) SetReply(content *event.MessageEventContent, replyToID typ return true } +type sendReactionContent struct { + event.ReactionEventContent + DoublePuppet string `json:"fi.mau.double_puppet_source,omitempty"` +} + +func (portal *Portal) HandleMessageReaction(intent *appservice.IntentAPI, user *User, info *types.MessageInfo, reaction *waProto.ReactionMessage, existingMsg *database.Message) { + if existingMsg != nil { + _, _ = intent.RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ + Reason: "The undecryptable message was actually a reaction", + }) + } + + target := portal.bridge.DB.Message.GetByJID(portal.Key, reaction.GetKey().GetId()) + if target == nil { + portal.log.Debugfln("Dropping reaction %s from %s to unknown message %s", info.ID, info.Sender, reaction.GetKey().GetId()) + return + } + + var content sendReactionContent + content.RelatesTo = event.RelatesTo{ + Type: event.RelAnnotation, + EventID: target.MXID, + Key: reaction.GetText(), + } + if intent.IsCustomPuppet { + content.DoublePuppet = doublePuppetValue + } + resp, err := intent.SendMassagedMessageEvent(portal.MXID, event.EventReaction, &content, info.Timestamp.UnixMilli()) + if err != nil { + portal.log.Errorfln("Failed to bridge reaction %s from %s to %s: %v", info.ID, info.Sender, target.JID, err) + return + } + + portal.finishHandling(existingMsg, info, resp.EventID, database.MsgReaction, database.MsgNoError) + portal.upsertReaction(intent, target.JID, info.Sender, resp.EventID, info.ID) +} + func (portal *Portal) HandleMessageRevoke(user *User, info *types.MessageInfo, key *waProto.MessageKey) bool { msg := portal.bridge.DB.Message.GetByJID(portal.Key, key.GetId()) if msg == nil || msg.IsFakeMXID() { @@ -2194,7 +2234,7 @@ func (portal *Portal) handleMediaRetry(retry *events.MediaRetry, source *User) { return } portal.log.Debugfln("Successfully edited %s -> %s after retry notification for %s", msg.MXID, resp.EventID, retry.MessageID) - msg.UpdateMXID(resp.EventID, database.MsgNoError) + msg.UpdateMXID(resp.EventID, database.MsgNormal, database.MsgNoError) } func (portal *Portal) requestMediaRetry(user *User, eventID id.EventID) { @@ -2466,7 +2506,7 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP replyToID := content.GetReplyTo() if len(replyToID) > 0 { replyToMsg := portal.bridge.DB.Message.GetByMXID(replyToID) - if replyToMsg != nil && !replyToMsg.IsFakeJID() { + if replyToMsg != nil && !replyToMsg.IsFakeJID() && replyToMsg.Type == database.MsgNormal { ctxInfo.StanzaId = &replyToMsg.JID ctxInfo.Participant = proto.String(replyToMsg.Sender.ToNonAD().String()) // Using blank content here seems to work fine on all official WhatsApp apps. @@ -2664,7 +2704,7 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) { } portal.MarkDisappearing(evt.ID, portal.ExpirationTime, true) info := portal.generateMessageInfo(sender) - dbMsg := portal.markHandled(nil, info, evt.ID, false, true, database.MsgNoError) + dbMsg := portal.markHandled(nil, info, evt.ID, false, true, database.MsgNormal, database.MsgNoError) portal.log.Debugln("Sending event", evt.ID, "to WhatsApp", info.ID) ts, err := sender.Client.SendMessage(portal.Key.JID, info.ID, msg) if err != nil { @@ -2685,6 +2725,78 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) { } } +func (portal *Portal) HandleMatrixReaction(sender *User, evt *event.Event) { + // TODO checkpoints + portal.log.Debugfln("Received reaction event %s from %s", evt.ID, evt.Sender) + content, ok := evt.Content.Parsed.(*event.ReactionEventContent) + if !ok { + portal.log.Debugfln("Failed to handle reaction event %s: unexpected parsed content type %T", evt.ID, evt.Content.Parsed) + return + } + target := portal.bridge.DB.Message.GetByMXID(content.RelatesTo.EventID) + if target == nil || target.Type == database.MsgReaction { + portal.log.Debugfln("Dropping reaction to unknown event %s", content.RelatesTo.EventID) + return + } + info := portal.generateMessageInfo(sender) + dbMsg := portal.markHandled(nil, info, evt.ID, false, true, database.MsgReaction, database.MsgNoError) + portal.upsertReaction(nil, target.JID, sender.JID, evt.ID, info.ID) + portal.log.Debugln("Sending reaction", evt.ID, "to WhatsApp", info.ID) + ts, err := portal.sendReactionToWhatsApp(sender, info.ID, target, content.RelatesTo.Key, evt.Timestamp) + if err != nil { + portal.log.Errorfln("Error sending reaction: %v", err) + } else { + portal.log.Debugfln("Handled Matrix reaction %s", evt.ID) + portal.sendDeliveryReceipt(evt.ID) + dbMsg.MarkSent(ts) + } +} + +func (portal *Portal) sendReactionToWhatsApp(sender *User, id types.MessageID, target *database.Message, key string, timestamp int64) (time.Time, error) { + var messageKeyParticipant *string + if !portal.IsPrivateChat() { + messageKeyParticipant = proto.String(target.Sender.ToNonAD().String()) + } + return sender.Client.SendMessage(portal.Key.JID, id, &waProto.Message{ + ReactionMessage: &waProto.ReactionMessage{ + Key: &waProto.MessageKey{ + RemoteJid: proto.String(portal.Key.JID.String()), + FromMe: proto.Bool(target.Sender.User == sender.JID.User), + Id: proto.String(target.JID), + Participant: messageKeyParticipant, + }, + Text: proto.String(key), + GroupingKey: proto.String(key), // TODO is this correct? + SenderTimestampMs: proto.Int64(timestamp), + }, + }) +} + +func (portal *Portal) upsertReaction(intent *appservice.IntentAPI, targetJID types.MessageID, senderJID types.JID, mxid id.EventID, jid types.MessageID) { + dbReaction := portal.bridge.DB.Reaction.GetByTargetJID(portal.Key, targetJID, senderJID) + if dbReaction == nil { + dbReaction = portal.bridge.DB.Reaction.New() + dbReaction.Chat = portal.Key + dbReaction.TargetJID = targetJID + dbReaction.Sender = senderJID + } else { + portal.log.Debugfln("Redacting old Matrix reaction %s after new one (%s) was sent", dbReaction.MXID, mxid) + var err error + if intent != nil { + _, err = intent.RedactEvent(portal.MXID, dbReaction.MXID) + } + if intent == nil || errors.Is(err, mautrix.MForbidden) { + _, err = portal.MainIntent().RedactEvent(portal.MXID, dbReaction.MXID) + } + if err != nil { + portal.log.Warnfln("Failed to remove old reaction %s: %v", dbReaction.MXID, err) + } + } + dbReaction.MXID = mxid + dbReaction.JID = jid + dbReaction.Upsert() +} + func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) { if !portal.canBridgeFrom(sender, "redaction") { return @@ -2712,8 +2824,24 @@ func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) { return } - portal.log.Debugfln("Sending redaction %s of %s/%s to WhatsApp", evt.ID, msg.MXID, msg.JID) - _, err := sender.Client.RevokeMessage(portal.Key.JID, msg.JID) + var err error + if msg.Type == database.MsgReaction { + if reaction := portal.bridge.DB.Reaction.GetByMXID(evt.Redacts); reaction == nil { + portal.log.Debugfln("Ignoring redaction of reaction %s: reaction database entry not found", evt.ID) + portal.bridge.AS.SendErrorMessageSendCheckpoint(evt, appservice.StepRemote, errors.New("reaction database entry not found"), true, 0) + return + } else if reactionTarget := reaction.GetTarget(); reactionTarget == nil { + portal.log.Debugfln("Ignoring redaction of reaction %s: reaction target message not found", evt.ID) + portal.bridge.AS.SendErrorMessageSendCheckpoint(evt, appservice.StepRemote, errors.New("reaction target message not found"), true, 0) + return + } else { + portal.log.Debugfln("Sending redaction reaction %s of %s/%s to WhatsApp", evt.ID, msg.MXID, msg.JID) + _, err = portal.sendReactionToWhatsApp(sender, "", reactionTarget, "", evt.Timestamp) + } + } else { + portal.log.Debugfln("Sending redaction %s of %s/%s to WhatsApp", evt.ID, msg.MXID, msg.JID) + _, err = sender.Client.RevokeMessage(portal.Key.JID, msg.JID) + } if err != nil { portal.log.Errorfln("Error handling Matrix redaction %s: %v", evt.ID, err) portal.bridge.AS.SendErrorMessageSendCheckpoint(evt, appservice.StepRemote, err, true, 0) diff --git a/provisioning.go b/provisioning.go index 84a02c3..ac40ffd 100644 --- a/provisioning.go +++ b/provisioning.go @@ -475,7 +475,7 @@ func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) { qrChan, err := user.Login(ctx) if err != nil { - user.log.Errorf("Failed to log in from provisioning API:", err) + user.log.Errorln("Failed to log in from provisioning API:", err) if errors.Is(err, ErrAlreadyLoggedIn) { go user.Connect() _ = c.WriteJSON(Error{ diff --git a/user.go b/user.go index fe25da7..8965159 100644 --- a/user.go +++ b/user.go @@ -626,7 +626,7 @@ func (user *User) HandleEvent(event interface{}) { go user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Message: v.String()}) user.bridge.Metrics.TrackConnectionState(user.JID, false) case *events.Disconnected: - go user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect}) + go user.sendBridgeState(BridgeState{StateEvent: StateTransientDisconnect, Message: "Disconnected from WhatsApp. Trying to reconnect."}) user.bridge.Metrics.TrackConnectionState(user.JID, false) case *events.Contact: go user.syncPuppet(v.JID, "contact event")