Эх сурвалжийг харах

Add option to bridge polls into MSC3381 format

Tulir Asokan 2 жил өмнө
parent
commit
beb956973e

+ 1 - 0
config/bridge.go

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

+ 1 - 0
config/upgrade.go

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

+ 3 - 0
example-config.yaml

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

+ 108 - 5
portal.go

@@ -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,
-				"max_choices": msg.GetSelectableOptionsCount(),
+				"option_names":             optionNames,
+				"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()),