From 21f62e3bbfe453b3ec3083db248003ca59c90c09 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 7 Oct 2024 14:21:56 +0300 Subject: [PATCH] msgconv/from-whatsapp: add support for polls --- ROADMAP.md | 4 +- cmd/mautrix-whatsapp/legacymigrate.go | 1 + pkg/connector/config.go | 2 + pkg/connector/connector.go | 4 +- pkg/connector/example-config.yaml | 2 + pkg/connector/id.go | 36 ------ pkg/msgconv/matrixpoll.go | 108 +++++++++++++++++ pkg/msgconv/msgconv.go | 4 + pkg/msgconv/wa-poll.go | 164 ++++++++++++++++++++++++-- pkg/waid/dbmeta.go | 1 + 10 files changed, 278 insertions(+), 48 deletions(-) create mode 100644 pkg/msgconv/matrixpoll.go diff --git a/ROADMAP.md b/ROADMAP.md index 1af2eefa..ad1b7cca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,8 +31,8 @@ * [x] Location messages * [x] Contact messages * [x] Replies - * [ ] Polls - * [ ] Poll votes + * [x] Polls + * [x] Poll votes * [ ] Chat types * [x] Private chat * [x] Group chat diff --git a/cmd/mautrix-whatsapp/legacymigrate.go b/cmd/mautrix-whatsapp/legacymigrate.go index d40bd5d3..e3d2eb69 100644 --- a/cmd/mautrix-whatsapp/legacymigrate.go +++ b/cmd/mautrix-whatsapp/legacymigrate.go @@ -56,6 +56,7 @@ func migrateLegacyConfig(helper up.Helper) { bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "status_broadcast_tag"}, []string{"network", "status_broadcast_tag"}) bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "whatsapp_thumbnail"}, []string{"network", "whatsapp_thumbnail"}) bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "url_previews"}, []string{"network", "url_previews"}) + bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "extev_polls"}, []string{"network", "extev_polls"}) bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "force_active_delivery_receipts"}, []string{"network", "force_active_delivery_receipts"}) bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "history_sync", "max_initial_conversations"}, []string{"network", "history_sync", "max_initial_conversations"}) bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "history_sync", "request_full_sync"}, []string{"network", "history_sync", "request_full_sync"}) diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 0e29c330..58c1f1cf 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -42,6 +42,7 @@ type Config struct { StatusBroadcastTag event.RoomTag `yaml:"status_broadcast_tag"` WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"` URLPreviews bool `yaml:"url_previews"` + ExtEvPolls bool `yaml:"extev_polls"` ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"` AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"` @@ -100,6 +101,7 @@ func upgradeConfig(helper up.Helper) { helper.Copy(up.Str, "status_broadcast_tag") helper.Copy(up.Bool, "whatsapp_thumbnail") helper.Copy(up.Bool, "url_previews") + helper.Copy(up.Bool, "extev_polls") helper.Copy(up.Bool, "force_active_delivery_receipts") helper.Copy(up.Str, "animated_sticker", "target") diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 8909eb3b..8cfebbf9 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -47,8 +47,9 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) { wa.Bridge = bridge wa.MsgConv = msgconv.New(bridge) wa.MsgConv.AnimatedStickerConfig = wa.Config.AnimatedSticker - wa.MsgConv.FetchURLPreviews = wa.Config.URLPreviews + wa.MsgConv.ExtEvPolls = wa.Config.ExtEvPolls wa.MsgConv.OldMediaSuffix = "Requesting old media is not enabled on this bridge." + wa.MsgConv.FetchURLPreviews = wa.Config.URLPreviews if wa.Config.HistorySync.MediaRequests.AutoRequestMedia { if wa.Config.HistorySync.MediaRequests.RequestMethod == MediaRequestMethodImmediate { wa.MsgConv.OldMediaSuffix = "Media will be requested from your phone automatically soon." @@ -57,6 +58,7 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) { } } wa.DB = wadb.New(bridge.ID, bridge.DB.Database, bridge.Log.With().Str("db_section", "whatsapp").Logger()) + wa.MsgConv.DB = wa.DB wa.Bridge.Commands.(*commands.Processor).AddHandlers( cmdAccept, ) diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml index bffeb896..7c2ce08d 100644 --- a/pkg/connector/example-config.yaml +++ b/pkg/connector/example-config.yaml @@ -45,6 +45,8 @@ whatsapp_thumbnail: false # and send it to WhatsApp? URL previews can always be sent using the `com.beeper.linkpreviews` # key in the event content even if this is disabled. url_previews: false +# Should polls be sent using unstable MSC3381 event types? +extev_polls: false # Should the bridge always send "active" delivery receipts (two gray ticks on WhatsApp) # even if the user isn't marked as online (e.g. when presence bridging isn't enabled)? # diff --git a/pkg/connector/id.go b/pkg/connector/id.go index c6815376..8bb42930 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -50,39 +50,3 @@ func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.Mes } return key } - -//lint:ignore U1000 - TODO use this function -func (wa *WhatsAppClient) keyToMessageID(chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID { - sender = sender.ToNonAD() - var err error - if !key.GetFromMe() { - if key.GetParticipant() != "" { - sender, err = types.ParseJID(key.GetParticipant()) - if err != nil { - // TODO log somehow? - return "" - } - if sender.Server == types.LegacyUserServer { - sender.Server = types.DefaultUserServer - } - } else if chat.Server == types.DefaultUserServer { - ownID := ptr.Val(wa.Device.ID).ToNonAD() - if sender.User == ownID.User { - sender = chat - } else { - sender = ownID - } - } else { - // TODO log somehow? - return "" - } - } - remoteJID, err := types.ParseJID(key.GetRemoteJID()) - if err == nil && !remoteJID.IsEmpty() { - // TODO use remote jid in other cases? - if remoteJID.Server == types.GroupServer { - chat = remoteJID - } - } - return waid.MakeMessageID(chat, sender, key.GetID()) -} diff --git a/pkg/msgconv/matrixpoll.go b/pkg/msgconv/matrixpoll.go new file mode 100644 index 00000000..f2298e04 --- /dev/null +++ b/pkg/msgconv/matrixpoll.go @@ -0,0 +1,108 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "reflect" + + "maunium.net/go/mautrix/event" +) + +var ( + TypeMSC3381PollStart = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.start"} + TypeMSC3381PollResponse = event.Type{Class: event.MessageEventType, Type: "org.matrix.msc3381.poll.response"} +) + +type PollResponseContent struct { + RelatesTo event.RelatesTo `json:"m.relates_to"` + V1Response struct { + Answers []string `json:"answers"` + } `json:"org.matrix.msc3381.poll.response"` + V2Selections []string `json:"org.matrix.msc3381.v2.selections"` +} + +func (content *PollResponseContent) GetRelatesTo() *event.RelatesTo { + return &content.RelatesTo +} + +func (content *PollResponseContent) OptionalGetRelatesTo() *event.RelatesTo { + if content.RelatesTo.Type == "" { + return nil + } + return &content.RelatesTo +} + +func (content *PollResponseContent) SetRelatesTo(rel *event.RelatesTo) { + content.RelatesTo = *rel +} + +type MSC1767Message struct { + Text string `json:"org.matrix.msc1767.text,omitempty"` + HTML string `json:"org.matrix.msc1767.html,omitempty"` + Message []struct { + MimeType string `json:"mimetype"` + Body string `json:"body"` + } `json:"org.matrix.msc1767.message,omitempty"` +} + +//lint:ignore U1000 Unused function +func msc1767ToWhatsApp(msg MSC1767Message) string { + for _, part := range msg.Message { + if part.MimeType == "text/html" && msg.HTML == "" { + msg.HTML = part.Body + } else if part.MimeType == "text/plain" && msg.Text == "" { + msg.Text = part.Body + } + } + if msg.HTML != "" { + return parseWAFormattingToHTML(msg.HTML, false) + } + return msg.Text +} + +type PollStartContent struct { + RelatesTo *event.RelatesTo `json:"m.relates_to"` + PollStart struct { + Kind string `json:"kind"` + MaxSelections int `json:"max_selections"` + Question MSC1767Message `json:"question"` + Answers []struct { + ID string `json:"id"` + MSC1767Message + } `json:"answers"` + } `json:"org.matrix.msc3381.poll.start"` +} + +func (content *PollStartContent) GetRelatesTo() *event.RelatesTo { + if content.RelatesTo == nil { + content.RelatesTo = &event.RelatesTo{} + } + return content.RelatesTo +} + +func (content *PollStartContent) OptionalGetRelatesTo() *event.RelatesTo { + return content.RelatesTo +} + +func (content *PollStartContent) SetRelatesTo(rel *event.RelatesTo) { + content.RelatesTo = rel +} + +func init() { + event.TypeMap[TypeMSC3381PollResponse] = reflect.TypeOf(PollResponseContent{}) + event.TypeMap[TypeMSC3381PollStart] = reflect.TypeOf(PollStartContent{}) +} diff --git a/pkg/msgconv/msgconv.go b/pkg/msgconv/msgconv.go index edcbc862..131581f4 100644 --- a/pkg/msgconv/msgconv.go +++ b/pkg/msgconv/msgconv.go @@ -19,6 +19,8 @@ package msgconv import ( "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/format" + + "maunium.net/go/mautrix-whatsapp/pkg/connector/wadb" ) type AnimatedStickerConfig struct { @@ -32,10 +34,12 @@ type AnimatedStickerConfig struct { type MessageConverter struct { Bridge *bridgev2.Bridge + DB *wadb.Database MaxFileSize int64 HTMLParser *format.HTMLParser AnimatedStickerConfig AnimatedStickerConfig FetchURLPreviews bool + ExtEvPolls bool OldMediaSuffix string } diff --git a/pkg/msgconv/wa-poll.go b/pkg/msgconv/wa-poll.go index ea4ca31c..8b3e235f 100644 --- a/pkg/msgconv/wa-poll.go +++ b/pkg/msgconv/wa-poll.go @@ -18,29 +18,175 @@ package msgconv import ( "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + "go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + + "maunium.net/go/mautrix-whatsapp/pkg/waid" ) -func (mc *MessageConverter) convertPollCreationMessage(ctx context.Context, message *waE2E.PollCreationMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { +func (mc *MessageConverter) convertPollCreationMessage(ctx context.Context, msg *waE2E.PollCreationMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { + optionNames := make([]string, len(msg.GetOptions())) + optionsListText := make([]string, len(optionNames)) + optionsListHTML := make([]string, len(optionNames)) + msc3381Answers := make([]map[string]any, len(optionNames)) + for i, opt := range msg.GetOptions() { + optionNames[i] = opt.GetOptionName() + optionsListText[i] = fmt.Sprintf("%d. %s\n", i+1, optionNames[i]) + optionsListHTML[i] = fmt.Sprintf("
  • %s
  • ", 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(), + } + } + body := fmt.Sprintf("%s\n\n%s\n\n(This message is a poll. Please open WhatsApp to vote.)", msg.GetName(), strings.Join(optionsListText, "\n")) + formattedBody := fmt.Sprintf("

    %s

      %s

    (This message is a poll. Please open WhatsApp to vote.)

    ", event.TextToHTML(msg.GetName()), strings.Join(optionsListHTML, "")) + maxChoices := int(msg.GetSelectableOptionsCount()) + if maxChoices <= 0 { + maxChoices = len(optionNames) + } + evtType := event.EventMessage + if mc.ExtEvPolls { + evtType = TypeMSC3381PollStart + } + return &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, + Type: evtType, Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Polls are not yet supported", + Body: body, + MsgType: event.MsgText, + Format: event.FormatHTML, + FormattedBody: formattedBody, }, - }, nil + Extra: map[string]any{ + // Custom metadata + "fi.mau.whatsapp.poll": map[string]any{ + "option_names": optionNames, + "selectable_options_count": msg.GetSelectableOptionsCount(), + }, + // 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{ + "org.matrix.msc1767.text": msg.GetName(), + }, + "answers": msc3381Answers, + }, + }, + }, msg.GetContextInfo() +} + +func (mc *MessageConverter) keyToMessageID(ctx context.Context, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID { + sender = sender.ToNonAD() + var err error + if !key.GetFromMe() { + if key.GetParticipant() != "" { + sender, err = types.ParseJID(key.GetParticipant()) + if err != nil { + // TODO log somehow? + return "" + } + if sender.Server == types.LegacyUserServer { + sender.Server = types.DefaultUserServer + } + } else if chat.Server == types.DefaultUserServer { + ownID := ptr.Val(getClient(ctx).Store.ID).ToNonAD() + if sender.User == ownID.User { + sender = chat + } else { + sender = ownID + } + } else { + // TODO log somehow? + return "" + } + } + remoteJID, err := types.ParseJID(key.GetRemoteJID()) + if err == nil && !remoteJID.IsEmpty() { + // TODO use remote jid in other cases? + if remoteJID.Server == types.GroupServer { + chat = remoteJID + } + } + return waid.MakeMessageID(chat, sender, key.GetID()) +} + +var failedPollUpdatePart = &bridgev2.ConvertedMessagePart{ + Type: TypeMSC3381PollResponse, + Content: &event.MessageEventContent{}, + DontBridge: true, } -func (mc *MessageConverter) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, message *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { +func (mc *MessageConverter) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { + log := zerolog.Ctx(ctx) + pollMessageID := mc.keyToMessageID(ctx, info.Chat, info.Sender, msg.PollCreationMessageKey) + pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "") + if err != nil { + log.Err(err).Msg("Failed to get poll update target message") + return failedPollUpdatePart, nil + } + vote, err := getClient(ctx).DecryptPollVote(&events.Message{ + Info: *info, + Message: &waE2E.Message{PollUpdateMessage: msg}, + }) + if err != nil { + log.Err(err).Msg("Failed to decrypt vote message") + return failedPollUpdatePart, nil + } + + selectedHashes := make([]string, len(vote.GetSelectedOptions())) + if pollMessage.Metadata.(*waid.MessageMetadata).IsMatrixPoll { + mappedAnswers, err := mc.DB.PollOption.GetIDs(ctx, pollMessage.MXID, vote.GetSelectedOptions()) + if err != nil { + log.Err(err).Msg("Failed to get poll option IDs") + return failedPollUpdatePart, nil + } + for i, opt := range vote.GetSelectedOptions() { + if len(opt) != 32 { + log.Warn().Int("hash_len", len(opt)).Msg("Unexpected option hash length in vote") + continue + } + var ok bool + selectedHashes[i], ok = mappedAnswers[[32]byte(opt)] + if !ok { + log.Warn().Hex("option_hash", opt).Msg("Didn't find ID for option in vote") + } + } + } else { + for i, opt := range vote.GetSelectedOptions() { + selectedHashes[i] = hex.EncodeToString(opt) + } + } return &bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, + Type: TypeMSC3381PollResponse, Content: &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: "Polls are not yet supported", + RelatesTo: &event.RelatesTo{ + Type: event.RelReference, + EventID: pollMessage.MXID, + }, + }, + Extra: map[string]any{ + "org.matrix.msc3381.poll.response": map[string]any{ + "answers": selectedHashes, + }, }, }, nil } diff --git a/pkg/waid/dbmeta.go b/pkg/waid/dbmeta.go index 11cc6d4a..655a41d0 100644 --- a/pkg/waid/dbmeta.go +++ b/pkg/waid/dbmeta.go @@ -71,6 +71,7 @@ type MessageMetadata struct { BroadcastListJID *types.JID `json:"broadcast_list_jid,omitempty"` GroupInvite *GroupInviteMeta `json:"group_invite,omitempty"` MediaMeta json.RawMessage `json:"media_meta,omitempty"` + IsMatrixPoll bool `json:"is_matrix_poll,omitempty"` } type ReactionMetadata struct {