Browse Source

Add support for group invite messages

Tulir Asokan 3 years ago
parent
commit
630095e28a
7 changed files with 138 additions and 110 deletions
  1. 70 2
      commands.go
  2. 1 1
      custompuppet.go
  3. 1 1
      go.mod
  4. 2 2
      go.sum
  5. 1 1
      main.go
  6. 3 2
      matrix.go
  7. 60 101
      portal.go

+ 70 - 2
commands.go

@@ -60,6 +60,7 @@ type CommandEvent struct {
 	User    *User
 	Command string
 	Args    []string
+	ReplyTo id.EventID
 }
 
 // Reply sends a reply to command as notice
@@ -77,7 +78,7 @@ func (ce *CommandEvent) Reply(msg string, args ...interface{}) {
 }
 
 // Handle handles messages to the bridge
-func (handler *CommandHandler) Handle(roomID id.RoomID, user *User, message string) {
+func (handler *CommandHandler) Handle(roomID id.RoomID, user *User, message string, replyTo id.EventID) {
 	args := strings.Fields(message)
 	if len(args) == 0 {
 		args = []string{"unknown-command"}
@@ -91,6 +92,7 @@ func (handler *CommandHandler) Handle(roomID id.RoomID, user *User, message stri
 		User:    user,
 		Command: strings.ToLower(args[0]),
 		Args:    args[1:],
+		ReplyTo: replyTo,
 	}
 	handler.log.Debugfln("%s sent '%s' in %s", user.MXID, message, roomID)
 	handler.CommandMux(ce)
@@ -130,7 +132,7 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
 		handler.CommandLogout(ce)
 	case "toggle":
 		handler.CommandToggle(ce)
-	case "set-relay", "unset-relay", "login-matrix", "sync", "list", "open", "pm", "invite-link", "check-invite", "join", "create":
+	case "set-relay", "unset-relay", "login-matrix", "sync", "list", "open", "pm", "invite-link", "check-invite", "join", "create", "accept":
 		if !ce.User.HasSession() {
 			ce.Reply("You are not logged in. Use the `login` command to log into WhatsApp.")
 			return
@@ -160,6 +162,8 @@ func (handler *CommandHandler) CommandMux(ce *CommandEvent) {
 			handler.CommandJoin(ce)
 		case "create":
 			handler.CommandCreate(ce)
+		case "accept":
+			handler.CommandAccept(ce)
 		}
 	default:
 		ce.Reply("Unknown command, use the `help` command for help.")
@@ -281,6 +285,35 @@ func (handler *CommandHandler) CommandJoin(ce *CommandEvent) {
 	ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid)
 }
 
+func (handler *CommandHandler) CommandAccept(ce *CommandEvent) {
+	if ce.Portal == nil || len(ce.ReplyTo) == 0 {
+		ce.Reply("You must reply to a group invite message when using this command.")
+		return
+	}
+	evt, err := ce.Portal.MainIntent().GetEvent(ce.RoomID, ce.ReplyTo)
+	if err != nil {
+		handler.log.Errorln("Failed to get event %s to handle !wa accept command: %v", ce.ReplyTo, err)
+		ce.Reply("Failed to get reply event")
+		return
+	}
+	meta, ok := evt.Content.Raw[inviteMetaField].(map[string]interface{})
+	if !ok {
+		ce.Reply("That doesn't look like a group invite message.")
+		return
+	}
+	jid, inviter, code, expiration, ok := parseInviteMeta(meta)
+	if !ok {
+		ce.Reply("That doesn't look like a group invite message.")
+		return
+	}
+	err = ce.User.Client.AcceptGroupInvite(jid, inviter, code, expiration)
+	if err != nil {
+		ce.Reply("Failed to accept group invite: %v", err)
+		return
+	}
+	ce.Reply("Successfully accepted the invite, the portal should be created momentarily")
+}
+
 const cmdCreateHelp = `create - Create a group chat.`
 
 func (handler *CommandHandler) CommandCreate(ce *CommandEvent) {
@@ -353,6 +386,41 @@ func (handler *CommandHandler) CommandCreate(ce *CommandEvent) {
 	//ce.User.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity})
 }
 
+func parseInviteMeta(meta map[string]interface{}) (jid, inviter types.JID, code string, expiration int64, ok bool) {
+	var fieldFound bool
+	code, fieldFound = meta["code"].(string)
+	if !fieldFound {
+		return
+	}
+	expirationStr, fieldFound := meta["expiration"].(string)
+	if !fieldFound {
+		return
+	}
+	inviterStr, fieldFound := meta["inviter"].(string)
+	if !fieldFound {
+		return
+	}
+	jidStr, fieldFound := meta["jid"].(string)
+	if !fieldFound {
+		return
+	}
+	var err error
+	expiration, err = strconv.ParseInt(expirationStr, 10, 64)
+	if err != nil {
+		return
+	}
+	inviter, err = types.ParseJID(inviterStr)
+	if err != nil {
+		return
+	}
+	jid, err = types.ParseJID(jidStr)
+	if err != nil {
+		return
+	}
+	ok = true
+	return
+}
+
 const cmdSetPowerLevelHelp = `set-pl [user ID] <power level> - Change the power level in a portal room. Only for bridge admins.`
 
 func (handler *CommandHandler) CommandSetPowerLevel(ce *CommandEvent) {

+ 1 - 1
custompuppet.go

@@ -217,7 +217,7 @@ func (puppet *Puppet) handleReceiptEvent(portal *Portal, event *event.Event) {
 	for eventID, receipts := range *event.Content.AsReceipt() {
 		if receipt, ok := receipts.Read[puppet.CustomMXID]; !ok {
 			// Ignore receipt events where this user isn't present.
-		} else if isDoublePuppeted, _ := receipt.Extra["net.maunium.whatsapp.puppet"].(bool); isDoublePuppeted {
+		} else if isDoublePuppeted, _ := receipt.Extra[doublePuppetField].(bool); isDoublePuppeted {
 			puppet.customUser.log.Debugfln("Ignoring double puppeted read receipt %+v", event.Content.Raw)
 			// Ignore double puppeted read receipts.
 		} else if message := puppet.bridge.DB.Message.GetByMXID(eventID); message != nil {

+ 1 - 1
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/mattn/go-sqlite3 v1.14.9
 	github.com/prometheus/client_golang v1.11.0
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	go.mau.fi/whatsmeow v0.0.0-20211031175440-39cd01efeed7
+	go.mau.fi/whatsmeow v0.0.0-20211031184143-96a325ea0d2e
 	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/yaml.v2 v2.4.0

+ 2 - 2
go.sum

@@ -139,8 +139,8 @@ github.com/tidwall/sjson v1.2.3 h1:5+deguEhHSEjmuICXZ21uSSsXotWMA0orU783+Z7Cp8=
 github.com/tidwall/sjson v1.2.3/go.mod h1:5WdjKx3AQMvCJ4RG6/2UYT7dLrGvJUV1x4jdTAyGvZs=
 go.mau.fi/libsignal v0.0.0-20211024113310-f9fc6a1855f2 h1:xpQTMgJGGaF+c8jV/LA/FVXAPJxZbSAGeflOc+Ly6uQ=
 go.mau.fi/libsignal v0.0.0-20211024113310-f9fc6a1855f2/go.mod h1:3XlVlwOfp8f9Wri+C1D4ORqgUsN4ZvunJOoPjQMBhos=
-go.mau.fi/whatsmeow v0.0.0-20211031175440-39cd01efeed7 h1:AxqjTj5ejuTUGrpG21Ot/dIjY946OjveZM08SACeDhw=
-go.mau.fi/whatsmeow v0.0.0-20211031175440-39cd01efeed7/go.mod h1:ODEmmqeUn9eBDQHFc1S902YA3YFLtmaBujYRRFl53jI=
+go.mau.fi/whatsmeow v0.0.0-20211031184143-96a325ea0d2e h1:XZzLOVrnccvvzZz+PhonjTRfHmycuToZiwBNiI+g1KM=
+go.mau.fi/whatsmeow v0.0.0-20211031184143-96a325ea0d2e/go.mod h1:ODEmmqeUn9eBDQHFc1S902YA3YFLtmaBujYRRFl53jI=
 golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

+ 1 - 1
main.go

@@ -17,6 +17,7 @@
 package main
 
 import (
+	_ "embed"
 	"errors"
 	"fmt"
 	"os"
@@ -26,7 +27,6 @@ import (
 	"sync"
 	"syscall"
 	"time"
-	_ "embed"
 
 	"google.golang.org/protobuf/proto"
 

+ 3 - 2
matrix.go

@@ -315,7 +315,7 @@ func (mx *MatrixHandler) shouldIgnoreEvent(evt *event.Event) bool {
 	if _, isPuppet := mx.bridge.ParsePuppetMXID(evt.Sender); evt.Sender == mx.bridge.Bot.UserID || isPuppet {
 		return true
 	}
-	isCustomPuppet, ok := evt.Content.Raw["net.maunium.whatsapp.puppet"].(bool)
+	isCustomPuppet, ok := evt.Content.Raw[doublePuppetField].(bool)
 	if ok && isCustomPuppet && mx.bridge.GetPuppetByCustomMXID(evt.Sender) != nil {
 		return true
 	}
@@ -406,6 +406,7 @@ func (mx *MatrixHandler) HandleMessage(evt *event.Event) {
 
 	user := mx.bridge.GetUserByMXID(evt.Sender)
 	content := evt.Content.AsMessage()
+	content.RemoveReplyFallback()
 	if user.Whitelisted && content.MsgType == event.MsgText {
 		commandPrefix := mx.bridge.Config.Bridge.CommandPrefix
 		hasCommandPrefix := strings.HasPrefix(content.Body, commandPrefix)
@@ -413,7 +414,7 @@ func (mx *MatrixHandler) HandleMessage(evt *event.Event) {
 			content.Body = strings.TrimLeft(content.Body[len(commandPrefix):], " ")
 		}
 		if hasCommandPrefix || evt.RoomID == user.ManagementRoom {
-			mx.cmd.Handle(evt.RoomID, user, content.Body)
+			mx.cmd.Handle(evt.RoomID, user, content.Body, content.GetReplyTo())
 			return
 		}
 	}

+ 60 - 101
portal.go

@@ -233,6 +233,7 @@ func (portal *Portal) shouldCreateRoom(msg PortalMessage) bool {
 		waMsg.DocumentMessage,
 		waMsg.ContactMessage,
 		waMsg.LocationMessage,
+		waMsg.GroupInviteMessage,
 	}
 	for _, message := range supportedMessages {
 		if message != nil {
@@ -262,7 +263,9 @@ func (portal *Portal) getMessageType(waMsg *waProto.Message) string {
 		return "contact"
 	case waMsg.LocationMessage != nil:
 		return "location"
-	case waMsg.GetProtocolMessage() != nil:
+	case waMsg.GroupInviteMessage != nil:
+		return "group invite"
+	case waMsg.ProtocolMessage != nil:
 		switch waMsg.GetProtocolMessage().GetType() {
 		case waProto.ProtocolMessage_REVOKE:
 			return "revoke"
@@ -294,6 +297,8 @@ func (portal *Portal) convertMessage(intent *appservice.IntentAPI, source *User,
 		return portal.convertContactMessage(intent, waMsg.GetContactMessage())
 	case waMsg.LocationMessage != nil:
 		return portal.convertLocationMessage(intent, waMsg.GetLocationMessage())
+	case waMsg.GroupInviteMessage != nil:
+		return portal.convertGroupInviteMessage(intent, info, waMsg.GetGroupInviteMessage())
 	default:
 		return nil
 	}
@@ -322,7 +327,7 @@ func (portal *Portal) handleUndecryptableMessage(source *User, evt *events.Undec
 	}
 	intent := portal.getMessageIntent(source, &evt.Info)
 	content := undecryptableMessageContent
-	resp, err := portal.sendMessage(intent, event.EventMessage, &content, evt.Info.Timestamp.UnixMilli())
+	resp, err := portal.sendMessage(intent, event.EventMessage, &content, nil, evt.Info.Timestamp.UnixMilli())
 	if err != nil {
 		portal.log.Errorln("Failed to send decryption error of %s to Matrix: %v", evt.Info.ID, err)
 	}
@@ -359,7 +364,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) {
 		if existingMsg != nil {
 			converted.Content.SetEdit(existingMsg.MXID)
 		}
-		resp, err := portal.sendMessage(converted.Intent, converted.Type, converted.Content, evt.Info.Timestamp.UnixMilli())
+		resp, err := portal.sendMessage(converted.Intent, converted.Type, converted.Content, converted.Extra, evt.Info.Timestamp.UnixMilli())
 		if err != nil {
 			portal.log.Errorln("Failed to send %s to Matrix: %v", msgID, err)
 		} else {
@@ -367,7 +372,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) {
 		}
 		// TODO figure out how to handle captions with undecryptable messages turning decryptable
 		if converted.Caption != nil && existingMsg == nil {
-			resp, err = portal.sendMessage(converted.Intent, converted.Type, converted.Caption, evt.Info.Timestamp.UnixMilli())
+			resp, err = portal.sendMessage(converted.Intent, converted.Type, converted.Caption, nil, evt.Info.Timestamp.UnixMilli())
 			if err != nil {
 				portal.log.Errorln("Failed to send caption of %s to Matrix: %v", msgID, err)
 			} else {
@@ -894,17 +899,20 @@ func (portal *Portal) parseWebMessageInfo(webMsg *waProto.WebMessageInfo) *types
 	return &info
 }
 
-const backfillIDField = "net.maunium.whatsapp.id"
+const backfillIDField = "fi.mau.whatsapp.backfill_msg_id"
+const doublePuppetField = "net.maunium.whatsapp.puppet"
 
-func (portal *Portal) wrapBatchEvent(info *types.MessageInfo, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent) (*event.Event, error) {
-	wrappedContent := event.Content{
-		Parsed: content,
-		Raw: map[string]interface{}{
-			backfillIDField: info.ID,
-		},
+func (portal *Portal) wrapBatchEvent(info *types.MessageInfo, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}) (*event.Event, error) {
+	if extraContent == nil {
+		extraContent = map[string]interface{}{}
 	}
+	extraContent[backfillIDField] = info.ID
 	if intent.IsCustomPuppet {
-		wrappedContent.Raw["net.maunium.whatsapp.puppet"] = intent.IsCustomPuppet
+		extraContent[doublePuppetField] = intent.IsCustomPuppet
+	}
+	wrappedContent := event.Content{
+		Parsed: content,
+		Raw:    extraContent,
 	}
 	newEventType, err := portal.encrypt(&wrappedContent, eventType)
 	if err != nil {
@@ -919,12 +927,12 @@ func (portal *Portal) wrapBatchEvent(info *types.MessageInfo, intent *appservice
 }
 
 func (portal *Portal) appendBatchEvents(converted *ConvertedMessage, info *types.MessageInfo, eventsArray *[]*event.Event, infoArray *[]*types.MessageInfo) error {
-	mainEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Content)
+	mainEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Content, converted.Extra)
 	if err != nil {
 		return err
 	}
 	if converted.Caption != nil {
-		captionEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Caption)
+		captionEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Caption, nil)
 		if err != nil {
 			return err
 		}
@@ -1441,34 +1449,8 @@ func (portal *Portal) HandleMessageRevoke(user *User, info *types.MessageInfo, k
 	return true
 }
 
-//func (portal *Portal) HandleFakeMessage(_ *User, message FakeMessage) bool {
-//	if portal.isRecentlyHandled(message.ID) {
-//		return false
-//	}
-//
-//	content := event.MessageEventContent{
-//		MsgType: event.MsgNotice,
-//		Body:    message.Text,
-//	}
-//	if message.Alert {
-//		content.MsgType = event.MsgText
-//	}
-//	_, err := portal.sendMainIntentMessage(content)
-//	if err != nil {
-//		portal.log.Errorfln("Failed to handle fake message %s: %v", message.ID, err)
-//		return true
-//	}
-//
-//	portal.recentlyHandledLock.Lock()
-//	index := portal.recentlyHandledIndex
-//	portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
-//	portal.recentlyHandledLock.Unlock()
-//	portal.recentlyHandled[index] = message.ID
-//	return true
-//}
-
-func (portal *Portal) sendMainIntentMessage(content interface{}) (*mautrix.RespSendEvent, error) {
-	return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, 0)
+func (portal *Portal) sendMainIntentMessage(content *event.MessageEventContent) (*mautrix.RespSendEvent, error) {
+	return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, nil, 0)
 }
 
 func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (event.Type, error) {
@@ -1486,12 +1468,13 @@ func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (eve
 	return eventType, nil
 }
 
-func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
-	wrappedContent := event.Content{Parsed: content}
+func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
+	wrappedContent := event.Content{Parsed: content, Raw: extraContent}
 	if timestamp != 0 && intent.IsCustomPuppet {
-		wrappedContent.Raw = map[string]interface{}{
-			"net.maunium.whatsapp.puppet": intent.IsCustomPuppet,
+		if wrappedContent.Raw == nil {
+			wrappedContent.Raw = map[string]interface{}{}
 		}
+		wrappedContent.Raw[doublePuppetField] = intent.IsCustomPuppet
 	}
 	var err error
 	eventType, err = portal.encrypt(&wrappedContent, eventType)
@@ -1510,6 +1493,7 @@ type ConvertedMessage struct {
 	Intent  *appservice.IntentAPI
 	Type    event.Type
 	Content *event.MessageEventContent
+	Extra   map[string]interface{}
 	Caption *event.MessageEventContent
 }
 
@@ -1531,57 +1515,6 @@ func (portal *Portal) convertTextMessage(intent *appservice.IntentAPI, msg *waPr
 	return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content}
 }
 
-//func (portal *Portal) HandleStubMessage(source *User, message whatsapp.StubMessage, isBackfill bool) bool {
-//	if portal.bridge.Config.Bridge.ChatMetaSync && (!portal.IsBroadcastList() || isBackfill) {
-//		// Chat meta sync is enabled, so we use chat update commands and full-syncs instead of message history
-//		// However, broadcast lists don't have update commands, so we handle these if it's not a backfill
-//		return false
-//	}
-//	intent := portal.startHandling(source, message.Info, fmt.Sprintf("stub %s", message.Type.String()))
-//	if intent == nil {
-//		return false
-//	}
-//	var senderJID string
-//	if message.Info.FromMe {
-//		senderJID = source.JID
-//	} else {
-//		senderJID = message.Info.SenderJid
-//	}
-//	var eventID id.EventID
-//	// TODO find more real event IDs
-//	// TODO timestamp massaging
-//	switch message.Type {
-//	case waProto.WebMessageInfo_GROUP_CHANGE_SUBJECT:
-//		portal.UpdateName(message.FirstParam, "", intent, true)
-//	case waProto.WebMessageInfo_GROUP_CHANGE_ICON:
-//		portal.UpdateAvatar(source, nil, true)
-//	case waProto.WebMessageInfo_GROUP_CHANGE_DESCRIPTION:
-//		if isBackfill {
-//			// TODO fetch topic from server
-//		}
-//		//portal.UpdateTopic(message.FirstParam, "", intent, true)
-//	case waProto.WebMessageInfo_GROUP_CHANGE_ANNOUNCE:
-//		eventID = portal.RestrictMessageSending(message.FirstParam == "on")
-//	case waProto.WebMessageInfo_GROUP_CHANGE_RESTRICT:
-//		eventID = portal.RestrictMetadataChanges(message.FirstParam == "on")
-//	case waProto.WebMessageInfo_GROUP_PARTICIPANT_ADD, waProto.WebMessageInfo_GROUP_PARTICIPANT_INVITE, waProto.WebMessageInfo_BROADCAST_ADD:
-//		eventID = portal.HandleWhatsAppInvite(source, senderJID, intent, message.Params)
-//	case waProto.WebMessageInfo_GROUP_PARTICIPANT_REMOVE, waProto.WebMessageInfo_GROUP_PARTICIPANT_LEAVE, waProto.WebMessageInfo_BROADCAST_REMOVE:
-//		portal.HandleWhatsAppKick(source, senderJID, message.Params)
-//	case waProto.WebMessageInfo_GROUP_PARTICIPANT_PROMOTE:
-//		eventID = portal.ChangeAdminStatus(message.Params, true)
-//	case waProto.WebMessageInfo_GROUP_PARTICIPANT_DEMOTE:
-//		eventID = portal.ChangeAdminStatus(message.Params, false)
-//	default:
-//		return false
-//	}
-//	if len(eventID) == 0 {
-//		eventID = id.EventID(fmt.Sprintf("net.maunium.whatsapp.fake::%s", message.Info.Id))
-//	}
-//	portal.markHandled(source, message.Info.Source, eventID, true)
-//	return true
-//}
-
 func (portal *Portal) convertLocationMessage(intent *appservice.IntentAPI, msg *waProto.LocationMessage) *ConvertedMessage {
 	url := msg.GetUrl()
 	if len(url) == 0 {
@@ -1630,6 +1563,33 @@ func (portal *Portal) convertLocationMessage(intent *appservice.IntentAPI, msg *
 	return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content}
 }
 
+const inviteMsg = `<a href="https://matrix.to/#/%s">%s</a> has invited you to join %s:
+<blockquote>%s</blockquote>
+The invite expires at %s. Reply to this message with <code>!wa accept</code> to accept the invite.`
+const inviteMetaField = "fi.mau.whatsapp.invite"
+
+func (portal *Portal) convertGroupInviteMessage(intent *appservice.IntentAPI, info *types.MessageInfo, msg *waProto.GroupInviteMessage) *ConvertedMessage {
+	puppet := portal.bridge.GetPuppetByJID(info.Sender)
+	expiry := time.Unix(msg.GetInviteExpiration(), 0)
+	htmlMessage := fmt.Sprintf(inviteMsg, intent.UserID, html.EscapeString(puppet.Displayname), msg.GetGroupName(), html.EscapeString(msg.GetCaption()), expiry)
+	content := &event.MessageEventContent{
+		MsgType:       event.MsgText,
+		Body:          format.HTMLToText(htmlMessage),
+		Format:        event.FormatHTML,
+		FormattedBody: htmlMessage,
+	}
+	extraAttrs := map[string]interface{}{
+		inviteMetaField: map[string]interface{}{
+			"jid":        msg.GetGroupJid(),
+			"code":       msg.GetInviteCode(),
+			"expiration": strconv.FormatInt(msg.GetInviteExpiration(), 10),
+			"inviter":    info.Sender.ToNonAD().String(),
+		},
+	}
+	portal.SetReply(content, msg.GetContextInfo().GetStanzaId())
+	return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content, Extra: extraAttrs}
+}
+
 func (portal *Portal) convertContactMessage(intent *appservice.IntentAPI, msg *waProto.ContactMessage) *ConvertedMessage {
 	fileName := fmt.Sprintf("%s.vcf", msg.GetDisplayName())
 	data := []byte(msg.GetVcard())
@@ -1721,7 +1681,7 @@ func (portal *Portal) leaveWithPuppetMeta(intent *appservice.IntentAPI) (*mautri
 			Membership: event.MembershipLeave,
 		},
 		Raw: map[string]interface{}{
-			"net.maunium.whatsapp.puppet": true,
+			doublePuppetField: true,
 		},
 	}
 	return intent.SendStateEvent(portal.MXID, event.StateMember, intent.UserID.String(), &content)
@@ -1743,7 +1703,7 @@ func (portal *Portal) HandleWhatsAppInvite(source *User, senderJID *types.JID, j
 				AvatarURL:   puppet.AvatarURL.CUString(),
 			},
 			Raw: map[string]interface{}{
-				"net.maunium.whatsapp.puppet": true,
+				doublePuppetField: true,
 			},
 		}
 		resp, err := intent.SendStateEvent(portal.MXID, event.StateMember, puppet.MXID.String(), &content)
@@ -2211,7 +2171,6 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP
 	var ctxInfo waProto.ContextInfo
 	replyToID := content.GetReplyTo()
 	if len(replyToID) > 0 {
-		content.RemoveReplyFallback()
 		replyToMsg := portal.bridge.DB.Message.GetByMXID(replyToID)
 		if replyToMsg != nil {
 			ctxInfo.StanzaId = &replyToMsg.JID
@@ -2359,7 +2318,7 @@ func (portal *Portal) sendErrorMessage(message string, confirmed bool) id.EventI
 	if confirmed {
 		certainty = "was not"
 	}
-	resp, err := portal.sendMainIntentMessage(event.MessageEventContent{
+	resp, err := portal.sendMainIntentMessage(&event.MessageEventContent{
 		MsgType: event.MsgNotice,
 		Body:    fmt.Sprintf("\u26a0 Your message %s bridged: %v", certainty, message),
 	})