Add option to bridge polls into MSC3381 format

This commit is contained in:
Tulir Asokan 2022-11-17 23:30:42 +02:00
parent 1da1b9935b
commit beb956973e
4 changed files with 113 additions and 5 deletions

View file

@ -111,6 +111,7 @@ type BridgeConfig struct {
FederateRooms bool `yaml:"federate_rooms"` FederateRooms bool `yaml:"federate_rooms"`
URLPreviews bool `yaml:"url_previews"` URLPreviews bool `yaml:"url_previews"`
CaptionInMessage bool `yaml:"caption_in_message"` CaptionInMessage bool `yaml:"caption_in_message"`
ExtEvPolls int `yaml:"extev_polls"`
SendWhatsAppEdits bool `yaml:"send_whatsapp_edits"` SendWhatsAppEdits bool `yaml:"send_whatsapp_edits"`
MessageHandlingTimeout struct { MessageHandlingTimeout struct {

View file

@ -94,6 +94,7 @@ func DoUpgrade(helper *up.Helper) {
helper.Copy(up.Bool, "bridge", "crash_on_stream_replaced") helper.Copy(up.Bool, "bridge", "crash_on_stream_replaced")
helper.Copy(up.Bool, "bridge", "url_previews") helper.Copy(up.Bool, "bridge", "url_previews")
helper.Copy(up.Bool, "bridge", "caption_in_message") helper.Copy(up.Bool, "bridge", "caption_in_message")
helper.Copy(up.Int, "bridge", "extev_polls")
helper.Copy(up.Bool, "bridge", "send_whatsapp_edits") helper.Copy(up.Bool, "bridge", "send_whatsapp_edits")
helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "error_after") helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "error_after")
helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "deadline") helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "deadline")

View file

@ -298,6 +298,9 @@ bridge:
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552. # Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
# This is currently not supported in most clients. # This is currently not supported in most clients.
caption_in_message: false caption_in_message: false
# Should polls be sent using MSC3381 event types? This should either be 1 for original polls MSC,
# 2 for the updated MSC as of November 2022, or 0 to use legacy m.room.message (which doesn't support voting).
extev_polls: 0
# Should Matrix edits be bridged to WhatsApp edits? # Should Matrix edits be bridged to WhatsApp edits?
# Official WhatsApp clients don't render edits yet, but once they do, the bridge should work with them right away. # Official WhatsApp clients don't render edits yet, but once they do, the bridge should work with them right away.
send_whatsapp_edits: false send_whatsapp_edits: false

113
portal.go
View file

@ -19,6 +19,8 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -533,6 +535,8 @@ func (portal *Portal) convertMessage(intent *appservice.IntentAPI, source *User,
return portal.convertListResponseMessage(intent, waMsg.GetListResponseMessage()) return portal.convertListResponseMessage(intent, waMsg.GetListResponseMessage())
case waMsg.PollCreationMessage != nil: case waMsg.PollCreationMessage != nil:
return portal.convertPollCreationMessage(intent, waMsg.GetPollCreationMessage()) return portal.convertPollCreationMessage(intent, waMsg.GetPollCreationMessage())
case waMsg.PollUpdateMessage != nil:
return portal.convertPollUpdateMessage(intent, source, info, waMsg.GetPollUpdateMessage())
case waMsg.ImageMessage != nil: case waMsg.ImageMessage != nil:
return portal.convertMediaMessage(intent, source, info, waMsg.GetImageMessage(), "photo", isBackfill) return portal.convertMediaMessage(intent, source, info, waMsg.GetImageMessage(), "photo", isBackfill)
case waMsg.StickerMessage != nil: case waMsg.StickerMessage != nil:
@ -2095,20 +2099,88 @@ func (portal *Portal) convertListResponseMessage(intent *appservice.IntentAPI, m
} }
} }
func (portal *Portal) convertPollUpdateMessage(intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg *waProto.PollUpdateMessage) *ConvertedMessage {
if portal.bridge.Config.Bridge.ExtEvPolls == 0 {
return nil
}
pollMessage := portal.bridge.DB.Message.GetByJID(portal.Key, msg.GetPollCreationMessageKey().GetId())
if pollMessage == nil {
portal.log.Warnfln("Failed to convert vote message %s: poll message %s not found", info.ID, msg.GetPollCreationMessageKey().GetId())
return nil
}
vote, err := source.Client.DecryptPollVote(&events.Message{
Info: *info,
Message: &waProto.Message{PollUpdateMessage: msg},
})
if err != nil {
portal.log.Errorfln("Failed to decrypt vote message %s: %v", info.ID, err)
return nil
}
selectedHashes := make([]string, len(vote.GetSelectedOptions()))
for i, opt := range vote.GetSelectedOptions() {
selectedHashes[i] = hex.EncodeToString(opt)
}
evtType := event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.response"}
if portal.bridge.Config.Bridge.ExtEvPolls == 2 {
evtType.Type = "org.matrix.msc3381.v2.poll.response"
}
return &ConvertedMessage{
Intent: intent,
Type: evtType,
Content: &event.MessageEventContent{
RelatesTo: &event.RelatesTo{
Type: event.RelReference,
EventID: pollMessage.MXID,
},
},
Extra: map[string]any{
"org.matrix.msc3381.poll.response": map[string]any{
"answers": selectedHashes,
},
"org.matrix.msc3381.v2.selections": selectedHashes,
},
}
}
func (portal *Portal) convertPollCreationMessage(intent *appservice.IntentAPI, msg *waProto.PollCreationMessage) *ConvertedMessage { func (portal *Portal) convertPollCreationMessage(intent *appservice.IntentAPI, msg *waProto.PollCreationMessage) *ConvertedMessage {
optionsListText := make([]string, len(msg.GetOptions()))
optionsListHTML := make([]string, len(msg.GetOptions()))
optionNames := make([]string, len(msg.GetOptions())) optionNames := make([]string, len(msg.GetOptions()))
optionsListText := make([]string, len(optionNames))
optionsListHTML := make([]string, len(optionNames))
msc3381Answers := make([]map[string]any, len(optionNames))
msc3381V2Answers := make([]map[string]any, len(optionNames))
for i, opt := range msg.GetOptions() { for i, opt := range msg.GetOptions() {
optionNames[i] = opt.GetOptionName() optionNames[i] = opt.GetOptionName()
optionsListText[i] = fmt.Sprintf("%d. %s\n", i+1, optionNames[i]) optionsListText[i] = fmt.Sprintf("%d. %s\n", i+1, optionNames[i])
optionsListHTML[i] = fmt.Sprintf("<li>%s</li>", event.TextToHTML(optionNames[i])) optionsListHTML[i] = fmt.Sprintf("<li>%s</li>", event.TextToHTML(optionNames[i]))
optionHash := sha256.Sum256([]byte(opt.GetOptionName()))
optionHashStr := hex.EncodeToString(optionHash[:])
msc3381Answers[i] = map[string]any{
"id": optionHashStr,
"org.matrix.msc1767.text": opt.GetOptionName(),
}
msc3381V2Answers[i] = map[string]any{
"org.matrix.msc3381.v2.id": optionHashStr,
"org.matrix.msc1767.markup": []map[string]any{
{"mimetype": "text/plain", "body": opt.GetOptionName()},
},
}
} }
body := fmt.Sprintf("%s\n\n%s", msg.GetName(), strings.Join(optionsListText, "\n")) body := fmt.Sprintf("%s\n\n%s", msg.GetName(), strings.Join(optionsListText, "\n"))
formattedBody := fmt.Sprintf("<p>%s</p><ol>%s</ol>", event.TextToHTML(msg.GetName()), strings.Join(optionsListHTML, "")) formattedBody := fmt.Sprintf("<p>%s</p><ol>%s</ol>", event.TextToHTML(msg.GetName()), strings.Join(optionsListHTML, ""))
maxChoices := int(msg.GetSelectableOptionsCount())
if maxChoices <= 0 {
maxChoices = len(optionNames)
}
evtType := event.EventMessage
if portal.bridge.Config.Bridge.ExtEvPolls == 1 {
evtType.Type = "org.matrix.msc3381.poll.start"
} else if portal.bridge.Config.Bridge.ExtEvPolls == 2 {
evtType.Type = "org.matrix.msc3381.v2.poll.start"
}
return &ConvertedMessage{ return &ConvertedMessage{
Intent: intent, Intent: intent,
Type: event.EventMessage, Type: evtType,
Content: &event.MessageEventContent{ Content: &event.MessageEventContent{
Body: body, Body: body,
MsgType: event.MsgText, MsgType: event.MsgText,
@ -2116,9 +2188,40 @@ func (portal *Portal) convertPollCreationMessage(intent *appservice.IntentAPI, m
FormattedBody: formattedBody, FormattedBody: formattedBody,
}, },
Extra: map[string]any{ Extra: map[string]any{
// Custom metadata
"fi.mau.whatsapp.poll": map[string]any{ "fi.mau.whatsapp.poll": map[string]any{
"options": optionNames, "option_names": optionNames,
"max_choices": msg.GetSelectableOptionsCount(), "selectable_options_count": msg.GetSelectableOptionsCount(),
},
// Current extensible events (as of November 2022)
"org.matrix.msc1767.markup": []map[string]any{
{"mimetype": "text/html", "body": formattedBody},
{"mimetype": "text/plain", "body": body},
},
"org.matrix.msc3381.v2.poll": map[string]any{
"kind": "org.matrix.msc3381.v2.disclosed",
"max_selections": maxChoices,
"question": map[string]any{
"m.markup": []map[string]any{
{"mimetype": "text/plain", "body": msg.GetName()},
},
},
"answers": msc3381V2Answers,
},
// Legacy extensible events
"org.matrix.msc1767.message": []map[string]any{
{"mimetype": "text/html", "body": formattedBody},
{"mimetype": "text/plain", "body": body},
},
"org.matrix.msc3381.poll.start": map[string]any{
"kind": "org.matrix.msc3381.poll.disclosed",
"max_selections": maxChoices,
"question": map[string]any{
"m.text": msg.GetName(),
},
"answers": msc3381Answers,
}, },
}, },
ReplyTo: GetReply(msg.GetContextInfo()), ReplyTo: GetReply(msg.GetContextInfo()),