v2: start working on connector

This commit is contained in:
Rajeh Taher 2024-08-13 14:11:10 +03:00 committed by Tulir Asokan
parent 330823f9b7
commit ab3d8f3c9f
19 changed files with 2107 additions and 34 deletions

View file

@ -1,4 +1,28 @@
package main
import (
_ "go.mau.fi/util/dbutil/litestream"
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"maunium.net/go/mautrix-whatsapp/pkg/connector"
)
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
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()
}

24
go.mod
View file

@ -10,16 +10,16 @@ require (
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.1
go.mau.fi/util v0.6.1-0.20240719175439-20a6073e1dd4
github.com/tidwall/gjson v1.17.3
go.mau.fi/util v0.6.1-0.20240802175451-b430ebbffc98
go.mau.fi/webp v0.1.0
go.mau.fi/whatsmeow v0.0.0-20240828153534-8acde1ba8592
golang.org/x/exp v0.0.0-20240707233637-46b078467d37
go.mau.fi/whatsmeow v0.0.0-20240726213518-bb5852f056ca
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/image v0.18.0
golang.org/x/net v0.27.0
golang.org/x/sync v0.7.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.20240720173515-24ead553b23b
maunium.net/go/mautrix v0.19.1-0.20240809124413-8c0f705ee90b
)
require (
@ -30,20 +30,20 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.7.4 // indirect
go.mau.fi/libsignal v0.1.1 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // 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
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

46
go.sum
View file

@ -29,8 +29,9 @@ github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -55,43 +56,44 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
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.20240719175439-20a6073e1dd4 h1:CYKYs5jwJ0bFJqh6pRoWtC9NIJ0lz0/6i2SC4qEBFaU=
go.mau.fi/util v0.6.1-0.20240719175439-20a6073e1dd4/go.mod h1:ljYdq3sPfpICc3zMU+/mHV/sa4z0nKxc67hSBwnrk8U=
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/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-20240828153534-8acde1ba8592 h1:wVBSwcGNGaI8agrExAXSb6rx9lfp9GvUtsAdl0wFWEY=
go.mau.fi/whatsmeow v0.0.0-20240828153534-8acde1ba8592/go.mod h1:BhHKalSq0qNtSCuGIUIvoJyU5KbT4a7k8DQ5yw1Ssk4=
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/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
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/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.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
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.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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=
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=
@ -103,5 +105,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.20240720173515-24ead553b23b h1:Di+oGGsfDOdyCdKxFMVentY49lLIGyyWl2k6Kpx/S6w=
maunium.net/go/mautrix v0.19.1-0.20240720173515-24ead553b23b/go.mod h1:xP3DCXdPBUe1sPiugLbd5mRh/mJQWfGWyED1S8s9V7c=
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=

37
pkg/connector/chatinfo.go Normal file
View file

@ -0,0 +1,37 @@
package connector
import (
"context"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2"
)
func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return nil, nil
}
func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
jid := types.NewJID(string(ghost.ID), types.DefaultUserServer)
contact, err := wa.Client.Store.Contacts.GetContact(jid)
if err != nil {
return nil, err
}
// 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{},
}
name := wa.Main.Config.FormatDisplayname(jid, contact)
ui.Name = &name
ui.Identifiers = append(ui.Identifiers, "tel:"+jid.User)
// TODO: implement Profile picture stuff
return ui
}

256
pkg/connector/client.go Normal file
View file

@ -0,0 +1,256 @@
package connector
import (
"context"
"encoding/json"
"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"
)
type respGetProxy struct {
ProxyURL string `json:"proxy_url"`
}
func (wa *WhatsAppClient) getProxy(reason string) (string, error) {
if wa.Main.Config.WhatsApp.GetProxyURL == "" {
return wa.Main.Config.WhatsApp.Proxy, nil
}
parsed, err := url.Parse(wa.Main.Config.WhatsApp.GetProxyURL)
if err != nil {
return "", fmt.Errorf("failed to parse address: %w", err)
}
q := parsed.Query()
q.Set("reason", reason)
parsed.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return "", fmt.Errorf("failed to prepare request: %w", err)
}
req.Header.Set("User-Agent", mautrix.DefaultUserAgent)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
} else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
return "", fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
var respData respGetProxy
err = json.NewDecoder(resp.Body).Decode(&respData)
if err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
return respData.ProxyURL, nil
}
func (wa *WhatsAppClient) MakeNewClient() {
wa.Client = whatsmeow.NewClient(wa.Device, waLog.Zerolog(wa.UserLogin.Log.With().Str("component", "whatsmeow").Logger()))
if wa.Client.Store.ID != nil {
wa.Client.EnableAutoReconnect = true
} else {
wa.Client.EnableAutoReconnect = false // no auto reconnect unless we are logged in
}
wa.Client.AutomaticMessageRerequestFromPhone = true
wa.Client.SetForceActiveDeliveryReceipts(wa.Main.Config.Bridge.ForceActiveDeliveryReceipts)
wa.Client.AddEventHandler(wa.handleWAEvent)
//TODO: add tracking under PreRetryCallback and GetMessageForRetry (waiting till metrics/analytics are added)
if wa.Main.Config.WhatsApp.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.WhatsApp.ProxyOnlyLogin,
}); err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to set proxy address")
}
}
if wa.Main.Config.WhatsApp.ProxyOnlyLogin {
wa.Client.ToggleProxyOnlyForLogin(true)
}
}
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())
}
type WhatsAppClient struct {
Main *WhatsAppConnector
UserLogin *bridgev2.UserLogin
Client *whatsmeow.Client
Device *store.Device
State status.BridgeState
}
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(ctx 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
}
var (
_ bridgev2.NetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.TypingHandlingNetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.IdentifierResolvingNetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.GroupCreatingNetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.ContactListingNetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.RoomNameHandlingNetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.RoomAvatarHandlingNetworkAPI = (*WhatsAppClient)(nil)
//_ bridgev2.RoomTopicHandlingNetworkAPI = (*WhatsAppClient)(nil)
)
var pushCfg = &bridgev2.PushConfig{
Web: &bridgev2.WebPushConfig{},
}
func (wa *WhatsAppClient) GetPushConfigs() *bridgev2.PushConfig {
// implement get application server key (to be added to whatsmeow)
//pushCfg.Web.VapidKey = applicationServerKey
return pushCfg
}
func (wa *WhatsAppClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error {
if wa.Client == nil {
return bridgev2.ErrNotLoggedIn
}
if pushType != bridgev2.PushTypeWeb {
return fmt.Errorf("unsupported push type: %s", pushType)
}
//wa.Client.RegisterWebPush(ctx, token) (to be added to whatsmeow)
return nil
}
func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) {
if wa.Client == nil {
return
}
err := wa.Client.Logout()
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to log out")
}
}
func (wa *WhatsAppClient) IsThisUser(ctx 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(ctx context.Context) error {
wa.Client.Connect()
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
}

133
pkg/connector/config.go Normal file
View file

@ -0,0 +1,133 @@
package connector
import (
_ "embed"
"strings"
"text/template"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/event"
)
type MediaRequestMethod string
//go:embed example-config.yaml
var ExampleConfig string
type WhatsAppConfig struct {
WhatsApp struct {
OSName string `yaml:"os_name"`
BrowserName string `yaml:"browser_name"`
Proxy string `yaml:"proxy"`
GetProxyURL string `yaml:"get_proxy_url"`
ProxyOnlyLogin bool `yaml:"proxy_only_login"`
} `yaml:"whatsapp"`
Bridge struct {
DisplaynameTemplate string `yaml:"displayname_template"`
CallStartNotices bool `yaml:"call_start_notices"`
HistorySync struct {
RequestFullSync bool `yaml:"request_full_sync"`
FullSyncConfig struct {
DaysLimit uint32 `yaml:"days_limit"`
SizeLimit uint32 `yaml:"size_mb_limit"`
StorageQuota uint32 `yaml:"storage_quota_mb"`
} `yaml:"full_sync_config"`
MediaRequests struct {
AutoRequestMedia bool `yaml:"auto_request_media"`
RequestMethod MediaRequestMethod `yaml:"request_method"`
RequestLocalTime int `yaml:"request_local_time"`
MaxAsyncHandle int64 `yaml:"max_async_handle"`
} `yaml:"media_requests"`
} `yaml:"history_sync"`
UserAvatarSync bool `yaml:"user_avatar_sync"`
SendPresenceOnTyping bool `yaml:"send_presence_on_typing"`
ArchiveTag event.RoomTag `yaml:"archive_tag"`
PinnedTag event.RoomTag `yaml:"pinned_tag"`
TagOnlyOnCreate bool `yaml:"tag_only_on_create"`
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"`
} `yaml:"bridge"`
displaynameTemplate *template.Template `yaml:"-"`
}
func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "whatsapp", "os_name")
helper.Copy(up.Str, "whatsapp", "browser_name")
helper.Copy(up.Str, "whatsapp", "proxy")
helper.Copy(up.Str, "whatsapp", "get_proxy_url")
helper.Copy(up.Bool, "whatsapp", "proxy_only_login")
helper.Copy(up.Str, "bridge", "displayname_template")
helper.Copy(up.Bool, "bridge", "call_start_notices")
helper.Copy(up.Bool, "bridge", "history_sync", "request_full_sync")
helper.Copy(up.Int, "bridge", "history_sync", "full_sync_config", "days_limit")
helper.Copy(up.Int, "bridge", "history_sync", "full_sync_config", "size_mb_limit")
helper.Copy(up.Int, "bridge", "history_sync", "full_sync_config", "storage_quota_mb")
helper.Copy(up.Bool, "bridge", "history_sync", "media_requests", "auto_request_media")
helper.Copy(up.Str, "bridge", "history_sync", "media_requests", "request_method")
helper.Copy(up.Int, "bridge", "history_sync", "media_requests", "request_local_time")
helper.Copy(up.Int, "bridge", "history_sync", "media_requests", "max_async_handle")
helper.Copy(up.Bool, "bridge", "user_avatar_sync")
helper.Copy(up.Bool, "bridge", "send_presence_on_typing")
helper.Copy(up.Str, "bridge", "archive_tag")
helper.Copy(up.Str, "bridge", "pinned_tag")
helper.Copy(up.Bool, "bridge", "tag_only_on_create")
helper.Copy(up.Bool, "bridge", "enable_status_broadcast")
helper.Copy(up.Bool, "bridge", "disable_status_broadcast_send")
helper.Copy(up.Bool, "bridge", "mute_status_broadcast")
helper.Copy(up.Str, "bridge", "status_broadcast_tag")
helper.Copy(up.Bool, "bridge", "whatsapp_thumbnail")
helper.Copy(up.Bool, "bridge", "url_previews")
}
type DisplaynameParams struct {
types.ContactInfo
Phone string
JID string
}
func (c *WhatsAppConfig) FormatDisplayname(jid types.JID, contact types.ContactInfo) string {
var nameBuf strings.Builder
err := c.displaynameTemplate.Execute(&nameBuf, &DisplaynameParams{
ContactInfo: contact,
Phone: "+" + jid.User,
JID: "+" + jid.User,
})
if err != nil {
panic(err)
}
return nameBuf.String()
}
func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) {
return ExampleConfig, wa.Config, up.SimpleUpgrader(upgradeConfig)
}

View file

@ -1 +1,141 @@
package connector
import (
"context"
"strings"
"text/template"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix-whatsapp/pkg/msgconv"
)
type WhatsAppConnector struct {
Bridge *bridgev2.Bridge
Config *WhatsAppConfig
DeviceStore *sqlstore.Container
MsgConv *msgconv.MessageConverter
}
var _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
var _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
func NewConnector() *WhatsAppConnector {
return &WhatsAppConnector{
Config: &WhatsAppConfig{},
}
}
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
println("SetMaxFileSize unimplemented")
}
var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
DisappearingMessages: true,
AggressiveUpdateInfo: false,
}
func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
return WhatsAppGeneralCaps
}
func (wa *WhatsAppConnector) GetName() bridgev2.BridgeName {
return bridgev2.BridgeName{
DisplayName: "WhatsApp",
NetworkURL: "https://whatsapp.com",
NetworkIcon: "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr",
NetworkID: "whatsapp",
BeeperBridgeType: "whatsapp",
DefaultPort: 29318,
}
}
func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
var err error
wa.Config.displaynameTemplate, err = template.New("displayname").Parse(wa.Config.Bridge.DisplaynameTemplate)
if err != nil {
// TODO return error or do this later?
panic(err)
}
wa.Bridge = bridge
wa.MsgConv = msgconv.New(bridge)
wa.DeviceStore = sqlstore.NewWithDB(
bridge.DB.RawDB,
bridge.DB.Dialect.String(),
waLog.Zerolog(bridge.Log.With().Str("db_section", "whatsmeow").Logger()),
)
store.DeviceProps.Os = proto.String(wa.Config.WhatsApp.OSName)
store.DeviceProps.RequireFullSync = proto.Bool(wa.Config.Bridge.HistorySync.RequestFullSync)
if fsc := wa.Config.Bridge.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
store.DeviceProps.HistorySyncConfig = &waCompanionReg.DeviceProps_HistorySyncConfig{
FullSyncDaysLimit: proto.Uint32(fsc.DaysLimit),
FullSyncSizeMbLimit: proto.Uint32(fsc.SizeLimit),
StorageQuotaMb: proto.Uint32(fsc.StorageQuota),
}
}
platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(wa.Config.WhatsApp.BrowserName)]
if ok {
store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_PlatformType(platformID).Enum()
}
}
func (wa *WhatsAppConnector) Start(ctx context.Context) error {
err := wa.DeviceStore.Upgrade()
if err != nil {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsmeow"}
}
ver, err := whatsmeow.GetLatestVersion(nil)
if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number")
} else {
wa.Bridge.Log.Debug().
Stringer("hardcoded_version", store.GetWAVersion()).
Stringer("latest_version", *ver).
Msg("Got latest WhatsApp web version number")
store.SetWAVersion(*ver)
}
return nil
}
func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
loginMetadata := login.Metadata.(*UserLoginMetadata)
jid := types.JID{
Device: loginMetadata.WADeviceID,
Server: types.DefaultUserServer,
User: string(login.ID),
}
device, err := wa.DeviceStore.GetDevice(jid)
if err != nil {
return err
}
w := &WhatsAppClient{
Main: wa,
UserLogin: login,
Device: device,
}
w.MakeNewClient()
err = w.Client.Connect()
if err != nil {
login.Log.Err(err).Msg("Error connecting to WhatsApp")
}
login.Client = w
return nil
}

21
pkg/connector/dbmeta.go Normal file
View file

@ -0,0 +1,21 @@
package connector
import (
"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{} },
}
}
type UserLoginMetadata struct {
WADeviceID uint16 `json:"wa_device_id"`
//TODO: Add phone last ping/seen
}

52
pkg/connector/events.go Normal file
View file

@ -0,0 +1,52 @@
package connector
import (
"context"
"time"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
type WAMessageEvent struct {
*events.Message
portalKey networkid.PortalKey
wa *WhatsAppClient
}
var (
_ bridgev2.RemoteMessage = (*WAMessageEvent)(nil)
_ bridgev2.RemoteEventWithTimestamp = (*WAMessageEvent)(nil)
)
func (evt *WAMessageEvent) GetType() bridgev2.RemoteEventType {
return bridgev2.RemoteEventMessage
}
func (evt *WAMessageEvent) GetPortalKey() networkid.PortalKey {
return evt.portalKey
}
func (evt *WAMessageEvent) AddLogContext(c zerolog.Context) zerolog.Context {
return c.Str("message_id", evt.Info.ID).Uint64("sender_id", evt.Info.Sender.UserInt())
}
func (evt *WAMessageEvent) GetSender() bridgev2.EventSender {
return evt.wa.makeEventSender(int64(evt.Info.Sender.UserInt()))
}
func (evt *WAMessageEvent) GetID() networkid.MessageID {
return waid.MakeMessageID(evt.Info.Chat, evt.Info.Sender, evt.Info.ID)
}
func (evt *WAMessageEvent) GetTimestamp() time.Time {
return evt.GetTimestamp()
}
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), nil
}

View file

@ -0,0 +1,102 @@
# Config for things that are directly sent to WhatsApp.
whatsapp:
# Device name that's shown in the "WhatsApp Web" section in the mobile app.
os_name: Mautrix-WhatsApp bridge
# Browser name that determines the logo shown in the mobile app.
# Must be "unknown" for a generic icon or a valid browser name if you want a specific icon.
# List of valid browser names: https://github.com/tulir/whatsmeow/blob/efc632c008604016ddde63bfcfca8de4e5304da9/binary/proto/def.proto#L43-L64
browser_name: unknown
# Proxy to use for all WhatsApp connections.
proxy: null
# Alternative to proxy: an HTTP endpoint that returns the proxy URL to use for WhatsApp connections.
get_proxy_url: null
# Whether the proxy options should only apply to the login websocket and not to authenticated connections.
proxy_only_login: false
# Bridge config
bridge:
# Displayname template for WhatsApp users.
# {{.PushName}} - nickname set by the WhatsApp user
# {{.BusinessName}} - validated WhatsApp business name
# {{.Phone}} - phone number (international format)
displayname_template: "{{or .BusinessName .PushName .JID}} (WA)"
# Should incoming calls send a message to the Matrix room?
call_start_notices: true
# 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:
# Number of days of history to request.
# The limit seems to be around 3 years, but using higher values doesn't break.
days_limit: null
# This is presumably the maximum size of the transferred history sync blob, which may affect what the phone includes in the blob.
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.
media_requests:
# Should the expired media be automatically requested from the server as part of the backfill process?
auto_request_media: true
# Whether to request the media immediately after the media message is backfilled ("immediate")
# or at a specific time of the day ("local_time").
request_method: immediate
# If request_method is "local_time", what time should the requests be sent (in minutes after midnight)?
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

@ -0,0 +1,155 @@
package connector
import (
"context"
"fmt"
"github.com/rs/zerolog"
"go.mau.fi/util/variationselector"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
var (
_ bridgev2.EditHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.ReactionHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.RedactionHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*WhatsAppClient)(nil)
)
func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
waMsg, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, msg.Event, msg.Content, msg.ReplyTo, msg.Portal)
if err != nil {
return nil, fmt.Errorf("failed to convert message: %w", err)
}
messageID := wa.Client.GenerateMessageID()
chatJID, err := types.ParseJID(string(msg.Portal.ID))
if err != nil {
return nil, err
}
senderJID := wa.Device.ID
resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, whatsmeow.SendRequestExtra{
ID: messageID,
})
if err != nil {
return nil, err
}
return &bridgev2.MatrixMessageResponse{
DB: &database.Message{
ID: waid.MakeMessageID(chatJID, senderJID.ToNonAD(), messageID),
SenderID: networkid.UserID(wa.UserLogin.ID),
Timestamp: resp.Timestamp,
},
}, nil
}
func (wa *WhatsAppClient) PreHandleMatrixReaction(_ context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
return bridgev2.MatrixReactionPreResponse{
SenderID: networkid.UserID(wa.UserLogin.ID),
EmojiID: "",
Emoji: variationselector.Remove(msg.Content.RelatesTo.Key),
MaxReactions: 1,
}, nil
}
func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) {
messageID, err := waid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return nil, err
}
portalJID, err := types.ParseJID(string(msg.Portal.ID))
if err != nil {
return nil, err
}
reactionMsg := &waE2E.Message{
ReactionMessage: &waE2E.ReactionMessage{
Key: wa.messageIDToKey(messageID),
Text: proto.String(msg.PreHandleResp.Emoji),
SenderTimestampMS: proto.Int64(msg.Event.Timestamp),
},
}
resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg)
zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response")
return nil, err
}
func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
messageID, err := waid.ParseMessageID(msg.TargetReaction.MessageID)
if err != nil {
return err
}
portalJID, err := types.ParseJID(string(msg.Portal.ID))
if err != nil {
return err
}
reactionMsg := &waE2E.Message{
ReactionMessage: &waE2E.ReactionMessage{
Key: wa.messageIDToKey(messageID),
Text: proto.String(""),
SenderTimestampMS: proto.Int64(msg.Event.Timestamp),
},
}
resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg)
zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response")
return err
}
func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error {
log := zerolog.Ctx(ctx)
messageID, err := waid.ParseMessageID(edit.EditTarget.ID)
if err != nil {
return err
}
portalJID, err := types.ParseJID(string(edit.Portal.ID))
if err != nil {
return err
}
//TODO: DO CONVERSION VIA msgconv FUNC
//TODO: IMPLEMENT MEDIA CAPTION EDITS
editMessage := wa.Client.BuildEdit(portalJID, messageID.ID, &waE2E.Message{
Conversation: proto.String(edit.Content.Body),
})
resp, err := wa.Client.SendMessage(ctx, portalJID, editMessage)
log.Trace().Any("response", resp).Msg("WhatsApp edit response")
return err
}
func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
log := zerolog.Ctx(ctx)
messageID, err := waid.ParseMessageID(msg.TargetMessage.ID)
if err != nil {
return err
}
portalJID, err := types.ParseJID(string(msg.Portal.ID))
if err != nil {
return err
}
revokeMessage := wa.Client.BuildRevoke(messageID.Chat, messageID.Sender, messageID.ID)
resp, err := wa.Client.SendMessage(ctx, portalJID, revokeMessage)
log.Trace().Any("response", resp).Msg("WhatsApp delete response")
return err
}
func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
//TODO implement me
panic("implement me")
}

View file

@ -0,0 +1,108 @@
package connector
import (
"context"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridge/status"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
const (
WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect"
WAPermanentError status.BridgeStateErrorCode = "wa-unknown-permanent-error"
)
func (wa *WhatsAppClient) handleWAEvent(rawEvt any) {
log := wa.UserLogin.Log
switch evt := rawEvt.(type) {
case events.Message:
log.Trace().
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")
return
}
wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &WAMessageEvent{
Message: &evt,
portalKey: portalKey,
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:
evtType = bridgev2.RemoteEventReadReceipt
case types.ReceiptTypeDelivered:
evtType = bridgev2.RemoteEventDeliveryReceipt
}
targets := make([]networkid.MessageID, len(evt.MessageIDs))
for i, id := range evt.MessageIDs {
targets[i] = waid.MakeMessageID(evt.Chat, *wa.Device.ID, id)
}
wa.Main.Bridge.QueueRemoteEvent(wa.UserLogin, &simplevent.Receipt{
EventMeta: simplevent.EventMeta{
Type: evtType,
LogContext: nil,
PortalKey: portalKey,
Sender: wa.makeWAEventSender(evt.Sender),
Timestamp: evt.Timestamp,
},
Targets: targets,
})
case *events.Connected:
log.Debug().Msg("Connected to WhatsApp socket")
wa.State = status.BridgeState{StateEvent: status.StateConnected}
wa.UserLogin.BridgeState.Send(wa.State)
case *events.Disconnected:
log.Debug().Msg("Disconnected from WhatsApp socket")
wa.State = status.BridgeState{
StateEvent: status.StateTransientDisconnect,
Error: WADisconnected,
}
wa.UserLogin.BridgeState.Send(wa.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()
wa.Device.Delete()
wa.resetWADevice()
wa.Client = nil
}
log.Debug().Msg("Reconnecting e2ee client after WhatsApp 415 error")
go wa.Connect(context.Background())
}
}
wa.State = status.BridgeState{
StateEvent: status.StateUnknownError,
Error: WAPermanentError,
Message: evt.PermanentDisconnectDescription(),
}
wa.UserLogin.BridgeState.Send(wa.State)
default:
log.Debug().Type("event_type", rawEvt).Msg("Unhandled WhatsApp event")
}
}

33
pkg/connector/id.go Normal file
View file

@ -0,0 +1,33 @@
package connector
import (
"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) makeWAPortalKey(chatJID types.JID) (key networkid.PortalKey, ok bool) {
key.ID = waid.MakeWAPortalID(chatJID)
switch chatJID.Server {
case types.DefaultUserServer, types.GroupServer: //TODO: LID support + other types?
key.Receiver = wa.UserLogin.ID // does this also apply for groups ?!?!
default:
return
}
ok = true
return
}
func (wa *WhatsAppClient) makeWAEventSender(sender types.JID) bridgev2.EventSender {
return wa.makeEventSender(int64(sender.UserInt()))
}
func (wa *WhatsAppClient) makeEventSender(id int64) bridgev2.EventSender {
return bridgev2.EventSender{
IsFromMe: waid.MakeUserLoginID(id) == wa.UserLogin.ID,
Sender: waid.MakeUserID(id),
SenderLogin: waid.MakeUserLoginID(id),
}
}

161
pkg/connector/login.go Normal file
View file

@ -0,0 +1,161 @@
package connector
import (
"context"
"fmt"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/store"
"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",
},
//TODO
/* {
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(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
//TODO: ADD PAIRING CODE HERE
if flowID != "qr" {
return nil, fmt.Errorf("invalid login flow ID")
}
return &QRLogin{User: user, Main: wa}, nil
}
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()
go func() {
for range qr.QRChan {
}
}()
}
const (
LoginStepQR = "fi.mau.whatsapp.login.qr"
LoginStepComplete = "fi.mau.whatsapp.login.complete"
)
func (qr *QRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
log := qr.Main.Bridge.Log.With().
Str("action", "login").
Stringer("user_id", qr.User.MXID).
Logger()
qrCtx, cancel := context.WithCancel(log.WithContext(context.Background()))
qr.cancelChan = cancel
device := qr.Main.DeviceStore.NewDevice()
wa := &WhatsAppClient{
Main: qr.Main,
Device: device,
}
wa.MakeNewClient()
qr.Client = wa.Client
var err error
qr.QRChan, err = qr.Client.GetQRChannel(qrCtx)
if err != nil {
return nil, err
}
var resp whatsmeow.QRChannelItem
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():
cancel()
return nil, ctx.Err()
}
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
}
func (qr *QRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
if qr.QRChan == nil {
return nil, fmt.Errorf("login not started")
}
defer qr.cancelChan()
select {
case resp := <-qr.QRChan:
if resp.Event != "success" {
return nil, fmt.Errorf("did not pair properly, err: %s", resp.Event)
}
case <-ctx.Done():
return nil, ctx.Err()
}
newLoginID := waid.MakeWAUserLoginID(qr.Client.Store.ID)
ul, err := qr.User.NewLogin(ctx, &database.UserLogin{
ID: newLoginID,
RemoteName: qr.Client.Store.PushName,
Metadata: &UserLoginMetadata{
WADeviceID: qr.Client.Store.ID.Device,
},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
})
if err != nil {
return nil, fmt.Errorf("failed to create user login: %w", err)
}
err = qr.Main.LoadUserLogin(context.Background(), ul)
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", qr.Client.Store.PushName, newLoginID),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
UserLogin: ul,
},
}, nil
}

View file

@ -0,0 +1,3 @@
package wadb
// TODO: ADD POLL AND HISTORYSYNC AND MEDIABACKFILLREQUEST DBs

261
pkg/msgconv/from-matrix.go Normal file
View file

@ -0,0 +1,261 @@
package msgconv
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
func (mc *MessageConverter) ToWhatsApp(
ctx context.Context,
client *whatsmeow.Client,
evt *event.Event,
content *event.MessageEventContent,
replyTo *database.Message,
portal *bridgev2.Portal,
) (*waE2E.Message, error) {
if evt.Type == event.EventSticker {
content.MsgType = event.MsgImage
}
message := &waE2E.Message{}
contextInfo := &waE2E.ContextInfo{}
if replyTo != nil {
msgID, err := waid.ParseMessageID(replyTo.ID)
if err == nil {
contextInfo.StanzaID = proto.String(msgID.ID)
contextInfo.RemoteJID = proto.String(msgID.Chat.String())
contextInfo.Participant = proto.String(msgID.Sender.String())
contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")}
} else {
return nil, err
}
}
switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote:
message = mc.constructTextMessage(ctx, content, portal, contextInfo)
case event.MessageType(event.EventSticker.Type), event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
uploaded, mime, err := mc.reuploadFileToWhatsApp(ctx, client, content)
if err != nil {
return nil, err
}
message = mc.constructMediaMessage(content, uploaded, contextInfo, mime)
case event.MsgLocation:
lat, long, err := parseGeoURI(content.GeoURI)
if err != nil {
return nil, err
}
message.LocationMessage = &waE2E.LocationMessage{
DegreesLatitude: &lat,
DegreesLongitude: &long,
Comment: &content.Body,
ContextInfo: contextInfo,
}
default:
return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType)
}
return message, nil
}
func (mc *MessageConverter) constructMediaMessage(content *event.MessageEventContent, uploaded *whatsmeow.UploadResponse, contextInfo *waE2E.ContextInfo, mime string) *waE2E.Message {
caption := content.Body
switch content.MsgType {
case event.MessageType(event.EventSticker.Type):
return &waE2E.Message{
StickerMessage: &waE2E.StickerMessage{
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mime),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength),
ContextInfo: contextInfo,
},
}
case event.MsgAudio:
return &waE2E.Message{
AudioMessage: &waE2E.AudioMessage{
PTT: proto.Bool(content.MSC3245Voice != nil),
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mime),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength),
ContextInfo: contextInfo,
},
}
case event.MsgImage:
return &waE2E.Message{
ImageMessage: &waE2E.ImageMessage{
Caption: proto.String(caption),
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mime),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength),
ContextInfo: contextInfo,
},
}
case event.MsgVideo:
return &waE2E.Message{
VideoMessage: &waE2E.VideoMessage{
Caption: proto.String(caption),
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mime),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength),
ContextInfo: contextInfo,
},
}
case event.MsgFile:
return &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{
Caption: proto.String(caption),
URL: proto.String(uploaded.URL),
DirectPath: proto.String(uploaded.DirectPath),
MediaKey: uploaded.MediaKey,
Mimetype: proto.String(mime),
FileEncSHA256: uploaded.FileEncSHA256,
FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength),
ContextInfo: contextInfo,
},
}
default:
return nil
}
}
func (mc *MessageConverter) constructTextMessage(ctx context.Context, content *event.MessageEventContent, portal *bridgev2.Portal, contextInfo *waE2E.ContextInfo) *waE2E.Message {
mentions := make([]string, 0)
parseCtx := format.NewContext(ctx)
parseCtx.ReturnData["mentions"] = mentions
parseCtx.ReturnData["portal"] = portal
parsed := mc.HTMLParser.Parse(content.FormattedBody, parseCtx)
if len(mentions) > 0 {
contextInfo.MentionedJID = mentions
}
return &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
Text: proto.String(parsed),
ContextInfo: contextInfo,
},
}
}
func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx format.Context) string {
if len(mxid) == 0 || mxid[0] != '@' {
return format.DefaultPillConverter(displayname, mxid, eventID, ctx)
}
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")
return displayname
} else if ghost != nil {
jid, err := types.ParseJID(string(ghost.ID))
if jid.User == "" || err != nil {
return displayname
}
} 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
} else if user != nil {
portal := ctx.ReturnData["portal"].(*bridgev2.Portal)
login, _, _ := portal.FindPreferredLogin(ctx.Ctx, user, false)
if login == nil {
return displayname
}
jid = waid.ParseWAUserLoginID(login.ID)
} else {
return displayname
}
mentions := ctx.ReturnData["mentions"].([]string)
mentions = append(mentions, jid.String())
return fmt.Sprintf("@%s", jid.User)
}
func (mc *MessageConverter) reuploadFileToWhatsApp(ctx context.Context, client *whatsmeow.Client, content *event.MessageEventContent) (*whatsmeow.UploadResponse, string, error) {
mime := content.Info.MimeType
fileName := content.Body
if content.FileName != "" {
fileName = content.FileName
}
data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
if mime == "" {
mime = http.DetectContentType(data)
}
var mediaType whatsmeow.MediaType
switch content.MsgType {
case event.MsgImage, event.MessageType(event.EventSticker.Type):
mediaType = whatsmeow.MediaImage
case event.MsgVideo:
mediaType = whatsmeow.MediaVideo
case event.MsgAudio:
mediaType = whatsmeow.MediaAudio
case event.MsgFile:
fallthrough
default:
mediaType = whatsmeow.MediaDocument
}
uploaded, err := client.Upload(ctx, data, mediaType)
if err != nil {
zerolog.Ctx(ctx).Debug().
Str("file_name", fileName).
Str("mime_type", mime).
Bool("is_voice_clip", content.MSC3245Voice != nil).
Msg("Failed upload metadata")
return nil, "", fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
}
return &uploaded, mime, nil
}
func parseGeoURI(uri string) (lat, long float64, err error) {
if !strings.HasPrefix(uri, "geo:") {
err = fmt.Errorf("uri doesn't have geo: prefix")
return
}
// Remove geo: prefix and anything after ;
coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0]
if splitCoordinates := strings.Split(coordinates, ","); len(splitCoordinates) != 2 {
err = fmt.Errorf("didn't find exactly two numbers separated by a comma")
} else if lat, err = strconv.ParseFloat(splitCoordinates[0], 64); err != nil {
err = fmt.Errorf("latitude is not a number: %w", err)
} else if long, err = strconv.ParseFloat(splitCoordinates[1], 64); err != nil {
err = fmt.Errorf("longitude is not a number: %w", err)
}
return
}

View file

@ -0,0 +1,459 @@
package msgconv
import (
"context"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"strings"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
func (mc *MessageConverter) ToMatrix(ctx context.Context, portal *bridgev2.Portal, client *whatsmeow.Client, intent bridgev2.MatrixAPI, message *events.Message) *bridgev2.ConvertedMessage {
cm := &bridgev2.ConvertedMessage{
Parts: make([]*bridgev2.ConvertedMessagePart, 0),
}
if message.Message.GetAudioMessage() != nil {
cm.Parts = append(cm.Parts)
} else if message.Message.GetDocumentMessage() != nil {
cm.Parts = append(cm.Parts)
} else if message.Message.GetImageMessage() != nil {
cm.Parts = append(cm.Parts)
} else if message.Message.GetStickerMessage() != nil {
cm.Parts = append(cm.Parts)
} else if message.Message.GetVideoMessage() != nil {
cm.Parts = append(cm.Parts)
} else {
}
return cm
}
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
}
func (mc *MessageConverter) WhatsAppTextToMatrix(ctx context.Context, text string, mentions []string) *bridgev2.ConvertedMessagePart {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Mentions: &event.Mentions{},
Body: text,
}
if 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.MakeWAUserID(&parsed))
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)))
}
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
}
}
/*func convertWhatsAppAttachment(
ctx context.Context,
mc *MessageConverter,
msg *waE2E.Message,
) (media, caption *bridgev2.ConvertedMessagePart, err error) {
if len(msg.GetCaption().GetText()) > 0 {
caption = mc.WhatsAppTextToMatrix(ctx, msgWithCaption.GetCaption())
caption.Content.MsgType = event.MsgText
}
metadata = typedTransport.GetAncillary()
transport := typedTransport.GetIntegral().GetTransport()
media, err = mc.reuploadWhatsAppAttachment(ctx, transport, mediaType, convert)
return
}*/
func (mc *MessageConverter) reuploadWhatsAppAttachment(
ctx context.Context,
message whatsmeow.DownloadableMessage,
mimeType string,
client *whatsmeow.Client,
intent bridgev2.MatrixAPI,
portal *bridgev2.Portal,
) (*bridgev2.ConvertedMessagePart, error) {
data, err := client.Download(message)
if err != nil {
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
}
var fileName string
mxc, file, err := intent.UploadMedia(ctx, portal.MXID, data, fileName, mimeType)
if err != nil {
return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
}
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
Body: fileName,
URL: mxc,
Info: &event.FileInfo{
MimeType: mimeType,
Size: len(data),
},
File: file,
},
Extra: make(map[string]any),
}, nil
}
/*
func (mc *MessageConverter) convertWhatsAppImage(ctx context.Context, image *waConsumerApplication.ConsumerApplication_ImageMessage) (converted, caption *bridgev2.ConvertedMessagePart, err error) {
metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.ImageTransport](ctx, mc, image, whatsmeow.MediaImage, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) {
fileName := "image" + exmime.ExtensionFromMimetype(mimeType)
return data, mimeType, fileName, nil
})
if converted != nil {
converted.Content.MsgType = event.MsgImage
converted.Content.Info.Width = int(metadata.GetWidth())
converted.Content.Info.Height = int(metadata.GetHeight())
}
return
}
func (mc *MessageConverter) convertWhatsAppSticker(ctx context.Context, sticker *waConsumerApplication.ConsumerApplication_StickerMessage) (converted, caption *bridgev2.ConvertedMessagePart, err error) {
metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.StickerTransport](ctx, mc, sticker, whatsmeow.MediaImage, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) {
fileName := "sticker" + exmime.ExtensionFromMimetype(mimeType)
return data, mimeType, fileName, nil
})
if converted != nil {
converted.Type = event.EventSticker
converted.Content.Info.Width = int(metadata.GetWidth())
converted.Content.Info.Height = int(metadata.GetHeight())
}
return
}
func (mc *MessageConverter) convertWhatsAppDocument(ctx context.Context, document *waConsumerApplication.ConsumerApplication_DocumentMessage) (converted, caption *bridgev2.ConvertedMessagePart, err error) {
_, converted, caption, err = convertWhatsAppAttachment[*waMediaTransport.DocumentTransport](ctx, mc, document, whatsmeow.MediaDocument, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) {
fileName := document.GetFileName()
if fileName == "" {
fileName = "file" + exmime.ExtensionFromMimetype(mimeType)
}
return data, mimeType, fileName, nil
})
if converted != nil {
converted.Content.MsgType = event.MsgFile
}
return
}
func (mc *MessageConverter) convertWhatsAppAudio(ctx context.Context, audio *waConsumerApplication.ConsumerApplication_AudioMessage) (converted, caption *bridgev2.ConvertedMessagePart, err error) {
// Treat all audio messages as voice messages, official clients don't set the flag for some reason
isVoiceMessage := true // audio.GetPTT()
metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.AudioTransport](ctx, mc, audio, whatsmeow.MediaAudio, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) {
fileName := "audio" + exmime.ExtensionFromMimetype(mimeType)
if isVoiceMessage && !strings.HasPrefix(mimeType, "audio/ogg") {
data, err = ffmpeg.ConvertBytes(ctx, data, ".ogg", []string{}, []string{"-c:a", "libopus"}, mimeType)
if err != nil {
return data, mimeType, fileName, fmt.Errorf("%w audio to ogg/opus: %w", bridgev2.ErrMediaConvertFailed, err)
}
fileName += ".ogg"
mimeType = "audio/ogg"
}
return data, mimeType, fileName, nil
})
if converted != nil {
converted.Content.MsgType = event.MsgAudio
converted.Content.Info.Duration = int(metadata.GetSeconds() * 1000)
if isVoiceMessage {
converted.Content.MSC3245Voice = &event.MSC3245Voice{}
converted.Content.MSC1767Audio = &event.MSC1767Audio{
Duration: converted.Content.Info.Duration,
Waveform: []int{},
}
}
}
return
}
func (mc *MessageConverter) convertWhatsAppVideo(ctx context.Context, video *waE2E.VideoMessage) (converted, caption *bridgev2.ConvertedMessagePart, err error) {
metadata, converted, caption, err := convertWhatsAppAttachment[*waMediaTransport.VideoTransport](ctx, mc, video, whatsmeow.MediaVideo, func(ctx context.Context, data []byte, mimeType string) ([]byte, string, string, error) {
fileName := "video" + exmime.ExtensionFromMimetype(mimeType)
return data, mimeType, fileName, nil
})
if converted != nil {
converted.Content.MsgType = event.MsgVideo
converted.Content.Info.Width = int(metadata.GetWidth())
converted.Content.Info.Height = int(metadata.GetHeight())
converted.Content.Info.Duration = int(metadata.GetSeconds() * 1000)
// FB is annoying and sends images in video containers sometimes
if converted.Content.Info.MimeType == "image/gif" {
converted.Content.MsgType = event.MsgImage
} else if metadata.GetGifPlayback() {
converted.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,
}
}
}
return
}
func (mc *MessageConverter) convertWhatsAppMedia(ctx context.Context, rawContent *waConsumerApplication.ConsumerApplication_Content) (converted, caption *bridgev2.ConvertedMessagePart, err error) {
switch content := rawContent.GetContent().(type) {
case *waConsumerApplication.ConsumerApplication_Content_ImageMessage:
return mc.convertWhatsAppImage(ctx, content.ImageMessage)
case *waConsumerApplication.ConsumerApplication_Content_StickerMessage:
return mc.convertWhatsAppSticker(ctx, content.StickerMessage)
case *waConsumerApplication.ConsumerApplication_Content_ViewOnceMessage:
switch realContent := content.ViewOnceMessage.GetViewOnceContent().(type) {
case *waConsumerApplication.ConsumerApplication_ViewOnceMessage_ImageMessage:
return mc.convertWhatsAppImage(ctx, realContent.ImageMessage)
case *waConsumerApplication.ConsumerApplication_ViewOnceMessage_VideoMessage:
return mc.convertWhatsAppVideo(ctx, realContent.VideoMessage)
default:
return nil, nil, fmt.Errorf("unrecognized view once message type %T", realContent)
}
case *waConsumerApplication.ConsumerApplication_Content_DocumentMessage:
return mc.convertWhatsAppDocument(ctx, content.DocumentMessage)
case *waConsumerApplication.ConsumerApplication_Content_AudioMessage:
return mc.convertWhatsAppAudio(ctx, content.AudioMessage)
case *waConsumerApplication.ConsumerApplication_Content_VideoMessage:
return mc.convertWhatsAppVideo(ctx, content.VideoMessage)
default:
return nil, nil, fmt.Errorf("unrecognized media message type %T", content)
}
}*/
func (mc *MessageConverter) appName() string {
return "WhatsApp app"
}
/*
func (mc *MessageConverter) waConsumerToMatrix(ctx context.Context, rawContent *waConsumerApplication.ConsumerApplication_Content) (parts []*bridgev2.ConvertedMessagePart) {
parts = make([]*bridgev2.ConvertedMessagePart, 0, 2)
switch content := rawContent.GetContent().(type) {
case *waConsumerApplication.ConsumerApplication_Content_MessageText:
parts = append(parts, mc.WhatsAppTextToMatrix(ctx, content.MessageText))
case *waConsumerApplication.ConsumerApplication_Content_ExtendedTextMessage:
part := mc.WhatsAppTextToMatrix(ctx, content.ExtendedTextMessage.GetText())
// TODO convert url previews
parts = append(parts, part)
case *waConsumerApplication.ConsumerApplication_Content_ImageMessage,
*waConsumerApplication.ConsumerApplication_Content_StickerMessage,
*waConsumerApplication.ConsumerApplication_Content_ViewOnceMessage,
*waConsumerApplication.ConsumerApplication_Content_DocumentMessage,
*waConsumerApplication.ConsumerApplication_Content_AudioMessage,
*waConsumerApplication.ConsumerApplication_Content_VideoMessage:
converted, caption, err := mc.convertWhatsAppMedia(ctx, rawContent)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to convert media message")
converted = &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Failed to transfer media",
},
}
}
parts = append(parts, converted)
if caption != nil {
parts = append(parts, caption)
}
case *waConsumerApplication.ConsumerApplication_Content_LocationMessage:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgLocation,
Body: content.LocationMessage.GetLocation().GetName() + "\n" + content.LocationMessage.GetAddress(),
GeoURI: fmt.Sprintf("geo:%f,%f", content.LocationMessage.GetLocation().GetDegreesLatitude(), content.LocationMessage.GetLocation().GetDegreesLongitude()),
},
})
case *waConsumerApplication.ConsumerApplication_Content_LiveLocationMessage:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgLocation,
Body: "Live location sharing started\n\nYou can see the location in the " + mc.appName(),
GeoURI: fmt.Sprintf("geo:%f,%f", content.LiveLocationMessage.GetLocation().GetDegreesLatitude(), content.LiveLocationMessage.GetLocation().GetDegreesLongitude()),
},
})
case *waConsumerApplication.ConsumerApplication_Content_ContactMessage:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported message (contact)\n\nPlease open in " + mc.appName(),
},
})
case *waConsumerApplication.ConsumerApplication_Content_ContactsArrayMessage:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported message (contacts array)\n\nPlease open in " + mc.appName(),
},
})
default:
zerolog.Ctx(ctx).Warn().Type("content_type", content).Msg("Unrecognized content type")
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Unsupported message (%T)\n\nPlease open in %s", content, mc.appName()),
},
})
}
return
}
func (mc *MessageConverter) waLocationMessageToMatrix(ctx context.Context, content *waArmadilloXMA.ExtendedContentMessage, parsedURL *url.URL) (parts []*bridgev2.ConvertedMessagePart) {
lat := parsedURL.Query().Get("lat")
long := parsedURL.Query().Get("long")
return []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgLocation,
GeoURI: fmt.Sprintf("geo:%s,%s", lat, long),
Body: fmt.Sprintf("%s\n%s", content.GetTitleText(), content.GetSubtitleText()),
},
}}
}
func (mc *MessageConverter) waExtendedContentMessageToMatrix(ctx context.Context, content *waArmadilloXMA.ExtendedContentMessage) (parts []*bridgev2.ConvertedMessagePart) {
body := content.GetMessageText()
for _, cta := range content.GetCtas() {
parsedURL, err := url.Parse(cta.GetNativeURL())
if err != nil {
continue
}
if parsedURL.Scheme == "messenger" && parsedURL.Host == "location_share" {
return mc.waLocationMessageToMatrix(ctx, content, parsedURL)
}
if parsedURL.Scheme == "https" && !strings.Contains(body, cta.GetNativeURL()) {
if body == "" {
body = cta.GetNativeURL()
} else {
body = fmt.Sprintf("%s\n\n%s", body, cta.GetNativeURL())
}
}
}
return []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
},
Extra: map[string]any{
"fi.mau.meta.temporary_unsupported_type": "Armadillo ExtendedContentMessage",
},
}}
}
func (mc *MessageConverter) waArmadilloToMatrix(ctx context.Context, rawContent *waArmadilloApplication.Armadillo_Content) (parts []*bridgev2.ConvertedMessagePart, replyOverride *waCommon.MessageKey) {
parts = make([]*bridgev2.ConvertedMessagePart, 0, 2)
switch content := rawContent.GetContent().(type) {
case *waArmadilloApplication.Armadillo_Content_ExtendedContentMessage:
return mc.waExtendedContentMessageToMatrix(ctx, content.ExtendedContentMessage), nil
case *waArmadilloApplication.Armadillo_Content_BumpExistingMessage_:
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: "Bumped a message",
},
})
replyOverride = content.BumpExistingMessage.GetKey()
//case *waArmadilloApplication.Armadillo_Content_RavenMessage_:
// // TODO
default:
zerolog.Ctx(ctx).Warn().Type("content_type", content).Msg("Unrecognized armadillo content type")
parts = append(parts, &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: fmt.Sprintf("Unsupported message (%T)\n\nPlease open in %s", content, mc.appName()),
},
})
}
return
}
func (mc *MessageConverter) WhatsAppToMatrix(ctx context.Context, portal *bridgev2.Portal, client *whatsmeow.Client, intent bridgev2.MatrixAPI, evt *events.FBMessage) *bridgev2.ConvertedMessage {
ctx = context.WithValue(ctx, contextKeyWAClient, client)
ctx = context.WithValue(ctx, contextKeyIntent, intent)
ctx = context.WithValue(ctx, contextKeyPortal, portal)
cm := &bridgev2.ConvertedMessage{}
var replyOverride *waCommon.MessageKey
switch typedMsg := evt.Message.(type) {
case *waConsumerApplication.ConsumerApplication:
cm.Parts = mc.waConsumerToMatrix(ctx, typedMsg.GetPayload().GetContent())
case *waArmadilloApplication.Armadillo:
cm.Parts, replyOverride = mc.waArmadilloToMatrix(ctx, typedMsg.GetPayload().GetContent())
default:
cm.Parts = []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported message content type",
},
}}
}
var sender id.UserID
var replyTo id.EventID
if qm := evt.Application.GetMetadata().GetQuotedMessage(); qm != nil {
pcp, _ := types.ParseJID(qm.GetParticipant())
// TODO what if participant is not set?
cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: metaid.MakeWAMessageID(evt.Info.Chat, pcp, qm.GetStanzaID()),
}
} else if replyOverride != nil {
pcp, _ := types.ParseJID(replyOverride.GetParticipant())
// TODO what if participant is not set?
cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: metaid.MakeWAMessageID(evt.Info.Chat, pcp, qm.GetStanzaID()),
}
}
for _, part := range cm.Parts {
if part.Content.Mentions == nil {
part.Content.Mentions = &event.Mentions{}
}
if replyTo != "" {
part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo)
if !slices.Contains(part.Content.Mentions.UserIDs, sender) {
part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender)
}
}
}
return cm
}
*/

55
pkg/msgconv/msgconv.go Normal file
View file

@ -0,0 +1,55 @@
// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package msgconv
import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/format"
)
type MessageConverter struct {
Bridge *bridgev2.Bridge
MaxFileSize int64
AsyncFiles bool
HTMLParser *format.HTMLParser
}
func New(br *bridgev2.Bridge) *MessageConverter {
mc := &MessageConverter{
Bridge: br,
MaxFileSize: 50 * 1024 * 1024,
}
mc.HTMLParser = &format.HTMLParser{
PillConverter: mc.convertPill,
BoldConverter: func(text string, ctx format.Context) string {
return "*" + text + "*"
},
ItalicConverter: func(text string, ctx format.Context) string {
return "_" + text + "_"
},
StrikethroughConverter: func(text string, ctx format.Context) string {
return "~" + text + "~"
},
MonospaceConverter: func(text string, ctx format.Context) string {
return "`" + text + "`"
},
MonospaceBlockConverter: func(code, language string, ctx format.Context) string {
return "```\n" + code + "\n```"
},
}
return mc
}

71
pkg/waid/id.go Normal file
View file

@ -0,0 +1,71 @@
package waid
import (
"fmt"
"strconv"
"strings"
"go.mau.fi/whatsmeow/types"
"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 {
return networkid.PortalID(jid.User)
}
func MakeWAUserID(jid *types.JID) networkid.UserID {
return networkid.UserID(jid.User)
}
func ParseWAUserLoginID(user networkid.UserLoginID) types.JID {
return types.JID{
Server: types.DefaultUserServer,
User: string(user),
}
}
func MakeWAUserLoginID(jid *types.JID) networkid.UserLoginID {
return networkid.UserLoginID(jid.User)
}
func MakeUserID(user int64) networkid.UserID {
return networkid.UserID(strconv.Itoa(int(user)))
}
func MakeUserLoginID(user int64) networkid.UserLoginID {
return networkid.UserLoginID(MakeUserID(user))
}
func MakeMessageID(chat, sender types.JID, id types.MessageID) networkid.MessageID {
return networkid.MessageID(fmt.Sprintf("%s:%s:%s", chat.String(), sender.ToNonAD().String(), id))
}
type ParsedMessageID struct {
Chat types.JID
Sender types.JID
ID types.MessageID
}
func ParseMessageID(messageID networkid.MessageID) (*ParsedMessageID, error) {
parts := strings.SplitN(string(messageID), ":", 3)
if len(parts) == 3 {
chat, err := types.ParseJID(parts[0])
if err != nil {
return nil, err
}
sender, err := types.ParseJID(parts[1])
if err != nil {
return nil, err
}
return &ParsedMessageID{Chat: chat, Sender: sender, ID: parts[2]}, nil
} else {
return nil, fmt.Errorf("invalid message id")
}
}