mirror of
https://github.com/tulir/mautrix-whatsapp
synced 2024-12-04 20:52:54 +01:00
msgconv/from-whatsapp: add support for polls
This commit is contained in:
parent
a351d2b150
commit
21f62e3bbf
10 changed files with 278 additions and 48 deletions
|
@ -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
|
||||
|
|
|
@ -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"})
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)?
|
||||
#
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
108
pkg/msgconv/matrixpoll.go
Normal file
108
pkg/msgconv/matrixpoll.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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{})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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("<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(),
|
||||
}
|
||||
}
|
||||
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("<p>%s</p><ol>%s</ol><p>(This message is a poll. Please open WhatsApp to vote.)</p>", 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) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, message *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
|
||||
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, 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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue