diff --git a/commands.go b/commands.go index 0ba86b2..7fbf9b1 100644 --- a/commands.go +++ b/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] - Change the power level in a portal room. Only for bridge admins.` func (handler *CommandHandler) CommandSetPowerLevel(ce *CommandEvent) { diff --git a/custompuppet.go b/custompuppet.go index e7eb124..c1a9036 100644 --- a/custompuppet.go +++ b/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 { diff --git a/go.mod b/go.mod index 55fcc57..e09dd73 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 254c11f..846a028 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 1d49226..301649a 100644 --- a/main.go +++ b/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" diff --git a/matrix.go b/matrix.go index 92869bd..3618bba 100644 --- a/matrix.go +++ b/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 } } diff --git a/portal.go b/portal.go index 26fa653..7e6bf51 100644 --- a/portal.go +++ b/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) { +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 { + extraContent[doublePuppetField] = intent.IsCustomPuppet + } wrappedContent := event.Content{ Parsed: content, - Raw: map[string]interface{}{ - backfillIDField: info.ID, - }, - } - if intent.IsCustomPuppet { - wrappedContent.Raw["net.maunium.whatsapp.puppet"] = intent.IsCustomPuppet + 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 = `%s has invited you to join %s: +
%s
+The invite expires at %s. Reply to this message with !wa accept 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), })