Add support for bridging reactions

This commit is contained in:
Tulir Asokan 2022-03-05 21:22:31 +02:00
parent 601864131e
commit 1eb210c249
15 changed files with 363 additions and 54 deletions

View File

@ -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 * Added automatic sending of hidden messages to primary device to prevent
false-positive disconnection warnings if there have been no messages sent or false-positive disconnection warnings if there have been no messages sent or
received in >12 days. received in >12 days.
* Added proper error message when WhatsApp rejects the connection due to the * Added proper error message when WhatsApp rejects the connection due to the
bridge being out of date. 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. * 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) # v0.2.4 (2022-02-16)

View File

@ -7,6 +7,7 @@
* [x] Media/files * [x] Media/files
* [x] Replies * [x] Replies
* [x] Message redactions * [x] Message redactions
* [x] Reactions
* [x] Presence * [x] Presence
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts * [x] Read receipts
@ -34,6 +35,7 @@
* [x] Status broadcast * [x] Status broadcast
* [ ] Broadcast list (not currently supported on WhatsApp web) * [ ] Broadcast list (not currently supported on WhatsApp web)
* [x] Message deletions * [x] Message deletions
* [x] Reactions
* [x] Avatars * [x] Avatars
* [ ] Presence * [ ] Presence
* [x] Typing notifications * [x] Typing notifications

View File

@ -38,7 +38,6 @@ type BridgeConfig struct {
PortalMessageBuffer int `yaml:"portal_message_buffer"` PortalMessageBuffer int `yaml:"portal_message_buffer"`
CallStartNotices bool `yaml:"call_start_notices"` CallStartNotices bool `yaml:"call_start_notices"`
IdentityChangeNotices bool `yaml:"identity_change_notices"` IdentityChangeNotices bool `yaml:"identity_change_notices"`
ReactionNotices bool `yaml:"reaction_notices"`
HistorySync struct { HistorySync struct {
CreatePortals bool `yaml:"create_portals"` CreatePortals bool `yaml:"create_portals"`

View File

@ -75,7 +75,6 @@ func (helper *UpgradeHelper) doUpgrade() {
helper.Copy(Int, "bridge", "portal_message_buffer") helper.Copy(Int, "bridge", "portal_message_buffer")
helper.Copy(Bool, "bridge", "call_start_notices") helper.Copy(Bool, "bridge", "call_start_notices")
helper.Copy(Bool, "bridge", "identity_change_notices") helper.Copy(Bool, "bridge", "identity_change_notices")
helper.Copy(Bool, "bridge", "reaction_notices")
helper.Copy(Bool, "bridge", "history_sync", "create_portals") helper.Copy(Bool, "bridge", "history_sync", "create_portals")
helper.Copy(Int, "bridge", "history_sync", "max_age") helper.Copy(Int, "bridge", "history_sync", "max_age")
helper.Copy(Bool, "bridge", "history_sync", "backfill") helper.Copy(Bool, "bridge", "history_sync", "backfill")

View File

@ -40,10 +40,11 @@ type Database struct {
log log.Logger log log.Logger
dialect string dialect string
User *UserQuery User *UserQuery
Portal *PortalQuery Portal *PortalQuery
Puppet *PuppetQuery Puppet *PuppetQuery
Message *MessageQuery Message *MessageQuery
Reaction *ReactionQuery
DisappearingMessage *DisappearingMessageQuery DisappearingMessage *DisappearingMessageQuery
} }
@ -75,6 +76,10 @@ func New(cfg config.DatabaseConfig, baseLog log.Logger) (*Database, error) {
db: db, db: db,
log: db.log.Sub("Message"), log: db.log.Sub("Message"),
} }
db.Reaction = &ReactionQuery{
db: db,
log: db.log.Sub("Reaction"),
}
db.DisappearingMessage = &DisappearingMessageQuery{ db.DisappearingMessage = &DisappearingMessageQuery{
db: db, db: db,
log: db.log.Sub("DisappearingMessage"), log: db.log.Sub("DisappearingMessage"),

View File

@ -43,27 +43,27 @@ func (mq *MessageQuery) New() *Message {
const ( const (
getAllMessagesQuery = ` 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 WHERE chat_jid=$1 AND chat_receiver=$2
` `
getMessageByJIDQuery = ` 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 WHERE chat_jid=$1 AND chat_receiver=$2 AND jid=$3
` `
getMessageByMXIDQuery = ` 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 WHERE mxid=$1
` `
getLastMessageInChatQuery = ` 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 WHERE chat_jid=$1 AND chat_receiver=$2 AND timestamp<=$3 AND sent=true ORDER BY timestamp DESC LIMIT 1
` `
getFirstMessageInChatQuery = ` 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 WHERE chat_jid=$1 AND chat_receiver=$2 AND sent=true ORDER BY timestamp ASC LIMIT 1
` `
getMessagesBetweenQuery = ` 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 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" MsgErrMediaNotFound MessageErrorType = "media_not_found"
) )
type MessageType string
const (
MsgUnknown MessageType = ""
MsgFake MessageType = "fake"
MsgNormal MessageType = "message"
MsgReaction MessageType = "reaction"
)
type Message struct { type Message struct {
db *Database db *Database
log log.Logger log log.Logger
@ -140,8 +149,9 @@ type Message struct {
Sender types.JID Sender types.JID
Timestamp time.Time Timestamp time.Time
Sent bool Sent bool
Type MessageType
Error MessageErrorType
Error MessageErrorType
BroadcastListJID types.JID BroadcastListJID types.JID
} }
@ -155,7 +165,7 @@ func (msg *Message) IsFakeJID() bool {
func (msg *Message) Scan(row Scannable) *Message { func (msg *Message) Scan(row Scannable) *Message {
var ts int64 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 err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
msg.log.Errorln("Database scan failed:", err) msg.log.Errorln("Database scan failed:", err)
@ -175,9 +185,9 @@ func (msg *Message) Insert() {
sender = "" sender = ""
} }
_, err := msg.db.Exec(`INSERT INTO message _, err := msg.db.Exec(`INSERT INTO message
(chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, error, broadcast_list_jid) (chat_jid, chat_receiver, jid, mxid, sender, timestamp, sent, type, error, broadcast_list_jid)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, 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.Error, msg.BroadcastListJID) 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 { if err != nil {
msg.log.Warnfln("Failed to insert %s@%s: %v", msg.Chat, msg.JID, err) 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.MXID = mxid
msg.Type = newType
msg.Error = newError 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 { if err != nil {
msg.log.Warnfln("Failed to update %s@%s: %v", msg.Chat, msg.JID, err) msg.log.Warnfln("Failed to update %s@%s: %v", msg.Chat, msg.JID, err)
} }

106
database/reaction.go Normal file
View File

@ -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)
}

View File

@ -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
}}
}

View File

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

View File

@ -107,8 +107,6 @@ bridge:
call_start_notices: true call_start_notices: true
# Should another user's cryptographic identity changing send a message to Matrix? # Should another user's cryptographic identity changing send a message to Matrix?
identity_change_notices: false 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 portal_message_buffer: 128
# Settings for handling history sync payloads. These settings only apply right after login, # 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 # 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. # Existing users won't be affected when these are changed.
default_bridge_receipts: true default_bridge_receipts: true
default_bridge_presence: true default_bridge_presence: true
# Send the presence as "available" to whatsapp when users start typing on a portal. # 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 # 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. # users to see when the whatsapp user on the other side is typing during a conversation.
send_presence_on_typing: false send_presence_on_typing: false
# Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp) # Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp)

View File

@ -48,6 +48,7 @@ type portalToBackfill struct {
type wrappedInfo struct { type wrappedInfo struct {
*types.MessageInfo *types.MessageInfo
Type database.MessageType
Error database.MessageErrorType Error database.MessageErrorType
} }
@ -503,10 +504,10 @@ func (portal *Portal) appendBatchEvents(converted *ConvertedMessage, info *types
return err return err
} }
*eventsArray = append(*eventsArray, mainEvt, captionEvt) *eventsArray = append(*eventsArray, mainEvt, captionEvt)
*infoArray = append(*infoArray, &wrappedInfo{info, converted.Error}, nil) *infoArray = append(*infoArray, &wrappedInfo{info, database.MsgNormal, converted.Error}, nil)
} else { } else {
*eventsArray = append(*eventsArray, mainEvt) *eventsArray = append(*eventsArray, mainEvt)
*infoArray = append(*infoArray, &wrappedInfo{info, converted.Error}) *infoArray = append(*infoArray, &wrappedInfo{info, database.MsgNormal, converted.Error})
} }
if converted.MultiEvent != nil { if converted.MultiEvent != nil {
for _, subEvtContent := range converted.MultiEvent { 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 { } 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) portal.log.Warnfln("Didn't find info of message %s (event %s) to register it in the database", msgID, eventID)
} else { } else {
portal.markHandled(nil, info.MessageInfo, eventID, true, false, info.Error) portal.markHandled(nil, info.MessageInfo, eventID, true, false, info.Type, info.Error)
} }
} }
} else { } else {
for i := 0; i < len(infos); i++ { for i := 0; i < len(infos); i++ {
if infos[i] != nil { 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)) portal.log.Infofln("Successfully sent %d events", len(eventIDs))

View File

@ -471,22 +471,24 @@ func (mx *MatrixHandler) HandleReaction(evt *event.Event) {
} }
user := mx.bridge.GetUserByMXID(evt.Sender) user := mx.bridge.GetUserByMXID(evt.Sender)
if user == nil || !user.RelayWhitelisted { if user == nil || !user.Whitelisted || !user.IsLoggedIn() {
return return
} }
portal := mx.bridge.GetPortalByMXID(evt.RoomID) portal := mx.bridge.GetPortalByMXID(evt.RoomID)
if portal == nil || (!user.Whitelisted && !portal.HasRelaybot()) { if portal == nil {
return return
} }
content := evt.Content.AsReaction() content := evt.Content.AsReaction()
if content.RelatesTo.Key == "click to retry" || strings.HasPrefix(content.RelatesTo.Key, "\u267b") { // ♻️ if content.RelatesTo.Key == "click to retry" || strings.HasPrefix(content.RelatesTo.Key, "\u267b") { // ♻️
portal.requestMediaRetry(user, content.RelatesTo.EventID) portal.requestMediaRetry(user, content.RelatesTo.EventID)
} else if mx.bridge.Config.Bridge.ReactionNotices { } else {
_, _ = portal.sendMainIntentMessage(&event.MessageEventContent{ if portal.IsPrivateChat() && user.JID.User != portal.Key.Receiver.User {
MsgType: event.MsgNotice, // One user can only react once, so we don't use the relay user for reactions
Body: fmt.Sprintf("\u26a0 Reactions are not yet supported by WhatsApp."), return
}) }
portal.HandleMatrixReaction(user, evt)
} }
} }

158
portal.go
View File

@ -554,7 +554,7 @@ func (portal *Portal) handleUndecryptableMessage(source *User, evt *events.Undec
if err != nil { if err != nil {
portal.log.Errorln("Failed to send decryption error of %s to Matrix: %v", evt.Info.ID, err) 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) { func (portal *Portal) handleFakeMessage(msg fakeMessage) {
@ -587,7 +587,7 @@ func (portal *Portal) handleFakeMessage(msg fakeMessage) {
MessageSource: types.MessageSource{ MessageSource: types.MessageSource{
Sender: msg.Sender, 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 { 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" { } else if msgType == "revoke" {
portal.HandleMessageRevoke(source, &evt.Info, evt.Message.GetProtocolMessage().GetKey()) portal.HandleMessageRevoke(source, &evt.Info, evt.Message.GetProtocolMessage().GetKey())
if existingMsg != nil { if existingMsg != nil {
_, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{ _, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{
Reason: "The undecryptable message was actually the deletion of another message", 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 { } else {
portal.log.Warnfln("Unhandled message: %+v (%s)", evt.Info, msgType) 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{ _, _ = portal.MainIntent().RedactEvent(portal.MXID, existingMsg.MXID, mautrix.ReqRedact{
Reason: "The undecryptable message contained an unsupported message type", 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 return
} }
@ -696,7 +698,7 @@ func (portal *Portal) isRecentlyHandled(id types.MessageID, error database.Messa
return false 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 { if msg == nil {
msg = portal.bridge.DB.Message.New() msg = portal.bridge.DB.Message.New()
msg.Chat = portal.Key msg.Chat = portal.Key
@ -705,13 +707,14 @@ func (portal *Portal) markHandled(msg *database.Message, info *types.MessageInfo
msg.Timestamp = info.Timestamp msg.Timestamp = info.Timestamp
msg.Sender = info.Sender msg.Sender = info.Sender
msg.Sent = isSent msg.Sent = isSent
msg.Type = msgType
msg.Error = error msg.Error = error
if info.IsIncomingBroadcast() { if info.IsIncomingBroadcast() {
msg.BroadcastListJID = info.Chat msg.BroadcastListJID = info.Chat
} }
msg.Insert() msg.Insert()
} else { } else {
msg.UpdateMXID(mxid, error) msg.UpdateMXID(mxid, msgType, error)
} }
if recent { if recent {
@ -740,8 +743,8 @@ func (portal *Portal) getMessageIntent(user *User, info *types.MessageInfo) *app
return portal.getMessagePuppet(user, info).IntentFor(portal) return portal.getMessagePuppet(user, info).IntentFor(portal)
} }
func (portal *Portal) finishHandling(existing *database.Message, message *types.MessageInfo, mxid id.EventID, error database.MessageErrorType) { 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, error) portal.markHandled(existing, message, mxid, true, true, msgType, error)
portal.sendDeliveryReceipt(mxid) portal.sendDeliveryReceipt(mxid)
var suffix string var suffix string
if error == database.MsgErrDecryptionFailed { if error == database.MsgErrDecryptionFailed {
@ -749,7 +752,7 @@ func (portal *Portal) finishHandling(existing *database.Message, message *types.
} else if error == database.MsgErrMediaNotFound { } else if error == database.MsgErrMediaNotFound {
suffix = "(media not found notice)" 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) { func (portal *Portal) kickExtraUsers(participantMap map[types.JID]bool) {
@ -1417,6 +1420,43 @@ func (portal *Portal) SetReply(content *event.MessageEventContent, replyToID typ
return true 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 { func (portal *Portal) HandleMessageRevoke(user *User, info *types.MessageInfo, key *waProto.MessageKey) bool {
msg := portal.bridge.DB.Message.GetByJID(portal.Key, key.GetId()) msg := portal.bridge.DB.Message.GetByJID(portal.Key, key.GetId())
if msg == nil || msg.IsFakeMXID() { if msg == nil || msg.IsFakeMXID() {
@ -2194,7 +2234,7 @@ func (portal *Portal) handleMediaRetry(retry *events.MediaRetry, source *User) {
return return
} }
portal.log.Debugfln("Successfully edited %s -> %s after retry notification for %s", msg.MXID, resp.EventID, retry.MessageID) 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) { 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() replyToID := content.GetReplyTo()
if len(replyToID) > 0 { if len(replyToID) > 0 {
replyToMsg := portal.bridge.DB.Message.GetByMXID(replyToID) 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.StanzaId = &replyToMsg.JID
ctxInfo.Participant = proto.String(replyToMsg.Sender.ToNonAD().String()) ctxInfo.Participant = proto.String(replyToMsg.Sender.ToNonAD().String())
// Using blank content here seems to work fine on all official WhatsApp apps. // 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) portal.MarkDisappearing(evt.ID, portal.ExpirationTime, true)
info := portal.generateMessageInfo(sender) 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) portal.log.Debugln("Sending event", evt.ID, "to WhatsApp", info.ID)
ts, err := sender.Client.SendMessage(portal.Key.JID, info.ID, msg) ts, err := sender.Client.SendMessage(portal.Key.JID, info.ID, msg)
if err != nil { 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) { func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) {
if !portal.canBridgeFrom(sender, "redaction") { if !portal.canBridgeFrom(sender, "redaction") {
return return
@ -2712,8 +2824,24 @@ func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) {
return return
} }
portal.log.Debugfln("Sending redaction %s of %s/%s to WhatsApp", evt.ID, msg.MXID, msg.JID) var err error
_, err := sender.Client.RevokeMessage(portal.Key.JID, msg.JID) 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 { if err != nil {
portal.log.Errorfln("Error handling Matrix redaction %s: %v", evt.ID, err) portal.log.Errorfln("Error handling Matrix redaction %s: %v", evt.ID, err)
portal.bridge.AS.SendErrorMessageSendCheckpoint(evt, appservice.StepRemote, err, true, 0) portal.bridge.AS.SendErrorMessageSendCheckpoint(evt, appservice.StepRemote, err, true, 0)

View File

@ -475,7 +475,7 @@ func (prov *ProvisioningAPI) Login(w http.ResponseWriter, r *http.Request) {
qrChan, err := user.Login(ctx) qrChan, err := user.Login(ctx)
if err != nil { 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) { if errors.Is(err, ErrAlreadyLoggedIn) {
go user.Connect() go user.Connect()
_ = c.WriteJSON(Error{ _ = c.WriteJSON(Error{

View File

@ -626,7 +626,7 @@ func (user *User) HandleEvent(event interface{}) {
go user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Message: v.String()}) go user.sendBridgeState(BridgeState{StateEvent: StateBadCredentials, Message: v.String()})
user.bridge.Metrics.TrackConnectionState(user.JID, false) user.bridge.Metrics.TrackConnectionState(user.JID, false)
case *events.Disconnected: 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) user.bridge.Metrics.TrackConnectionState(user.JID, false)
case *events.Contact: case *events.Contact:
go user.syncPuppet(v.JID, "contact event") go user.syncPuppet(v.JID, "contact event")