mirror of
https://github.com/mautrix/whatsapp.git
synced 2025-03-14 14:15:38 +00:00
v2: start working on connector
This commit is contained in:
parent
330823f9b7
commit
ab3d8f3c9f
19 changed files with 2107 additions and 34 deletions
|
@ -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
24
go.mod
|
@ -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
46
go.sum
|
@ -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
37
pkg/connector/chatinfo.go
Normal 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
256
pkg/connector/client.go
Normal 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
133
pkg/connector/config.go
Normal 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)
|
||||
}
|
|
@ -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
21
pkg/connector/dbmeta.go
Normal 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
52
pkg/connector/events.go
Normal 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
|
||||
}
|
102
pkg/connector/example-config.yaml
Normal file
102
pkg/connector/example-config.yaml
Normal 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
|
155
pkg/connector/handlematrix.go
Normal file
155
pkg/connector/handlematrix.go
Normal 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")
|
||||
}
|
108
pkg/connector/handlewhatsapp.go
Normal file
108
pkg/connector/handlewhatsapp.go
Normal 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
33
pkg/connector/id.go
Normal 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
161
pkg/connector/login.go
Normal 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
|
||||
}
|
3
pkg/connector/wadb/database.go
Normal file
3
pkg/connector/wadb/database.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package wadb
|
||||
|
||||
// TODO: ADD POLL AND HISTORYSYNC AND MEDIABACKFILLREQUEST DBs
|
261
pkg/msgconv/from-matrix.go
Normal file
261
pkg/msgconv/from-matrix.go
Normal 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
|
||||
}
|
459
pkg/msgconv/from-whatsapp.go
Normal file
459
pkg/msgconv/from-whatsapp.go
Normal 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
55
pkg/msgconv/msgconv.go
Normal 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
71
pkg/waid/id.go
Normal 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")
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue