Add support for group invite messages

This commit is contained in:
Tulir Asokan 2021-10-31 20:42:53 +02:00
parent 1e5d5c1a3e
commit 630095e28a
7 changed files with 138 additions and 110 deletions

View file

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

View file

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

2
go.mod
View file

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

4
go.sum
View file

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

View file

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

View file

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

161
portal.go
View file

@ -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 = `<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),
})