v2: update things

This commit is contained in:
Tulir Asokan 2024-09-06 17:41:26 +03:00
parent 8fecdebf68
commit 2aef3b3f54
22 changed files with 1255 additions and 984 deletions

View file

@ -1,7 +1,6 @@
package main
import (
_ "go.mau.fi/util/dbutil/litestream"
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"maunium.net/go/mautrix-whatsapp/pkg/connector"
@ -15,14 +14,16 @@ var (
BuildTime = "unknown"
)
var c = &connector.WhatsAppConnector{}
var m = mxmain.BridgeMain{
Name: "mautrix-whatsapp",
URL: "https://github.com/mautrix/whatsapp",
Description: "A Matrix-WhatsApp puppeting bridge.",
Version: "0.11.0",
Connector: c,
}
func main() {
m := mxmain.BridgeMain{
Name: "mautrix-whatsapp",
URL: "https://github.com/mautrix/whatsapp",
Description: "A Matrix-WhatsApp puppeting bridge.",
Version: "0.10.9",
Connector: connector.NewConnector(),
}
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}

21
go.mod
View file

@ -1,25 +1,26 @@
module maunium.net/go/mautrix-whatsapp
go 1.21
go 1.22
require (
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.22
github.com/mattn/go-sqlite3 v1.14.23
github.com/prometheus/client_golang v1.19.1
github.com/rs/zerolog v1.33.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/tidwall/gjson v1.17.3
go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98
go.mau.fi/util v0.7.1-0.20240904173517-ca3b3fe376c2
go.mau.fi/webp v0.1.0
go.mau.fi/whatsmeow v0.0.0-20240726213518-bb5852f056ca
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
go.mau.fi/whatsmeow v0.0.0-20240828153534-8acde1ba8592
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
golang.org/x/image v0.18.0
golang.org/x/net v0.28.0
golang.org/x/sync v0.8.0
google.golang.org/protobuf v1.34.2
maunium.net/go/mautrix v0.19.1-0.20240809124413-8c0f705ee90b
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.20.1-0.20240906105454-33d724bf4c78
)
require (
@ -31,10 +32,11 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
@ -42,9 +44,8 @@ require (
go.mau.fi/libsignal v0.1.1 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)

33
go.sum
View file

@ -32,8 +32,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw=
github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -47,8 +49,9 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
@ -69,18 +72,18 @@ github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/libsignal v0.1.1 h1:m/0PGBh4QKP/I1MQ44ti4C0fMbLMuHb95cmDw01FIpI=
go.mau.fi/libsignal v0.1.1/go.mod h1:QLs89F/OA3ThdSL2Wz2p+o+fi8uuQUz0e1BRa6ExdBw=
go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98 h1:gJ0peWecBm6TtlxKFVIc1KbooXSCHtPfsfb2Eha5A0A=
go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98/go.mod h1:S1juuPWGau2GctPY3FR/4ec/MDLhAG2QPhdnUwpzWIo=
go.mau.fi/util v0.7.1-0.20240904173517-ca3b3fe376c2 h1:VZQlKBbeJ7KOlYSh6BnN5uWQTY/ypn/bJv0YyEd+pXc=
go.mau.fi/util v0.7.1-0.20240904173517-ca3b3fe376c2/go.mod h1:WgYvbt9rVmoFeajP97NunQU7AjgvTPiNExN3oTHeePs=
go.mau.fi/webp v0.1.0 h1:BHObH/DcFntT9KYun5pDr0Ot4eUZO8k2C7eP7vF4ueA=
go.mau.fi/webp v0.1.0/go.mod h1:e42Z+VMFrUMS9cpEwGRIor+lQWO8oUAyPyMtcL+NMt8=
go.mau.fi/whatsmeow v0.0.0-20240726213518-bb5852f056ca h1:L0Pc6fi5RevuEASIP6Nd65/HZwCK8wTwm62FEly6UeY=
go.mau.fi/whatsmeow v0.0.0-20240726213518-bb5852f056ca/go.mod h1:BhHKalSq0qNtSCuGIUIvoJyU5KbT4a7k8DQ5yw1Ssk4=
go.mau.fi/whatsmeow v0.0.0-20240828153534-8acde1ba8592 h1:wVBSwcGNGaI8agrExAXSb6rx9lfp9GvUtsAdl0wFWEY=
go.mau.fi/whatsmeow v0.0.0-20240828153534-8acde1ba8592/go.mod h1:BhHKalSq0qNtSCuGIUIvoJyU5KbT4a7k8DQ5yw1Ssk4=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
@ -90,10 +93,10 @@ golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -105,5 +108,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.19.1-0.20240809124413-8c0f705ee90b h1:Gn2UryUziwkRk7veueA2CUTNKy1CrAVkt3QJn1xff90=
maunium.net/go/mautrix v0.19.1-0.20240809124413-8c0f705ee90b/go.mod h1:ZWyxoQxRTBxzWIMs0kQCVogZIY0clTu33h102veCT/Q=
maunium.net/go/mautrix v0.20.1-0.20240906105454-33d724bf4c78 h1:F8ls6UNW8QlTpQ2QAnPuCN5gVB30Y3ojBYfXb9T6U64=
maunium.net/go/mautrix v0.20.1-0.20240906105454-33d724bf4c78/go.mod h1:l6nYvD5/FMSrAZ/IP1AqJV0b47SRl/0uQNRiy4CcSVk=

View file

@ -0,0 +1,60 @@
package connector
import (
"context"
"time"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
)
var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
DisappearingMessages: true,
AggressiveUpdateInfo: false,
}
func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
return WhatsAppGeneralCaps
}
const WAMaxFileSize = 2000 * 1024 * 1024
var whatsappCaps = &bridgev2.NetworkRoomCapabilities{
FormattedText: true,
UserMentions: true,
LocationMessages: true,
Captions: true,
Replies: true,
Edits: true,
EditMaxCount: 10,
EditMaxAge: 15 * time.Minute,
Deletes: true,
DeleteMaxAge: 48 * time.Hour,
DefaultFileRestriction: &bridgev2.FileRestriction{
MaxSize: WAMaxFileSize,
},
Files: map[event.MessageType]bridgev2.FileRestriction{
event.MsgImage: {
MaxSize: WAMaxFileSize,
MimeTypes: []string{"image/png", "image/jpeg"},
},
event.MsgAudio: {
MaxSize: WAMaxFileSize,
MimeTypes: []string{"audio/mpeg", "audio/mp4", "audio/ogg", "audio/aac", "audio/amr"},
},
event.MsgVideo: {
MaxSize: WAMaxFileSize,
MimeTypes: []string{"video/mp4", "video/3gpp"},
},
event.MsgFile: {
MaxSize: WAMaxFileSize,
},
},
ReadReceipts: true,
Reactions: true,
ReactionCount: 1,
}
func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities {
return whatsappCaps
}

View file

@ -3,9 +3,11 @@ package connector
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waHistorySync"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
@ -15,103 +17,275 @@ import (
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
var moderatorPL = 50
func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (wrapped *bridgev2.ChatInfo, err error) {
portalJID, err := waid.ParsePortalID(portal.ID)
if err != nil {
return nil, err
}
switch portalJID.Server {
case types.DefaultUserServer:
wrapped = wa.wrapDMInfo(portalJID)
case types.BroadcastServer:
if portalJID == types.StatusBroadcastJID {
wrapped = wa.wrapStatusBroadcastInfo()
} else {
return nil, fmt.Errorf("broadcast list bridging is currently not supported")
}
case types.GroupServer:
info, err := wa.Client.GetGroupInfo(portalJID)
if err != nil {
return nil, err
}
wrapped = wa.wrapGroupInfo(info)
case types.NewsletterServer:
info, err := wa.Client.GetNewsletterInfo(portalJID)
if err != nil {
return nil, err
}
wrapped = wa.wrapNewsletterInfo(info)
default:
return nil, fmt.Errorf("unsupported server %s", portalJID.Server)
}
var conv *waHistorySync.Conversation
applyHistoryInfo(wrapped, conv)
wa.applyChatSettings(ctx, portalJID, wrapped)
return wrapped, nil
}
var (
_ bridgev2.ContactListingNetworkAPI = (*WhatsAppClient)(nil)
)
func updateDisappearingTimerSetAt(ts int64) bridgev2.ExtraUpdater[*bridgev2.Portal] {
return func(_ context.Context, portal *bridgev2.Portal) bool {
meta := portal.Metadata.(*PortalMetadata)
if meta.DisappearingTimerSetAt != ts {
meta.DisappearingTimerSetAt = ts
return true
}
return false
}
}
func (wa *WhatsAppClient) GetChatInfo(_ context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
myJID := wa.Client.Store.ID.ToNonAD()
members := &bridgev2.ChatMemberList{
IsFull: true,
Members: []bridgev2.ChatMember{
{
EventSender: wa.makeEventSender(&myJID),
Membership: event.MembershipJoin,
PowerLevel: &moderatorPL,
func (wa *WhatsAppClient) applyChatSettings(ctx context.Context, chatID types.JID, info *bridgev2.ChatInfo) {
chat, err := wa.Client.Store.ChatSettings.GetChatSettings(chatID)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings")
return
}
info.UserLocal = &bridgev2.UserLocalPortalInfo{
MutedUntil: ptr.Ptr(chat.MutedUntil),
}
if chat.Pinned {
info.UserLocal.Tag = ptr.Ptr(event.RoomTagFavourite)
} else if chat.Archived {
info.UserLocal.Tag = ptr.Ptr(event.RoomTagLowPriority)
}
}
func applyHistoryInfo(info *bridgev2.ChatInfo, conv *waHistorySync.Conversation) {
if conv == nil {
return
}
info.CanBackfill = true
info.UserLocal = &bridgev2.UserLocalPortalInfo{
MutedUntil: ptr.Ptr(time.Unix(int64(conv.GetMuteEndTime()), 0)),
}
if conv.GetPinned() > 0 {
info.UserLocal.Tag = ptr.Ptr(event.RoomTagFavourite)
} else if conv.GetArchived() {
info.UserLocal.Tag = ptr.Ptr(event.RoomTagLowPriority)
}
if conv.GetEphemeralExpiration() > 0 {
info.Disappear = &database.DisappearingSetting{
Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(conv.GetEphemeralExpiration()) * time.Second,
}
info.ExtraUpdates = bridgev2.MergeExtraUpdaters(info.ExtraUpdates, updateDisappearingTimerSetAt(conv.GetEphemeralSettingTimestamp()))
}
}
const StatusBroadcastTopic = "WhatsApp status updates from your contacts"
const StatusBroadcastName = "WhatsApp Status Broadcast"
const BroadcastTopic = "WhatsApp broadcast list"
const UnnamedBroadcastName = "Unnamed broadcast list"
const PrivateChatTopic = "WhatsApp private chat"
func (wa *WhatsAppClient) wrapDMInfo(jid types.JID) *bridgev2.ChatInfo {
info := &bridgev2.ChatInfo{
Topic: ptr.Ptr(PrivateChatTopic),
Members: &bridgev2.ChatMemberList{
IsFull: true,
TotalMemberCount: 2,
OtherUserID: waid.MakeUserID(jid),
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(jid): {EventSender: wa.makeEventSender(jid)},
waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(wa.JID)},
},
PowerLevels: nil,
},
Type: ptr.Ptr(database.RoomTypeDM),
}
if jid == wa.JID.ToNonAD() {
// For chats with self, force-split the members so the user's own ghost is always in the room.
info.Members.MemberMap = map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(jid): {EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(jid)}},
"": {EventSender: bridgev2.EventSender{IsFromMe: true}},
}
}
return info
}
func (wa *WhatsAppClient) wrapStatusBroadcastInfo() *bridgev2.ChatInfo {
return &bridgev2.ChatInfo{
Name: ptr.Ptr(StatusBroadcastName),
Topic: ptr.Ptr(StatusBroadcastTopic),
Members: &bridgev2.ChatMemberList{
IsFull: false,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(wa.JID)},
},
},
Type: ptr.Ptr(database.RoomTypeDefault),
CanBackfill: false,
}
jid, _ := types.ParseJID(string(portal.ID))
if jid.Server == types.GroupServer {
return &bridgev2.ChatInfo{}, nil
}
if networkid.UserLoginID(jid.User) != wa.UserLogin.ID {
members.Members = append(members.Members, bridgev2.ChatMember{
EventSender: wa.makeEventSender(&jid),
Membership: event.MembershipJoin,
})
}
return &bridgev2.ChatInfo{
Members: members,
Type: ptr.Ptr(database.RoomTypeDM),
}, nil
}
func (wa *WhatsAppClient) GetUserInfo(_ context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
jid := types.NewJID(string(ghost.ID), types.DefaultUserServer)
const (
nobodyPL = 99
superAdminPL = 75
adminPL = 50
defaultPL = 0
)
contact, err := wa.Client.Store.Contacts.GetContact(jid)
if err != nil {
return nil, err
func (wa *WhatsAppClient) wrapGroupInfo(info *types.GroupInfo) *bridgev2.ChatInfo {
sendEventPL := defaultPL
if info.IsAnnounce {
sendEventPL = adminPL
}
// make user info from contact info
return wa.contactToUserInfo(jid, contact), nil
}
func (wa *WhatsAppClient) contactToUserInfo(jid types.JID, contact types.ContactInfo) *bridgev2.UserInfo {
isBot := false
ui := &bridgev2.UserInfo{
IsBot: &isBot,
Identifiers: []string{},
metaChangePL := defaultPL
if info.IsLocked {
metaChangePL = adminPL
}
name := wa.Main.Config.FormatDisplayname(jid, contact)
ui.Name = &name
ui.Identifiers = append(ui.Identifiers, "tel:+"+jid.User)
avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{
Preview: false,
IsCommunity: false,
})
if avatar != nil && err == nil {
ui.Avatar = &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID),
Get: func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithPath(avatar.DirectPath, nil, nil, nil, 0, "", "")
wrapped := &bridgev2.ChatInfo{
Name: ptr.Ptr(info.Name),
Topic: ptr.Ptr(info.Topic),
Members: &bridgev2.ChatMemberList{
IsFull: !info.IsIncognito,
TotalMemberCount: len(info.Participants),
MemberMap: make(map[networkid.UserID]bridgev2.ChatMember, len(info.Participants)),
PowerLevels: &bridgev2.PowerLevelOverrides{
EventsDefault: &sendEventPL,
StateDefault: ptr.Ptr(nobodyPL),
Ban: ptr.Ptr(nobodyPL),
// TODO allow invites if bridge config says to allow them, or maybe if relay mode is enabled?
Events: map[event.Type]int{
event.StateRoomName: metaChangePL,
event.StateRoomAvatar: metaChangePL,
event.StateTopic: metaChangePL,
event.EventReaction: defaultPL,
event.EventRedaction: defaultPL,
// TODO always allow poll responses
},
},
},
Disappear: &database.DisappearingSetting{
Type: database.DisappearingTypeAfterRead,
Timer: time.Duration(info.DisappearingTimer) * time.Second,
},
}
for _, pcp := range info.Participants {
if pcp.JID.Server != types.DefaultUserServer {
continue
}
member := bridgev2.ChatMember{
EventSender: wa.makeEventSender(pcp.JID),
Membership: event.MembershipJoin,
}
if pcp.IsSuperAdmin {
member.PowerLevel = ptr.Ptr(superAdminPL)
} else if pcp.IsAdmin {
member.PowerLevel = ptr.Ptr(adminPL)
} else {
member.PowerLevel = ptr.Ptr(defaultPL)
}
wrapped.Members.MemberMap[waid.MakeUserID(pcp.JID)] = member
}
return ui
if !info.LinkedParentJID.IsEmpty() {
wrapped.ParentID = ptr.Ptr(waid.MakePortalID(info.LinkedParentJID))
}
if info.IsParent {
wrapped.Type = ptr.Ptr(database.RoomTypeSpace)
} else {
wrapped.Type = ptr.Ptr(database.RoomTypeDefault)
}
return wrapped
}
func (wa *WhatsAppClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) {
contacts, err := wa.Client.Store.Contacts.GetAllContacts()
if err != nil {
return nil, err
}
resp := make([]*bridgev2.ResolveIdentifierResponse, len(contacts))
index := 0
for jid := range contacts {
contact := contacts[jid]
contactResp := &bridgev2.ResolveIdentifierResponse{
UserInfo: wa.contactToUserInfo(jid, contact),
//Chat: wa.makeCreateDMResponse(contact), TODO implement
func (wa *WhatsAppClient) wrapNewsletterInfo(info *types.NewsletterMetadata) *bridgev2.ChatInfo {
ownPowerLevel := defaultPL
var mutedUntil *time.Time
if info.ViewerMeta != nil {
switch info.ViewerMeta.Role {
case types.NewsletterRoleAdmin:
ownPowerLevel = adminPL
case types.NewsletterRoleOwner:
ownPowerLevel = superAdminPL
}
contactResp.UserID = waid.MakeUserID(&jid)
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, contactResp.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get ghost for %s: %w", jid, err)
switch info.ViewerMeta.Mute {
case types.NewsletterMuteOn:
mutedUntil = &event.MutedForever
case types.NewsletterMuteOff:
mutedUntil = &bridgev2.Unmuted
}
contactResp.Ghost = ghost
resp[index] = contactResp
index++
}
return resp, nil
avatar := &bridgev2.Avatar{}
if info.ThreadMeta.Picture != nil {
avatar.ID = networkid.AvatarID(info.ThreadMeta.Picture.ID)
avatar.Get = func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithPath(info.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "")
}
} else if info.ThreadMeta.Preview.ID != "" {
avatar.ID = networkid.AvatarID(info.ThreadMeta.Preview.ID)
avatar.Get = func(ctx context.Context) ([]byte, error) {
meta, err := wa.Client.GetNewsletterInfo(info.ID)
if err != nil {
return nil, fmt.Errorf("failed to fetch full res avatar info: %w", err)
} else if meta.ThreadMeta.Picture == nil {
return nil, fmt.Errorf("full res avatar info is missing")
}
return wa.Client.DownloadMediaWithPath(meta.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "")
}
} else {
avatar.ID = "remove"
avatar.Remove = true
}
return &bridgev2.ChatInfo{
Name: ptr.Ptr(info.ThreadMeta.Name.Text),
Topic: ptr.Ptr(info.ThreadMeta.Description.Text),
Avatar: avatar,
UserLocal: &bridgev2.UserLocalPortalInfo{
MutedUntil: mutedUntil,
},
Members: &bridgev2.ChatMemberList{
TotalMemberCount: info.ThreadMeta.SubscriberCount,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(wa.JID): {
EventSender: wa.makeEventSender(wa.JID),
PowerLevel: &ownPowerLevel,
},
},
PowerLevels: &bridgev2.PowerLevelOverrides{
EventsDefault: ptr.Ptr(adminPL),
StateDefault: ptr.Ptr(nobodyPL),
Ban: ptr.Ptr(nobodyPL),
Events: map[event.Type]int{
event.StateRoomName: adminPL,
event.StateRoomAvatar: adminPL,
event.StateTopic: adminPL,
event.EventReaction: defaultPL,
event.EventRedaction: defaultPL,
// TODO always allow poll responses
},
},
},
Type: ptr.Ptr(database.RoomTypeDefault),
}
}

View file

@ -6,20 +6,15 @@ import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types"
waLog "go.mau.fi/whatsmeow/util/log"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
@ -32,11 +27,11 @@ type respGetProxy struct {
ProxyURL string `json:"proxy_url"`
}
func (wa *WhatsAppClient) getProxy(reason string) (string, error) {
if wa.Main.Config.GetProxyURL == "" {
return wa.Main.Config.Proxy, nil
func (wa *WhatsAppConnector) getProxy(reason string) (string, error) {
if wa.Config.GetProxyURL == "" {
return wa.Config.Proxy, nil
}
parsed, err := url.Parse(wa.Main.Config.GetProxyURL)
parsed, err := url.Parse(wa.Config.GetProxyURL)
if err != nil {
return "", fmt.Errorf("failed to parse address: %w", err)
}
@ -62,81 +57,20 @@ func (wa *WhatsAppClient) getProxy(reason string) (string, error) {
return respData.ProxyURL, nil
}
func (wa *WhatsAppClient) MakeNewClient(log zerolog.Logger) {
wa.Client = whatsmeow.NewClient(wa.Device, waLog.Zerolog(log))
if wa.Device.ID != nil {
wa.Client.AddEventHandler(wa.handleWAEvent)
wa.Client.EnableAutoReconnect = true
} else {
wa.Client.EnableAutoReconnect = false // no auto reconnect unless we are logged in
func (wa *WhatsAppConnector) updateProxy(client *whatsmeow.Client, isLogin bool) error {
if wa.Config.ProxyOnlyLogin && !isLogin {
return nil
}
wa.Client.AutomaticMessageRerequestFromPhone = true
wa.Client.SetForceActiveDeliveryReceipts(wa.Main.Config.ForceActiveDeliveryReceipts)
//TODO: add tracking under PreRetryCallback and GetMessageForRetry (waiting till metrics/analytics are added)
if wa.Main.Config.ProxyOnlyLogin || wa.Device.ID == nil {
if proxy, err := wa.getProxy("login"); err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to get proxy address")
} else if err = wa.Client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{
NoMedia: wa.Main.Config.ProxyOnlyLogin,
}); err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to set proxy address")
}
reason := "connect"
if isLogin {
reason = "login"
}
if wa.Main.Config.ProxyOnlyLogin {
wa.Client.ToggleProxyOnlyForLogin(true)
if proxy, err := wa.getProxy(reason); err != nil {
return fmt.Errorf("failed to get proxy address: %w", err)
} else if err = client.SetProxyAddress(proxy); err != nil {
return fmt.Errorf("failed to set proxy address: %w", err)
}
}
func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.MessageKey {
key := &waCommon.MessageKey{
RemoteJID: ptr.Ptr(id.Chat.String()),
ID: ptr.Ptr(id.ID),
}
if id.Sender.User == string(wa.UserLogin.ID) {
key.FromMe = ptr.Ptr(true)
}
if id.Chat.Server != types.MessengerServer && id.Chat.Server != types.DefaultUserServer {
key.Participant = ptr.Ptr(id.Sender.String())
}
return key
}
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())
return nil
}
type WhatsAppClient struct {
@ -144,38 +78,7 @@ type WhatsAppClient struct {
UserLogin *bridgev2.UserLogin
Client *whatsmeow.Client
Device *store.Device
}
var whatsappCaps = &bridgev2.NetworkRoomCapabilities{
FormattedText: true,
UserMentions: true,
LocationMessages: true,
Captions: true,
Replies: true,
Edits: true,
EditMaxCount: 10,
EditMaxAge: 15 * time.Minute,
Deletes: true,
DeleteMaxAge: 48 * time.Hour,
DefaultFileRestriction: &bridgev2.FileRestriction{
// 100MB is the limit for videos by default.
// HQ on images and videos can be enabled
// Documents can do 2GB, TODO implementation
MaxSize: 100 * 1024 * 1024,
},
//TODO: implement
Files: map[event.MessageType]bridgev2.FileRestriction{},
ReadReceipts: true,
Reactions: true,
ReactionCount: 1,
}
func (wa *WhatsAppClient) GetCapabilities(_ context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities {
if portal.Receiver == wa.UserLogin.ID && portal.ID == networkid.PortalID(wa.UserLogin.ID) {
// note to self mode, not implemented yet
return nil
}
return whatsappCaps
JID types.JID
}
var (
@ -211,6 +114,29 @@ func (wa *WhatsAppClient) RegisterPushNotifications(_ context.Context, pushType
return nil
}
func (wa *WhatsAppClient) IsThisUser(_ context.Context, userID networkid.UserID) bool {
return userID == waid.MakeUserID(wa.JID)
}
func (wa *WhatsAppClient) Connect(ctx context.Context) error {
if wa.Client == nil {
state := status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: WANotLoggedIn,
}
wa.UserLogin.BridgeState.Send(state)
return nil
}
if err := wa.Main.updateProxy(wa.Client, false); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
}
return wa.Client.Connect()
}
func (wa *WhatsAppClient) Disconnect() {
wa.Client.Disconnect()
}
func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) {
if wa.Client == nil {
return
@ -221,44 +147,6 @@ func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) {
}
}
func (wa *WhatsAppClient) IsThisUser(_ context.Context, userID networkid.UserID) bool {
if wa.Client == nil {
return false
}
return userID == networkid.UserID(wa.Client.Store.ID.User)
}
func (wa *WhatsAppClient) Connect(_ context.Context) error {
if wa.Client != nil {
return wa.Client.Connect()
} else {
state := status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: WANotLoggedIn,
}
wa.UserLogin.BridgeState.Send(state)
return nil
}
}
func (wa *WhatsAppClient) Disconnect() {
wa.Client.Disconnect()
}
func (wa *WhatsAppClient) IsLoggedIn() bool {
if wa.Client == nil {
return false
}
return wa.Client.IsLoggedIn()
}
func (wa *WhatsAppClient) FullReconnect() {
panic("unimplemented")
}
func (wa *WhatsAppClient) canReconnect() bool { return false }
func (wa *WhatsAppClient) resetWADevice() {
wa.Device = nil
wa.UserLogin.Metadata.(*UserLoginMetadata).WADeviceID = 0
return wa.Client != nil && wa.Client.IsLoggedIn()
}

View file

@ -7,15 +7,21 @@ import (
up "go.mau.fi/util/configupgrade"
"go.mau.fi/whatsmeow/types"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/event"
)
type MediaRequestMethod string
const (
MediaRequestMethodImmediate MediaRequestMethod = "immediate"
MediaRequestMethodLocalTime = "local_time"
)
//go:embed example-config.yaml
var ExampleConfig string
type WhatsAppConfig struct {
type Config struct {
OSName string `yaml:"os_name"`
BrowserName string `yaml:"browser_name"`
@ -25,7 +31,15 @@ type WhatsAppConfig struct {
DisplaynameTemplate string `yaml:"displayname_template"`
CallStartNotices bool `yaml:"call_start_notices"`
CallStartNotices bool `yaml:"call_start_notices"`
SendPresenceOnTyping bool `yaml:"send_presence_on_typing"`
EnableStatusBroadcast bool `yaml:"enable_status_broadcast"`
DisableStatusBroadcastSend bool `yaml:"disable_status_broadcast_send"`
MuteStatusBroadcast bool `yaml:"mute_status_broadcast"`
StatusBroadcastTag event.RoomTag `yaml:"status_broadcast_tag"`
WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"`
URLPreviews bool `yaml:"url_previews"`
ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"`
HistorySync struct {
RequestFullSync bool `yaml:"request_full_sync"`
@ -43,25 +57,22 @@ type WhatsAppConfig struct {
} `yaml:"media_requests"`
} `yaml:"history_sync"`
UserAvatarSync bool `yaml:"user_avatar_sync"`
displaynameTemplate *template.Template `yaml:"-"`
}
SendPresenceOnTyping bool `yaml:"send_presence_on_typing"`
type umConfig Config
ArchiveTag event.RoomTag `yaml:"archive_tag"`
PinnedTag event.RoomTag `yaml:"pinned_tag"`
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
func (c *Config) UnmarshalYAML(node *yaml.Node) error {
err := node.Decode((*umConfig)(c))
if err != nil {
return err
}
EnableStatusBroadcast bool `yaml:"enable_status_broadcast"`
DisableStatusBroadcastSend bool `yaml:"disable_status_broadcast_send"`
MuteStatusBroadcast bool `yaml:"mute_status_broadcast"`
StatusBroadcastTag event.RoomTag `yaml:"status_broadcast_tag"`
WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"`
URLPreviews bool `yaml:"url_previews"`
ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"`
displaynameTemplate *template.Template `yaml:"-"`
c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate)
if err != nil {
return err
}
return nil
}
func upgradeConfig(helper up.Helper) {
@ -75,34 +86,22 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "displayname_template")
helper.Copy(up.Bool, "call_start_notices")
helper.Copy(up.Bool, "history_sync", "request_full_sync")
helper.Copy(up.Int, "history_sync", "full_sync_config", "days_limit")
helper.Copy(up.Int, "history_sync", "full_sync_config", "size_mb_limit")
helper.Copy(up.Int, "history_sync", "full_sync_config", "storage_quota_mb")
helper.Copy(up.Bool, "history_sync", "media_requests", "auto_request_media")
helper.Copy(up.Str, "history_sync", "media_requests", "request_method")
helper.Copy(up.Int, "history_sync", "media_requests", "request_local_time")
helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle")
helper.Copy(up.Bool, "user_avatar_sync")
helper.Copy(up.Bool, "send_presence_on_typing")
helper.Copy(up.Str, "archive_tag")
helper.Copy(up.Str, "pinned_tag")
helper.Copy(up.Bool, "tag_only_on_create")
helper.Copy(up.Bool, "enable_status_broadcast")
helper.Copy(up.Bool, "disable_status_broadcast_send")
helper.Copy(up.Bool, "mute_status_broadcast")
helper.Copy(up.Str, "status_broadcast_tag")
helper.Copy(up.Bool, "whatsapp_thumbnail")
helper.Copy(up.Bool, "url_previews")
helper.Copy(up.Bool, "history_sync", "request_full_sync")
helper.Copy(up.Int, "history_sync", "full_sync_config", "days_limit")
helper.Copy(up.Int, "history_sync", "full_sync_config", "size_mb_limit")
helper.Copy(up.Int, "history_sync", "full_sync_config", "storage_quota_mb")
helper.Copy(up.Bool, "history_sync", "media_requests", "auto_request_media")
helper.Copy(up.Str, "history_sync", "media_requests", "request_method")
helper.Copy(up.Int, "history_sync", "media_requests", "request_local_time")
helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle")
}
type DisplaynameParams struct {
@ -110,7 +109,7 @@ type DisplaynameParams struct {
Phone string
}
func (c *WhatsAppConfig) FormatDisplayname(jid types.JID, contact types.ContactInfo) string {
func (c *Config) FormatDisplayname(jid types.JID, contact types.ContactInfo) string {
var nameBuf strings.Builder
err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{
ContactInfo: contact,
@ -123,5 +122,14 @@ func (c *WhatsAppConfig) FormatDisplayname(jid types.JID, contact types.ContactI
}
func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) {
return ExampleConfig, wa.Config, up.SimpleUpgrader(upgradeConfig)
return ExampleConfig, &wa.Config, &up.StructUpgrader{
SimpleUpgrader: up.SimpleUpgrader(upgradeConfig),
Blocks: [][]string{
{"proxy"},
{"displayname_template"},
{"call_start_notices"},
{"history_sync"},
},
Base: ExampleConfig,
}
}

View file

@ -3,7 +3,6 @@ package connector
import (
"context"
"strings"
"text/template"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
@ -19,7 +18,7 @@ import (
type WhatsAppConnector struct {
Bridge *bridgev2.Bridge
Config *WhatsAppConfig
Config Config
DeviceStore *sqlstore.Container
MsgConv *msgconv.MessageConverter
}
@ -27,23 +26,8 @@ type WhatsAppConnector struct {
var _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
var _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
func NewConnector() *WhatsAppConnector {
return &WhatsAppConnector{
Config: &WhatsAppConfig{},
}
}
func (wa *WhatsAppConnector) SetMaxFileSize(_ int64) {
println("SetMaxFileSize unimplemented")
}
var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
DisappearingMessages: true,
AggressiveUpdateInfo: false,
}
func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
return WhatsAppGeneralCaps
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
wa.MsgConv.MaxFileSize = maxSize
}
func (wa *WhatsAppConnector) GetName() bridgev2.BridgeName {
@ -58,12 +42,6 @@ func (wa *WhatsAppConnector) GetName() bridgev2.BridgeName {
}
func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
var err error
wa.Config.displaynameTemplate, err = template.New("displayname").Parse(wa.Config.DisplaynameTemplate)
if err != nil {
// TODO return error or do this later?
panic(err)
}
wa.Bridge = bridge
wa.MsgConv = msgconv.New(bridge)
@ -110,8 +88,7 @@ func (wa *WhatsAppConnector) Start(_ context.Context) error {
func (wa *WhatsAppConnector) LoadUserLogin(_ context.Context, login *bridgev2.UserLogin) error {
loginMetadata := login.Metadata.(*UserLoginMetadata)
jid := waid.ParseWAUserLoginID(login.ID)
jid.Device = loginMetadata.WADeviceID
jid := waid.ParseUserLoginID(login.ID, loginMetadata.WADeviceID)
device, err := wa.DeviceStore.GetDevice(jid)
if err != nil {
@ -122,12 +99,16 @@ func (wa *WhatsAppConnector) LoadUserLogin(_ context.Context, login *bridgev2.Us
Main: wa,
UserLogin: login,
Device: device,
JID: jid,
}
log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger()
if device != nil {
w.MakeNewClient(log)
w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log))
w.Client.AddEventHandler(w.handleWAEvent)
w.Client.AutomaticMessageRerequestFromPhone = true
w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts)
}
login.Client = w

View file

@ -1,17 +1,23 @@
package connector
import (
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/bridgev2/database"
)
func (wa *WhatsAppConnector) GetDBMetaTypes() database.MetaTypes {
println("GetDBMetaTypes unimplemented")
return database.MetaTypes{
Ghost: nil,
Message: nil,
Reaction: nil,
Portal: nil,
UserLogin: func() any { return &UserLoginMetadata{} },
Ghost: func() any {
return &GhostMetadata{}
},
Message: nil,
Reaction: nil,
Portal: func() any {
return &PortalMetadata{}
},
UserLogin: func() any {
return &UserLoginMetadata{}
},
}
}
@ -19,3 +25,12 @@ type UserLoginMetadata struct {
WADeviceID uint16 `json:"wa_device_id"`
//TODO: Add phone last ping/seen
}
type PortalMetadata struct {
DisappearingTimerSetAt int64 `json:"disappearing_timer_set_at,omitempty"`
}
type GhostMetadata struct {
AvatarFetchAttempted bool `json:"avatar_fetch_attempted,omitempty"`
LastSync jsontime.Unix `json:"last_sync,omitempty"`
}

View file

@ -2,8 +2,10 @@ package connector
import (
"context"
"time"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridgev2"
@ -15,12 +17,13 @@ import (
type WAMessageEvent struct {
*events.Message
portalKey networkid.PortalKey
wa *WhatsAppClient
wa *WhatsAppClient
}
var (
_ bridgev2.RemoteMessage = (*WAMessageEvent)(nil)
_ bridgev2.RemoteMessageWithTransactionID = (*WAMessageEvent)(nil)
_ bridgev2.RemoteEventWithTimestamp = (*WAMessageEvent)(nil)
_ bridgev2.RemoteEventThatMayCreatePortal = (*WAMessageEvent)(nil)
_ bridgev2.RemoteReaction = (*WAMessageEvent)(nil)
_ bridgev2.RemoteReactionRemove = (*WAMessageEvent)(nil)
@ -29,13 +32,13 @@ var (
)
func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
// only change text and captions
if len(existing) > 1 {
zerolog.Ctx(ctx).Warn().Msg("Got edit to message with multiple parts")
}
editedMsg := evt.Message.Message.GetProtocolMessage().GetEditedMessage()
cm := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, editedMsg, nil) // for media messages, it is better not to do this
// TODO edits to media captions may not contain the media
cm := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, editedMsg)
return &bridgev2.ConvertedEdit{
ModifiedParts: []*bridgev2.ConvertedEditPart{cm.Parts[0].ToEditPart(existing[0])},
}, nil
@ -71,11 +74,7 @@ func (evt *WAMessageEvent) GetTargetMessage() networkid.MessageID {
}
func (evt *WAMessageEvent) GetReactionEmoji() (string, networkid.EmojiID) {
if reactionMsg := evt.Message.Message.GetReactionMessage(); reactionMsg != nil {
return reactionMsg.GetText(), ""
} else {
return "", ""
}
return evt.Message.Message.GetReactionMessage().GetText(), ""
}
func (evt *WAMessageEvent) GetRemovedEmojiID() networkid.EmojiID {
@ -88,16 +87,16 @@ func (evt *WAMessageEvent) ShouldCreatePortal() bool {
func (evt *WAMessageEvent) GetType() bridgev2.RemoteEventType {
waMsg := evt.Message.Message
if reactionMsg := waMsg.GetReactionMessage(); reactionMsg != nil {
if reactionMsg.GetText() == "" {
if waMsg.ReactionMessage != nil {
if waMsg.ReactionMessage.GetText() == "" {
return bridgev2.RemoteEventReactionRemove
}
return bridgev2.RemoteEventReaction
} else if protocolMsg := waMsg.GetProtocolMessage(); protocolMsg != nil {
protocolType := protocolMsg.GetType()
if protocolType == 0 { // REVOKE (message deletes)
} else if waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.Type != nil {
switch *waMsg.ProtocolMessage.Type {
case waE2E.ProtocolMessage_REVOKE:
return bridgev2.RemoteEventMessageRemove
} else if protocolType == 14 { // Message edits
case waE2E.ProtocolMessage_MESSAGE_EDIT:
return bridgev2.RemoteEventEdit
}
}
@ -105,21 +104,30 @@ func (evt *WAMessageEvent) GetType() bridgev2.RemoteEventType {
}
func (evt *WAMessageEvent) GetPortalKey() networkid.PortalKey {
return evt.portalKey
return evt.wa.makeWAPortalKey(evt.Info.Chat)
}
func (evt *WAMessageEvent) AddLogContext(c zerolog.Context) zerolog.Context {
return c.Str("message_id", evt.Info.ID).Uint64("sender_id", evt.Info.Sender.UserInt())
return c.Str("message_id", evt.Info.ID).Stringer("sender_id", evt.Info.Sender)
}
func (evt *WAMessageEvent) GetTimestamp() time.Time {
return evt.Info.Timestamp
}
func (evt *WAMessageEvent) GetSender() bridgev2.EventSender {
return evt.wa.makeEventSender(&evt.Info.Sender)
return evt.wa.makeEventSender(evt.Info.Sender)
}
func (evt *WAMessageEvent) GetID() networkid.MessageID {
return waid.MakeMessageID(evt.Info.Chat, evt.Info.Sender, evt.Info.ID)
}
func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
return evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, evt.Message.Message, &evt.Message.Info), nil
func (evt *WAMessageEvent) GetTransactionID() networkid.TransactionID {
// TODO for newsletter messages, there's a different transaction ID
return networkid.TransactionID(evt.GetID())
}
func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
return evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, evt.Message.Message), nil
}

View file

@ -21,13 +21,40 @@ displayname_template: "{{or .FullName .BusinessName .PushName .Phone}} (WA)"
# Should incoming calls send a message to the Matrix room?
call_start_notices: true
# Send the presence as "available" to whatsapp when users start typing on a portal.
# This works as a workaround for homeservers that do not support presence, and allows
# users to see when the whatsapp user on the other side is typing during a conversation.
send_presence_on_typing: false
# Should WhatsApp status messages be bridged into a Matrix room?
# Disabling this won't affect already created status broadcast rooms.
enable_status_broadcast: true
# Should sending WhatsApp status messages be allowed?
# This can cause issues if the user has lots of contacts, so it's disabled by default.
disable_status_broadcast_send: true
# Should the status broadcast room be muted and moved into low priority by default?
# This is only applied when creating the room, the user can unmute it later.
mute_status_broadcast: true
# Tag to apply to the status broadcast room.
status_broadcast_tag: m.lowpriority
# Should the bridge use thumbnails from WhatsApp?
# They're disabled by default due to very low resolution.
whatsapp_thumbnail: false
# Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview,
# 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 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)?
#
# By default, the bridge acts like WhatsApp web, which only sends active delivery
# receipts when it's in the foreground.
force_active_delivery_receipts: false
# Settings for handling history sync payloads.
history_sync:
# Should the bridge request a full sync from the phone when logging in?
# This bumps the size of history syncs from 3 months to 1 year.
request_full_sync: false
# Configuration parameters that are sent to the phone along with the request full sync flag.
# By default, (when the values are null or 0), the config isn't sent at all.
full_sync_config:
@ -38,7 +65,6 @@ history_sync:
size_mb_limit: null
# This is presumably the local storage quota, which may affect what the phone includes in the history sync blob.
storage_quota_mb: null
# Settings for media requests. If the media expired, then it will not be on the WA servers.
# Media can always be requested by reacting with the ♻️ (recycle) emoji.
# These settings determine if the media requests should be done automatically during or after backfill.
@ -52,48 +78,3 @@ history_sync:
request_local_time: 120
# Maximum number of media request responses to handle in parallel per user.
max_async_handle: 2
# Should puppet avatars be fetched from the server even if an avatar is already set?
user_avatar_sync: true
# Send the presence as "available" to whatsapp when users start typing on a portal.
# This works as a workaround for homeservers that do not support presence, and allows
# users to see when the whatsapp user on the other side is typing during a conversation.
send_presence_on_typing: false
# When using double puppeting, should the archived chats be moved to a specific tag in Matrix?
# Note that WhatsApp un-archives chats when a message is received, which will also be mirrored to Matrix.
# This can be set to a tag (e.g. m.lowpriority), or null to disable.
archive_tag: null
# Same as above, but for pinned chats. The favorite tag is called m.favourite
pinned_tag: null
# Should mute status and tags only be bridged when the portal room is created?
tag_only_on_create: true
# Should WhatsApp status messages be bridged into a Matrix room?
# Disabling this won't affect already created status broadcast rooms.
enable_status_broadcast: true
# Should sending WhatsApp status messages be allowed?
# This can cause issues if the user has lots of contacts, so it's disabled by default.
disable_status_broadcast_send: true
# Should the status broadcast room be muted and moved into low priority by default?
# This is only applied when creating the room, the user can unmute it later.
mute_status_broadcast: true
# Tag to apply to the status broadcast room.
status_broadcast_tag: m.lowpriority
# Should the bridge use thumbnails from WhatsApp?
# They're disabled by default due to very low resolution.
whatsapp_thumbnail: false
# Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview,
# 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 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)?
#
# By default, the bridge acts like WhatsApp web, which only sends active delivery
# receipts when it's in the foreground.
force_active_delivery_receipts: false

View file

@ -32,11 +32,12 @@ func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2
return nil, fmt.Errorf("failed to convert message: %w", err)
}
messageID := wa.Client.GenerateMessageID()
chatJID, err := types.ParseJID(string(msg.Portal.ID))
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return nil, err
}
senderJID := wa.Device.ID
wrappedMsgID := waid.MakeMessageID(chatJID, wa.JID, messageID)
msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID))
resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, whatsmeow.SendRequestExtra{
ID: messageID,
})
@ -45,10 +46,11 @@ func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2
}
return &bridgev2.MatrixMessageResponse{
DB: &database.Message{
ID: waid.MakeMessageID(chatJID, senderJID.ToNonAD(), messageID),
ID: wrappedMsgID,
SenderID: networkid.UserID(wa.UserLogin.ID),
Timestamp: resp.Timestamp,
},
RemovePending: networkid.TransactionID(wrappedMsgID),
}, nil
}
@ -66,7 +68,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev
return nil, err
}
portalJID, err := types.ParseJID(string(msg.Portal.ID))
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return nil, err
}
@ -89,7 +91,7 @@ func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *b
return err
}
portalJID, err := types.ParseJID(string(msg.Portal.ID))
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
@ -109,24 +111,32 @@ func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *b
func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error {
log := zerolog.Ctx(ctx)
editID := wa.Client.GenerateMessageID()
messageID, err := waid.ParseMessageID(edit.EditTarget.ID)
if err != nil {
return err
}
portalJID, err := types.ParseJID(string(edit.Portal.ID))
portalJID, err := waid.ParsePortalID(edit.Portal.ID)
if err != nil {
return err
}
//TODO: DO CONVERSION VIA msgconv FUNC
//TODO: IMPLEMENT MEDIA CAPTION EDITS
//TODO: PRESERVE MEDIA
editMessage := wa.Client.BuildEdit(portalJID, messageID.ID, &waE2E.Message{
Conversation: proto.String(edit.Content.Body),
})
waMsg, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, edit.Event, edit.Content, nil, edit.Portal)
if err != nil {
return fmt.Errorf("failed to convert message: %w", err)
}
convertedEdit := wa.Client.BuildEdit(messageID.Chat, messageID.ID, waMsg)
if edit.OrigSender == nil {
convertedEdit.EditedMessage.Message.ProtocolMessage.TimestampMS = proto.Int64(edit.Event.Timestamp)
}
resp, err := wa.Client.SendMessage(ctx, portalJID, editMessage)
//wrappedMsgID := waid.MakeMessageID(portalJID, wa.JID, messageID)
//edit.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID))
resp, err := wa.Client.SendMessage(ctx, portalJID, convertedEdit, whatsmeow.SendRequestExtra{
ID: editID,
})
log.Trace().Any("response", resp).Msg("WhatsApp edit response")
return err
}
@ -138,7 +148,7 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br
return err
}
portalJID, err := types.ParseJID(string(msg.Portal.ID))
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
@ -150,47 +160,80 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br
return err
}
func (wa *WhatsAppClient) HandleMatrixReadReceipt(_ context.Context, receipt *bridgev2.MatrixReadReceipt) error {
if receipt.ExactMessage == nil {
func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error {
if !receipt.ReadUpTo.After(receipt.LastRead) {
return nil
}
messageID, err := waid.ParseMessageID(receipt.ExactMessage.ID)
if receipt.LastRead.IsZero() {
receipt.LastRead = receipt.ReadUpTo.Add(-5 * time.Second)
}
portalJID, err := waid.ParsePortalID(receipt.Portal.ID)
if err != nil {
return err
}
err = wa.Client.MarkRead([]types.MessageID{messageID.ID}, time.Now(), messageID.Chat, messageID.Sender)
messages, err := receipt.Portal.Bridge.DB.Message.GetMessagesBetweenTimeQuery(ctx, receipt.Portal.PortalKey, receipt.LastRead, receipt.ReadUpTo)
if err != nil {
return fmt.Errorf("failed to get messages to mark as read: %w", err)
} else if len(messages) == 0 {
return nil
}
log := zerolog.Ctx(ctx)
log.Trace().
Time("last_read", receipt.LastRead).
Time("read_up_to", receipt.ReadUpTo).
Int("message_count", len(messages)).
Msg("Handling read receipt")
messagesToRead := make(map[types.JID][]string)
for _, msg := range messages {
parsed, err := waid.ParseMessageID(msg.ID)
if err != nil {
continue
}
if msg.SenderID == networkid.UserID(wa.UserLogin.ID) {
continue
}
var key types.JID
// In group chats, group receipts by sender. In DMs, just use blank key (no participant field).
if parsed.Sender != parsed.Chat {
key = parsed.Sender
}
messagesToRead[key] = append(messagesToRead[key], parsed.ID)
}
for messageSender, ids := range messagesToRead {
err = wa.Client.MarkRead(ids, receipt.Receipt.Timestamp, portalJID, messageSender)
if err != nil {
log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read")
}
}
return err
}
func (wa *WhatsAppClient) HandleMatrixTyping(_ context.Context, msg *bridgev2.MatrixTyping) error {
portalJID, err := types.ParseJID(string(msg.Portal.ID))
func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
var chatPresence types.ChatPresence
var mediaPresence types.ChatPresenceMedia
if msg.IsTyping {
chatPresence = "composing"
chatPresence = types.ChatPresenceComposing
} else {
chatPresence = "paused"
chatPresence = types.ChatPresencePaused
}
switch msg.Type {
case bridgev2.TypingTypeText:
mediaPresence = ""
mediaPresence = types.ChatPresenceMediaText
case bridgev2.TypingTypeRecordingMedia:
mediaPresence = "audio"
mediaPresence = types.ChatPresenceMediaAudio
case bridgev2.TypingTypeUploadingMedia:
return nil
}
err = wa.Client.SendChatPresence(portalJID, chatPresence, mediaPresence)
if wa.Main.Config.SendPresenceOnTyping {
err = wa.Client.SendPresence(types.PresenceAvailable)
if err != nil {
wa.UserLogin.Log.Warn().Err(err).Msg("Failed to set presence on typing")
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence on typing")
}
}
return err
return wa.Client.SendChatPresence(portalJID, chatPresence, mediaPresence)
}

View file

@ -1,7 +1,6 @@
package connector
import (
"context"
"fmt"
"time"
@ -16,10 +15,37 @@ import (
)
const (
WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect"
WAPermanentError status.BridgeStateErrorCode = "wa-unknown-permanent-error"
WALoggedOut status.BridgeStateErrorCode = "wa-logged-out"
WAMainDeviceGone status.BridgeStateErrorCode = "wa-main-device-gone"
WAUnknownLogout status.BridgeStateErrorCode = "wa-unknown-logout"
WANotConnected status.BridgeStateErrorCode = "wa-not-connected"
WAConnecting status.BridgeStateErrorCode = "wa-connecting"
WAKeepaliveTimeout status.BridgeStateErrorCode = "wa-keepalive-timeout"
WAPhoneOffline status.BridgeStateErrorCode = "wa-phone-offline"
WAConnectionFailed status.BridgeStateErrorCode = "wa-connection-failed"
WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect"
WAStreamReplaced status.BridgeStateErrorCode = "wa-stream-replaced"
WAStreamError status.BridgeStateErrorCode = "wa-stream-error"
WAClientOutdated status.BridgeStateErrorCode = "wa-client-outdated"
WATemporaryBan status.BridgeStateErrorCode = "wa-temporary-ban"
)
func init() {
status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.",
WAMainDeviceGone: "Your phone was logged out from WhatsApp. Relogin to continue using the bridge.",
WAUnknownLogout: "You were logged out for an unknown reason. Relogin to continue using the bridge.",
WANotConnected: "You're not connected to WhatsApp",
WAConnecting: "Reconnecting to WhatsApp...",
WAKeepaliveTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.",
WAPhoneOffline: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.",
WAConnectionFailed: "Connecting to the WhatsApp web servers failed.",
WADisconnected: "Disconnected from WhatsApp. Trying to reconnect.",
WAClientOutdated: "Connect failure: 405 client outdated. Bridge must be updated.",
WAStreamReplaced: "Stream replaced: the bridge was started in another location.",
})
}
func (wa *WhatsAppClient) handleWAEvent(rawEvt any) {
log := wa.UserLogin.Log
@ -29,23 +55,14 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) {
Any("info", evt.Info).
Any("payload", evt.Message).
Msg("Received WhatsApp message")
portalKey, ok := wa.makeWAPortalKey(evt.Info.Chat)
if !ok {
log.Warn().Stringer("chat", evt.Info.Chat).Msg("Ignoring WhatsApp message with unknown chat JID")
if evt.Info.Chat.Server == types.HiddenUserServer || evt.Info.Sender.Server == types.HiddenUserServer {
return
}
wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &WAMessageEvent{
Message: evt,
portalKey: portalKey,
wa: wa,
Message: evt,
wa: wa,
})
case *events.Receipt:
portalKey, ok := wa.makeWAPortalKey(evt.Chat)
if !ok {
log.Warn().Stringer("chat", evt.Chat).Msg("Ignoring WhatsApp receipt with unknown chat JID")
return
}
var evtType bridgev2.RemoteEventType
switch evt.Type {
case types.ReceiptTypeRead, types.ReceiptTypeReadSelf:
@ -63,88 +80,74 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) {
}
wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.Receipt{
EventMeta: simplevent.EventMeta{
Type: evtType,
LogContext: nil,
PortalKey: portalKey,
Sender: wa.makeEventSender(&evt.Sender),
Timestamp: evt.Timestamp,
Type: evtType,
PortalKey: wa.makeWAPortalKey(evt.Chat),
Sender: wa.makeEventSender(evt.Sender),
Timestamp: evt.Timestamp,
},
Targets: targets,
})
case *events.ChatPresence:
portalKey, ok := wa.makeWAPortalKey(evt.Chat)
if !ok {
log.Warn().Stringer("chat", evt.Chat).Msg("Ignoring WhatsApp receipt with unknown chat JID")
return
typingType := bridgev2.TypingTypeText
timeout := 15 * time.Second
if evt.Media == types.ChatPresenceMediaAudio {
typingType = bridgev2.TypingTypeRecordingMedia
}
Type := bridgev2.TypingTypeText
Timeout := 1000
if evt.Media == "audio" {
Type = bridgev2.TypingTypeRecordingMedia
}
if evt.State == "paused" {
Timeout = 0
if evt.State == types.ChatPresencePaused {
timeout = 0
}
wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.Typing{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventTyping,
LogContext: nil,
PortalKey: portalKey,
Sender: wa.makeEventSender(&evt.Sender),
PortalKey: wa.makeWAPortalKey(evt.Chat),
Sender: wa.makeEventSender(evt.Sender),
Timestamp: time.Now(),
},
Timeout: time.Duration(Timeout),
Type: Type,
Timeout: timeout,
Type: typingType,
})
case *events.Connected:
log.Debug().Msg("Connected to WhatsApp socket")
state := status.BridgeState{StateEvent: status.StateConnected}
wa.UserLogin.BridgeState.Send(state)
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
case *events.Disconnected:
log.Debug().Msg("Disconnected from WhatsApp socket")
state := status.BridgeState{
StateEvent: status.StateTransientDisconnect,
Error: WADisconnected,
// Don't send the normal transient disconnect state if we're already in a different transient disconnect state.
// TODO remove this if/when the phone offline state is moved to a sub-state of CONNECTED
if wa.UserLogin.BridgeState.GetPrev().Error != WAPhoneOffline && wa.PhoneRecentlySeen(false) {
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WADisconnected})
}
wa.UserLogin.BridgeState.Send(state)
case events.PermanentDisconnect:
switch e := evt.(type) {
case *events.LoggedOut:
if e.Reason == events.ConnectFailureLoggedOut && !e.OnConnect && wa.canReconnect() {
wa.resetWADevice()
log.Debug().Msg("Doing full reconnect after WhatsApp 401 error")
go wa.FullReconnect()
}
case *events.ConnectFailure:
if e.Reason == events.ConnectFailureNotFound {
if wa.Client != nil {
wa.Client.Disconnect()
err := wa.Device.Delete()
if err != nil {
log.Error().Msg(fmt.Sprintf("Error deleting device %s", err.Error()))
return
}
wa.resetWADevice()
wa.Client = nil
}
log.Debug().Msg("Reconnecting e2ee client after WhatsApp 415 error")
go func() {
err := wa.Connect(context.Background())
if err != nil {
log.Error().Msg(fmt.Sprintf("Error connecting %s", err.Error()))
return
}
}()
}
case *events.StreamError:
var message string
if evt.Code != "" {
message = fmt.Sprintf("Unknown stream error with code %s", evt.Code)
} else if children := evt.Raw.GetChildren(); len(children) > 0 {
message = fmt.Sprintf("Unknown stream error (contains %s node)", children[0].Tag)
} else {
message = "Unknown stream error"
}
state := status.BridgeState{
wa.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: WAPermanentError,
Message: evt.PermanentDisconnectDescription(),
}
wa.UserLogin.BridgeState.Send(state)
Error: WAStreamError,
Message: message,
})
case *events.StreamReplaced:
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: WAStreamReplaced})
case *events.ConnectFailure:
wa.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateUnknownError,
Error: status.BridgeStateErrorCode(fmt.Sprintf("wa-connect-failure-%d", evt.Reason)),
Message: fmt.Sprintf("Unknown connection failure: %s (%s)", evt.Reason, evt.Message),
})
case *events.ClientOutdated:
wa.UserLogin.Log.Error().Msg("Got a client outdated connect failure. The bridge is likely out of date, please update immediately.")
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: WAClientOutdated})
case *events.TemporaryBan:
wa.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: WATemporaryBan,
Message: evt.String(),
})
default:
log.Debug().Type("event_type", rawEvt).Msg("Unhandled WhatsApp event")
}

View file

@ -1,6 +1,8 @@
package connector
import (
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
@ -8,25 +10,68 @@ import (
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
func (wa *WhatsAppClient) makeWAPortalKey(chatJID types.JID) (key networkid.PortalKey, ok bool) {
key.ID = waid.MakeWAPortalID(chatJID)
func (wa *WhatsAppClient) makeWAPortalKey(chatJID types.JID) (key networkid.PortalKey) {
key.ID = waid.MakePortalID(chatJID)
switch chatJID.Server {
case types.DefaultUserServer: //TODO: LID support + other types?
key.Receiver = wa.UserLogin.ID // does this also apply for groups ?!?!
case types.GroupServer:
fallthrough //TODO: HANDLE
default:
return
case types.DefaultUserServer, types.HiddenUserServer, types.BroadcastServer:
key.Receiver = wa.UserLogin.ID
}
ok = true
return
}
func (wa *WhatsAppClient) makeEventSender(id *types.JID) bridgev2.EventSender {
func (wa *WhatsAppClient) makeEventSender(id types.JID) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: waid.MakeUserLoginID(id) == wa.UserLogin.ID,
Sender: waid.MakeUserID(id),
SenderLogin: waid.MakeUserLoginID(id),
}
}
func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.MessageKey {
key := &waCommon.MessageKey{
RemoteJID: ptr.Ptr(id.Chat.String()),
ID: ptr.Ptr(id.ID),
}
if id.Sender.User == string(wa.UserLogin.ID) {
key.FromMe = ptr.Ptr(true)
}
if id.Chat.Server != types.MessengerServer && id.Chat.Server != types.DefaultUserServer {
key.Participant = ptr.Ptr(id.Sender.String())
}
return key
}
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())
}

View file

@ -3,229 +3,165 @@ package connector
import (
"context"
"fmt"
"regexp"
"net/http"
"sync/atomic"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
func (wa *WhatsAppConnector) GetLoginFlows() []bridgev2.LoginFlow {
return []bridgev2.LoginFlow{
{
Name: "QR",
Description: "Scan a QR code to pair the bridge to your WhatsApp account",
ID: "qr",
},
{
Name: "Pairing code",
Description: "Input your phone number to get a pairing code, to pair the bridge to your WhatsApp account",
ID: "pairing-code",
},
}
}
func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
if flowID == "qr" {
return &QRLogin{User: user, Main: wa}, nil
} else if flowID == "pairing-code" {
return &PairingCodeLogin{User: user, Main: wa}, nil
} else {
return nil, fmt.Errorf("invalid login flow ID")
}
}
func makeQRChan(connector *WhatsAppConnector, user *bridgev2.User) (client *whatsmeow.Client, qrChan <-chan whatsmeow.QRChannelItem, cancelFunc context.CancelFunc, err error) {
log := connector.Bridge.Log.With().
Str("action", "login").
Stringer("user_id", user.MXID).
Logger()
qrCtx, cancel := context.WithCancel(log.WithContext(context.Background()))
cancelFunc = cancel
device := connector.DeviceStore.NewDevice()
wa := &WhatsAppClient{
Main: connector,
Device: device,
}
wa.MakeNewClient(log)
client = wa.Client
qrChan, err = client.GetQRChannel(qrCtx)
if err != nil {
return nil, nil, nil, err
}
err = client.Connect()
if err != nil {
return nil, nil, nil, err
}
return
}
func makeUserLogin(ctx context.Context, user *bridgev2.User, client *whatsmeow.Client) (ul *bridgev2.UserLogin, err error) {
newLoginID := waid.MakeUserLoginID(client.Store.ID)
ul, err = user.NewLogin(ctx, &database.UserLogin{
ID: newLoginID,
RemoteName: client.Store.PushName,
Metadata: &UserLoginMetadata{
WADeviceID: client.Store.ID.Device,
},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
})
return
}
type QRLogin struct {
User *bridgev2.User
Main *WhatsAppConnector
Client *whatsmeow.Client
cancelChan context.CancelFunc
QRChan <-chan whatsmeow.QRChannelItem
AccData *store.Device
}
var _ bridgev2.LoginProcessDisplayAndWait = (*QRLogin)(nil)
func (qr *QRLogin) Cancel() {
qr.cancelChan()
qr.Client.Disconnect()
}
const (
LoginStepQR = "fi.mau.whatsapp.login.qr"
LoginStepComplete = "fi.mau.whatsapp.login.complete"
LoginStepPairingCodeInput = "fi.mau.whatsapp.login.pairing.code"
LoginStepPairingCode = "fi.mau.whatsapp.login.pairing.code"
LoginStepIDQR = "fi.mau.whatsapp.login.qr"
LoginStepIDPhoneNumber = "fi.mau.whatsapp.login.phone"
LoginStepIDCode = "fi.mau.whatsapp.login.code"
LoginStepIDComplete = "fi.mau.whatsapp.login.complete"
LoginFlowIDQR = "qr"
LoginFlowIDPhone = "phone"
)
func (qr *QRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
client, qrChan, cancelFunc, err := makeQRChan(qr.Main, qr.User)
if err != nil {
return nil, err
}
qr.QRChan = qrChan
qr.cancelChan = cancelFunc
qr.Client = client
var resp whatsmeow.QRChannelItem
func (wa *WhatsAppConnector) GetLoginFlows() []bridgev2.LoginFlow {
return []bridgev2.LoginFlow{{
Name: "QR",
Description: "Scan a QR code to pair the bridge to your WhatsApp account",
ID: LoginFlowIDQR,
}, {
Name: "Pairing code",
Description: "Input your phone number to get a pairing code, to pair the bridge to your WhatsApp account",
ID: LoginFlowIDPhone,
}}
}
select {
case resp = <-qr.QRChan:
if resp.Error != nil {
return nil, resp.Error
} else if resp.Event != "code" {
return nil, fmt.Errorf("invalid QR channel event: %s", resp.Event)
}
case <-ctx.Done():
qr.Cancel()
return nil, ctx.Err()
var (
ErrLoginClientOutdated = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.CLIENT_OUTDATED",
Err: "Got client outdated error while waiting for QRs. The bridge must be updated to continue.",
StatusCode: http.StatusInternalServerError,
}
ErrLoginMultideviceNotEnabled = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.MULTIDEVICE_NOT_ENABLED",
Err: "Please enable WhatsApp web multidevice and scan the QR code again.",
StatusCode: http.StatusBadRequest,
}
ErrLoginTimeout = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.LOGIN_TIMEOUT",
Err: "Entering code or scanning QR timed out. Please try again.",
StatusCode: http.StatusBadRequest,
}
ErrUnexpectedDisconnect = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.LOGIN_UNEXPECTED_EVENT",
Err: "Unexpected event while waiting for login",
StatusCode: http.StatusInternalServerError,
}
)
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: LoginStepQR,
Instructions: "Scan the QR code on the WhatsApp mobile app to log in",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeQR,
Data: resp.Code,
},
func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
return &WALogin{
User: user,
Main: wa,
PhoneCode: flowID == LoginFlowIDPhone,
Log: user.Log.With().
Str("action", "login").
Bool("phone_code", flowID == LoginFlowIDPhone).
Logger(),
}, nil
}
func (qr *QRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
if qr.QRChan == nil {
return nil, fmt.Errorf("login not started")
}
type WALogin struct {
User *bridgev2.User
Main *WhatsAppConnector
Client *whatsmeow.Client
Log zerolog.Logger
PhoneCode bool
select {
case resp := <-qr.QRChan:
if resp.Event == "code" {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: LoginStepQR,
Instructions: "Scan the QR code on the WhatsApp mobile app to log in",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeQR,
Data: resp.Code,
},
}, nil
} else if resp.Event != "success" {
qr.Cancel()
return nil, fmt.Errorf("did not pair properly, err: %s", resp.Event)
}
case <-ctx.Done():
qr.Cancel()
return nil, ctx.Err()
}
QRs []string
StartTime time.Time
LoginError error
LoginSuccess *events.PairSuccess
WaitForQRs exsync.Event
LoginComplete exsync.Event
PrevQRIndex atomic.Int32
defer qr.Cancel()
ul, err := makeUserLogin(ctx, qr.User, qr.Client)
if err != nil {
return nil, fmt.Errorf("failed to create user login: %w", err)
}
err = ul.Client.Connect(ul.Log.WithContext(context.Background()))
if err != nil {
return nil, fmt.Errorf("failed to connect after login: %w", err)
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: LoginStepComplete,
Instructions: fmt.Sprintf("Successfully logged in as %s / %s", ul.RemoteName, ul.ID),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
Closed atomic.Bool
EventHandlerID uint32
}
type PairingCodeLogin struct {
User *bridgev2.User
Main *WhatsAppConnector
Client *whatsmeow.Client
cancelChan context.CancelFunc
QRChan <-chan whatsmeow.QRChannelItem
var (
_ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil)
_ bridgev2.LoginProcessUserInput = (*WALogin)(nil)
)
AccData *store.Device
}
const LoginConnectWait = 15 * time.Second
var _ bridgev2.LoginProcessUserInput = (*PairingCodeLogin)(nil)
var _ bridgev2.LoginProcessDisplayAndWait = (*PairingCodeLogin)(nil)
func (pc *PairingCodeLogin) Cancel() {
pc.Client.Disconnect()
pc.cancelChan()
go func() {
for range pc.QRChan {
}
}()
}
func (pc *PairingCodeLogin) SubmitUserInput(_ context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
phoneNumber := input["phone_number"]
if phoneNumber == "" {
return nil, fmt.Errorf("invalid or missing phone number")
}
pairingCode, err := pc.Client.PairPhone(phoneNumber, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil {
func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
device := wl.Main.DeviceStore.NewDevice()
wl.Client = whatsmeow.NewClient(device, waLog.Zerolog(wl.Log))
wl.EventHandlerID = wl.Client.AddEventHandler(wl.handleEvent)
if err := wl.Main.updateProxy(wl.Client, true); err != nil {
return nil, err
}
if wl.PhoneCode {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: LoginStepIDPhoneNumber,
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: []bridgev2.LoginInputDataField{{
Type: bridgev2.LoginInputFieldTypePhoneNumber,
ID: "phone_number",
Name: "Phone number",
Description: "Your WhatsApp phone number in international format",
}},
},
}, nil
}
err := wl.Client.Connect()
if err != nil {
wl.Log.Err(err).Msg("Failed to connect to WhatsApp for QR login")
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, LoginConnectWait)
defer cancel()
err = wl.WaitForQRs.Wait(ctx)
if err != nil {
wl.Log.Warn().Err(err).Msg("Timed out waiting for first QR")
wl.Cancel()
return nil, err
}
return makeQRStep(wl.QRs[0]), nil
}
func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
ctx, cancel := context.WithTimeout(ctx, LoginConnectWait)
defer cancel()
err := wl.Client.Connect()
if err != nil {
wl.Log.Err(err).Msg("Failed to connect to WhatsApp for phone code login")
return nil, err
}
err = wl.WaitForQRs.Wait(ctx)
if err != nil {
wl.Log.Warn().Err(err).Msg("Timed out waiting for connection")
return nil, fmt.Errorf("failed to wait for connection: %w", err)
}
pairingCode, err := wl.Client.PairPhone(input["phone_number"], true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil {
wl.Log.Err(err).Msg("Failed to request phone code login")
return nil, err
}
wl.Log.Debug().Msg("Phone code login started")
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: LoginStepPairingCode,
Instructions: "Input the pairing code on the WhatsApp mobile app to log in",
StepID: LoginStepIDCode,
Instructions: "Input the pairing code in the WhatsApp mobile app to log in",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeCode,
Data: pairingCode,
@ -233,77 +169,146 @@ func (pc *PairingCodeLogin) SubmitUserInput(_ context.Context, input map[string]
}, nil
}
var onlyNumberRegex = regexp.MustCompile(`\D+`)
func (pc *PairingCodeLogin) Start(_ context.Context) (*bridgev2.LoginStep, error) {
client, qrChan, cancelFunc, err := makeQRChan(pc.Main, pc.User)
if err != nil {
return nil, err
}
pc.QRChan = qrChan
pc.cancelChan = cancelFunc
pc.Client = client
func makeQRStep(qr string) *bridgev2.LoginStep {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: LoginStepPairingCodeInput,
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: []bridgev2.LoginInputDataField{
{
Type: bridgev2.LoginInputFieldTypePhoneNumber,
ID: "phone_number",
Name: "phone number",
Description: "An international phone number format without symbols",
Validate: func(s string) (string, error) {
return onlyNumberRegex.ReplaceAllString(s, ""), nil
},
},
},
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: LoginStepIDQR,
Instructions: "Scan the QR code with the WhatsApp mobile app to log in",
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeQR,
Data: qr,
},
}, nil
}
}
func (pc *PairingCodeLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
var qrIntervals = []time.Duration{1 * time.Minute, 20 * time.Second, 20 * time.Second, 20 * time.Second, 20 * time.Second, 20 * time.Second}
select {
case resp := <-pc.QRChan:
if resp.Event == "code" {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeDisplayAndWait,
StepID: LoginStepPairingCode,
DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
Type: bridgev2.LoginDisplayTypeNothing,
},
}, nil
} else if resp.Event != "success" {
pc.Cancel()
return nil, fmt.Errorf("did not pair properly, err: %s", resp.Event)
func (wl *WALogin) getQRIndex() (currentIndex int, timeUntilNext time.Duration) {
timeSinceStart := time.Since(wl.StartTime)
var sum time.Duration
for i, interval := range qrIntervals {
if timeSinceStart > sum+interval {
sum += interval
} else {
return i, interval - (timeSinceStart - sum)
}
case <-ctx.Done():
pc.Cancel()
return nil, ctx.Err()
}
return -1, 0
}
func (wl *WALogin) handleEvent(rawEvt any) {
if wl.Closed.Load() {
return
}
switch evt := rawEvt.(type) {
case *events.QR:
wl.Log.Debug().Int("code_count", len(evt.Codes)).Msg("Received QR codes")
wl.QRs = evt.Codes
wl.StartTime = time.Now()
wl.WaitForQRs.Set()
return
case *events.QRScannedWithoutMultidevice:
wl.Log.Error().Msg("QR code scanned without multidevice enabled")
wl.LoginError = ErrLoginMultideviceNotEnabled
case *events.ClientOutdated:
wl.Log.Error().Msg("Got client outdated error")
wl.LoginError = ErrLoginClientOutdated
case *events.PairSuccess:
wl.Log.Info().Any("event_data", evt).Msg("Got pair successful event")
wl.LoginSuccess = evt
case *events.PairError:
wl.Log.Error().Any("event_data", evt).Msg("Got pair error event")
wl.LoginError = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.PAIR_ERROR",
Err: evt.Error.Error(),
StatusCode: http.StatusInternalServerError,
}
case *events.Disconnected:
wl.Log.Warn().Msg("Got disconnected event (login timed out)")
wl.LoginError = ErrLoginTimeout
case *events.Connected, *events.ConnectFailure, *events.LoggedOut, *events.TemporaryBan:
wl.Log.Warn().Any("event_data", evt).Type("event_type", evt).Msg("Got unexpected disconnect event")
wl.LoginError = ErrUnexpectedDisconnect
default:
wl.Log.Warn().Type("event_type", evt).Msg("Got unexpected event")
return
}
wl.LoginComplete.Set()
}
func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
if wl.PhoneCode {
err := wl.LoginComplete.Wait(ctx)
if err != nil {
wl.Cancel()
return nil, err
}
} else {
prevIndex := int(wl.PrevQRIndex.Load())
currentIndex, timeUntilNext := wl.getQRIndex()
nextIndex := currentIndex + 1
logEvt := wl.Log.Debug().
Int("prev_index", prevIndex).
Int("current_index", currentIndex)
if currentIndex > prevIndex {
logEvt.Msg("Returning new QR immediately")
wl.PrevQRIndex.Store(int32(currentIndex))
return makeQRStep(wl.QRs[currentIndex]), nil
}
logEvt.Int("next_index", nextIndex).Stringer("time_until_next", timeUntilNext).Msg("Waiting for next QR")
select {
case <-time.After(timeUntilNext):
if nextIndex < 0 || nextIndex >= len(wl.QRs) {
wl.Log.Debug().Msg("No more QRs to return")
wl.Cancel()
return nil, ErrLoginTimeout
}
wl.PrevQRIndex.Store(int32(nextIndex))
return makeQRStep(wl.QRs[nextIndex]), nil
case <-ctx.Done():
wl.Cancel()
return nil, ctx.Err()
case <-wl.LoginComplete.GetChan():
wl.Cancel()
}
}
wl.Log.Debug().Err(wl.LoginError).Msg("Login completed")
if wl.LoginError != nil {
return nil, wl.LoginError
}
defer pc.Cancel()
ul, err := makeUserLogin(ctx, pc.User, pc.Client)
newLoginID := waid.MakeUserLoginID(wl.LoginSuccess.ID)
ul, err := wl.User.NewLogin(ctx, &database.UserLogin{
ID: newLoginID,
RemoteName: "+" + wl.LoginSuccess.ID.User,
Metadata: &UserLoginMetadata{
WADeviceID: wl.LoginSuccess.ID.Device,
},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
})
if err != nil {
return nil, fmt.Errorf("failed to create user login: %w", err)
}
err = ul.Client.Connect(ul.Log.WithContext(context.Background()))
if err != nil {
return nil, fmt.Errorf("failed to connect after login: %w", err)
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: LoginStepComplete,
Instructions: fmt.Sprintf("Successfully logged in as %s / %s", ul.RemoteName, ul.ID),
StepID: LoginStepIDComplete,
Instructions: fmt.Sprintf("Successfully logged in as %s", ul.RemoteName),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
}
func (wl *WALogin) Cancel() {
wl.Closed.Store(true)
wl.Client.RemoveEventHandler(wl.EventHandlerID)
wl.Client.Disconnect()
}

View file

@ -0,0 +1,6 @@
package connector
func (wa *WhatsAppClient) PhoneRecentlySeen(doPing bool) bool {
// TODO implement
return true
}

View file

@ -2,10 +2,12 @@ package connector
import (
"context"
"errors"
"fmt"
"strings"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
@ -13,12 +15,11 @@ import (
var (
_ bridgev2.IdentifierResolvingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.ContactListingNetworkAPI = (*WhatsAppClient)(nil)
)
var (
LooksLikeEmailErr = fmt.Errorf("WhatsApp only supports phone numbers as user identifiers. Number looks like email")
NotLoggedInErr = fmt.Errorf("User is not logged in to WhatsApp. No session")
NoResponseErr = fmt.Errorf("Didn't get a response to checking if the number is on WhatsApp. Error checking number")
ErrInputLooksLikeEmail = bridgev2.WrapRespErr(errors.New("WhatsApp only supports phone numbers as user identifiers. Number looks like email"), mautrix.MInvalidParam)
)
func looksEmaily(str string) bool {
@ -31,34 +32,59 @@ func looksEmaily(str string) bool {
return false
}
func (wa *WhatsAppClient) ValidateIdentifer(number string) (types.JID, error) {
func (wa *WhatsAppClient) validateIdentifer(number string) (types.JID, error) {
if strings.HasSuffix(number, "@"+types.DefaultUserServer) {
jid, _ := types.ParseJID(number)
number = "+" + jid.User
}
if looksEmaily(number) {
return types.EmptyJID, LooksLikeEmailErr
} else if !wa.Client.IsLoggedIn() {
return types.EmptyJID, NotLoggedInErr
return types.EmptyJID, ErrInputLooksLikeEmail
} else if wa.Client == nil || !wa.Client.IsLoggedIn() {
return types.EmptyJID, bridgev2.ErrNotLoggedIn
} else if resp, err := wa.Client.IsOnWhatsApp([]string{number}); err != nil {
return types.EmptyJID, fmt.Errorf("Failed to check if number is on WhatsApp: %v", err)
return types.EmptyJID, fmt.Errorf("failed to check if number is on WhatsApp: %w", err)
} else if len(resp) == 0 {
return types.EmptyJID, NoResponseErr
return types.EmptyJID, fmt.Errorf("the server did not respond to the query")
} else if !resp[0].IsIn {
return types.EmptyJID, fmt.Errorf("The server said +%s is not on WhatsApp", resp[0].JID.User)
return types.EmptyJID, bridgev2.WrapRespErr(fmt.Errorf("the server said +%s is not on WhatsApp", resp[0].JID.User), mautrix.MNotFound)
} else {
return resp[0].JID, nil
}
}
func (wa *WhatsAppClient) ResolveIdentifier(_ context.Context, identifier string, _ bool) (*bridgev2.ResolveIdentifierResponse, error) {
jid, err := wa.ValidateIdentifer(identifier)
func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) {
jid, err := wa.validateIdentifer(identifier)
if err != nil {
return nil, fmt.Errorf("failed to parse identifier: %w", err)
return nil, err
}
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
if err != nil {
return nil, fmt.Errorf("failed to get ghost: %w", err)
}
return &bridgev2.ResolveIdentifierResponse{
UserID: waid.MakeUserID(&jid),
Ghost: ghost,
UserID: waid.MakeUserID(jid),
Chat: &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(jid)},
}, nil
}
func (wa *WhatsAppClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) {
contacts, err := wa.Client.Store.Contacts.GetAllContacts()
if err != nil {
return nil, err
}
resp := make([]*bridgev2.ResolveIdentifierResponse, len(contacts))
i := 0
for jid, contactInfo := range contacts {
ghost, _ := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
resp[i] = &bridgev2.ResolveIdentifierResponse{
Ghost: ghost,
UserID: waid.MakeUserID(jid),
UserInfo: wa.contactToUserInfo(ctx, jid, contactInfo, false),
Chat: &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(jid)},
}
i++
}
return resp, nil
}

60
pkg/connector/userinfo.go Normal file
View file

@ -0,0 +1,60 @@
package connector
import (
"context"
"fmt"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
jid := waid.ParseUserID(ghost.ID)
contact, err := wa.Client.Store.Contacts.GetContact(jid)
if err != nil {
return nil, err
}
fetchAvatar := !ghost.Metadata.(*GhostMetadata).AvatarFetchAttempted
return wa.contactToUserInfo(ctx, jid, contact, fetchAvatar), nil
}
func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) *bridgev2.UserInfo {
ui := &bridgev2.UserInfo{
Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, contact)),
IsBot: ptr.Ptr(jid.IsBot()),
Identifiers: []string{fmt.Sprintf("tel:%s", jid.User)},
}
if getAvatar {
ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, markAvatarFetchAttempted)
avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{
Preview: false,
IsCommunity: false,
})
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get avatar info")
} else if avatar != nil {
ui.Avatar = &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID),
Get: func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithPath(avatar.DirectPath, nil, nil, nil, 0, "", "")
},
}
}
}
return ui
}
func markAvatarFetchAttempted(_ context.Context, ghost *bridgev2.Ghost) bool {
meta := ghost.Metadata.(*GhostMetadata)
if !meta.AvatarFetchAttempted {
meta.AvatarFetchAttempted = true
return true
}
return false
}

View file

@ -17,6 +17,7 @@ import (
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"golang.org/x/exp/slices"
"golang.org/x/image/webp"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
@ -195,7 +196,7 @@ func (mc *MessageConverter) constructTextMessage(ctx context.Context, content *e
mentions := make([]string, 0)
parseCtx := format.NewContext(ctx)
parseCtx.ReturnData["mentions"] = mentions
parseCtx.ReturnData["mentions"] = &mentions
parseCtx.ReturnData["portal"] = portal
var text string
if content.Format == event.FormatHTML {
@ -223,13 +224,10 @@ func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx f
var jid types.JID
ghost, err := mc.Bridge.GetGhostByMXID(ctx.Ctx, id.UserID(mxid))
if err != nil {
zerolog.Ctx(ctx.Ctx).Err(err).Str("mxid", mxid).Msg("Failed to get user for mention")
zerolog.Ctx(ctx.Ctx).Err(err).Str("mxid", mxid).Msg("Failed to get ghost for mention")
return displayname
} else if ghost != nil {
jid, err = types.ParseJID(string(ghost.ID))
if jid.String() == "" || err != nil {
return displayname
}
jid = waid.ParseUserID(ghost.ID)
} else if user, err := mc.Bridge.GetExistingUserByMXID(ctx.Ctx, id.UserID(mxid)); err != nil {
zerolog.Ctx(ctx.Ctx).Err(err).Str("mxid", mxid).Msg("Failed to get user for mention")
return displayname
@ -239,12 +237,12 @@ func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx f
if login == nil {
return displayname
}
jid = waid.ParseWAUserLoginID(login.ID)
jid = waid.ParseUserLoginID(login.ID, 0)
} else {
return displayname
}
mentions := ctx.ReturnData["mentions"].([]string)
mentions = append(mentions, jid.String())
mentions := ctx.ReturnData["mentions"].(*[]string)
*mentions = append(*mentions, jid.String())
return fmt.Sprintf("@%s", jid.User)
}
@ -419,10 +417,17 @@ func getAudioInfo(content *event.MessageEventContent) (output []byte, Duration u
if waveform != nil {
return nil, uint32(audioInfo.Duration / 1000)
}
output = make([]byte, len(waveform))
for i, part := range waveform {
output[i] = byte(part / 4)
maxVal := slices.Max(waveform)
output = make([]byte, len(waveform))
if maxVal < 256 {
for i, part := range waveform {
output[i] = byte(part)
}
} else {
for i, part := range waveform {
output[i] = min(byte(part/4), 255)
}
}
return
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"html"
"image"
_ "image/gif"
_ "image/jpeg"
@ -13,11 +14,13 @@ import (
"strings"
"github.com/rs/zerolog"
"go.mau.fi/util/exslices"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@ -32,110 +35,55 @@ type MediaMessage interface {
GetMimetype() string
}
func getAudioMessageInfo(audioMessage *waE2E.AudioMessage) (waveform []int, duration int) {
if audioMessage.Waveform != nil {
waveform = make([]int, len(audioMessage.Waveform))
maxWave := 0
for i, part := range audioMessage.Waveform {
waveform[i] = int(part)
if waveform[i] > maxWave {
maxWave = waveform[i]
}
}
multiplier := 0
if maxWave > 0 {
multiplier = 1024 / maxWave
}
if multiplier > 32 {
multiplier = 32
}
for i := range waveform {
waveform[i] *= multiplier
}
}
duration = int(audioMessage.GetSeconds() * 1000)
return
type MediaInfo struct {
event.FileInfo
Waveform []int
IsGif bool
Caption string
MsgType event.MessageType
ContextInfo *waE2E.ContextInfo
}
func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user networkid.UserID) (id.UserID, string, error) {
ghost, err := mc.Bridge.GetGhostByID(ctx, user)
if err != nil {
return "", "", fmt.Errorf("failed to get ghost by ID: %w", err)
}
login := mc.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(user))
if login != nil {
return login.UserMXID, ghost.Name, nil
}
return ghost.Intent.GetMXID(), ghost.Name, nil
}
type ExtraMediaInfo struct {
Waveform []int
Duration int
IsGif bool
Caption string
MsgType event.MessageType
}
func getMediaMessageFileInfo(msg *waE2E.Message) (message MediaMessage, info event.FileInfo, contextInfo *waE2E.ContextInfo, extraInfo ExtraMediaInfo) {
message = nil
info = event.FileInfo{}
extraInfo = ExtraMediaInfo{}
contextInfo = &waE2E.ContextInfo{}
func getMediaMessageFileInfo(msg *waE2E.Message) (message MediaMessage, info MediaInfo) {
if msg.GetAudioMessage() != nil {
info.MsgType = event.MsgAudio
message = msg.AudioMessage
info.Duration = int(msg.AudioMessage.GetSeconds() * 1000)
waveform, duration := getAudioMessageInfo(msg.AudioMessage)
extraInfo.Waveform = waveform
extraInfo.Duration = duration
extraInfo.MsgType = event.MsgAudio
info.Waveform = exslices.CastFunc(msg.AudioMessage.Waveform, func(from byte) int { return int(from) })
} else if msg.GetDocumentMessage() != nil {
info.MsgType = event.MsgFile
message = msg.DocumentMessage
extraInfo.Caption = msg.DocumentMessage.GetCaption()
extraInfo.MsgType = event.MsgFile
info.Caption = msg.DocumentMessage.GetCaption()
} else if msg.GetImageMessage() != nil {
info.MsgType = event.MsgImage
message = msg.ImageMessage
info.Width = int(msg.ImageMessage.GetWidth())
info.Height = int(msg.ImageMessage.GetHeight())
extraInfo.Caption = msg.DocumentMessage.GetCaption()
extraInfo.MsgType = event.MsgImage
info.Caption = msg.ImageMessage.GetCaption()
} else if msg.GetStickerMessage() != nil {
message = msg.StickerMessage
info.Width = int(msg.StickerMessage.GetWidth())
info.Height = int(msg.StickerMessage.GetHeight())
extraInfo.MsgType = event.MessageType(event.EventSticker.Type)
} else if msg.GetVideoMessage() != nil {
info.MsgType = event.MsgVideo
message = msg.VideoMessage
info.Duration = int(msg.VideoMessage.GetSeconds() * 1000)
info.Width = int(msg.VideoMessage.GetWidth())
info.Height = int(msg.VideoMessage.GetHeight())
extraInfo.Caption = msg.DocumentMessage.GetCaption()
if msg.VideoMessage.GetGifPlayback() {
extraInfo.IsGif = true
}
extraInfo.MsgType = event.MsgVideo
info.Caption = msg.VideoMessage.GetCaption()
info.IsGif = msg.VideoMessage.GetGifPlayback()
} else {
return
}
if message != nil {
info.Size = int(message.GetFileLength())
info.MimeType = message.GetMimetype()
contextInfo = message.GetContextInfo()
}
info.Size = int(message.GetFileLength())
info.MimeType = message.GetMimetype()
info.ContextInfo = message.GetContextInfo()
return
}
@ -168,54 +116,45 @@ func convertContactMessage(ctx context.Context, intent bridgev2.MatrixAPI, porta
return cmp, nil
}
func (mc *MessageConverter) parseMessageContextInfo(ctx context.Context, cm *bridgev2.ConvertedMessage, contextInfo *waE2E.ContextInfo, msgInfo types.MessageInfo) *bridgev2.ConvertedMessage {
for _, part := range cm.Parts {
content := part.Content
content.Mentions = &event.Mentions{
UserIDs: make([]id.UserID, 0),
}
if mentions := contextInfo.GetMentionedJID(); len(mentions) > 0 {
content.Format = event.FormatHTML
content.FormattedBody = event.TextToHTML(content.Body)
for _, jid := range mentions {
parsed, err := types.ParseJID(jid)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID")
continue
}
mxid, displayname, err := mc.getBasicUserInfo(ctx, waid.MakeUserID(&parsed))
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to get user info")
continue
}
content.Mentions.UserIDs = append(content.Mentions.UserIDs, mxid)
mentionText := "@" + parsed.User
content.Body = strings.ReplaceAll(content.Body, mentionText, displayname)
content.FormattedBody = strings.ReplaceAll(content.FormattedBody, mentionText, fmt.Sprintf(`<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), event.TextToHTML(displayname)))
}
}
if qm := contextInfo.GetQuotedMessage(); qm != nil {
pcp, _ := types.ParseJID(contextInfo.GetParticipant())
chat, _ := types.ParseJID(contextInfo.GetRemoteJID())
if chat.String() == "" {
chat = msgInfo.Chat
}
cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()),
}
}
func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user networkid.UserID) (id.UserID, string, error) {
ghost, err := mc.Bridge.GetGhostByID(ctx, user)
if err != nil {
return "", "", fmt.Errorf("failed to get ghost by ID: %w", err)
}
login := mc.Bridge.GetCachedUserLoginByID(networkid.UserLoginID(user))
if login != nil {
return login.UserMXID, ghost.Name, nil
}
return ghost.Intent.GetMXID(), ghost.Name, nil
}
return cm
func (mc *MessageConverter) addMentions(ctx context.Context, mentionedJID []string, into *event.MessageEventContent) {
if len(mentionedJID) == 0 {
return
}
into.EnsureHasHTML()
for _, jid := range mentionedJID {
parsed, err := types.ParseJID(jid)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID")
continue
}
mxid, displayname, err := mc.getBasicUserInfo(ctx, waid.MakeUserID(parsed))
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to get user info")
continue
}
into.Mentions.UserIDs = append(into.Mentions.UserIDs, mxid)
mentionText := "@" + parsed.User
into.Body = strings.ReplaceAll(into.Body, mentionText, displayname)
into.FormattedBody = strings.ReplaceAll(into.FormattedBody, mentionText, fmt.Sprintf(`<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), html.EscapeString(displayname)))
}
}
func (mc *MessageConverter) reuploadWhatsAppAttachment(
ctx context.Context,
message MediaMessage,
fileInfo event.FileInfo,
fileInfo *event.FileInfo,
client *whatsmeow.Client,
intent bridgev2.MatrixAPI,
portal *bridgev2.Portal,
@ -230,19 +169,17 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
}
cmp := &bridgev2.ConvertedMessagePart{
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
Body: fileName,
FileName: fileName,
Info: fileInfo,
URL: mxc,
Info: &fileInfo,
File: file,
},
Extra: make(map[string]any),
}
return cmp, nil
}, nil
}
func convertLocationMessage(ctx context.Context, intent bridgev2.MatrixAPI, portal *bridgev2.Portal, msg *waE2E.LocationMessage) *bridgev2.ConvertedMessagePart {
@ -273,8 +210,8 @@ func convertLocationMessage(ctx context.Context, intent bridgev2.MatrixAPI, port
if len(msg.GetJPEGThumbnail()) > 0 {
thumbnailMime := http.DetectContentType(msg.GetJPEGThumbnail())
uploadedThumbnail, _, _ := intent.UploadMedia(ctx, portal.MXID, msg.GetJPEGThumbnail(), "thumb.jpeg", thumbnailMime)
if uploadedThumbnail != "" {
thumbnailURL, thumbnailFile, err := intent.UploadMedia(ctx, portal.MXID, msg.GetJPEGThumbnail(), "thumb.jpeg", thumbnailMime)
if err == nil {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.GetJPEGThumbnail()))
content.Info = &event.FileInfo{
ThumbnailInfo: &event.FileInfo{
@ -283,7 +220,8 @@ func convertLocationMessage(ctx context.Context, intent bridgev2.MatrixAPI, port
Height: cfg.Height,
MimeType: thumbnailMime,
},
ThumbnailURL: uploadedThumbnail,
ThumbnailURL: thumbnailURL,
ThumbnailFile: thumbnailFile,
}
}
}
@ -294,79 +232,98 @@ func convertLocationMessage(ctx context.Context, intent bridgev2.MatrixAPI, port
}
}
func (mc *MessageConverter) ToMatrix(ctx context.Context, portal *bridgev2.Portal, client *whatsmeow.Client, intent bridgev2.MatrixAPI, message *waE2E.Message, msgInfo *types.MessageInfo) *bridgev2.ConvertedMessage {
cm := &bridgev2.ConvertedMessage{
Parts: make([]*bridgev2.ConvertedMessagePart, 0),
func makeMediaFailure(mediaType string) *bridgev2.ConvertedMessagePart {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Failed to bridge %s, please view it on the WhatsApp app", mediaType),
},
}
}
media, fileInfo, contextInfo, extraInfo := getMediaMessageFileInfo(message)
func (mc *MessageConverter) ToMatrix(ctx context.Context, portal *bridgev2.Portal, client *whatsmeow.Client, intent bridgev2.MatrixAPI, message *waE2E.Message) *bridgev2.ConvertedMessage {
var part *bridgev2.ConvertedMessagePart
var contextInfo *waE2E.ContextInfo
var err error
media, info := getMediaMessageFileInfo(message)
if media != nil {
cmp, err := mc.reuploadWhatsAppAttachment(ctx, media, fileInfo, client, intent, portal)
contextInfo = info.ContextInfo
part, err = mc.reuploadWhatsAppAttachment(ctx, media, &info.FileInfo, client, intent, portal)
if err != nil {
// TODO: THROW SOME ERROR HERE!?
return cm
}
part = makeMediaFailure("attachment")
} else {
part.Content.MsgType = info.MsgType
if message.StickerMessage != nil {
part.Type = event.EventSticker
}
if extraInfo.MsgType != "" {
cmp.Content.MsgType = extraInfo.MsgType
}
if extraInfo.Waveform != nil {
cmp.Content.MSC3245Voice = &event.MSC3245Voice{}
cmp.Content.MSC1767Audio = &event.MSC1767Audio{
Duration: extraInfo.Duration,
Waveform: extraInfo.Waveform,
if info.Waveform != nil {
part.Content.MSC3245Voice = &event.MSC3245Voice{}
part.Content.MSC1767Audio = &event.MSC1767Audio{
Duration: info.Duration,
Waveform: info.Waveform,
}
}
if info.Caption != "" {
part.Content.Body = info.Caption
}
if info.IsGif {
part.Extra["info"] = map[string]any{
"fi.mau.gif": true,
"fi.mau.loop": true,
"fi.mau.autoplay": true,
"fi.mau.hide_controls": true,
"fi.mau.no_audio": true,
}
}
}
if extraInfo.Caption != "" {
cmp.Content.Body = extraInfo.Caption
}
if extraInfo.IsGif {
cmp.Extra["info"] = map[string]any{
"fi.mau.gif": true,
"fi.mau.loop": true,
"fi.mau.autoplay": true,
"fi.mau.hide_controls": true,
"fi.mau.no_audio": true,
}
}
cm.Parts = append(cm.Parts, cmp)
} else if location := message.GetLocationMessage(); location != nil {
cmp := convertLocationMessage(ctx, intent, portal, location)
part = convertLocationMessage(ctx, intent, portal, location)
contextInfo = location.GetContextInfo()
cm.Parts = append(cm.Parts, cmp)
} else if contacts := message.GetContactMessage(); contacts != nil {
cmp, err := convertContactMessage(ctx, intent, portal, contacts)
part, err = convertContactMessage(ctx, intent, portal, contacts)
if err != nil {
// TODO: THROW SOME ERROR HERE!?
return cm
part = makeMediaFailure("contact message")
}
contextInfo = contacts.GetContextInfo()
cm.Parts = append(cm.Parts, cmp)
} else {
cmp := &bridgev2.ConvertedMessagePart{
part = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
},
}
if extendedText := message.GetExtendedTextMessage(); extendedText != nil {
cmp.Content.Body = extendedText.GetText()
part.Content.Body = extendedText.GetText()
contextInfo = extendedText.GetContextInfo()
} else if conversation := message.GetConversation(); conversation != "" {
cmp.Content.Body = conversation
part.Content.Body = conversation
contextInfo = nil
} else {
cmp.Content.MsgType = event.MsgNotice
cmp.Content.Body = "Unknown message type, please view it on the WhatsApp app"
part.Content.MsgType = event.MsgNotice
part.Content.Body = "Unknown message type, please view it on the WhatsApp app"
}
cm.Parts = append(cm.Parts, cmp)
}
// TODO lots of message types missing
if contextInfo != nil && msgInfo != nil {
cm = mc.parseMessageContextInfo(ctx, cm, contextInfo, *msgInfo)
part.Content.Mentions = &event.Mentions{}
mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content)
cm := &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{part},
Disappear: database.DisappearingSetting{},
}
if contextInfo.GetStanzaID() != "" {
pcp, _ := types.ParseJID(contextInfo.GetParticipant())
chat, _ := types.ParseJID(contextInfo.GetRemoteJID())
if chat.IsEmpty() {
chat, _ = waid.ParsePortalID(portal.ID)
}
cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()),
}
}
return cm

View file

@ -24,7 +24,6 @@ import (
type MessageConverter struct {
Bridge *bridgev2.Bridge
MaxFileSize int64
AsyncFiles bool
HTMLParser *format.HTMLParser
}

View file

@ -8,25 +8,19 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid"
)
/*func ParseWAPortalID(portal networkid.PortalID, server string) types.JID {
return types.JID{
User: string(portal),
Server: server,
}
}*/
func MakeWAPortalID(jid types.JID) networkid.PortalID {
func MakePortalID(jid types.JID) networkid.PortalID {
return networkid.PortalID(jid.ToNonAD().String())
}
func ParseWAUserLoginID(user networkid.UserLoginID) types.JID {
return types.JID{
Server: types.DefaultUserServer,
User: string(user),
func ParsePortalID(portal networkid.PortalID) (types.JID, error) {
parsed, err := types.ParseJID(string(portal))
if err != nil {
return types.EmptyJID, fmt.Errorf("invalid portal ID: %w", err)
}
return parsed, nil
}
func MakeUserID(user *types.JID) networkid.UserID {
func MakeUserID(user types.JID) networkid.UserID {
return networkid.UserID(user.User)
}
@ -34,8 +28,16 @@ func ParseUserID(user networkid.UserID) types.JID {
return types.NewJID(string(user), types.DefaultUserServer)
}
func MakeUserLoginID(user *types.JID) networkid.UserLoginID {
return networkid.UserLoginID(MakeUserID(user))
func MakeUserLoginID(user types.JID) networkid.UserLoginID {
return networkid.UserLoginID(user.User)
}
func ParseUserLoginID(user networkid.UserLoginID, deviceID uint16) types.JID {
return types.JID{
Server: types.DefaultUserServer,
User: string(user),
Device: deviceID,
}
}
func MakeMessageID(chat, sender types.JID, id types.MessageID) networkid.MessageID {