Browse Source

Add support for bridging reactions

Tulir Asokan 3 years ago
parent
commit
1eb210c249

+ 21 - 2
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)
 

+ 2 - 0
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

+ 0 - 1
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"`

+ 0 - 1
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")

+ 9 - 4
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"),

+ 24 - 13
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)
 	}

+ 106 - 0
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 <https://www.gnu.org/licenses/>.
+
+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)
+}

+ 39 - 0
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
+	}}
+}

+ 1 - 1
database/upgrades/upgrades.go

@@ -40,7 +40,7 @@ type upgrade struct {
 	fn      upgradeFunc
 }
 
-const NumberOfUpgrades = 38
+const NumberOfUpgrades = 39
 
 var upgrades [NumberOfUpgrades]upgrade
 

+ 2 - 4
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)

+ 5 - 4
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))

+ 9 - 7
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)
 	}
 }
 

+ 143 - 15
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)

+ 1 - 1
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{

+ 1 - 1
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")