1
0
Fork 0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-05-15 02:06:53 -04:00

Compare commits

..

1 commit

Author SHA1 Message Date
Rajeh Taher
31344c4507 client,connector: Support external whatsmeow event handlers 2025-10-07 23:14:32 +03:00
47 changed files with 572 additions and 2133 deletions

View file

@ -7,12 +7,10 @@ type: Bug
--- ---
<!-- Include relevant logs, the bridge version and other important details here --> <!--
Remember to include relevant logs, the bridge version and any other details.
### Checklist It's always best to ask in the Matrix room first, especially if you aren't sure
what details are needed. Issues with insufficient detail will likely just be
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. --> ignored or closed immediately.
-->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
* [ ] The bug is still present on the main branch. The `!wa version` command output is: ``

View file

@ -11,14 +11,14 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go-version: ["1.25", "1.26"] go-version: ["1.24", "1.25"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }} name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
cache: true cache: true

View file

@ -17,7 +17,7 @@ jobs:
lock-stale: lock-stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v6 - uses: dessant/lock-threads@v5
id: lock id: lock
with: with:
issue-inactive-days: 90 issue-inactive-days: 90

View file

@ -9,7 +9,7 @@ repos:
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang - repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.4 rev: v1.0.0-rc.2
hooks: hooks:
- id: go-imports-repo - id: go-imports-repo
args: args:

View file

@ -1,70 +1,3 @@
# v26.04
* Added support for @room mentions in both directions.
* Changed initial backfill to happen even if WhatsApp doesn't send full history.
* Fixed panic when handling updates to unknown polls from WhatsApp.
* Fixed some background loops not stopping when a user is logged out.
# v26.03
* Added option to save outgoing messages in the database to allow encryption
retries to work across restarts.
* Fixed contact list API not returning some contacts.
* Fixed business template messages with media duplicating the text part.
# v26.02
* Bumped minimum Go version to 1.25.
* Added automatic recovery for WhatsApp app state sync issues.
* Fixed LID redirects for some non-message events.
# v26.01
* Fixed broadcast list messages to LIDs causing split DMs.
* Fixed read receipts not working correctly in LID DMs.
* Fixed backfill sometimes racing with receiving LID mappings.
# v25.12
* Updated Docker image to Alpine 3.23.
* Fixed group member invites from Matrix not automatically disinviting the phone
number ghost when the invite is redirected to a LID ghost.
# v25.11
* Added interface support for notifying about failed invites when creating a
group and sending the invites via DM (only applicable to provisioning API).
* Added migration to automatically delete duplicate LID DM portals that were
created earlier.
* Changed contact list API to only include actual phone contacts.
* Removed extra unrecognized message notice when receiving live photos
(bridging the live photo video is not currently planned).
* Fixed pairing not working with latest WhatsApp Android version.
* Fixed replies, read receipts and typing notifications not being bridged
correctly after DM LID migration.
* Fixed backfill creating duplicate portals if history sync contains both LID
and phone number DM data.
* Fixed some cases of LID and phone number user infos getting out of sync.
* Fixed muting chat forever not being bridged correctly from WhatsApp.
* Fixed old mutes being re-applied on chat resync in some cases.
* Fixed backfilling failing if some reactions were missing sender info.
* Fixed space not being deleted when leaving community on WhatsApp.
* Fixed sticker size metadata on Matrix not matching how native WhatsApp Web
renders them.
* Fixed ratelimit errors in login not being exposed to the user properly
(thanks to [@dead8309] in [#852]).
[@dead8309]: https://github.com/dead8309
[#852]: https://github.com/mautrix/whatsapp/pull/852
# v25.10
* Switched to calendar versioning.
* Added support for bridging event edits.
* Fixed backfill creating incorrect disappearing timer change notices.
* Fixed previous messages not being marked as read when sending a new message.
* Fixed incoming call notices with LID addressing going into different DM room.
# v0.12.5 (2025-09-16) # v0.12.5 (2025-09-16)
* Removed legacy provisioning API and database legacy migration. * Removed legacy provisioning API and database legacy migration.

View file

@ -1,4 +1,4 @@
FROM golang:1-alpine3.23 AS builder FROM golang:1-alpine3.22 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
@ -6,7 +6,7 @@ COPY . /build
WORKDIR /build WORKDIR /build
RUN ./build.sh RUN ./build.sh
FROM alpine:3.23 FROM alpine:3.22
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337

View file

@ -1,6 +1,4 @@
ARG DOCKER_HUB="docker.io" FROM alpine:3.22
FROM ${DOCKER_HUB}/alpine:3.23
ENV UID=1337 \ ENV UID=1337 \
GID=1337 GID=1337

View file

@ -1,2 +1,4 @@
#!/bin/sh #!/bin/sh
BINARY_NAME=mautrix-whatsapp go tool maubuild "$@" MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags="$GO_LDFLAGS" "$@" ./cmd/mautrix-whatsapp

View file

@ -6,10 +6,7 @@ import (
"github.com/rs/zerolog/hlog" "github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp" "go.mau.fi/util/exhttp"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/matrix" "maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@ -43,7 +40,7 @@ func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
if userLogin == nil { if userLogin == nil {
return return
} }
if contacts, err := userLogin.Client.(*connector.WhatsAppClient).GetStore().Contacts.GetAllContacts(r.Context()); err != nil { if contacts, err := userLogin.Client.(*connector.WhatsAppClient).Device.Contacts.GetAllContacts(r.Context()); err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts") hlog.FromRequest(r).Err(err).Msg("Failed to fetch all contacts")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{ exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while fetching contact list", Error: "Internal server error while fetching contact list",
@ -113,41 +110,3 @@ func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
}, },
}) })
} }
func provAppStateDebug(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
client := userLogin.Client.(*connector.WhatsAppClient)
if client.Client == nil {
mautrix.MNotFound.WithMessage("WhatsApp client not connected").Write(w)
return
}
client.Client.AppStateDebugLogs = true
err := client.Client.FetchAppState(r.Context(), appstate.WAPatchName(r.PathValue("patch")), r.URL.Query().Get("full") == "1", false)
client.Client.AppStateDebugLogs = false
if err != nil {
mautrix.MUnknown.WithMessage("Failed to fetch app state: %v", err).Write(w)
} else {
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}
}
func provRecoverAppStateDebug(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
client := userLogin.Client.(*connector.WhatsAppClient)
if client.Client == nil {
mautrix.MNotFound.WithMessage("WhatsApp client not connected").Write(w)
return
}
resp, err := client.Client.SendPeerMessage(r.Context(), whatsmeow.BuildAppStateRecoveryRequest(appstate.WAPatchName(r.PathValue("patch"))))
if err != nil {
mautrix.MUnknown.WithMessage("Failed to send app state recovery request: %v", err).Write(w)
} else {
exhttp.WriteJSONResponse(w, http.StatusOK, resp)
}
}

View file

@ -18,8 +18,7 @@ var m = mxmain.BridgeMain{
Name: "mautrix-whatsapp", Name: "mautrix-whatsapp",
URL: "https://github.com/mautrix/whatsapp", URL: "https://github.com/mautrix/whatsapp",
Description: "A Matrix-WhatsApp puppeting bridge.", Description: "A Matrix-WhatsApp puppeting bridge.",
Version: "26.04", Version: "0.12.5",
SemCalVer: true,
Connector: &connector.WhatsAppConnector{}, Connector: &connector.WhatsAppConnector{},
} }
@ -29,8 +28,6 @@ func main() {
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/contacts", legacyProvContacts) m.Matrix.Provisioning.Router.HandleFunc("GET /v1/contacts", legacyProvContacts)
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/resolve_identifier/{number}", legacyProvResolveIdentifier) m.Matrix.Provisioning.Router.HandleFunc("GET /v1/resolve_identifier/{number}", legacyProvResolveIdentifier)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/pm/{number}", legacyProvResolveIdentifier) m.Matrix.Provisioning.Router.HandleFunc("POST /v1/pm/{number}", legacyProvResolveIdentifier)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/debug/appstate/{patch}", provAppStateDebug)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/debug/recover-appstate/{patch}", provRecoverAppStateDebug)
} }
} }
m.InitVersion(Tag, Commit, BuildTime) m.InitVersion(Tag, Commit, BuildTime)

View file

@ -1,24 +0,0 @@
//go:build amd64 && cgo && !noplugin
package main
import (
"fmt"
"os"
"plugin"
"go.mau.fi/util/exerrors"
"go.mau.fi/mautrix-whatsapp/pkg/connector"
)
func init() {
path := os.Getenv("WM_PLUGIN_PATH")
if path == "" {
return
}
fmt.Println("Loading plugin from", path)
plug := exerrors.Must(plugin.Open(path))
sym := exerrors.Must(plug.Lookup("NewClient"))
connector.NewMC = sym.(connector.NewMCFunc)
}

50
go.mod
View file

@ -1,53 +1,51 @@
module go.mau.fi/mautrix-whatsapp module go.mau.fi/mautrix-whatsapp
go 1.25.0 go 1.24.0
toolchain go1.26.2 toolchain go1.25.1
tool go.mau.fi/util/cmd/maubuild
require ( require (
github.com/lib/pq v1.12.3 github.com/lib/pq v1.10.9
github.com/rs/zerolog v1.35.1 github.com/rs/zerolog v1.34.0
github.com/tidwall/gjson v1.18.0 go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25
go.mau.fi/webp v0.2.0 go.mau.fi/webp v0.2.0
go.mau.fi/whatsmeow v0.0.0-20260513140310-c551a4055c0f go.mau.fi/whatsmeow v0.0.0-20251005083110-4fe97da162dc
golang.org/x/image v0.39.0 golang.org/x/image v0.31.0
golang.org/x/net v0.53.0 golang.org/x/net v0.44.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.17.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 maunium.net/go/mautrix v0.25.2-0.20251001115535-dd778ae0cdaf
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.14 // indirect github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.7.13 // indirect
go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/libsignal v0.2.1-0.20251004173110-6e0a3f2435ed // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.29.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect

90
go.sum
View file

@ -1,5 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
@ -10,17 +10,20 @@ github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -28,17 +31,21 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
@ -46,8 +53,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
@ -57,9 +64,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0/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/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 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 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@ -67,37 +73,37 @@ 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/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1-0.20251004173110-6e0a3f2435ed h1:f44xyYgZBCEic3OiKY4eYfDA2b3kBB/LlgVha4NpjRs=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= go.mau.fi/libsignal v0.2.1-0.20251004173110-6e0a3f2435ed/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25 h1:YPEmc+li7TF6C9AdRTcSLMb6yCHdF27/wNT7kFLIVNg= go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10 h1:EvX/di02gOriKN0xGDJuQ5mgiNdAF4LJc8moffI7Svo=
go.mau.fi/util v0.9.9-0.20260511124621-9241e81bdf25/go.mod h1:jE9FfhbgEgAwxei6lomO9v8zdCIATcquONUu4vjRwSs= go.mau.fi/util v0.9.2-0.20251001114608-d99877b9cc10/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/whatsmeow v0.0.0-20260513140310-c551a4055c0f h1:icWtsD1MH5nlo8mEpHMPZ9+1kgHkjmXQroYi0lHXKZ0= go.mau.fi/whatsmeow v0.0.0-20251005083110-4fe97da162dc h1:O19PRYoEZJyC7BgUM9PSillFLAkABiBK7AMORNwwK3Q=
go.mau.fi/whatsmeow v0.0.0-20260513140310-c551a4055c0f/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg= go.mau.fi/whatsmeow v0.0.0-20251005083110-4fe97da162dc/go.mod h1:Erq9GGhe5DuIGL9i2gp2LH4nEYkBB+lsgdu3i4KcvPU=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -107,5 +113,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4 h1:zNC9eVAhw8FhKpM3AxNAh/iy75UEYX91uJUvqqAYlvo= maunium.net/go/mautrix v0.25.2-0.20251001115535-dd778ae0cdaf h1:prmIYgiziW4A8H2v/TliQ7fis8uTWblabxyPIeLFlNg=
maunium.net/go/mautrix v0.27.1-0.20260513120123-5fba7e3afae4/go.mod h1:3sOGhXi3P1V6/NruTA0gujkvTypXVUraWktCuTGyDuM= maunium.net/go/mautrix v0.25.2-0.20251001115535-dd778ae0cdaf/go.mod h1:eWXuX2UAGye4AU7i/8Fv2L2Nh7L9kZtuv3R0O0n1KaM=

View file

@ -63,6 +63,11 @@ func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) {
for { for {
var resetTimer bool var resetTimer bool
select { select {
case evt := <-wa.historySyncs:
// The timer is stopped unconditionally and restarted if either handleWAHistorySync had conversations,
// or if the timer was previously started and hadn't reached the loop above yet.
dispatchTimer.Stop()
resetTimer, _ = wa.handleWAHistorySync(ctx, evt, false)
case <-wa.historySyncWakeup: case <-wa.historySyncWakeup:
dispatchTimer.Stop() dispatchTimer.Stop()
notif, rowid, err := wa.Main.DB.HSNotif.GetNext(ctx, wa.UserLogin.ID) notif, rowid, err := wa.Main.DB.HSNotif.GetNext(ctx, wa.UserLogin.ID)
@ -115,30 +120,14 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context,
Uint32("chunk_order", evt.GetChunkOrder()). Uint32("chunk_order", evt.GetChunkOrder()).
Uint32("progress", evt.GetProgress()). Uint32("progress", evt.GetProgress()).
Logger() Logger()
log.Debug(). log.Debug().Msg("Downloading history sync")
Int64("oldest_msg_in_chunk_ts", evt.GetOldestMsgInChunkTimestampSec()).
Any("full_request_meta", evt.GetFullHistorySyncOnDemandRequestMetadata()).
Any("access_status", evt.GetMessageAccessStatus()).
Str("peer_data_request_session_id", evt.GetPeerDataRequestSessionID()).
Msg("Downloading history sync")
blob, err := wa.Client.DownloadHistorySync(log.WithContext(ctx), evt, true) blob, err := wa.Client.DownloadHistorySync(log.WithContext(ctx), evt, true)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to download history sync") log.Err(err).Msg("Failed to download history sync")
return return
} }
if blob.GetSyncType() == waHistorySync.HistorySync_ON_DEMAND {
wa.handleOnDemandHistorySync(ctx, blob)
if err = wa.Main.DB.HSNotif.Delete(ctx, rowid); err != nil {
log.Err(err).Msg("Failed to delete queued on-demand history sync notification")
} else if err = wa.Client.DeleteMedia(ctx, whatsmeow.MediaHistory, evt.GetDirectPath(), evt.GetFileEncSHA256(), evt.GetEncHandle()); err != nil {
log.Err(err).Msg("Failed to delete history sync blob from server")
} else {
log.Debug().Msg("Finished handling on-demand history sync and deleted history sync blob from server")
}
return
}
err = wa.Main.DB.DoTxn(ctx, nil, func(ctx context.Context) (innerErr error) { err = wa.Main.DB.DoTxn(ctx, nil, func(ctx context.Context) (innerErr error) {
innerErr = wa.handleWAHistorySync(ctx, evt, blob, true) resetTimer, innerErr = wa.handleWAHistorySync(ctx, blob, true)
if innerErr != nil { if innerErr != nil {
return return
} }
@ -150,28 +139,13 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context,
}) })
if err != nil { if err != nil {
log.Err(err).Msg("Failed to store history sync notification data") log.Err(err).Msg("Failed to store history sync notification data")
} else {
resetTimer = blob.GetSyncType() == waHistorySync.HistorySync_INITIAL_BOOTSTRAP ||
blob.GetSyncType() == waHistorySync.HistorySync_RECENT ||
blob.GetSyncType() == waHistorySync.HistorySync_FULL
err = wa.Client.DeleteMedia(ctx, whatsmeow.MediaHistory, evt.GetDirectPath(), evt.GetFileEncSHA256(), evt.GetEncHandle())
if err != nil {
log.Err(err).Msg("Failed to delete history sync blob from server")
} else {
log.Debug().Msg("Deleted history sync blob from server")
}
} }
return return
} }
func (wa *WhatsAppClient) handleWAHistorySync( func (wa *WhatsAppClient) handleWAHistorySync(ctx context.Context, evt *waHistorySync.HistorySync, stopOnError bool) (bool, error) {
ctx context.Context,
notif *waE2E.HistorySyncNotification,
evt *waHistorySync.HistorySync,
stopOnError bool,
) error {
if evt == nil || evt.SyncType == nil { if evt == nil || evt.SyncType == nil {
return nil return false, nil
} }
log := wa.UserLogin.Log.With(). log := wa.UserLogin.Log.With().
Str("action", "store history sync"). Str("action", "store history sync").
@ -196,57 +170,36 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("recent_sticker_count", len(evt.GetRecentStickers())). Int("recent_sticker_count", len(evt.GetRecentStickers())).
Int("past_participant_count", len(evt.GetPastParticipants())). Int("past_participant_count", len(evt.GetPastParticipants())).
Msg("Ignoring history sync") Msg("Ignoring history sync")
return nil return false, nil
} }
log.Info(). log.Info().
Int("conversation_count", len(evt.GetConversations())). Int("conversation_count", len(evt.GetConversations())).
Int("past_participant_count", len(evt.GetPastParticipants())). Int("past_participant_count", len(evt.GetPastParticipants())).
Dict("notification_metadata", zerolog.Dict().
Int64("oldest_msg_in_chunk_ts", notif.GetOldestMsgInChunkTimestampSec()).
Any("full_request_meta", notif.GetFullHistorySyncOnDemandRequestMetadata()).
Any("access_status", notif.GetMessageAccessStatus()).
Str("peer_data_request_session_id", notif.GetPeerDataRequestSessionID())).
Msg("Storing history sync") Msg("Storing history sync")
start := time.Now() start := time.Now()
successfullySavedTotal := 0 successfullySavedTotal := 0
failedToSaveTotal := 0 failedToSaveTotal := 0
totalMessageCount := 0 totalMessageCount := 0
for _, conv := range evt.GetConversations() { for _, conv := range evt.GetConversations() {
log := log.With().
Int("msg_count", len(conv.GetMessages())).
Logger()
jid, err := types.ParseJID(conv.GetID()) jid, err := types.ParseJID(conv.GetID())
if err != nil { if err != nil {
totalMessageCount += len(conv.GetMessages()) totalMessageCount += len(conv.GetMessages())
log.Warn().Err(err). log.Warn().Err(err).
Str("chat_jid", conv.GetID()). Str("chat_jid", conv.GetID()).
Int("msg_count", len(conv.GetMessages())).
Msg("Failed to parse chat JID in history sync") Msg("Failed to parse chat JID in history sync")
continue continue
} else if jid.Server == types.BroadcastServer { } else if jid.Server == types.BroadcastServer {
log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync") log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync")
continue continue
} else { }
totalMessageCount += len(conv.GetMessages()) totalMessageCount += len(conv.GetMessages())
} log := log.With().
if jid.Server == types.HiddenUserServer { Stringer("chat_jid", jid).
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid) Int("msg_count", len(conv.GetMessages())).
if err != nil { Logger()
log.Err(err).Stringer("lid", jid).Msg("Failed to get PN for LID in history sync")
} else if pn.IsEmpty() {
log.Warn().Stringer("lid", jid).Msg("No PN found for LID in history sync")
} else {
log.Debug().
Stringer("lid", jid).
Stringer("pn", pn).
Msg("Rerouting LID DM to phone number in history sync")
jid = pn
}
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("chat_jid", jid)
})
var minTime, maxTime, firstItemTime, lastItemTime time.Time var minTime, maxTime time.Time
var minTimeIndex, maxTimeIndex int var minTimeIndex, maxTimeIndex int
ignoredTypes := 0 ignoredTypes := 0
@ -262,10 +215,6 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Msg("Dropping historical message due to parse error") Msg("Dropping historical message due to parse error")
continue continue
} }
if firstItemTime.IsZero() {
firstItemTime = msgEvt.Info.Timestamp
}
lastItemTime = msgEvt.Info.Timestamp
if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) { if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) {
minTime = msgEvt.Info.Timestamp minTime = msgEvt.Info.Timestamp
minTimeIndex = i minTimeIndex = i
@ -298,9 +247,6 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("lowest_time_index", minTimeIndex). Int("lowest_time_index", minTimeIndex).
Time("highest_time", maxTime). Time("highest_time", maxTime).
Int("highest_time_index", maxTimeIndex). Int("highest_time_index", maxTimeIndex).
Time("first_item_time", firstItemTime).
Time("last_item_time", lastItemTime).
Bool("highest_time_mismatch", firstItemTime != maxTime).
Dict("metadata", zerolog.Dict(). Dict("metadata", zerolog.Dict().
Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()). Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()).
Int64("ephemeral_setting_timestamp", conv.GetEphemeralSettingTimestamp()). Int64("ephemeral_setting_timestamp", conv.GetEphemeralSettingTimestamp()).
@ -309,9 +255,7 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Bool("archived", conv.GetArchived()). Bool("archived", conv.GetArchived()).
Uint32("pinned", conv.GetPinned()). Uint32("pinned", conv.GetPinned()).
Uint64("mute_end", conv.GetMuteEndTime()). Uint64("mute_end", conv.GetMuteEndTime()).
Uint32("unread_count", conv.GetUnreadCount()). Uint32("unread_count", conv.GetUnreadCount()),
Bool("end_of_history", conv.GetEndOfHistoryTransfer()).
Stringer("end_of_history_type", conv.GetEndOfHistoryTransferType()),
). ).
Msg("Collected messages to save from history sync conversation") Msg("Collected messages to save from history sync conversation")
@ -319,7 +263,7 @@ func (wa *WhatsAppClient) handleWAHistorySync(
err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv, maxTime)) err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv, maxTime))
if err != nil { if err != nil {
if stopOnError { if stopOnError {
return fmt.Errorf("failed to save conversation metadata for %s: %w", jid, err) return false, fmt.Errorf("failed to save conversation metadata for %s: %w", jid, err)
} }
log.Err(err).Msg("Failed to save conversation metadata") log.Err(err).Msg("Failed to save conversation metadata")
continue continue
@ -327,7 +271,7 @@ func (wa *WhatsAppClient) handleWAHistorySync(
err = wa.Main.DB.Message.Put(ctx, wa.UserLogin.ID, jid, messages) err = wa.Main.DB.Message.Put(ctx, wa.UserLogin.ID, jid, messages)
if err != nil { if err != nil {
if stopOnError { if stopOnError {
return fmt.Errorf("failed to save messages in %s: %w", jid, err) return false, fmt.Errorf("failed to save messages in %s: %w", jid, err)
} }
log.Err(err).Msg("Failed to save messages") log.Err(err).Msg("Failed to save messages")
failedToSaveTotal += len(messages) failedToSaveTotal += len(messages)
@ -337,7 +281,7 @@ func (wa *WhatsAppClient) handleWAHistorySync(
err = wa.Main.Bridge.DB.BackfillTask.MarkNotDone(ctx, wa.makeWAPortalKey(jid), wa.UserLogin.ID) err = wa.Main.Bridge.DB.BackfillTask.MarkNotDone(ctx, wa.makeWAPortalKey(jid), wa.UserLogin.ID)
if err != nil { if err != nil {
if stopOnError { if stopOnError {
return fmt.Errorf("failed to mark backfill task as not done for %s: %w", jid, err) return false, fmt.Errorf("failed to mark backfill task as not done for %s: %w", jid, err)
} }
log.Err(err).Msg("Failed to mark backfill task as not done") log.Err(err).Msg("Failed to mark backfill task as not done")
} }
@ -349,7 +293,9 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("total_message_count", totalMessageCount). Int("total_message_count", totalMessageCount).
Dur("duration", time.Since(start)). Dur("duration", time.Since(start)).
Msg("Finished storing history sync") Msg("Finished storing history sync")
return nil resetTimer := evt.GetSyncType() == waHistorySync.HistorySync_RECENT ||
evt.GetSyncType() == waHistorySync.HistorySync_FULL
return resetTimer, nil
} }
func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
@ -396,7 +342,7 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
log.Warn().Err(ctx.Err()).Msg("Context cancelled, stopping history sync portal creation") log.Warn().Err(ctx.Err()).Msg("Context cancelled, stopping history sync portal creation")
return return
} }
wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv, true) wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv)
if errors.Is(err, whatsmeow.ErrNotInGroup) { if errors.Is(err, whatsmeow.ErrNotInGroup) {
log.Debug().Stringer("chat_jid", conv.ChatJID). log.Debug().Stringer("chat_jid", conv.ChatJID).
Msg("Skipping creating room because the user is not a participant") Msg("Skipping creating room because the user is not a participant")
@ -473,67 +419,38 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
} }
var markRead bool var markRead bool
var startTime, endTime *time.Time var startTime, endTime *time.Time
var conv *wadb.Conversation
if params.Forward || wa.Main.Config.HistorySync.BackwardsOnDemand {
conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
if err != nil {
return nil, fmt.Errorf("failed to get conversation from database: %w", err)
}
}
if params.Forward { if params.Forward {
if params.AnchorMessage != nil { if params.AnchorMessage != nil {
startTime = ptr.Ptr(params.AnchorMessage.Timestamp) startTime = ptr.Ptr(params.AnchorMessage.Timestamp)
} }
if conv != nil { conv, err := wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
if err != nil {
return nil, fmt.Errorf("failed to get conversation from database: %w", err)
} else if conv != nil {
markRead = !ptr.Val(conv.MarkedAsUnread) && ptr.Val(conv.UnreadCount) == 0 markRead = !ptr.Val(conv.MarkedAsUnread) && ptr.Val(conv.UnreadCount) == 0
} }
} else { } else if params.Cursor != "" {
if params.AnchorMessage != nil {
endTime = ptr.Ptr(params.AnchorMessage.Timestamp)
}
if params.Cursor != "" {
endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64) endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse cursor: %w", err) return nil, fmt.Errorf("failed to parse cursor: %w", err)
} }
cursorTime := time.Unix(endTimeUnix, 0) endTime = ptr.Ptr(time.Unix(endTimeUnix, 0))
if endTime == nil || cursorTime.Before(*endTime) { } else if params.AnchorMessage != nil {
endTime = &cursorTime endTime = ptr.Ptr(params.AnchorMessage.Timestamp)
}
}
}
var anchorID types.MessageID
if params.AnchorMessage != nil {
parsedID, _ := waid.ParseMessageID(params.AnchorMessage.ID)
if parsedID != nil {
anchorID = parsedID.ID
}
}
var hasMore bool
if !params.Forward && wa.Main.Config.HistorySync.BackwardsOnDemand {
hasMore = conv != nil && ptr.Val(conv.EndOfHistoryTransferType) == waHistorySync.Conversation_COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY
} }
messages, err := wa.Main.DB.Message.GetBetween(ctx, wa.UserLogin.ID, portalJID, startTime, endTime, params.Count+1) messages, err := wa.Main.DB.Message.GetBetween(ctx, wa.UserLogin.ID, portalJID, startTime, endTime, params.Count+1)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load messages from database: %w", err) return nil, fmt.Errorf("failed to load messages from database: %w", err)
} else if len(messages) == 0 || (len(messages) == 1 && anchorID != "" && messages[0].GetKey().GetID() == anchorID) { } else if len(messages) == 0 {
wa.deleteHistorySyncMessages(ctx, portalJID, 0, 0)
if hasMore && !params.AllowSlowFetch {
return &bridgev2.FetchMessagesResponse{
MoreRequiresSlowFetch: true,
HasMore: true,
Forward: params.Forward,
}, nil
} else if hasMore {
return wa.fetchMessagesFromPhone(ctx, params)
}
return &bridgev2.FetchMessagesResponse{ return &bridgev2.FetchMessagesResponse{
HasMore: false, HasMore: false,
Forward: params.Forward, Forward: params.Forward,
}, nil }, nil
} }
if len(messages) > params.Count { hasMore := false
oldestTS := messages[len(messages)-1].GetMessageTimestamp() oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
if len(messages) > params.Count {
hasMore = true hasMore = true
// For safety, cut off messages with the oldest timestamp in the response. // For safety, cut off messages with the oldest timestamp in the response.
// Otherwise, if there are multiple messages with the same timestamp, the next fetch may miss some. // Otherwise, if there are multiple messages with the same timestamp, the next fetch may miss some.
@ -544,78 +461,19 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
} }
} }
} }
resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true) convertedMessages := make([]*bridgev2.BackfillMessage, len(messages))
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}
resp.HasMore = hasMore
resp.Forward = params.Forward
resp.MarkRead = markRead
return resp, nil
}
func (wa *WhatsAppClient) deleteHistorySyncMessages(ctx context.Context, portalJID types.JID, newestTS, oldestTS uint64) {
var err error
var rows int64
if (newestTS == 0 && oldestTS == 0) || !wa.Main.Bridge.Config.Backfill.Queue.AnyEnabled() {
// If the backfill queue isn't enabled, delete all messages after backfilling a batch.
rows, err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, portalJID)
} else {
// Otherwise just delete the messages that got backfilled
rows, err = wa.Main.DB.Message.DeleteBetween(ctx, wa.UserLogin.ID, portalJID, newestTS, oldestTS)
}
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("portal_jid", portalJID).
Uint64("newest_ts", newestTS).
Uint64("oldest_ts", oldestTS).
Msg("Failed to delete messages from database after backfill")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("portal_jid", portalJID).
Uint64("newest_ts", newestTS).
Uint64("oldest_ts", oldestTS).
Int64("rows_affected", rows).
Msg("Deleted history sync messages from database")
}
}
func (wa *WhatsAppClient) convertHistorySyncMessages(
ctx context.Context,
portal *bridgev2.Portal,
portalJID types.JID,
messages []*waWeb.WebMessageInfo,
explodeOnError bool,
) (*bridgev2.FetchMessagesResponse, error) {
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(messages))
var mediaRequests []*wadb.MediaRequest var mediaRequests []*wadb.MediaRequest
for i, msg := range messages { for i, msg := range messages {
evt, err := wa.Client.ParseWebMessage(portalJID, msg) evt, err := wa.Client.ParseWebMessage(portalJID, msg)
if err != nil { if err != nil {
if explodeOnError {
// This should never happen because the info is already parsed once before being stored in the database // This should never happen because the info is already parsed once before being stored in the database
return nil, fmt.Errorf("failed to parse info of message %s: %w", msg.GetKey().GetID(), err) return nil, fmt.Errorf("failed to parse info of message %s: %w", msg.GetKey().GetID(), err)
} }
zerolog.Ctx(ctx).Warn().Err(err). var mediaReq *wadb.MediaRequest
Int("msg_index", i).
Str("msg_id", msg.GetKey().GetID()).
Uint64("msg_time_seconds", msg.GetMessageTimestamp()).
Msg("Dropping historical message due to parse error")
continue
}
if !explodeOnError {
msgType := getMessageType(evt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
continue
}
}
isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension
converted, mediaReq := wa.convertHistorySyncMessage( convertedMessages[i], mediaReq = wa.convertHistorySyncMessage(
ctx, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions, ctx, params.Portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions,
) )
convertedMessages = append(convertedMessages, converted)
if mediaReq != nil { if mediaReq != nil {
mediaRequests = append(mediaRequests, mediaReq) mediaRequests = append(mediaRequests, mediaReq)
} }
@ -624,10 +482,24 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
return &bridgev2.FetchMessagesResponse{ return &bridgev2.FetchMessagesResponse{
Messages: convertedMessages, Messages: convertedMessages,
Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)), Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)),
HasMore: hasMore,
Forward: endTime == nil,
MarkRead: markRead,
// TODO set remaining or total count
CompleteCallback: func() { CompleteCallback: func() {
// TODO this only deletes after backfilling. If there's no need for backfill after a relogin, // TODO this only deletes after backfilling. If there's no need for backfill after a relogin,
// the messages will be stuck in the database // the messages will be stuck in the database
wa.deleteHistorySyncMessages(ctx, portalJID, newestTS, oldestTS) var err error
if !wa.Main.Bridge.Config.Backfill.Queue.Enabled && !wa.Main.Bridge.Config.Backfill.WillPaginateManually {
// If the backfill queue isn't enabled, delete all messages after backfilling a batch.
err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, portalJID)
} else {
// Otherwise just delete the messages that got backfilled
err = wa.Main.DB.Message.DeleteBetween(ctx, wa.UserLogin.ID, portalJID, newestTS, oldestTS)
}
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to delete messages from database after backfill")
}
if len(mediaRequests) > 0 { if len(mediaRequests) > 0 {
go func(ctx context.Context) { go func(ctx context.Context) {
for _, req := range mediaRequests { for _, req := range mediaRequests {
@ -645,94 +517,6 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
}, nil }, nil
} }
func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
if params.AnchorMessage == nil {
return nil, fmt.Errorf("anchor message is required to fetch messages from phone")
}
parsed, err := waid.ParseMessageID(params.AnchorMessage.ID)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor message ID: %w", err)
}
msgID := wa.Client.GenerateMessageID()
reqData := wa.Client.BuildHistorySyncRequest(&types.MessageInfo{
MessageSource: types.MessageSource{
Chat: parsed.Chat,
Sender: parsed.Sender,
IsFromMe: parsed.Sender.ToNonAD() == wa.JID.ToNonAD() || parsed.Sender.ToNonAD() == wa.Device.GetLID().ToNonAD(),
IsGroup: parsed.Chat.Server == types.GroupServer,
},
ID: parsed.ID,
Timestamp: params.AnchorMessage.Timestamp,
}, 50)
zerolog.Ctx(ctx).Debug().
Str("request_msg_id", msgID).
Any("anchor_msg_parsed", parsed).
Any("request_data", reqData).
Msg("Sending history sync request")
_, err = wa.Client.SendMessage(ctx, wa.JID.ToNonAD(), reqData, whatsmeow.SendRequestExtra{
ID: msgID,
Peer: true,
})
if err != nil {
return nil, fmt.Errorf("failed to send history sync request: %w", err)
}
return &bridgev2.FetchMessagesResponse{
HasMore: true,
Pending: true,
}, nil
}
func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *waHistorySync.HistorySync) {
if len(blob.GetConversations()) > 1 {
zerolog.Ctx(ctx).Warn().
Int("conversation_count", len(blob.GetConversations())).
Msg("Received on-demand history sync with multiple conversations")
}
for _, conv := range blob.GetConversations() {
portalJID, err := types.ParseJID(conv.GetID())
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", conv.GetID()).Msg("Failed to parse portal JID")
continue
}
portal, err := wa.Main.Bridge.GetPortalByKey(ctx, wa.makeWAPortalKey(portalJID))
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("portal_jid", portalJID).Msg("Failed to get portal for on-demand history sync")
continue
}
ctx := zerolog.Ctx(ctx).With().
Str("portal_id", string(portal.ID)).
Str("portal_receiver", string(portal.Receiver)).
Stringer("portal_mxid", portal.MXID).
Logger().WithContext(ctx)
portal.HandleRemoteBackfill(ctx, wa.UserLogin, &simplevent.Backfill{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventBackfill,
PortalKey: portal.PortalKey,
},
GetDataFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.FetchMessagesResponse, error) {
if len(conv.GetMessages()) == 0 {
return &bridgev2.FetchMessagesResponse{}, nil
}
messages := make([]*waWeb.WebMessageInfo, len(conv.GetMessages()))
for i, rawMsg := range conv.GetMessages() {
messages[i] = rawMsg.Message
}
zerolog.Ctx(ctx).Debug().
Int("message_count", len(messages)).
Stringer("end_of_history_type", conv.GetEndOfHistoryTransferType()).
Msg("Converting messages to bridge from on-demand history sync")
resp, err := wa.convertHistorySyncMessages(ctx, portal, portalJID, messages, false)
if err != nil {
return nil, err
}
resp.HasMore = conv.GetEndOfHistoryTransferType() == waHistorySync.Conversation_COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY
return resp, nil
},
})
}
}
func (wa *WhatsAppClient) convertHistorySyncMessage( func (wa *WhatsAppClient) convertHistorySyncMessage(
ctx context.Context, portal *bridgev2.Portal, info *types.MessageInfo, msg, rawMsg *waE2E.Message, isViewOnce bool, reactions []*waWeb.Reaction, ctx context.Context, portal *bridgev2.Portal, info *types.MessageInfo, msg, rawMsg *waE2E.Message, isViewOnce bool, reactions []*waWeb.Reaction,
) (*bridgev2.BackfillMessage, *wadb.MediaRequest) { ) (*bridgev2.BackfillMessage, *wadb.MediaRequest) {
@ -750,10 +534,10 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)), TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)),
Timestamp: info.Timestamp, Timestamp: info.Timestamp,
StreamOrder: info.Timestamp.Unix(), StreamOrder: info.Timestamp.Unix(),
Reactions: make([]*bridgev2.BackfillReaction, 0, len(reactions)), Reactions: make([]*bridgev2.BackfillReaction, len(reactions)),
} }
mediaReq := wa.processFailedMedia(ctx, portal.PortalKey, wrapped.ID, wrapped.ConvertedMessage, true) mediaReq := wa.processFailedMedia(ctx, portal.PortalKey, wrapped.ID, wrapped.ConvertedMessage, true)
for _, reaction := range reactions { for i, reaction := range reactions {
var sender types.JID var sender types.JID
if reaction.GetKey().GetFromMe() { if reaction.GetKey().GetFromMe() {
sender = wa.JID sender = wa.JID
@ -765,12 +549,12 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
if sender.IsEmpty() { if sender.IsEmpty() {
continue continue
} }
wrapped.Reactions = append(wrapped.Reactions, &bridgev2.BackfillReaction{ wrapped.Reactions[i] = &bridgev2.BackfillReaction{
TargetPart: ptr.Ptr(networkid.PartID("")), TargetPart: ptr.Ptr(networkid.PartID("")),
Timestamp: time.UnixMilli(reaction.GetSenderTimestampMS()), Timestamp: time.UnixMilli(reaction.GetSenderTimestampMS()),
Sender: wa.makeEventSender(ctx, sender), Sender: wa.makeEventSender(ctx, sender),
Emoji: reaction.GetText(), Emoji: reaction.GetText(),
}) }
} }
return wrapped, mediaReq return wrapped, mediaReq
} }

View file

@ -8,7 +8,6 @@ import (
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
@ -19,7 +18,6 @@ var WhatsAppGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
AggressiveUpdateInfo: true, AggressiveUpdateInfo: true,
ImplicitReadReceipts: true, ImplicitReadReceipts: true,
Provisioning: bridgev2.ProvisioningCapabilities{ Provisioning: bridgev2.ProvisioningCapabilities{
ImagePackImport: true,
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{ ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{
CreateDM: true, CreateDM: true,
LookupPhone: true, LookupPhone: true,
@ -52,7 +50,7 @@ func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit
} }
func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) { func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) {
return 1, 8 return 1, 4
} }
const WAMaxFileSize = 2000 * 1024 * 1024 const WAMaxFileSize = 2000 * 1024 * 1024
@ -67,7 +65,7 @@ func supportedIfFFmpeg() event.CapabilitySupportLevel {
} }
func capID() string { func capID() string {
base := "fi.mau.whatsapp.capabilities.2026_05_12" base := "fi.mau.whatsapp.capabilities.2025_08_25+1"
if ffmpeg.Supported() { if ffmpeg.Supported() {
return base + "+ffmpeg" return base + "+ffmpeg"
} }
@ -126,10 +124,10 @@ var whatsappCaps = &event.RoomFeatures{
event.CapMsgSticker: { event.CapMsgSticker: {
MimeTypes: map[string]event.CapabilitySupportLevel{ MimeTypes: map[string]event.CapabilitySupportLevel{
"image/webp": event.CapLevelFullySupported, "image/webp": event.CapLevelFullySupported,
// TODO see if sending lottie is possible
//"video/lottie+json": event.CapLevelFullySupported,
"image/png": event.CapLevelPartialSupport, "image/png": event.CapLevelPartialSupport,
"image/jpeg": event.CapLevelPartialSupport, "image/jpeg": event.CapLevelPartialSupport,
// This will only be accepted if it was imported from WhatsApp
"video/lottie+json": event.CapLevelPartialSupport,
}, },
Caption: event.CapLevelDropped, Caption: event.CapLevelDropped,
MaxSize: WAMaxFileSize, MaxSize: WAMaxFileSize,
@ -163,22 +161,12 @@ var whatsappCaps = &event.RoomFeatures{
MaxSize: WAMaxFileSize, MaxSize: WAMaxFileSize,
}, },
}, },
State: event.StateFeatureMap{
event.StateRoomName.Type: {Level: event.CapLevelFullySupported},
event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported},
event.StateTopic.Type: {Level: event.CapLevelFullySupported},
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
},
MemberActions: event.MemberFeatureMap{
event.MemberActionInvite: event.CapLevelFullySupported,
event.MemberActionKick: event.CapLevelFullySupported,
event.MemberActionLeave: event.CapLevelFullySupported,
},
MaxTextLength: MaxTextLength, MaxTextLength: MaxTextLength,
LocationMessage: event.CapLevelFullySupported, LocationMessage: event.CapLevelFullySupported,
Poll: event.CapLevelFullySupported, Poll: event.CapLevelFullySupported,
Reply: event.CapLevelFullySupported, Reply: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported, Edit: event.CapLevelFullySupported,
EditMaxCount: 10,
EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)), EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)),
Delete: event.CapLevelFullySupported, Delete: event.CapLevelFullySupported,
DeleteForMe: false, DeleteForMe: false,
@ -188,19 +176,11 @@ var whatsappCaps = &event.RoomFeatures{
ReadReceipts: true, ReadReceipts: true,
TypingNotifications: true, TypingNotifications: true,
DisappearingTimer: waDisappearingCap, DisappearingTimer: waDisappearingCap,
DeleteChat: true,
} }
var whatsappDMCaps *event.RoomFeatures
var whatsappCAGCaps *event.RoomFeatures var whatsappCAGCaps *event.RoomFeatures
func init() { func init() {
whatsappDMCaps = ptr.Clone(whatsappCaps)
whatsappDMCaps.ID = capID() + "+dm"
whatsappDMCaps.State = event.StateFeatureMap{
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
}
whatsappDMCaps.MemberActions = nil
whatsappCAGCaps = ptr.Clone(whatsappCaps) whatsappCAGCaps = ptr.Clone(whatsappCaps)
whatsappCAGCaps.ID = capID() + "+cag" whatsappCAGCaps.ID = capID() + "+cag"
whatsappCAGCaps.Reply = event.CapLevelUnsupported whatsappCAGCaps.Reply = event.CapLevelUnsupported
@ -210,8 +190,6 @@ func init() {
func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup { if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup {
return whatsappCAGCaps return whatsappCAGCaps
} else if portal.RoomType == database.RoomTypeDM {
return whatsappDMCaps
} }
return whatsappCaps return whatsappCaps
} }

View file

@ -26,10 +26,10 @@ func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port
if err != nil { if err != nil {
return nil, err return nil, err
} }
return wa.getChatInfo(ctx, portalJID, nil, portal.MXID == "") return wa.getChatInfo(ctx, portalJID, nil)
} }
func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, conv *wadb.Conversation, isNew bool) (wrapped *bridgev2.ChatInfo, err error) { func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, conv *wadb.Conversation) (wrapped *bridgev2.ChatInfo, err error) {
switch portalJID.Server { switch portalJID.Server {
case types.DefaultUserServer, types.HiddenUserServer, types.BotServer: case types.DefaultUserServer, types.HiddenUserServer, types.BotServer:
wrapped = wa.wrapDMInfo(ctx, portalJID) wrapped = wa.wrapDMInfo(ctx, portalJID)
@ -40,14 +40,14 @@ func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID,
return nil, fmt.Errorf("broadcast list bridging is currently not supported") return nil, fmt.Errorf("broadcast list bridging is currently not supported")
} }
case types.GroupServer: case types.GroupServer:
info, err := wa.Client.GetGroupInfo(ctx, portalJID) info, err := wa.Client.GetGroupInfo(portalJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
wrapped = wa.wrapGroupInfo(ctx, info) wrapped = wa.wrapGroupInfo(ctx, info)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt) wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt)
case types.NewsletterServer: case types.NewsletterServer:
info, err := wa.Client.GetNewsletterInfo(ctx, portalJID) info, err := wa.Client.GetNewsletterInfo(portalJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -55,12 +55,11 @@ func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID,
default: default:
return nil, fmt.Errorf("unsupported server %s", portalJID.Server) return nil, fmt.Errorf("unsupported server %s", portalJID.Server)
} }
wa.addExtrasToWrapped(ctx, portalJID, wrapped, conv, isNew) wa.addExtrasToWrapped(ctx, portalJID, wrapped, conv)
return wrapped, nil return wrapped, nil
} }
func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID types.JID, wrapped *bridgev2.ChatInfo, conv *wadb.Conversation, isNew bool) { func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID types.JID, wrapped *bridgev2.ChatInfo, conv *wadb.Conversation) {
if isNew {
if conv == nil { if conv == nil {
var err error var err error
conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID) conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
@ -71,7 +70,6 @@ func (wa *WhatsAppClient) addExtrasToWrapped(ctx context.Context, portalJID type
if conv != nil { if conv != nil {
wa.applyHistoryInfo(wrapped, conv) wa.applyHistoryInfo(wrapped, conv)
} }
}
wa.applyChatSettings(ctx, portalJID, wrapped) wa.applyChatSettings(ctx, portalJID, wrapped)
} }
@ -421,7 +419,7 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types.
existingID = "" existingID = ""
} }
var wrappedAvatar *bridgev2.Avatar var wrappedAvatar *bridgev2.Avatar
avatar, err := wa.Client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{ avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{
ExistingID: existingID, ExistingID: existingID,
IsCommunity: portal.RoomType == database.RoomTypeSpace, IsCommunity: portal.RoomType == database.RoomTypeSpace,
}) })
@ -494,7 +492,7 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
} else if info.ThreadMeta.Preview.ID != "" { } else if info.ThreadMeta.Preview.ID != "" {
avatar.ID = networkid.AvatarID(info.ThreadMeta.Preview.ID) avatar.ID = networkid.AvatarID(info.ThreadMeta.Preview.ID)
avatar.Get = func(ctx context.Context) ([]byte, error) { avatar.Get = func(ctx context.Context) ([]byte, error) {
meta, err := wa.Client.GetNewsletterInfo(ctx, info.ID) meta, err := wa.Client.GetNewsletterInfo(info.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch full res avatar info: %w", err) return nil, fmt.Errorf("failed to fetch full res avatar info: %w", err)
} else if meta.ThreadMeta.Picture == nil { } else if meta.ThreadMeta.Picture == nil {

View file

@ -27,8 +27,8 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exsync" "go.mau.fi/util/exsync"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
waBinary "go.mau.fi/whatsmeow/binary" waBinary "go.mau.fi/whatsmeow/binary"
"go.mau.fi/whatsmeow/proto/waHistorySync"
"go.mau.fi/whatsmeow/proto/waWa6" "go.mau.fi/whatsmeow/proto/waWa6"
"go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -38,7 +38,6 @@ import (
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -47,15 +46,14 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
w := &WhatsAppClient{ w := &WhatsAppClient{
Main: wa, Main: wa,
UserLogin: login, UserLogin: login,
MC: noopMCInstance,
historySyncs: make(chan *waHistorySync.HistorySync, 64),
historySyncWakeup: make(chan struct{}, 1), historySyncWakeup: make(chan struct{}, 1),
resyncQueue: make(map[types.JID]resyncQueueItem), resyncQueue: make(map[types.JID]resyncQueueItem),
directMediaRetries: make(map[networkid.MessageID]*directMediaRetry), directMediaRetries: make(map[networkid.MessageID]*directMediaRetry),
mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle), mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle),
pushNamesSynced: exsync.NewEvent(), pushNamesSynced: exsync.NewEvent(),
createDedup: exsync.NewSet[types.MessageID](), createDedup: exsync.NewSet[types.MessageID](),
appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time),
} }
login.Client = w login.Client = w
@ -75,9 +73,16 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger() log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger()
w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log)) w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log))
w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent) w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent)
if wa.ExternalEventHandler != nil {
w.Client.AddEventHandler(func(evt any) {
wa.ExternalEventHandler(w.Client, evt)
})
}
w.Client.SynchronousAck = true w.Client.SynchronousAck = true
w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0 if bridgev2.PortalEventBuffer == 0 {
w.Client.EnableDecryptedEventBuffer = true
w.Client.ManualHistorySyncDownload = true w.Client.ManualHistorySyncDownload = true
}
w.Client.SendReportingTokens = true w.Client.SendReportingTokens = true
w.Client.AutomaticMessageRerequestFromPhone = true w.Client.AutomaticMessageRerequestFromPhone = true
w.Client.GetMessageForRetry = w.trackNotFoundRetry w.Client.GetMessageForRetry = w.trackNotFoundRetry
@ -85,7 +90,6 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx) w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx)
w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts) w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts)
w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect
w.Client.UseRetryMessageStore = wa.Config.UseWhatsAppRetryStore
} else { } else {
w.UserLogin.Log.Warn().Stringer("jid", w.JID).Msg("No device found for user in whatsmeow store") w.UserLogin.Log.Warn().Stringer("jid", w.JID).Msg("No device found for user in whatsmeow store")
} }
@ -104,8 +108,8 @@ type WhatsAppClient struct {
Client *whatsmeow.Client Client *whatsmeow.Client
Device *store.Device Device *store.Device
JID types.JID JID types.JID
MC mClient
historySyncs chan *waHistorySync.HistorySync
historySyncWakeup chan struct{} historySyncWakeup chan struct{}
stopLoops atomic.Pointer[context.CancelFunc] stopLoops atomic.Pointer[context.CancelFunc]
resyncQueue map[types.JID]resyncQueueItem resyncQueue map[types.JID]resyncQueueItem
@ -119,9 +123,6 @@ type WhatsAppClient struct {
pushNamesSynced *exsync.Event pushNamesSynced *exsync.Event
lastPresence types.Presence lastPresence types.Presence
createDedup *exsync.Set[types.MessageID] createDedup *exsync.Set[types.MessageID]
appStateRecoveryLock sync.Mutex
appStateFullSyncAttempted map[appstate.WAPatchName]time.Time
} }
var ( var (
@ -129,7 +130,6 @@ var (
_ bridgev2.PushableNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.PushableNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.BackgroundSyncingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.BackgroundSyncingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.ChatViewingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.ChatViewingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.StickerImportingNetworkAPI = (*WhatsAppClient)(nil)
) )
var pushCfg = &bridgev2.PushConfig{ var pushCfg = &bridgev2.PushConfig{
@ -197,20 +197,13 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) {
wa.UserLogin.BridgeState.Send(state) wa.UserLogin.BridgeState.Send(state)
return return
} }
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect) wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect)
if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil { if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy") zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
} }
if ctx.Err() != nil {
return
}
wa.initMC()
wa.startLoops() wa.startLoops()
wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx) wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx)
zerolog.Ctx(ctx).Debug().Msg("Connecting to WhatsApp") if err := wa.Client.Connect(); err != nil {
if err := wa.Client.ConnectContext(ctx); err != nil {
wa.callStopLoops()
zerolog.Ctx(ctx).Err(err).Msg("Failed to connect to WhatsApp") zerolog.Ctx(ctx).Err(err).Msg("Failed to connect to WhatsApp")
state := status.BridgeState{ state := status.BridgeState{
StateEvent: status.StateUnknownError, StateEvent: status.StateUnknownError,
@ -266,11 +259,9 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev
return payload return payload
} }
defer func() { defer func() {
if cli := wa.Client; cli != nil { wa.Client.GetClientPayload = nil
cli.GetClientPayload = nil
}
}() }()
err := wa.Client.ConnectContext(ctx) err := wa.Client.Connect()
if err != nil { if err != nil {
return err return err
} }
@ -295,7 +286,7 @@ func (wa *WhatsAppClient) ConnectBackground(ctx context.Context, params *bridgev
func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error { func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
//lint:ignore SA1019 this is supposed to be dangerous //lint:ignore SA1019 this is supposed to be dangerous
resp, err := wa.Client.DangerousInternals().SendIQ(ctx, whatsmeow.DangerousInfoQuery{ resp, err := wa.Client.DangerousInternals().SendIQ(whatsmeow.DangerousInfoQuery{
Namespace: "urn:xmpp:whatsapp:push", Namespace: "urn:xmpp:whatsapp:push",
Type: "get", Type: "get",
To: types.ServerJID, To: types.ServerJID,
@ -303,6 +294,7 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
Tag: "pn", Tag: "pn",
Content: pn, Content: pn,
}}, }},
Context: ctx,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to send pn: %w", err) return fmt.Errorf("failed to send pn: %w", err)
@ -317,7 +309,7 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
} }
zerolog.Ctx(ctx).Debug().Str("cat_data", string(catContentBytes)).Msg("Received cat response from sending pn data") zerolog.Ctx(ctx).Debug().Str("cat_data", string(catContentBytes)).Msg("Received cat response from sending pn data")
//lint:ignore SA1019 this is supposed to be dangerous //lint:ignore SA1019 this is supposed to be dangerous
err = wa.Client.DangerousInternals().SendNode(ctx, waBinary.Node{ err = wa.Client.DangerousInternals().SendNode(waBinary.Node{
Tag: "ib", Tag: "ib",
Content: []waBinary.Node{{ Content: []waBinary.Node{{
Tag: "cat", Tag: "cat",
@ -332,12 +324,11 @@ func (wa *WhatsAppClient) sendPNData(ctx context.Context, pn string) error {
} }
func (wa *WhatsAppClient) startLoops() { func (wa *WhatsAppClient) startLoops() {
ctx, cancel := context.WithCancel(wa.Main.Bridge.BackgroundCtx) ctx, cancel := context.WithCancel(context.Background())
oldStop := wa.stopLoops.Swap(&cancel) oldStop := wa.stopLoops.Swap(&cancel)
if oldStop != nil { if oldStop != nil {
(*oldStop)() (*oldStop)()
} }
ctx = wa.UserLogin.Log.WithContext(ctx)
go wa.historySyncLoop(ctx) go wa.historySyncLoop(ctx)
go wa.ghostResyncLoop(ctx) go wa.ghostResyncLoop(ctx)
if mrc := wa.Main.Config.HistorySync.MediaRequests; mrc.AutoRequestMedia && mrc.RequestMethod == MediaRequestMethodLocalTime { if mrc := wa.Main.Config.HistorySync.MediaRequests; mrc.AutoRequestMedia && mrc.RequestMethod == MediaRequestMethodLocalTime {
@ -355,14 +346,10 @@ func (wa *WhatsAppClient) GetStore() *store.Device {
return store.NoopDevice return store.NoopDevice
} }
func (wa *WhatsAppClient) callStopLoops() { func (wa *WhatsAppClient) Disconnect() {
if stopHistorySyncLoop := wa.stopLoops.Swap(nil); stopHistorySyncLoop != nil { if stopHistorySyncLoop := wa.stopLoops.Swap(nil); stopHistorySyncLoop != nil {
(*stopHistorySyncLoop)() (*stopHistorySyncLoop)()
} }
}
func (wa *WhatsAppClient) Disconnect() {
wa.callStopLoops()
if cli := wa.Client; cli != nil { if cli := wa.Client; cli != nil {
cli.Disconnect() cli.Disconnect()
} }
@ -427,7 +414,7 @@ func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *brid
} }
if wa.lastPresence != presence { if wa.lastPresence != presence {
err := wa.updatePresence(ctx, presence) err := wa.updatePresence(presence)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence when viewing chat") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence when viewing chat")
} }
@ -462,19 +449,10 @@ func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *brid
return nil return nil
} }
func (wa *WhatsAppClient) updatePresence(ctx context.Context, presence types.Presence) error { func (wa *WhatsAppClient) updatePresence(presence types.Presence) error {
err := wa.Client.SendPresence(ctx, presence) err := wa.Client.SendPresence(presence)
if err == nil { if err == nil {
wa.lastPresence = presence wa.lastPresence = presence
} }
return err return err
} }
func (wa *WhatsAppClient) DownloadImagePack(ctx context.Context, url string) (*bridgev2.ImportedImagePack, error) {
return wa.Main.MsgConv.DownloadImagePack(ctx, wa.UserLogin.ID, wa.Client, url)
}
func (wa *WhatsAppClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) {
// TODO
return nil, nil
}

View file

@ -17,15 +17,12 @@
package connector package connector
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"html" "html"
"slices"
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exslices"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -68,7 +65,7 @@ func fnAccept(ce *commands.Event) {
ce.Reply("Login not found") ce.Reply("Login not found")
} else if !login.Client.IsLoggedIn() { } else if !login.Client.IsLoggedIn() {
ce.Reply("Not logged in") ce.Reply("Not logged in")
} else if err = login.Client.(*WhatsAppClient).Client.JoinGroupWithInvite(ce.Ctx, meta.JID, meta.Inviter, meta.Code, meta.Expiration); err != nil { } else if err = login.Client.(*WhatsAppClient).Client.JoinGroupWithInvite(meta.JID, meta.Inviter, meta.Code, meta.Expiration); err != nil {
ce.Log.Err(err).Msg("Failed to accept group invite") ce.Log.Err(err).Msg("Failed to accept group invite")
ce.Reply("Failed to accept group invite: %v", err) ce.Reply("Failed to accept group invite: %v", err)
} else { } else {
@ -123,6 +120,9 @@ func fnSync(ce *commands.Event) {
return return
} }
for _, group := range groups { for _, group := range groups {
wrapped := wa.wrapGroupInfo(ce.Ctx, group)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt)
wa.addExtrasToWrapped(ce.Ctx, group.JID, wrapped, nil)
login.QueueRemoteEvent(&simplevent.ChatResync{ login.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
@ -130,12 +130,7 @@ func fnSync(ce *commands.Event) {
LogContext: logContext, LogContext: logContext,
CreatePortal: true, CreatePortal: true,
}, },
GetChatInfoFunc: func(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { ChatInfo: wrapped,
wrapped := wa.wrapGroupInfo(ce.Ctx, group)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalLastSyncAt)
wa.addExtrasToWrapped(ce.Ctx, group.JID, wrapped, nil, portal.MXID == "")
return wrapped, nil
},
}) })
} }
ce.Reply("Queued syncs for %d groups", len(groups)) ce.Reply("Queued syncs for %d groups", len(groups))
@ -146,17 +141,7 @@ func fnSync(ce *commands.Event) {
wa.resyncContacts(true, false) wa.resyncContacts(true, false)
ce.React("✅") ce.React("✅")
case "appstate": case "appstate":
names := appstate.AllPatchNames[:] for _, name := range appstate.AllPatchNames {
if len(ce.Args) > 1 {
names = exslices.CastFuncFilter(ce.Args[1:], func(name string) (appstate.WAPatchName, bool) {
if !slices.Contains(appstate.AllPatchNames[:], appstate.WAPatchName(name)) {
ce.Reply("Invalid app state name `%s`", name)
return "", false
}
return appstate.WAPatchName(name), true
})
}
for _, name := range names {
err := wa.Client.FetchAppState(ce.Ctx, name, true, false) err := wa.Client.FetchAppState(ce.Ctx, name, true, false)
if errors.Is(err, appstate.ErrKeyNotFound) { if errors.Is(err, appstate.ErrKeyNotFound) {
ce.Reply("Key not found error syncing app state %s: %v\n\nKey requests are sent automatically, and the sync should happen in the background after your phone responds.", name, err) ce.Reply("Key not found error syncing app state %s: %v\n\nKey requests are sent automatically, and the sync should happen in the background after your phone responds.", name, err)
@ -204,7 +189,7 @@ func fnInviteLink(ce *commands.Event) {
ce.Reply("Can't get invite link to private chat") ce.Reply("Can't get invite link to private chat")
} else if portalJID.IsBroadcastList() { } else if portalJID.IsBroadcastList() {
ce.Reply("Can't get invite link to broadcast list") ce.Reply("Can't get invite link to broadcast list")
} else if link, err := wa.Client.GetGroupInviteLink(ce.Ctx, portalJID, reset); err != nil { } else if link, err := wa.Client.GetGroupInviteLink(portalJID, reset); err != nil {
ce.Reply("Failed to get invite link: %v", err) ce.Reply("Failed to get invite link: %v", err)
} else { } else {
ce.Reply(link) ce.Reply(link)
@ -234,14 +219,14 @@ func fnResolveLink(ce *commands.Event) {
} }
wa := login.Client.(*WhatsAppClient) wa := login.Client.(*WhatsAppClient)
if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) {
group, err := wa.Client.GetGroupInfoFromLink(ce.Ctx, ce.Args[0]) group, err := wa.Client.GetGroupInfoFromLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get group info: %v", err) ce.Reply("Failed to get group info: %v", err)
return return
} }
ce.Reply("That invite link points at %s (`%s`)", group.Name, group.JID) ce.Reply("That invite link points at %s (`%s`)", group.Name, group.JID)
} else if strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkDirectPrefix) { } else if strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.BusinessMessageLinkDirectPrefix) {
target, err := wa.Client.ResolveBusinessMessageLink(ce.Ctx, ce.Args[0]) target, err := wa.Client.ResolveBusinessMessageLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get business info: %v", err) ce.Reply("Failed to get business info: %v", err)
return return
@ -256,7 +241,7 @@ func fnResolveLink(ce *commands.Event) {
} }
ce.Reply("That link points at %s (+%s).%s", target.PushName, target.JID.User, message) ce.Reply("That link points at %s (+%s).%s", target.PushName, target.JID.User, message)
} else if strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkDirectPrefix) { } else if strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkPrefix) || strings.HasPrefix(ce.Args[0], whatsmeow.ContactQRLinkDirectPrefix) {
target, err := wa.Client.ResolveContactQRLink(ce.Ctx, ce.Args[0]) target, err := wa.Client.ResolveContactQRLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get contact info: %v", err) ce.Reply("Failed to get contact info: %v", err)
return return
@ -295,7 +280,7 @@ func fnJoin(ce *commands.Event) {
wa := login.Client.(*WhatsAppClient) wa := login.Client.(*WhatsAppClient)
if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) { if strings.HasPrefix(ce.Args[0], whatsmeow.InviteLinkPrefix) {
jid, err := wa.Client.JoinGroupWithLink(ce.Ctx, ce.Args[0]) jid, err := wa.Client.JoinGroupWithLink(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to join group: %v", err) ce.Reply("Failed to join group: %v", err)
return return
@ -303,12 +288,12 @@ func fnJoin(ce *commands.Event) {
ce.Log.Debug().Stringer("group_jid", jid).Msg("User successfully joined WhatsApp group with link") ce.Log.Debug().Stringer("group_jid", jid).Msg("User successfully joined WhatsApp group with link")
ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid) ce.Reply("Successfully joined group `%s`, the portal should be created momentarily", jid)
} else if strings.HasPrefix(ce.Args[0], whatsmeow.NewsletterLinkPrefix) { } else if strings.HasPrefix(ce.Args[0], whatsmeow.NewsletterLinkPrefix) {
info, err := wa.Client.GetNewsletterInfoWithInvite(ce.Ctx, ce.Args[0]) info, err := wa.Client.GetNewsletterInfoWithInvite(ce.Args[0])
if err != nil { if err != nil {
ce.Reply("Failed to get channel info: %v", err) ce.Reply("Failed to get channel info: %v", err)
return return
} }
err = wa.Client.FollowNewsletter(ce.Ctx, info.ID) err = wa.Client.FollowNewsletter(info.ID)
if err != nil { if err != nil {
ce.Reply("Failed to follow channel: %v", err) ce.Reply("Failed to follow channel: %v", err)
return return

View file

@ -50,7 +50,6 @@ type Config struct {
ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"` ForceActiveDeliveryReceipts bool `yaml:"force_active_delivery_receipts"`
DirectMediaAutoRequest bool `yaml:"direct_media_auto_request"` DirectMediaAutoRequest bool `yaml:"direct_media_auto_request"`
InitialAutoReconnect bool `yaml:"initial_auto_reconnect"` InitialAutoReconnect bool `yaml:"initial_auto_reconnect"`
UseWhatsAppRetryStore bool `yaml:"use_whatsapp_retry_store"`
AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"` AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"`
@ -70,8 +69,6 @@ type Config struct {
RequestLocalTime int `yaml:"request_local_time"` RequestLocalTime int `yaml:"request_local_time"`
MaxAsyncHandle int64 `yaml:"max_async_handle"` MaxAsyncHandle int64 `yaml:"max_async_handle"`
} `yaml:"media_requests"` } `yaml:"media_requests"`
BackwardsOnDemand bool `yaml:"backwards_on_demand"`
} `yaml:"history_sync"` } `yaml:"history_sync"`
displaynameTemplate *template.Template `yaml:"-"` displaynameTemplate *template.Template `yaml:"-"`
@ -119,7 +116,6 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Bool, "force_active_delivery_receipts") helper.Copy(up.Bool, "force_active_delivery_receipts")
helper.Copy(up.Bool, "direct_media_auto_request") helper.Copy(up.Bool, "direct_media_auto_request")
helper.Copy(up.Bool, "initial_auto_reconnect") helper.Copy(up.Bool, "initial_auto_reconnect")
helper.Copy(up.Bool, "use_whatsapp_retry_store")
helper.Copy(up.Str, "animated_sticker", "target") helper.Copy(up.Str, "animated_sticker", "target")
helper.Copy(up.Int, "animated_sticker", "args", "width") helper.Copy(up.Int, "animated_sticker", "args", "width")
@ -136,7 +132,6 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "history_sync", "media_requests", "request_method") helper.Copy(up.Str, "history_sync", "media_requests", "request_method")
helper.Copy(up.Int, "history_sync", "media_requests", "request_local_time") helper.Copy(up.Int, "history_sync", "media_requests", "request_local_time")
helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle") helper.Copy(up.Int, "history_sync", "media_requests", "max_async_handle")
helper.Copy(up.Bool, "history_sync", "backwards_on_demand")
} }
type DisplaynameParams struct { type DisplaynameParams struct {

View file

@ -20,15 +20,11 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net"
"net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/random" "go.mau.fi/util/random"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
@ -36,19 +32,16 @@ import (
"go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/store/sqlstore"
whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades" whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades"
"go.mau.fi/whatsmeow/types"
waLog "go.mau.fi/whatsmeow/util/log" waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/commands" "maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb" "go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
"go.mau.fi/mautrix-whatsapp/pkg/msgconv" "go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
type WhatsAppConnector struct { type WhatsAppConnector struct {
@ -64,6 +57,8 @@ type WhatsAppConnector struct {
mediaEditCache MediaEditCache mediaEditCache MediaEditCache
mediaEditCacheLock sync.RWMutex mediaEditCacheLock sync.RWMutex
stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc] stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
ExternalEventHandler func(client *whatsmeow.Client, rawEvt any)
} }
func init() { func init() {
@ -74,7 +69,6 @@ var (
_ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil) _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
_ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil) _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
_ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil) _ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil)
_ bridgev2.NetworkResettingNetwork = (*WhatsAppConnector)(nil)
) )
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) { func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
@ -128,12 +122,11 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
store.DeviceProps.Os = proto.String(wa.Config.OSName) store.DeviceProps.Os = proto.String(wa.Config.OSName)
store.DeviceProps.RequireFullSync = proto.Bool(wa.Config.HistorySync.RequestFullSync) store.DeviceProps.RequireFullSync = proto.Bool(wa.Config.HistorySync.RequestFullSync)
if fsc := wa.Config.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 { if fsc := wa.Config.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
if store.DeviceProps.HistorySyncConfig == nil { store.DeviceProps.HistorySyncConfig = &waCompanionReg.DeviceProps_HistorySyncConfig{
store.DeviceProps.HistorySyncConfig = &waCompanionReg.DeviceProps_HistorySyncConfig{} FullSyncDaysLimit: proto.Uint32(fsc.DaysLimit),
FullSyncSizeMbLimit: proto.Uint32(fsc.SizeLimit),
StorageQuotaMb: proto.Uint32(fsc.StorageQuota),
} }
store.DeviceProps.HistorySyncConfig.FullSyncDaysLimit = proto.Uint32(fsc.DaysLimit)
store.DeviceProps.HistorySyncConfig.FullSyncSizeMbLimit = proto.Uint32(fsc.SizeLimit)
store.DeviceProps.HistorySyncConfig.StorageQuotaMb = proto.Uint32(fsc.StorageQuota)
} }
platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(wa.Config.BrowserName)] platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(wa.Config.BrowserName)]
if ok { if ok {
@ -157,80 +150,9 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"} return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"}
} }
if !wa.Bridge.Background && wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dms_deleted") == "false" {
wa.deleteLIDDMsMigration(ctx)
}
return nil return nil
} }
func (wa *WhatsAppConnector) deleteLIDDMsMigration(ctx context.Context) {
log := zerolog.Ctx(ctx).With().Str("action", "delete lid dms").Logger()
portals, err := wa.Bridge.GetAllPortalsWithMXID(ctx)
if err != nil {
log.Err(err).Msg("Failed to get portals for LID DM deletion")
return
}
defer wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dms_deleted", "true")
if len(portals) == 0 {
log.Debug().Msg("No portals found")
return
}
portalsByKey := make(map[networkid.PortalKey]*bridgev2.Portal, len(portals))
for _, p := range portals {
if p.Receiver == "" || p.RoomType != database.RoomTypeDM {
continue
}
portalsByKey[p.PortalKey] = p
}
_, err = wa.DB.Exec(ctx, "DELETE FROM whatsapp_history_sync_conversation WHERE chat_jid LIKE '%@lid'")
if err != nil {
log.Err(err).Msg("Failed to remove LID conversations from history sync")
}
for key, portal := range portalsByKey {
parsedID, err := waid.ParsePortalID(key.ID)
if err != nil {
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to parse portal ID")
continue
} else if parsedID.Server != types.HiddenUserServer {
continue
}
var pnStr string
err = wa.DB.QueryRow(ctx, "SELECT pn FROM whatsmeow_lid_map WHERE lid=$1", parsedID.User).Scan(&pnStr)
if err != nil {
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to get PN for LID portal")
continue
}
key.ID = waid.MakePortalID(types.JID{User: pnStr, Server: types.DefaultUserServer})
_, pnPortalExists := portalsByKey[key]
if !pnPortalExists {
log.Warn().Str("portal_id", string(key.ID)).Msg("PN portal does not exist, not deleting LID DM")
continue
}
err = portal.Delete(ctx)
if err != nil {
log.Err(err).
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Failed to delete LID DM portal from database")
continue
}
err = wa.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false)
if err != nil {
log.Err(err).
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Failed to delete LID DM portal from Matrix")
continue
}
log.Debug().
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Deleted LID DM portal")
}
log.Info().Msg("Finished deleting LID DM portals")
}
func (wa *WhatsAppConnector) Stop() { func (wa *WhatsAppConnector) Stop() {
if stop := wa.stopMediaEditCacheLoop.Swap(nil); stop != nil { if stop := wa.stopMediaEditCacheLoop.Swap(nil); stop != nil {
(*stop)() (*stop)()
@ -239,8 +161,6 @@ func (wa *WhatsAppConnector) Stop() {
const kvWAVersion = "whatsapp_web_version" const kvWAVersion = "whatsapp_web_version"
var hardcodedWAVersion = store.GetWAVersion()
func (wa *WhatsAppConnector) onFirstBackgroundConnect() { func (wa *WhatsAppConnector) onFirstBackgroundConnect() {
verStr := wa.Bridge.DB.KV.Get(wa.Bridge.BackgroundCtx, kvWAVersion) verStr := wa.Bridge.DB.KV.Get(wa.Bridge.BackgroundCtx, kvWAVersion)
if verStr == "" { if verStr == "" {
@ -253,29 +173,20 @@ func (wa *WhatsAppConnector) onFirstBackgroundConnect() {
return return
} }
wa.Bridge.Log.Debug(). wa.Bridge.Log.Debug().
Stringer("hardcoded_version", hardcodedWAVersion). Stringer("hardcoded_version", store.GetWAVersion()).
Stringer("cached_version", ver). Stringer("cached_version", ver).
Msg("Using cached WhatsApp web version number") Msg("Using cached WhatsApp web version number")
store.SetWAVersion(ver) store.SetWAVersion(ver)
} }
func (wa *WhatsAppConnector) onFirstClientConnect() { func (wa *WhatsAppConnector) onFirstClientConnect() {
wa.Bridge.Log.Debug().Msg("Fetching latest WhatsApp web version number")
ctx := wa.Bridge.BackgroundCtx ctx := wa.Bridge.BackgroundCtx
ver, err := whatsmeow.GetLatestVersion(ctx, &http.Client{ ver, err := whatsmeow.GetLatestVersion(ctx, nil)
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ForceAttemptHTTP2: true,
},
Timeout: 10 * time.Second,
})
if err != nil { if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number") wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number")
} else { } else {
wa.Bridge.Log.Debug(). wa.Bridge.Log.Debug().
Stringer("hardcoded_version", hardcodedWAVersion). Stringer("hardcoded_version", store.GetWAVersion()).
Stringer("latest_version", *ver). Stringer("latest_version", *ver).
Msg("Got latest WhatsApp web version number") Msg("Got latest WhatsApp web version number")
store.SetWAVersion(*ver) store.SetWAVersion(*ver)
@ -291,13 +202,3 @@ func (wa *WhatsAppConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ e
// so nobody can tell the difference if we just generate random bytes. // so nobody can tell the difference if we just generate random bytes.
return networkid.RawTransactionID(whatsmeow.WebMessageIDPrefix + strings.ToUpper(hex.EncodeToString(random.Bytes(9)))) return networkid.RawTransactionID(whatsmeow.WebMessageIDPrefix + strings.ToUpper(hex.EncodeToString(random.Bytes(9))))
} }
func (wa *WhatsAppConnector) ResetHTTPTransport() {
// No-op for now, whatsmeow doesn't use the shared transport config yet
}
func (wa *WhatsAppConnector) ResetNetworkConnections() {
for _, login := range wa.Bridge.GetAllCachedUserLogins() {
login.Client.(*WhatsAppClient).Client.ResetConnection()
}
}

View file

@ -29,7 +29,6 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exsync" "go.mau.fi/util/exsync"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waMmsRetry" "go.mau.fi/whatsmeow/proto/waMmsRetry"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
@ -67,8 +66,6 @@ func (wa *WhatsAppConnector) Download(ctx context.Context, mediaID networkid.Med
return wa.downloadMessageDirectMedia(ctx, parsedID, params) return wa.downloadMessageDirectMedia(ctx, parsedID, params)
} else if parsedID.Avatar != nil { } else if parsedID.Avatar != nil {
return wa.downloadAvatarDirectMedia(ctx, parsedID, params) return wa.downloadAvatarDirectMedia(ctx, parsedID, params)
} else if parsedID.Sticker != nil {
return wa.downloadStickerDirectMedia(ctx, parsedID, params)
} else { } else {
return nil, fmt.Errorf("unexpected media ID parsing result") return nil, fmt.Errorf("unexpected media ID parsing result")
} }
@ -88,24 +85,17 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
return nil, fmt.Errorf("failed to get avatar cache entry: %w", err) return nil, fmt.Errorf("failed to get avatar cache entry: %w", err)
} }
if cachedInfo != nil && cachedInfo.Gone { if cachedInfo != nil && cachedInfo.Gone {
return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available (cached response)") return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available")
} else if cachedInfo == nil || cachedInfo.Expiry.Time.Before(time.Now().Add(5*time.Minute)) { } else if cachedInfo == nil || cachedInfo.Expiry.Time.Before(time.Now().Add(5*time.Minute)) {
zerolog.Ctx(ctx).Debug(). zerolog.Ctx(ctx).Debug().
Str("avatar_id", parsedID.Avatar.AvatarID). Str("avatar_id", parsedID.Avatar.AvatarID).
Msg("Refreshing avatar URL from WhatsApp servers") Msg("Refreshing avatar URL from WhatsApp servers")
avatar, err := waClient.Client.GetProfilePictureInfo(ctx, parsedID.Avatar.TargetJID, &whatsmeow.GetProfilePictureParams{ avatar, err := waClient.Client.GetProfilePictureInfo(parsedID.Avatar.TargetJID, &whatsmeow.GetProfilePictureParams{
IsCommunity: parsedID.Avatar.Community, IsCommunity: parsedID.Avatar.Community,
}) })
if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) || if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) ||
errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) || errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) ||
(err == nil && (avatar == nil || avatar.ID != parsedID.Avatar.AvatarID)) { (err == nil && (avatar == nil || avatar.ID != parsedID.Avatar.AvatarID)) {
zerolog.Ctx(ctx).Debug().
Err(err).
Stringer("target_jid", parsedID.Avatar.TargetJID).
Bool("is_community", parsedID.Avatar.Community).
Str("wanted_avatar_id", parsedID.Avatar.AvatarID).
Str("got_avatar_id", ptr.Val(avatar).ID).
Msg("Avatar is no longer available")
err = wa.DB.AvatarCache.Put(ctx, &wadb.AvatarCacheEntry{ err = wa.DB.AvatarCache.Put(ctx, &wadb.AvatarCacheEntry{
EntityJID: parsedID.Avatar.TargetJID, EntityJID: parsedID.Avatar.TargetJID,
AvatarID: parsedID.Avatar.AvatarID, AvatarID: parsedID.Avatar.AvatarID,
@ -118,7 +108,7 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
} }
return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available") return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available")
} else if err != nil { } else if err != nil {
return nil, mautrix.MUnknown.WithMessage("failed to refresh avatar url: %w", err).WithCanRetry(true) return nil, fmt.Errorf("failed to refresh avatar url: %w", err)
} }
cachedInfo = avatarInfoToCacheEntry(ctx, parsedID.Avatar.TargetJID, avatar) cachedInfo = avatarInfoToCacheEntry(ctx, parsedID.Avatar.TargetJID, avatar)
err = wa.DB.AvatarCache.Put(ctx, cachedInfo) err = wa.DB.AvatarCache.Put(ctx, cachedInfo)
@ -129,33 +119,17 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
} }
} }
return &mediaproxy.GetMediaResponseFile{ return &mediaproxy.GetMediaResponseFile{
Callback: func(w *os.File) (*mediaproxy.FileMeta, error) { Callback: func(w *os.File) error {
return &mediaproxy.FileMeta{}, waClient.Client.DownloadMediaWithPathToFile( return waClient.Client.DownloadMediaWithPathToFile(
ctx, cachedInfo.DirectPath, nil, nil, nil, 0, "", "", w, ctx, cachedInfo.DirectPath, nil, nil, nil, 0, "", "", w,
) )
}, },
ContentType: "", // TODO are avatars always jpeg?
}, nil }, nil
} }
func (wa *WhatsAppConnector) downloadStickerDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
ul := wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin)
if ul == nil {
return nil, fmt.Errorf("%w: user login %s not found", bridgev2.ErrNotLoggedIn, parsedID.UserLogin)
}
waClient := ul.Client.(*WhatsAppClient)
if waClient.Client == nil {
return nil, fmt.Errorf("no WhatsApp client found on login %s", parsedID.UserLogin)
}
sticker, err := wa.MsgConv.GetCachedSticker(ctx, waClient.Client, parsedID.Sticker.PackID, parsedID.Sticker.FileHash)
if err != nil {
return nil, err
} else if sticker == nil {
return nil, mautrix.MNotFound.WithMessage("Sticker not found in pack")
}
return wa.makeDirectMediaResponse(ctx, waClient, sticker, sticker.MimeType, "", nil, params)
}
func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) { func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
log := zerolog.Ctx(ctx)
msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, parsedID.Message.String()) msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, parsedID.Message.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get message: %w", err) return nil, fmt.Errorf("failed to get message: %w", err)
@ -187,38 +161,25 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par
} }
} }
if ul == nil || !ul.Client.IsLoggedIn() { if ul == nil || !ul.Client.IsLoggedIn() {
return nil, bridgev2.ErrNotLoggedIn return nil, fmt.Errorf("no logged in user found")
} }
waClient := ul.Client.(*WhatsAppClient) waClient := ul.Client.(*WhatsAppClient)
if waClient.Client == nil { if waClient.Client == nil {
return nil, fmt.Errorf("no WhatsApp client found on login") return nil, fmt.Errorf("no WhatsApp client found on login")
} }
return wa.makeDirectMediaResponse(ctx, waClient, keys, keys.MimeType, msg.ID, keys, params)
}
func (wa *WhatsAppConnector) makeDirectMediaResponse(
ctx context.Context,
waClient *WhatsAppClient,
dm whatsmeow.DownloadableMessage,
mimeType string,
msgID networkid.MessageID,
keys *msgconv.FailedMediaKeys,
params map[string]string,
) (mediaproxy.GetMediaResponse, error) {
return &mediaproxy.GetMediaResponseFile{ return &mediaproxy.GetMediaResponseFile{
Callback: func(f *os.File) (*mediaproxy.FileMeta, error) { Callback: func(f *os.File) error {
log := zerolog.Ctx(ctx) err := waClient.Client.DownloadToFile(ctx, keys, f)
err := waClient.Client.DownloadToFile(ctx, dm, f) if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) {
if keys != nil && (errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) || errors.Is(err, whatsmeow.ErrNoURLPresent)) {
val := params["fi.mau.whatsapp.reload_media"] val := params["fi.mau.whatsapp.reload_media"]
if val == "false" || (!wa.Config.DirectMediaAutoRequest && val != "true") { if val == "false" || (!wa.Config.DirectMediaAutoRequest && val != "true") {
return nil, ErrReloadNeeded return ErrReloadNeeded
} }
log.Trace().Msg("Media not found for direct download, requesting and waiting") log.Trace().Msg("Media not found for direct download, requesting and waiting")
err = waClient.requestAndWaitDirectMedia(ctx, msgID, keys) err = waClient.requestAndWaitDirectMedia(ctx, msg.ID, keys)
if err != nil { if err != nil {
log.Trace().Err(err).Msg("Failed to wait for media for direct download") log.Trace().Err(err).Msg("Failed to wait for media for direct download")
return nil, err return err
} }
log.Trace().Msg("Retrying download after successful retry") log.Trace().Msg("Retrying download after successful retry")
err = waClient.Client.DownloadToFile(ctx, keys, f) err = waClient.Client.DownloadToFile(ctx, keys, f)
@ -226,28 +187,27 @@ func (wa *WhatsAppConnector) makeDirectMediaResponse(
if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) { if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too") zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too")
} else if err != nil { } else if err != nil {
return nil, err return err
} }
if mimeType == "application/was" { if keys.MimeType == "application/was" {
if _, err := f.Seek(0, io.SeekStart); err != nil { if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to seek to start of sticker zip: %w", err) return fmt.Errorf("failed to seek to start of sticker zip: %w", err)
} else if zipData, err := io.ReadAll(f); err != nil { } else if zipData, err := io.ReadAll(f); err != nil {
return nil, fmt.Errorf("failed to read sticker zip: %w", err) return fmt.Errorf("failed to read sticker zip: %w", err)
} else if data, _, err := msgconv.ExtractAnimatedSticker(zipData); err != nil { } else if data, err := msgconv.ExtractAnimatedSticker(zipData); err != nil {
return nil, fmt.Errorf("failed to extract animated sticker: %w %x", err, zipData) return fmt.Errorf("failed to extract animated sticker: %w %x", err, zipData)
} else if _, err := f.WriteAt(data, 0); err != nil { } else if _, err := f.WriteAt(data, 0); err != nil {
return nil, fmt.Errorf("failed to write animated sticker to file: %w", err) return fmt.Errorf("failed to write animated sticker to file: %w", err)
} else if err := f.Truncate(int64(len(data))); err != nil { } else if err := f.Truncate(int64(len(data))); err != nil {
return nil, fmt.Errorf("failed to truncate animated sticker file: %w", err) return fmt.Errorf("failed to truncate animated sticker file: %w", err)
} }
mimeType = "video/lottie+json"
} }
return &mediaproxy.FileMeta{ return nil
ContentType: mimeType,
}, nil
}, },
// TODO?
ContentType: "",
}, nil }, nil
} }
@ -285,16 +245,12 @@ func (wa *WhatsAppClient) requestAndWaitDirectMedia(ctx context.Context, rawMsgI
} }
switch state.resultType { switch state.resultType {
case waMmsRetry.MediaRetryNotification_NOT_FOUND: case waMmsRetry.MediaRetryNotification_NOT_FOUND:
return mautrix.MNotFound.WithMessage("This media was not found on your phone.") return mautrix.MNotFound.WithMessage("Media not found on phone")
case waMmsRetry.MediaRetryNotification_DECRYPTION_ERROR:
return mautrix.MNotFound.WithMessage("Unable to retrieve media: phone reported a decryption error. The original message may have been deleted.")
case waMmsRetry.MediaRetryNotification_GENERAL_ERROR:
return mautrix.MNotFound.WithMessage("Unable to retrieve media: phone returned an error. Please ensure your phone is connected to the internet and WhatsApp is running.").WithCanRetry(true)
default: default:
return mautrix.MNotFound.WithMessage(fmt.Sprintf("Unable to retrieve media: phone returned error code %d", state.resultType)).WithCanRetry(true) return mautrix.MNotFound.WithMessage("Phone returned error response")
} }
case <-time.After(30 * time.Second): case <-time.After(30 * time.Second):
return mautrix.MNotFound.WithMessage("Phone did not respond in time. Please ensure your phone is connected to the internet and WhatsApp is open.").WithStatus(http.StatusGatewayTimeout).WithCanRetry(true) return mautrix.MNotFound.WithMessage("Phone did not respond in time").WithStatus(http.StatusGatewayTimeout)
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
} }
@ -306,7 +262,7 @@ func (wa *WhatsAppClient) requestDirectMedia(ctx context.Context, rawMsgID netwo
defer state.Unlock() defer state.Unlock()
if !state.requested { if !state.requested {
zerolog.Ctx(ctx).Debug().Msg("Sending request for missing media in direct download") zerolog.Ctx(ctx).Debug().Msg("Sending request for missing media in direct download")
err := wa.sendMediaRequestDirect(ctx, rawMsgID, key) err := wa.sendMediaRequestDirect(rawMsgID, key)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send media retry request: %w", err) return nil, fmt.Errorf("failed to send media retry request: %w", err)
} }

View file

@ -133,21 +133,13 @@ func (evt *WAMessageEvent) PreHandle(ctx context.Context, portal *bridgev2.Porta
return return
} }
meta := portal.Metadata.(*waid.PortalMetadata) meta := portal.Metadata.(*waid.PortalMetadata)
if meta.AddressingMode == types.AddressingModeLID && evt.Info.Sender.Server == types.DefaultUserServer {
evt.Info.Sender, evt.Info.SenderAlt = evt.Info.SenderAlt, evt.Info.Sender
zerolog.Ctx(ctx).Debug().
Stringer("lid", evt.Info.Sender).
Stringer("pn", evt.Info.SenderAlt).
Str("message_id", evt.Info.ID).
Msg("Forced phone number sender to LID in group message")
}
if meta.AddressingMode == types.AddressingModeLID || meta.LIDMigrationAttempted { if meta.AddressingMode == types.AddressingModeLID || meta.LIDMigrationAttempted {
return return
} }
log := zerolog.Ctx(ctx).With().Str("action", "group lid migration").Logger() log := zerolog.Ctx(ctx).With().Str("action", "group lid migration").Logger()
ctx = log.WithContext(ctx) ctx = log.WithContext(ctx)
meta.LIDMigrationAttempted = true meta.LIDMigrationAttempted = true
info, err := evt.wa.Client.GetGroupInfo(ctx, portalJID) info, err := evt.wa.Client.GetGroupInfo(portalJID)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get group info for lid migration") log.Err(err).Msg("Failed to get group info for lid migration")
return return
@ -217,15 +209,9 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
func (evt *WAMessageEvent) GetTargetMessage() networkid.MessageID { func (evt *WAMessageEvent) GetTargetMessage() networkid.MessageID {
if reactionMsg := evt.Message.GetReactionMessage(); reactionMsg != nil { if reactionMsg := evt.Message.GetReactionMessage(); reactionMsg != nil {
ctx := evt.wa.UserLogin.Log. return msgconv.KeyToMessageID(evt.wa.Client, evt.Info.Chat, evt.Info.Sender, reactionMsg.GetKey())
With().Str("action", "get reaction target message").Str("message_id", evt.Info.ID).Logger().
WithContext(evt.wa.Main.Bridge.BackgroundCtx)
return msgconv.KeyToMessageID(ctx, evt.wa.Client, evt.Info.Chat, evt.Info.Sender, reactionMsg.GetKey())
} else if protocolMsg := evt.Message.GetProtocolMessage(); protocolMsg != nil { } else if protocolMsg := evt.Message.GetProtocolMessage(); protocolMsg != nil {
ctx := evt.wa.UserLogin.Log. return msgconv.KeyToMessageID(evt.wa.Client, evt.Info.Chat, evt.Info.Sender, protocolMsg.GetKey())
With().Str("action", "get edit target message").Str("message_id", evt.Info.ID).Logger().
WithContext(evt.wa.Main.Bridge.BackgroundCtx)
return msgconv.KeyToMessageID(ctx, evt.wa.Client, evt.Info.Chat, evt.Info.Sender, protocolMsg.GetKey())
} }
return "" return ""
} }

View file

@ -64,11 +64,6 @@ force_active_delivery_receipts: false
direct_media_auto_request: true direct_media_auto_request: true
# Should the bridge automatically reconnect if it fails to connect on startup? # Should the bridge automatically reconnect if it fails to connect on startup?
initial_auto_reconnect: true initial_auto_reconnect: true
# WhatsApp messages are sometimes undecryptable. Should the bridge store messages it sends in the
# bridge database in order to accept retry receipts from other WhatsApp users for messages sent via
# the bridge? By default, the bridge only stores messages in memory, and therefore can't accept
# retry receipts if the bridge is restarted after the message is sent.
use_whatsapp_retry_store: false
# Settings for converting animated stickers. # Settings for converting animated stickers.
animated_sticker: animated_sticker:
@ -121,6 +116,3 @@ history_sync:
request_local_time: 120 request_local_time: 120
# Maximum number of media request responses to handle in parallel per user. # Maximum number of media request responses to handle in parallel per user.
max_async_handle: 2 max_async_handle: 2
# Use on-demand history sync requests for fetching older messages?
# This only applies when using the backfill queue, never for forward backfills.
backwards_on_demand: false

View file

@ -45,7 +45,6 @@ var (
_ bridgev2.MuteHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.MuteHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.TagHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.TagHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MarkedUnreadHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.MarkedUnreadHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.DeleteChatHandlingNetworkAPI = (*WhatsAppClient)(nil)
) )
func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) { func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
@ -107,13 +106,12 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.GetStore().GetLID(), req.ID) wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.GetStore().GetLID(), req.ID)
msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID)) msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID))
msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID2)) msg.AddPendingToIgnore(networkid.TransactionID(wrappedMsgID2))
zerolog.Ctx(ctx).Trace().Any("payload", waMsg).Msg("Outgoing message payload")
resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, *req) resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, *req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var pickedMessageID networkid.MessageID var pickedMessageID networkid.MessageID
if resp.Sender == wa.GetStore().GetLID() && chatJID.Server != types.DefaultUserServer { if resp.Sender == wa.GetStore().GetLID() {
pickedMessageID = wrappedMsgID2 pickedMessageID = wrappedMsgID2
msg.RemovePending(networkid.TransactionID(wrappedMsgID)) msg.RemovePending(networkid.TransactionID(wrappedMsgID))
} else { } else {
@ -323,7 +321,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
messagesToRead[key] = append(messagesToRead[key], parsed.ID) messagesToRead[key] = append(messagesToRead[key], parsed.ID)
} }
for messageSender, ids := range messagesToRead { for messageSender, ids := range messagesToRead {
err = wa.Client.MarkRead(ctx, ids, receipt.Receipt.Timestamp, portalJID, messageSender) err = wa.Client.MarkRead(ids, receipt.Receipt.Timestamp, portalJID, messageSender)
if err != nil { if err != nil {
log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read") log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read")
} }
@ -353,12 +351,12 @@ func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.
} }
if wa.Main.Config.SendPresenceOnTyping { if wa.Main.Config.SendPresenceOnTyping {
err = wa.updatePresence(ctx, types.PresenceAvailable) err = wa.updatePresence(types.PresenceAvailable)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence on typing") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence on typing")
} }
} }
return wa.Client.SendChatPresence(ctx, portalJID, chatPresence, mediaPresence) return wa.Client.SendChatPresence(portalJID, chatPresence, mediaPresence)
} }
var errUnsupportedDisappearingTimer = bridgev2.WrapErrorInStatus(errors.New("invalid value for disappearing timer")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) var errUnsupportedDisappearingTimer = bridgev2.WrapErrorInStatus(errors.New("invalid value for disappearing timer")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true)
@ -376,7 +374,7 @@ func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg
} }
settingTS := time.UnixMilli(msg.Event.Timestamp) settingTS := time.UnixMilli(msg.Event.Timestamp)
err = wa.Client.SetDisappearingTimer(ctx, portalJID, msg.Content.Timer.Duration, settingTS) err = wa.Client.SetDisappearingTimer(portalJID, msg.Content.Timer.Duration, settingTS)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -391,22 +389,18 @@ func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg
return true, nil return true, nil
} }
func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error) { func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (bool, error) {
if msg.Type.IsSelf && msg.OrigSender != nil {
return nil, nil
}
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return nil, err return false, err
} }
if msg.Portal.RoomType == database.RoomTypeDM { if msg.Portal.RoomType == database.RoomTypeDM {
switch msg.Type { switch msg.Type {
case bridgev2.Invite: case bridgev2.Invite:
return nil, fmt.Errorf("cannot invite additional user to dm") return false, fmt.Errorf("cannot invite additional user to dm")
default: default:
return nil, nil return false, nil
} }
} }
@ -419,7 +413,7 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg
case bridgev2.Leave, bridgev2.Kick: case bridgev2.Leave, bridgev2.Kick:
action = whatsmeow.ParticipantChangeRemove action = whatsmeow.ParticipantChangeRemove
default: default:
return nil, nil return false, nil
} }
switch target := msg.Target.(type) { switch target := msg.Target.(type) {
@ -428,26 +422,19 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg
case *bridgev2.UserLogin: case *bridgev2.UserLogin:
ghost, err := target.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID)) ghost, err := target.Bridge.GetGhostByID(ctx, networkid.UserID(target.ID))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ghost for user: %w", err) return false, fmt.Errorf("failed to get ghost for user: %w", err)
} }
changes[0] = waid.ParseUserID(ghost.ID) changes[0] = waid.ParseUserID(ghost.ID)
default: default:
return nil, fmt.Errorf("cannot get target intent: unknown type: %T", target) return false, fmt.Errorf("cannot get target intent: unknown type: %T", target)
} }
resp, err := wa.Client.UpdateGroupParticipants(ctx, portalJID, changes, action) _, err = wa.Client.UpdateGroupParticipants(portalJID, changes, action)
if err != nil { if err != nil {
return nil, err return false, err
} else if len(resp) == 0 {
return nil, fmt.Errorf("no response for participant change")
} else if resp[0].Error != 0 {
return nil, fmt.Errorf("failed to change participant: code %d", resp[0].Error)
} }
zerolog.Ctx(ctx).Debug().
Any("change_response", resp).
Msg("Handled membership change")
return &bridgev2.MatrixMembershipResult{RedirectTo: waid.MakeUserID(resp[0].JID)}, nil return true, nil
} }
func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) { func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
@ -460,7 +447,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev
return false, fmt.Errorf("cannot set room name for DM") return false, fmt.Errorf("cannot set room name for DM")
} }
err = wa.Client.SetGroupName(ctx, portalJID, msg.Content.Name) err = wa.Client.SetGroupName(portalJID, msg.Content.Name)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -483,7 +470,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridge
newID := wa.Client.GenerateMessageID() newID := wa.Client.GenerateMessageID()
oldID := msg.Portal.Metadata.(*waid.PortalMetadata).TopicID oldID := msg.Portal.Metadata.(*waid.PortalMetadata).TopicID
err = wa.Client.SetGroupTopic(ctx, portalJID, oldID, newID, msg.Content.Topic) err = wa.Client.SetGroupTopic(portalJID, oldID, newID, msg.Content.Topic)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -506,8 +493,8 @@ func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridg
} }
var data []byte var data []byte
if msg.Content.URL != "" { if msg.Content.URL != "" || msg.Content.MSC3414File != nil {
data, err = msg.Portal.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil) data, err = msg.Portal.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, msg.Content.MSC3414File)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to download avatar: %w", err) return false, fmt.Errorf("failed to download avatar: %w", err)
} }
@ -518,7 +505,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridg
} }
} }
avatarID, err := wa.Client.SetGroupPhoto(ctx, portalJID, data) avatarID, err := wa.Client.SetGroupPhoto(portalJID, data)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -608,10 +595,14 @@ func (wa *WhatsAppClient) HandleRoomTag(ctx context.Context, msg *bridgev2.Matri
return wa.Client.SendAppState(ctx, appstate.BuildPin(chatJID, isFavorite)) return wa.Client.SendAppState(ctx, appstate.BuildPin(chatJID, isFavorite))
} }
func (wa *WhatsAppClient) getLastMessageInfo(ctx context.Context, chatJID types.JID, portalKey networkid.PortalKey) (time.Time, *waCommon.MessageKey, error) { func (wa *WhatsAppClient) HandleMarkedUnread(ctx context.Context, msg *bridgev2.MatrixMarkedUnread) error {
msgs, err := wa.Main.Bridge.DB.Message.GetLastNInPortal(ctx, portalKey, 1) chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return time.Time{}, nil, fmt.Errorf("failed to get last message in portal: %w", err) return err
}
msgs, err := wa.Main.Bridge.DB.Message.GetLastNInPortal(ctx, msg.Portal.PortalKey, 1)
if err != nil {
return fmt.Errorf("failed to get last message in portal: %w", err)
} }
var lastTS time.Time var lastTS time.Time
var lastKey *waCommon.MessageKey var lastKey *waCommon.MessageKey
@ -632,41 +623,5 @@ func (wa *WhatsAppClient) getLastMessageInfo(ctx context.Context, chatJID types.
} }
} }
} }
return lastTS, lastKey, nil
}
func (wa *WhatsAppClient) HandleMarkedUnread(ctx context.Context, msg *bridgev2.MatrixMarkedUnread) error {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
lastTS, lastKey, err := wa.getLastMessageInfo(ctx, chatJID, msg.Portal.PortalKey)
if err != nil {
return err
}
return wa.Client.SendAppState(ctx, appstate.BuildMarkChatAsRead(chatJID, msg.Content.Unread, lastTS, lastKey)) return wa.Client.SendAppState(ctx, appstate.BuildMarkChatAsRead(chatJID, msg.Content.Unread, lastTS, lastKey))
} }
func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
if chatJID.Server == types.GroupServer {
memberInfo, err := wa.Main.Bridge.Matrix.GetMemberInfo(ctx, msg.Portal.MXID, wa.UserLogin.UserMXID)
if err != nil {
return fmt.Errorf("failed to get own member info: %w", err)
} else if memberInfo.Membership == event.MembershipJoin {
err = wa.Client.LeaveGroup(ctx, chatJID)
if err != nil {
// TODO ignore errors saying you already left the group?
return fmt.Errorf("failed to leave group before deleting chat: %w", err)
}
}
}
lastTS, lastKey, err := wa.getLastMessageInfo(ctx, chatJID, msg.Portal.PortalKey)
if err != nil {
return err
}
return wa.Client.SendAppState(ctx, appstate.BuildDeleteChat(chatJID, lastTS, lastKey, true))
}

View file

@ -25,10 +25,8 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
@ -100,9 +98,9 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
case *events.MarkChatAsRead: case *events.MarkChatAsRead:
success = wa.handleWAMarkChatAsRead(ctx, evt) success = wa.handleWAMarkChatAsRead(ctx, evt)
case *events.DeleteForMe: case *events.DeleteForMe:
success = wa.handleWADeleteForMe(ctx, evt) success = wa.handleWADeleteForMe(evt)
case *events.DeleteChat: case *events.DeleteChat:
success = wa.handleWADeleteChat(ctx, evt) success = wa.handleWADeleteChat(evt)
case *events.Mute: case *events.Mute:
success = wa.handleWAMute(evt) success = wa.handleWAMute(evt)
case *events.Archive: case *events.Archive:
@ -111,7 +109,9 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
success = wa.handleWAPin(evt) success = wa.handleWAPin(evt)
case *events.HistorySync: case *events.HistorySync:
wa.UserLogin.Log.Warn().Msg("Unexpected history sync event received") if wa.Main.Bridge.Config.Backfill.Enabled {
wa.historySyncs <- evt.Data
}
case *events.MediaRetry: case *events.MediaRetry:
wa.phoneSeen(evt.Timestamp) wa.phoneSeen(evt.Timestamp)
success = wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{MediaRetry: evt, wa: wa}).Success success = wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{MediaRetry: evt, wa: wa}).Success
@ -128,15 +128,21 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
success = wa.handleWAPictureUpdate(ctx, evt) success = wa.handleWAPictureUpdate(ctx, evt)
case *events.AppStateSyncComplete: case *events.AppStateSyncComplete:
wa.handleWAAppStateSyncComplete(ctx, evt) if len(wa.GetStore().PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
case *events.AppStateSyncError: err := wa.updatePresence(types.PresenceUnavailable)
wa.handleWAAppStateSyncError(ctx, evt) if err != nil {
log.Warn().Err(err).Msg("Failed to send presence after app state sync")
}
go wa.syncRemoteProfile(log.WithContext(context.Background()), nil)
} else if evt.Name == appstate.WAPatchCriticalUnblockLow {
go wa.resyncContacts(false, true)
}
case *events.AppState: case *events.AppState:
// Intentionally ignored // Intentionally ignored
case *events.PushNameSetting: case *events.PushNameSetting:
// Send presence available when connecting and when the pushname is changed. // Send presence available when connecting and when the pushname is changed.
// This makes sure that outgoing messages always have the right pushname. // This makes sure that outgoing messages always have the right pushname.
err := wa.updatePresence(ctx, types.PresenceUnavailable) err := wa.updatePresence(types.PresenceUnavailable)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to send presence after push name update") log.Warn().Err(err).Msg("Failed to send presence after push name update")
} }
@ -144,10 +150,6 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
if err != nil { if err != nil {
log.Err(err).Msg("Failed to update push name in store") log.Err(err).Msg("Failed to update push name in store")
} }
_, _, err = wa.GetStore().Contacts.PutPushName(ctx, wa.GetStore().GetLID().ToNonAD(), evt.Action.GetName())
if err != nil {
log.Err(err).Msg("Failed to update push name in store")
}
go wa.syncGhost(wa.JID.ToNonAD(), "push name setting", nil) go wa.syncGhost(wa.JID.ToNonAD(), "push name setting", nil)
case *events.Contact: case *events.Contact:
go wa.syncGhost(evt.JID, "contact event", nil) go wa.syncGhost(evt.JID, "contact event", nil)
@ -161,14 +163,13 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
if len(wa.GetStore().PushName) > 0 { if len(wa.GetStore().PushName) > 0 {
go func() { go func() {
err := wa.updatePresence(ctx, types.PresenceUnavailable) err := wa.updatePresence(types.PresenceUnavailable)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to send initial presence after connecting") log.Warn().Err(err).Msg("Failed to send initial presence after connecting")
} }
}() }()
go wa.syncRemoteProfile(ctx, nil) go wa.syncRemoteProfile(log.WithContext(context.Background()), nil)
} }
wa.MC.OnConnect(store.GetWAVersion()[2], wa.Device.Platform)
case *events.OfflineSyncPreview: case *events.OfflineSyncPreview:
log.Info(). log.Info().
Int("message_count", evt.Messages). Int("message_count", evt.Messages).
@ -246,20 +247,12 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
return return
} }
func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, evtType string, info *types.MessageSource, msgID any) { func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, info *types.MessageInfo) {
if (info.Chat.Server == types.HiddenUserServer || info.Chat.Server == types.BroadcastServer) &&
info.Sender.Server == types.HiddenUserServer && info.SenderAlt.IsEmpty() {
info.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Sender)
}
if info.Chat.Server == types.HiddenUserServer && info.IsFromMe && info.RecipientAlt.IsEmpty() {
info.RecipientAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Chat)
}
if info.Chat.Server == types.HiddenUserServer && info.Sender.ToNonAD() == info.Chat && info.SenderAlt.Server == types.DefaultUserServer { if info.Chat.Server == types.HiddenUserServer && info.Sender.ToNonAD() == info.Chat && info.SenderAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Stringer("lid", info.Sender). Stringer("lid", info.Sender).
Stringer("pn", info.SenderAlt). Stringer("pn", info.SenderAlt).
Any("message_id", msgID). Str("message_id", info.ID).
Str("evt_type", evtType).
Msg("Forced LID DM sender to phone number in incoming message") Msg("Forced LID DM sender to phone number in incoming message")
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
info.Chat = info.Sender.ToNonAD() info.Chat = info.Sender.ToNonAD()
@ -267,40 +260,21 @@ func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, evtType string,
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Stringer("lid", info.Chat). Stringer("lid", info.Chat).
Stringer("pn", info.RecipientAlt). Stringer("pn", info.RecipientAlt).
Any("message_id", msgID). Str("message_id", info.ID).
Str("evt_type", evtType).
Msg("Forced LID DM sender to phone number in own message sent from another device") Msg("Forced LID DM sender to phone number in own message sent from another device")
info.Chat = info.RecipientAlt.ToNonAD() info.Chat = info.RecipientAlt.ToNonAD()
if info.Sender.Server == types.HiddenUserServer {
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
if info.Sender.IsEmpty() {
info.Sender = wa.GetStore().GetJID()
info.Sender.Device = info.SenderAlt.Device
}
}
} else if info.Chat.Server == types.BroadcastServer && info.Sender.Server == types.HiddenUserServer && info.SenderAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Sender).
Stringer("pn", info.SenderAlt).
Stringer("chat", info.Chat).
Any("message_id", msgID).
Str("evt_type", evtType).
Msg("Forced LID broadcast list sender to phone number in incoming message")
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
} else if info.Sender.Server == types.BotServer && info.Chat.Server == types.HiddenUserServer { } else if info.Sender.Server == types.BotServer && info.Chat.Server == types.HiddenUserServer {
chatPN, err := wa.GetStore().LIDs.GetPNForLID(ctx, info.Chat) chatPN, err := wa.Device.LIDs.GetPNForLID(ctx, info.Chat)
if err != nil { if err != nil {
wa.UserLogin.Log.Err(err). wa.UserLogin.Log.Err(err).
Any("message_id", msgID). Str("message_id", info.ID).
Stringer("lid", info.Chat). Stringer("lid", info.Chat).
Str("evt_type", evtType).
Msg("Failed to get phone number of DM for incoming bot message") Msg("Failed to get phone number of DM for incoming bot message")
} else if !chatPN.IsEmpty() { } else if !chatPN.IsEmpty() {
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Stringer("lid", info.Chat). Stringer("lid", info.Chat).
Stringer("pn", chatPN). Stringer("pn", chatPN).
Any("message_id", msgID). Str("message_id", info.ID).
Str("evt_type", evtType).
Msg("Forced LID chat to phone number in bot message") Msg("Forced LID chat to phone number in bot message")
info.Chat = chatPN info.Chat = chatPN
} }
@ -309,10 +283,45 @@ func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, evtType string,
func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Message) (success bool) { func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Message) (success bool) {
success = true success = true
wa.rerouteWAMessage(ctx, &evt.Info)
wa.UserLogin.Log.Trace().
Any("info", evt.Info).
Any("payload", evt.Message).
Msg("Received WhatsApp message")
if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast {
return return
} }
if evt.Info.IsFromMe &&
evt.Message.GetProtocolMessage().GetHistorySyncNotification() != nil &&
wa.Main.Bridge.Config.Backfill.Enabled &&
wa.Client.ManualHistorySyncDownload {
wa.saveWAHistorySyncNotification(ctx, evt.Message.ProtocolMessage.HistorySyncNotification)
}
messageAssoc := evt.Message.GetMessageContextInfo().GetMessageAssociation()
if assocType := messageAssoc.GetAssociationType(); assocType == waE2E.MessageAssociation_HD_IMAGE_DUAL_UPLOAD || assocType == waE2E.MessageAssociation_HD_VIDEO_DUAL_UPLOAD {
parentKey := messageAssoc.GetParentMessageKey()
associatedMessage := evt.Message.GetAssociatedChildMessage().GetMessage()
wa.UserLogin.Log.Debug().
Str("message_id", evt.Info.ID).
Str("parent_id", parentKey.GetID()).
Stringer("assoc_type", assocType).
Msg("Received HD replacement message, converting to edit")
protocolMsg := &waE2E.ProtocolMessage{
Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(),
Key: parentKey,
EditedMessage: associatedMessage,
}
evt.Message = &waE2E.Message{
ProtocolMessage: protocolMsg,
}
}
parsedMessageType := getMessageType(evt.Message) parsedMessageType := getMessageType(evt.Message)
if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") {
return
}
if encReact := evt.Message.GetEncReactionMessage(); encReact != nil { if encReact := evt.Message.GetEncReactionMessage(); encReact != nil {
decrypted, err := wa.Client.DecryptReaction(ctx, evt) decrypted, err := wa.Client.DecryptReaction(ctx, evt)
if err != nil { if err != nil {
@ -334,58 +343,13 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
if encMessage := evt.Message.GetSecretEncryptedMessage(); encMessage != nil { if encMessage := evt.Message.GetSecretEncryptedMessage(); encMessage != nil {
decrypted, err := wa.Client.DecryptSecretEncryptedMessage(ctx, evt) decrypted, err := wa.Client.DecryptSecretEncryptedMessage(ctx, evt)
if err != nil { if err != nil {
wa.UserLogin.Log.Err(err). wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt message")
Str("message_id", evt.Info.ID).
Stringer("evt_sender", evt.Info.Sender).
Any("target_message_key", encMessage.TargetMessageKey).
Msg("Failed to decrypt secret-encrypted message")
return return
} }
evt.RawMessage = decrypted evt.RawMessage = decrypted
evt.UnwrapRaw() evt.UnwrapRaw()
parsedMessageType = getMessageType(evt.Message) parsedMessageType = getMessageType(evt.Message)
} }
wa.rerouteWAMessage(ctx, "message", &evt.Info.MessageSource, evt.Info.ID)
wa.UserLogin.Log.Trace().
Any("info", evt.Info).
Any("payload", evt.Message).
Msg("Received WhatsApp message")
if evt.Info.IsFromMe &&
evt.Message.GetProtocolMessage().GetHistorySyncNotification() != nil &&
wa.Main.Bridge.Config.Backfill.Enabled {
wa.saveWAHistorySyncNotification(ctx, evt.Message.ProtocolMessage.HistorySyncNotification)
}
if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") {
return
}
messageAssoc := evt.Message.GetMessageContextInfo().GetMessageAssociation()
if assocType := messageAssoc.GetAssociationType(); assocType == waE2E.MessageAssociation_HD_IMAGE_DUAL_UPLOAD || assocType == waE2E.MessageAssociation_HD_VIDEO_DUAL_UPLOAD {
parentKey := messageAssoc.GetParentMessageKey()
associatedMessage := evt.Message.GetAssociatedChildMessage().GetMessage()
wa.UserLogin.Log.Debug().
Str("message_id", evt.Info.ID).
Str("parent_id", parentKey.GetID()).
Stringer("assoc_type", assocType).
Msg("Received HD replacement message, converting to edit")
protocolMsg := &waE2E.ProtocolMessage{
Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(),
Key: parentKey,
EditedMessage: associatedMessage,
}
evt.Message = &waE2E.Message{
ProtocolMessage: protocolMsg,
}
} else if assocType == waE2E.MessageAssociation_MOTION_PHOTO {
//evt.Message = evt.Message.GetAssociatedChildMessage().GetMessage()
wa.UserLogin.Log.Debug().
Str("message_id", evt.Info.ID).
Str("parent_id", messageAssoc.GetParentMessageKey().GetID()).
Msg("Ignoring motion photo update")
return
}
res := wa.UserLogin.QueueRemoteEvent(&WAMessageEvent{ res := wa.UserLogin.QueueRemoteEvent(&WAMessageEvent{
MessageInfoWrapper: &MessageInfoWrapper{ MessageInfoWrapper: &MessageInfoWrapper{
Info: evt.Info, Info: evt.Info,
@ -400,7 +364,7 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
} }
func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool { func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool {
wa.rerouteWAMessage(ctx, "undecryptable message", &evt.Info.MessageSource, evt.Info.ID) wa.rerouteWAMessage(ctx, &evt.Info)
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Any("info", evt.Info). Any("info", evt.Info).
Bool("unavailable", evt.IsUnavailable). Bool("unavailable", evt.IsUnavailable).
@ -424,8 +388,22 @@ func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt
} }
func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) { func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) {
origChat := evt.Chat if evt.Chat.Server == types.HiddenUserServer && evt.Sender.ToNonAD() == evt.Chat && evt.SenderAlt.Server == types.DefaultUserServer {
wa.rerouteWAMessage(ctx, "receipt", &evt.MessageSource, evt.MessageIDs) wa.UserLogin.Log.Debug().
Stringer("lid", evt.Sender).
Stringer("pn", evt.SenderAlt).
Strs("message_id", evt.MessageIDs).
Msg("Forced LID DM sender to phone number in incoming receipt")
evt.Sender, evt.SenderAlt = evt.SenderAlt, evt.Sender
evt.Chat = evt.Sender.ToNonAD()
} else if evt.Chat.Server == types.HiddenUserServer && evt.IsFromMe && evt.RecipientAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug().
Stringer("lid", evt.Chat).
Stringer("pn", evt.RecipientAlt).
Strs("message_id", evt.MessageIDs).
Msg("Forced LID DM sender to phone number in own receipt sent from another device")
evt.Chat = evt.RecipientAlt.ToNonAD()
}
if evt.IsFromMe && evt.Sender.Device == 0 { if evt.IsFromMe && evt.Sender.Device == 0 {
wa.phoneSeen(evt.Timestamp) wa.phoneSeen(evt.Timestamp)
} }
@ -444,12 +422,8 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei
messageSender := wa.JID messageSender := wa.JID
if !evt.MessageSender.IsEmpty() { if !evt.MessageSender.IsEmpty() {
messageSender = evt.MessageSender messageSender = evt.MessageSender
// Second part of rerouting receipts in LID chats
if messageSender == origChat && evt.Chat != origChat {
messageSender = evt.Chat
}
} else if evt.Chat.Server == types.GroupServer && evt.Sender.Server == types.HiddenUserServer { } else if evt.Chat.Server == types.GroupServer && evt.Sender.Server == types.HiddenUserServer {
lid := wa.GetStore().GetLID() lid := wa.Device.GetLID()
if !lid.IsEmpty() { if !lid.IsEmpty() {
messageSender = lid messageSender = lid
} }
@ -470,15 +444,6 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei
} }
func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, evt *events.ChatPresence) { func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, evt *events.ChatPresence) {
if evt.Chat.Server == types.HiddenUserServer && evt.Sender.ToNonAD() == evt.Chat {
if evt.SenderAlt.IsEmpty() {
evt.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, evt.Sender)
}
if evt.SenderAlt.Server == types.DefaultUserServer {
evt.Sender, evt.SenderAlt = evt.SenderAlt, evt.Sender
evt.Chat = evt.Sender.ToNonAD()
}
}
typingType := bridgev2.TypingTypeText typingType := bridgev2.TypingTypeText
timeout := 15 * time.Second timeout := 15 * time.Second
if evt.Media == types.ChatPresenceMediaAudio { if evt.Media == types.ChatPresenceMediaAudio {
@ -508,7 +473,7 @@ func (wa *WhatsAppClient) handleWALogout(reason events.ConnectFailureReason, onC
} else if reason == events.ConnectFailureMainDeviceGone { } else if reason == events.ConnectFailureMainDeviceGone {
errorCode = WAMainDeviceGone errorCode = WAMainDeviceGone
} }
wa.Disconnect() wa.Client.Disconnect()
wa.Client = nil wa.Client = nil
wa.JID = types.EmptyJID wa.JID = types.EmptyJID
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0 wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0
@ -544,7 +509,6 @@ func (wa *WhatsAppClient) handleWACallStart(ctx context.Context, group, sender,
Sender: wa.makeEventSender(ctx, sender), Sender: wa.makeEventSender(ctx, sender),
CreatePortal: true, CreatePortal: true,
Timestamp: ts, Timestamp: ts,
StreamOrder: ts.Unix(),
}, },
Data: callType, Data: callType,
ID: waid.MakeFakeMessageID(chat, sender, "call-"+id), ID: waid.MakeFakeMessageID(chat, sender, "call-"+id),
@ -563,10 +527,6 @@ func convertCallStart(ctx context.Context, portal *bridgev2.Portal, intent bridg
Content: &event.MessageEventContent{ Content: &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: text, Body: text,
BeeperActionMessage: &event.BeeperActionMessage{
Type: event.BeeperActionMessageCall,
CallType: event.BeeperActionMessageCallType(callType),
},
}, },
}}, }},
}, nil }, nil
@ -611,38 +571,34 @@ func convertIdentityChange(ctx context.Context, portal *bridgev2.Portal, intent
}, nil }, nil
} }
func (wa *WhatsAppClient) handleWADeleteChat(ctx context.Context, evt *events.DeleteChat) bool { func (wa *WhatsAppClient) handleWADeleteChat(evt *events.DeleteChat) bool {
chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID)
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatDelete, Type: bridgev2.RemoteEventChatDelete,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(evt.JID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
OnlyForMe: true, OnlyForMe: true,
Children: true,
}).Success }).Success
} }
func (wa *WhatsAppClient) handleWADeleteForMe(ctx context.Context, evt *events.DeleteForMe) bool { func (wa *WhatsAppClient) handleWADeleteForMe(evt *events.DeleteForMe) bool {
chatJID := wa.maybeConvertJIDToLID(ctx, evt.ChatJID)
return wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{ return wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessageRemove, Type: bridgev2.RemoteEventMessageRemove,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(evt.ChatJID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
TargetMessage: waid.MakeMessageID(chatJID, evt.SenderJID, evt.MessageID), TargetMessage: waid.MakeMessageID(evt.ChatJID, evt.SenderJID, evt.MessageID),
OnlyForMe: true, OnlyForMe: true,
}).Success }).Success
} }
func (wa *WhatsAppClient) handleWAMarkChatAsRead(ctx context.Context, evt *events.MarkChatAsRead) bool { func (wa *WhatsAppClient) handleWAMarkChatAsRead(ctx context.Context, evt *events.MarkChatAsRead) bool {
chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID)
return wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ return wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventReadReceipt, Type: bridgev2.RemoteEventReadReceipt,
PortalKey: wa.makeWAPortalKey(chatJID), PortalKey: wa.makeWAPortalKey(evt.JID),
Sender: wa.makeEventSender(ctx, wa.JID), Sender: wa.makeEventSender(ctx, wa.JID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
@ -788,7 +744,7 @@ func (wa *WhatsAppClient) handleWAMute(evt *events.Mute) bool {
var mutedUntil time.Time var mutedUntil time.Time
if evt.Action.GetMuted() { if evt.Action.GetMuted() {
mutedUntil = event.MutedForever mutedUntil = event.MutedForever
if evt.Action.GetMuteEndTimestamp() > 0 { if evt.Action.GetMuteEndTimestamp() != 0 {
mutedUntil = time.Unix(evt.Action.GetMuteEndTimestamp(), 0) mutedUntil = time.Unix(evt.Action.GetMuteEndTimestamp(), 0)
} }
} else { } else {
@ -818,99 +774,3 @@ func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) bool {
Tag: &tag, Tag: &tag,
}) })
} }
func (wa *WhatsAppClient) handleWAAppStateSyncComplete(ctx context.Context, evt *events.AppStateSyncComplete) {
log := zerolog.Ctx(ctx).With().
Str("patch_name", string(evt.Name)).
Uint64("patch_version", evt.Version).
Logger()
if len(wa.GetStore().PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
err := wa.updatePresence(ctx, types.PresenceUnavailable)
if err != nil {
log.Warn().Err(err).Msg("Failed to send presence after app state sync")
}
go wa.syncRemoteProfile(log.WithContext(context.Background()), nil)
} else if evt.Name == appstate.WAPatchCriticalUnblockLow {
go wa.resyncContacts(false, true)
}
wa.appStateRecoveryLock.Lock()
defer wa.appStateRecoveryLock.Unlock()
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
if ts, exists := meta.AppStateRecoveryAttempted[evt.Name]; exists {
delete(wa.appStateFullSyncAttempted, evt.Name)
delete(meta.AppStateRecoveryAttempted, evt.Name)
err := wa.UserLogin.Save(ctx)
if err != nil {
log.Err(err).Msg("Failed to save login metadata after unmarking app state recovery as attempted")
} else {
log.Info().
Time("recovery_ts", ts).
Msg("Unmarked app state recovery as attempted after successful full sync")
}
} else if ts, exists = wa.appStateFullSyncAttempted[evt.Name]; exists {
delete(wa.appStateFullSyncAttempted, evt.Name)
log.Debug().Time("full_sync_ts", ts).Msg("Unmarked app state full sync attempted after successful sync")
}
}
func (wa *WhatsAppClient) handleWAAppStateSyncError(ctx context.Context, evt *events.AppStateSyncError) {
log := zerolog.Ctx(ctx).With().
Str("patch_name", string(evt.Name)).
Logger()
wa.appStateRecoveryLock.Lock()
defer wa.appStateRecoveryLock.Unlock()
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
lastRecovery := meta.AppStateRecoveryAttempted[evt.Name]
lastFullSync := wa.appStateFullSyncAttempted[evt.Name]
if !lastRecovery.IsZero() && time.Since(lastRecovery) < 48*time.Hour {
log.Debug().Err(evt.Error).
Time("last_recovery_attempt", lastRecovery).
Time("last_full_sync_attempt", lastFullSync).
Msg("App state sync failed, but recovery already attempted")
return
}
if !evt.FullSync {
if !lastFullSync.IsZero() {
log.Debug().
Err(evt.Error).
Time("last_full_sync_attempt", lastFullSync).
Msg("App state sync failed, but full sync already attempted")
return
}
wa.appStateFullSyncAttempted[evt.Name] = time.Now()
log.Info().
Err(evt.Error).
Msg("Trying full sync for app state after partial sync error")
go func() {
err := wa.Client.FetchAppState(ctx, evt.Name, true, false)
if err != nil {
log.Err(err).Msg("Full app state sync failed")
} else {
log.Debug().Msg("Full app state sync succeeded")
}
}()
return
}
log.Info().
Err(evt.Error).
Msg("Trying recovery for app state after full sync error")
if meta.AppStateRecoveryAttempted == nil {
meta.AppStateRecoveryAttempted = make(map[appstate.WAPatchName]time.Time)
}
meta.AppStateRecoveryAttempted[evt.Name] = time.Now()
err := wa.UserLogin.Save(ctx)
if err != nil {
log.Err(err).Msg("Failed to save login metadata after marking app state recovery as attempted")
}
go func() {
resp, err := wa.Client.SendPeerMessage(ctx, whatsmeow.BuildAppStateRecoveryRequest(evt.Name))
if err != nil {
log.Err(err).Msg("Failed to send app state recovery request")
} else {
log.Debug().
Str("message_id", resp.ID).
Time("message_ts", resp.Timestamp).
Msg("Sent app state recovery request")
}
}()
}

View file

@ -39,7 +39,7 @@ func (wa *WhatsAppClient) makeEventSender(ctx context.Context, id types.JID) bri
} else if id.Server == types.DefaultUserServer { } else if id.Server == types.DefaultUserServer {
senderLoginJID = id senderLoginJID = id
} else if id.Server == types.HiddenUserServer { } else if id.Server == types.HiddenUserServer {
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, id) pn, err := wa.Device.LIDs.GetPNForLID(ctx, id)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err). zerolog.Ctx(ctx).Err(err).
Stringer("lid", id). Stringer("lid", id).
@ -68,16 +68,3 @@ func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.Mes
} }
return key return key
} }
func (wa *WhatsAppClient) maybeConvertJIDToLID(ctx context.Context, chatJID types.JID) types.JID {
if chatJID.Server == types.HiddenUserServer {
if pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, chatJID); err != nil {
wa.UserLogin.Log.Err(err).
Stringer("lid", chatJID).
Msg("Failed to get phone number for LID chat")
} else if !pn.IsEmpty() {
return pn.ToNonAD()
}
}
return chatJID
}

View file

@ -74,11 +74,6 @@ var (
Err: "Phone number must be in international format", Err: "Phone number must be in international format",
StatusCode: http.StatusBadRequest, StatusCode: http.StatusBadRequest,
} }
ErrRateLimitedByWhatsApp = bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.RATE_LIMITED",
Err: "Rate limited by WhatsApp",
StatusCode: http.StatusTooManyRequests,
}
) )
func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
@ -133,6 +128,11 @@ func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
wl.Client.EnableAutoReconnect = false wl.Client.EnableAutoReconnect = false
wl.Client.DisableLoginAutoReconnect = true wl.Client.DisableLoginAutoReconnect = true
wl.EventHandlerID = wl.Client.AddEventHandler(wl.handleEvent) wl.EventHandlerID = wl.Client.AddEventHandler(wl.handleEvent)
if wl.Main.ExternalEventHandler != nil {
wl.Client.AddEventHandler(func(evt any) {
wl.Main.ExternalEventHandler(wl.Client, evt)
})
}
if err := wl.Main.updateProxy(ctx, wl.Client, true); err != nil { if err := wl.Main.updateProxy(ctx, wl.Client, true); err != nil {
return nil, err return nil, err
} }
@ -201,8 +201,6 @@ func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string)
return nil, ErrPhoneNumberTooShort return nil, ErrPhoneNumberTooShort
} else if errors.Is(err, whatsmeow.ErrPhoneNumberIsNotInternational) { } else if errors.Is(err, whatsmeow.ErrPhoneNumberIsNotInternational) {
return nil, ErrPhoneNumberIsNotInternational return nil, ErrPhoneNumberIsNotInternational
} else if errors.Is(err, whatsmeow.ErrIQRateOverLimit) {
return nil, ErrRateLimitedByWhatsApp
} }
return nil, err return nil, err
} }
@ -359,7 +357,7 @@ func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
} }
ul.Client.(*WhatsAppClient).isNewLogin = true ul.Client.(*WhatsAppClient).isNewLogin = true
ul.Client.Connect(ul.Log.WithContext(wl.Main.Bridge.BackgroundCtx)) ul.Client.Connect(ul.Log.WithContext(context.Background()))
return &bridgev2.LoginStep{ return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete, Type: bridgev2.LoginStepTypeComplete,

View file

@ -1,81 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2026 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 connector
import (
"context"
"encoding/json"
"time"
"go.mau.fi/whatsmeow"
waBinary "go.mau.fi/whatsmeow/binary"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
type NewMCFunc = func(json.RawMessage, mWAClient) mClient
var NewMC NewMCFunc
func (wa *WhatsAppClient) initMC() {
if NewMC != nil {
wa.MC = NewMC(wa.UserLogin.Metadata.(*waid.UserLoginMetadata).MData, wa)
}
}
type mClient = interface {
OnConnect(version uint32, platform string)
}
type noopMC struct{}
var noopMCInstance mClient = &noopMC{}
func (n *noopMC) OnConnect(version uint32, platform string) {}
type mWAClient = interface {
MSend(data []byte)
MSave(data json.RawMessage)
}
var _ mWAClient = (*WhatsAppClient)(nil)
// Deprecated: ignore DangerousInternal error
func (wa *WhatsAppClient) MSend(bytes []byte) {
_, err := wa.Client.DangerousInternals().SendIQAsync(wa.Main.Bridge.BackgroundCtx, whatsmeow.DangerousInfoQuery{
Namespace: "w:stats",
Type: "set",
To: types.ServerJID,
Content: []waBinary.Node{{
Tag: "add",
Attrs: waBinary.Attrs{"t": time.Now().Unix()},
Content: bytes,
}},
})
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to send stats")
}
}
func (wa *WhatsAppClient) MSave(s json.RawMessage) {
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).MData = s
err := wa.UserLogin.Save(context.Background())
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to save MC data")
}
}

View file

@ -137,7 +137,7 @@ func (wa *WhatsAppClient) sendMediaRequest(ctx context.Context, req *wadb.MediaR
req.Status = wadb.MediaBackfillRequestStatusRequestSkipped req.Status = wadb.MediaBackfillRequestStatusRequestSkipped
return return
} }
err = wa.sendMediaRequestDirect(ctx, req.MessageID, req.MediaKey) err = wa.sendMediaRequestDirect(req.MessageID, req.MediaKey)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to send media retry request") log.Err(err).Msg("Failed to send media retry request")
req.Status = wadb.MediaBackfillRequestStatusRequestFailed req.Status = wadb.MediaBackfillRequestStatusRequestFailed
@ -148,12 +148,12 @@ func (wa *WhatsAppClient) sendMediaRequest(ctx context.Context, req *wadb.MediaR
} }
} }
func (wa *WhatsAppClient) sendMediaRequestDirect(ctx context.Context, rawMsgID networkid.MessageID, key []byte) error { func (wa *WhatsAppClient) sendMediaRequestDirect(rawMsgID networkid.MessageID, key []byte) error {
msgID, err := waid.ParseMessageID(rawMsgID) msgID, err := waid.ParseMessageID(rawMsgID)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse message ID: %w", err) return fmt.Errorf("failed to parse message ID: %w", err)
} }
return wa.Client.SendMediaRetryReceipt(ctx, &types.MessageInfo{ return wa.Client.SendMediaRetryReceipt(&types.MessageInfo{
ID: msgID.ID, ID: msgID.ID,
MessageSource: types.MessageSource{ MessageSource: types.MessageSource{
IsFromMe: msgID.Sender.User == wa.JID.User, IsFromMe: msgID.Sender.User == wa.JID.User,

View file

@ -58,12 +58,7 @@ func (wa *WhatsAppConnector) updateProxy(ctx context.Context, client *whatsmeow.
} }
if proxy, err := wa.getProxy(reason); err != nil { if proxy, err := wa.getProxy(reason); err != nil {
return fmt.Errorf("failed to get proxy address: %w", err) return fmt.Errorf("failed to get proxy address: %w", err)
} else if proxy == "" { } else if err = client.SetProxyAddress(proxy); err != nil {
return nil
} else if err = client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{
OnlyLogin: wa.Config.ProxyOnlyLogin,
NoMedia: wa.Config.ProxyOnlyLogin,
}); err != nil {
return fmt.Errorf("failed to set proxy address: %w", err) return fmt.Errorf("failed to set proxy address: %w", err)
} }
zerolog.Ctx(ctx).Debug().Msg("Enabled proxy") zerolog.Ctx(ctx).Debug().Msg("Enabled proxy")

View file

@ -35,7 +35,6 @@ import (
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -62,7 +61,7 @@ func looksEmaily(str string) bool {
return false return false
} }
func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string) (types.JID, error) { func (wa *WhatsAppClient) validateIdentifer(number string) (types.JID, error) {
if strings.HasSuffix(number, "@"+types.BotServer) || strings.HasSuffix(number, "@"+types.HiddenUserServer) { if strings.HasSuffix(number, "@"+types.BotServer) || strings.HasSuffix(number, "@"+types.HiddenUserServer) {
return types.ParseJID(number) return types.ParseJID(number)
} else if strings.HasPrefix(number, waid.BotPrefix) || strings.HasPrefix(number, waid.LIDPrefix) { } else if strings.HasPrefix(number, waid.BotPrefix) || strings.HasPrefix(number, waid.LIDPrefix) {
@ -76,7 +75,7 @@ func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string)
return types.EmptyJID, ErrInputLooksLikeEmail return types.EmptyJID, ErrInputLooksLikeEmail
} else if wa.Client == nil || !wa.Client.IsLoggedIn() { } else if wa.Client == nil || !wa.Client.IsLoggedIn() {
return types.EmptyJID, bridgev2.ErrNotLoggedIn return types.EmptyJID, bridgev2.ErrNotLoggedIn
} else if resp, err := wa.Client.IsOnWhatsApp(ctx, []string{number}); err != nil { } else if resp, err := wa.Client.IsOnWhatsApp([]string{number}); err != nil {
return types.EmptyJID, fmt.Errorf("failed to check if number is on WhatsApp: %w", err) return types.EmptyJID, fmt.Errorf("failed to check if number is on WhatsApp: %w", err)
} else if len(resp) == 0 { } else if len(resp) == 0 {
return types.EmptyJID, fmt.Errorf("the server did not respond to the query") return types.EmptyJID, fmt.Errorf("the server did not respond to the query")
@ -110,7 +109,7 @@ func (wa *WhatsAppConnector) ValidateUserID(id networkid.UserID) bool {
func (wa *WhatsAppClient) startChatLIDToPN(ctx context.Context, jid types.JID) (types.JID, error) { func (wa *WhatsAppClient) startChatLIDToPN(ctx context.Context, jid types.JID) (types.JID, error) {
if jid.Server == types.HiddenUserServer { if jid.Server == types.HiddenUserServer {
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid) pn, err := wa.Device.LIDs.GetPNForLID(ctx, jid)
if err != nil { if err != nil {
return jid, fmt.Errorf("failed to get phone number for lid: %w", err) return jid, fmt.Errorf("failed to get phone number for lid: %w", err)
} else if pn.IsEmpty() { } else if pn.IsEmpty() {
@ -144,7 +143,7 @@ func (wa *WhatsAppClient) CreateChatWithGhost(ctx context.Context, ghost *bridge
} }
func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier string, startChat bool) (*bridgev2.ResolveIdentifierResponse, error) {
origJID, err := wa.validateIdentifer(ctx, identifier) origJID, err := wa.validateIdentifer(identifier)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -165,11 +164,11 @@ func (wa *WhatsAppClient) ResolveIdentifier(ctx context.Context, identifier stri
} }
func (wa *WhatsAppClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveIdentifierResponse, error) {
return wa.getContactList(ctx, "", true) return wa.getContactList(ctx, "")
} }
func (wa *WhatsAppClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) {
return wa.getContactList(ctx, strings.ToLower(query), false) return wa.getContactList(ctx, strings.ToLower(query))
} }
func matchesQuery(str string, query string) bool { func matchesQuery(str string, query string) bool {
@ -179,7 +178,7 @@ func matchesQuery(str string, query string) bool {
return strings.Contains(strings.ToLower(str), query) return strings.Contains(strings.ToLower(str), query)
} }
func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onlyContacts bool) ([]*bridgev2.ResolveIdentifierResponse, error) { func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string) ([]*bridgev2.ResolveIdentifierResponse, error) {
if !wa.IsLoggedIn() { if !wa.IsLoggedIn() {
return nil, mautrix.MForbidden.WithMessage("You must be logged in to list contacts") return nil, mautrix.MForbidden.WithMessage("You must be logged in to list contacts")
} }
@ -189,9 +188,6 @@ func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onl
} }
resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(contacts)) resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(contacts))
for jid, contactInfo := range contacts { for jid, contactInfo := range contacts {
if onlyContacts && (contactInfo.FirstName == "" && contactInfo.FullName == "") {
continue
}
if !matchesQuery(contactInfo.PushName, filter) && !matchesQuery(contactInfo.FullName, filter) && !matchesQuery(jid.User, filter) { if !matchesQuery(contactInfo.PushName, filter) && !matchesQuery(contactInfo.FullName, filter) && !matchesQuery(jid.User, filter) {
continue continue
} }
@ -217,13 +213,7 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou
CreateKey: createKey, CreateKey: createKey,
} }
for i, participant := range params.Participants { for i, participant := range params.Participants {
jid := waid.ParseUserID(participant) req.Participants[i] = waid.ParseUserID(participant)
// Normalize to PN if it's a LID
jid, err := wa.startChatLIDToPN(ctx, jid)
if err != nil {
return nil, fmt.Errorf("failed to normalize participant %s: %w", participant, err)
}
req.Participants[i] = jid
} }
if params.Parent != nil { if params.Parent != nil {
var err error var err error
@ -240,10 +230,10 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou
} }
var avatarBytes []byte var avatarBytes []byte
var avatarMXC id.ContentURIString var avatarMXC id.ContentURIString
if params.Avatar != nil && params.Avatar.URL != "" { if params.Avatar != nil {
avatarMXC = params.Avatar.URL avatarMXC = params.Avatar.URL
var err error var err error
avatarBytes, err = wa.Main.Bridge.Bot.DownloadMedia(ctx, params.Avatar.URL, nil) avatarBytes, err = wa.Main.Bridge.Bot.DownloadMedia(ctx, params.Avatar.URL, params.Avatar.MSC3414File)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to download avatar: %w", err) return nil, fmt.Errorf("failed to download avatar: %w", err)
} }
@ -256,39 +246,6 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create group: %w", err) return nil, fmt.Errorf("failed to create group: %w", err)
} }
failedParticipants := make(map[networkid.UserID]*bridgev2.CreateChatFailedParticipant)
filteredParticipants := resp.Participants[:0]
for _, pcp := range resp.Participants {
if pcp.Error != 0 {
var inviteContent *event.Content
if pcp.AddRequest != nil {
inviteContent = &event.Content{
Raw: map[string]any{
msgconv.GroupInviteMetaField: &waid.GroupInviteMeta{
JID: resp.JID,
Code: pcp.AddRequest.Code,
Expiration: pcp.AddRequest.Expiration.Unix(),
Inviter: wa.JID.ToNonAD(),
GroupName: resp.Name,
IsParentGroup: resp.IsParent,
},
},
Parsed: &event.MessageEventContent{
Body: "Invitation to join my WhatsApp group",
MsgType: event.MsgText,
},
}
}
failedParticipants[waid.MakeUserID(pcp.JID)] = &bridgev2.CreateChatFailedParticipant{
Reason: fmt.Sprintf("error %d", pcp.Error),
InviteEventType: event.EventMessage.Type,
InviteContent: inviteContent,
}
} else {
filteredParticipants = append(filteredParticipants, pcp)
}
}
resp.Participants = filteredParticipants
portal, err := wa.Main.Bridge.GetPortalByKey(ctx, wa.makeWAPortalKey(resp.JID)) portal, err := wa.Main.Bridge.GetPortalByKey(ctx, wa.makeWAPortalKey(resp.JID))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get portal: %w", err) return nil, fmt.Errorf("failed to get portal: %w", err)
@ -324,14 +281,13 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou
} }
changed := false changed := false
if avatarBytes != nil { if avatarBytes != nil {
avatarID, err := wa.Client.SetGroupPhoto(ctx, resp.JID, avatarBytes) avatarID, err := wa.Client.SetGroupPhoto(resp.JID, avatarBytes)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set group avatar after creating group") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set group avatar after creating group")
} else { } else {
portal.AvatarID = networkid.AvatarID(avatarID) portal.AvatarID = networkid.AvatarID(avatarID)
portal.AvatarHash = sha256.Sum256(avatarBytes) portal.AvatarHash = sha256.Sum256(avatarBytes)
portal.AvatarMXC = avatarMXC portal.AvatarMXC = avatarMXC
portal.AvatarSet = true
groupInfo.Avatar = &bridgev2.Avatar{ groupInfo.Avatar = &bridgev2.Avatar{
ID: portal.AvatarID, ID: portal.AvatarID,
MXC: portal.AvatarMXC, MXC: portal.AvatarMXC,
@ -342,7 +298,7 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou
} }
if params.Topic != nil { if params.Topic != nil {
newTopicID := wa.Client.GenerateMessageID() newTopicID := wa.Client.GenerateMessageID()
err = wa.Client.SetGroupTopic(ctx, resp.JID, "", newTopicID, params.Topic.Topic) err = wa.Client.SetGroupTopic(resp.JID, "", newTopicID, params.Topic.Topic)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set group topic after creating group") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set group topic after creating group")
} else { } else {
@ -363,7 +319,5 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou
PortalKey: wa.makeWAPortalKey(resp.JID), PortalKey: wa.makeWAPortalKey(resp.JID),
Portal: portal, Portal: portal,
PortalInfo: groupInfo, PortalInfo: groupInfo,
FailedParticipants: failedParticipants,
}, nil }, nil
} }

View file

@ -149,7 +149,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID
return return
} }
log.Debug().Array("jids", exzerolog.ArrayOfStringers(ghostJIDs)).Msg("Doing background sync for users") log.Debug().Array("jids", exzerolog.ArrayOfStringers(ghostJIDs)).Msg("Doing background sync for users")
infos, err := wa.Client.GetUserInfo(ctx, ghostJIDs) infos, err := wa.Client.GetUserInfo(ghostJIDs)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get user info for background sync") log.Err(err).Msg("Failed to get user info for background sync")
return return
@ -194,67 +194,38 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID,
} else if jid == types.LegacyPSAJID || jid == types.PSAJID { } else if jid == types.LegacyPSAJID || jid == types.PSAJID {
contact.PushName = "WhatsApp" contact.PushName = "WhatsApp"
} }
var altJID types.JID var phone string
if jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer { if jid.Server == types.DefaultUserServer {
var err error phone = "+" + jid.User
altJID, err = wa.GetStore().GetAltJID(ctx, jid) } else if jid.Server == types.HiddenUserServer {
pnJID, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("source_jid", jid).Msg("Failed to get alt JID") zerolog.Ctx(ctx).Err(err).Stringer("lid", jid).Msg("Failed to get PN for LID")
} else if altJID.IsEmpty() { } else if pnJID.IsEmpty() {
zerolog.Ctx(ctx).Debug().Stringer("source_jid", jid).Msg("Alternate JID not found in contactToUserInfo") zerolog.Ctx(ctx).Debug().Stringer("lid", jid).Msg("Phone number not found for LID in contactToUserInfo")
} else { } else {
extraContact, err := wa.GetStore().Contacts.GetContact(ctx, altJID) phone = "+" + pnJID.User
extraContact, err := wa.GetStore().Contacts.GetContact(ctx, pnJID)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err). zerolog.Ctx(ctx).Err(err).
Stringer("source_jid", jid). Stringer("lid", jid).
Stringer("alt_jid", altJID). Stringer("pn_jid", pnJID).
Msg("Failed to get contact info from alternate JID") Msg("Failed to get contact info from PN")
} else { } else {
// Phone contact info should only be stored for phone number JIDs
if altJID.Server == types.DefaultUserServer {
if contact.FirstName == "" { if contact.FirstName == "" {
contact.FirstName = extraContact.FirstName contact.FirstName = extraContact.FirstName
} }
if contact.FullName == "" { if contact.FullName == "" {
contact.FullName = extraContact.FullName contact.FullName = extraContact.FullName
} }
}
if contact.PushName == "" { if contact.PushName == "" {
contact.PushName = extraContact.PushName contact.PushName = extraContact.PushName
} }
if contact.BusinessName == "" { if contact.BusinessName == "" {
contact.BusinessName = extraContact.BusinessName contact.BusinessName = extraContact.BusinessName
} }
if contact.PushName != "" && extraContact.PushName != "" && contact.PushName != extraContact.PushName {
zerolog.Ctx(ctx).Debug().
Stringer("source_jid", jid).
Stringer("alt_jid", altJID).
Str("source_push_name", contact.PushName).
Str("alt_push_name", extraContact.PushName).
Msg("Conflicting push names between JIDs")
if altJID.Server == types.DefaultUserServer {
contact.PushName = extraContact.PushName
} }
} }
if contact.BusinessName != "" && extraContact.BusinessName != "" && contact.BusinessName != extraContact.BusinessName {
zerolog.Ctx(ctx).Debug().
Stringer("source_jid", jid).
Stringer("alt_jid", altJID).
Str("source_push_name", contact.BusinessName).
Str("alt_push_name", extraContact.BusinessName).
Msg("Conflicting business names between JIDs")
if altJID.Server == types.DefaultUserServer {
contact.BusinessName = extraContact.BusinessName
}
}
}
}
}
var phone string
if jid.Server == types.DefaultUserServer {
phone = "+" + jid.User
} else if altJID.Server == types.DefaultUserServer {
phone = "+" + altJID.User
} }
ui := &bridgev2.UserInfo{ ui := &bridgev2.UserInfo{
Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)), Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)),
@ -331,7 +302,7 @@ func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.
existingID = "" existingID = ""
} }
var wrappedAvatar *bridgev2.Avatar var wrappedAvatar *bridgev2.Avatar
avatar, err := wa.Client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{ExistingID: existingID}) avatar, err := wa.Client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ExistingID: existingID})
if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) { if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) {
wrappedAvatar = &bridgev2.Avatar{ wrappedAvatar = &bridgev2.Avatar{
ID: "remove", ID: "remove",
@ -407,9 +378,9 @@ func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JI
var altJID types.JID var altJID types.JID
var err error var err error
if jid.Server == types.HiddenUserServer { if jid.Server == types.HiddenUserServer {
altJID, err = wa.GetStore().LIDs.GetPNForLID(ctx, jid) altJID, err = wa.Device.LIDs.GetPNForLID(ctx, jid)
} else if jid.Server == types.DefaultUserServer { } else if jid.Server == types.DefaultUserServer {
altJID, err = wa.GetStore().LIDs.GetLIDForPN(ctx, jid) altJID, err = wa.Device.LIDs.GetLIDForPN(ctx, jid)
} }
if err != nil { if err != nil {
log.Warn().Err(err). log.Warn().Err(err).

View file

@ -116,12 +116,9 @@ func (mq *MessageQuery) GetBetween(ctx context.Context, loginID networkid.UserLo
AsList() AsList()
} }
func (mq *MessageQuery) DeleteBetween(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, before, after uint64) (int64, error) { func (mq *MessageQuery) DeleteBetween(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID, before, after uint64) error {
res, err := mq.Exec(ctx, deleteHistorySyncMessagesBetweenQuery, mq.BridgeID, loginID, chatJID, before, after) _, err := mq.Exec(ctx, deleteHistorySyncMessagesBetweenQuery, mq.BridgeID, loginID, chatJID, before, after)
if err != nil { return err
return 0, err
}
return res.RowsAffected()
} }
func (mq *MessageQuery) DeleteAll(ctx context.Context, loginID networkid.UserLoginID) error { func (mq *MessageQuery) DeleteAll(ctx context.Context, loginID networkid.UserLoginID) error {
@ -129,12 +126,9 @@ func (mq *MessageQuery) DeleteAll(ctx context.Context, loginID networkid.UserLog
return err return err
} }
func (mq *MessageQuery) DeleteAllInChat(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (int64, error) { func (mq *MessageQuery) DeleteAllInChat(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) error {
res, err := mq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, mq.BridgeID, loginID, chatJID) _, err := mq.Exec(ctx, deleteHistorySyncMessagesForPortalQuery, mq.BridgeID, loginID, chatJID)
if err != nil { return err
return 0, err
}
return res.RowsAffected()
} }
func (mq *MessageQuery) ConversationHasMessages(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (exists bool, err error) { func (mq *MessageQuery) ConversationHasMessages(ctx context.Context, loginID networkid.UserLoginID, chatJID types.JID) (exists bool, err error) {

View file

@ -1,4 +1,4 @@
-- v0 -> v9 (compatible with v3+): Latest revision -- v0 -> v7 (compatible with v3+): Latest revision
CREATE TABLE whatsapp_poll_option_id ( CREATE TABLE whatsapp_poll_option_id (
bridge_id TEXT NOT NULL, bridge_id TEXT NOT NULL,

View file

@ -1,2 +0,0 @@
-- v8 (compatible with v3+): Mark LID DMs for deletion
INSERT INTO kv_store (bridge_id, key, value) VALUES ('', 'whatsapp_lid_dms_deleted', 'false');

View file

@ -1,3 +0,0 @@
-- v9 (compatible with v3+): Mark LID DMs for deletion (again)
DELETE FROM kv_store WHERE bridge_id='' AND key='whatsapp_lid_dms_deleted';
INSERT INTO kv_store (bridge_id, key, value) VALUES ('', 'whatsapp_lid_dms_deleted', 'false');

View file

@ -19,8 +19,6 @@ package msgconv
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"image" "image"
@ -50,13 +48,7 @@ import (
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
func (mc *MessageConverter) generateContextInfo( func (mc *MessageConverter) generateContextInfo(ctx context.Context, replyTo *database.Message, portal *bridgev2.Portal, perMessageTimer *event.BeeperDisappearingTimer) *waE2E.ContextInfo {
ctx context.Context,
replyTo *database.Message,
portal *bridgev2.Portal,
perMessageTimer *event.BeeperDisappearingTimer,
roomMention bool,
) *waE2E.ContextInfo {
contextInfo := &waE2E.ContextInfo{} contextInfo := &waE2E.ContextInfo{}
if replyTo != nil { if replyTo != nil {
msgID, err := waid.ParseMessageID(replyTo.ID) msgID, err := waid.ParseMessageID(replyTo.ID)
@ -64,7 +56,6 @@ func (mc *MessageConverter) generateContextInfo(
contextInfo.StanzaID = proto.String(msgID.ID) contextInfo.StanzaID = proto.String(msgID.ID)
contextInfo.Participant = proto.String(msgID.Sender.String()) contextInfo.Participant = proto.String(msgID.Sender.String())
contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")} contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")}
contextInfo.QuotedType = waE2E.ContextInfo_EXPLICIT.Enum()
} else { } else {
zerolog.Ctx(ctx).Warn().Err(err). zerolog.Ctx(ctx).Warn().Err(err).
Stringer("reply_to_event_id", replyTo.MXID). Stringer("reply_to_event_id", replyTo.MXID).
@ -85,9 +76,6 @@ func (mc *MessageConverter) generateContextInfo(
if setAt > 0 && contextInfo.Expiration != nil { if setAt > 0 && contextInfo.Expiration != nil {
contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt) contextInfo.EphemeralSettingTimestamp = ptr.Ptr(setAt)
} }
if roomMention {
contextInfo.NonJIDMentions = proto.Uint32(1)
}
return contextInfo return contextInfo
} }
@ -107,15 +95,11 @@ func (mc *MessageConverter) ToWhatsApp(
} }
message := &waE2E.Message{} message := &waE2E.Message{}
contextInfo := mc.generateContextInfo(ctx, replyTo, portal, content.BeeperDisappearingTimer, content.Mentions != nil && content.Mentions.Room) contextInfo := mc.generateContextInfo(ctx, replyTo, portal, content.BeeperDisappearingTimer)
switch content.MsgType { switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote: case event.MsgText, event.MsgNotice, event.MsgEmote:
var err error message = mc.constructTextMessage(ctx, content, contextInfo)
message, err = mc.constructTextMessage(ctx, content, evt.Content.Raw, contextInfo)
if err != nil {
return nil, nil, err
}
case event.MessageType(event.EventSticker.Type), event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: case event.MessageType(event.EventSticker.Type), event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile:
uploaded, thumbnail, mime, err := mc.reuploadFileToWhatsApp(ctx, content) uploaded, thumbnail, mime, err := mc.reuploadFileToWhatsApp(ctx, content)
if err != nil { if err != nil {
@ -202,7 +186,6 @@ func (mc *MessageConverter) constructMediaMessage(
FileSHA256: uploaded.FileSHA256, FileSHA256: uploaded.FileSHA256,
FileLength: proto.Uint64(uploaded.FileLength), FileLength: proto.Uint64(uploaded.FileLength),
URL: proto.String(uploaded.URL), URL: proto.String(uploaded.URL),
IsLottie: proto.Bool(mime == "application/was"),
}, },
} }
case event.MsgAudio: case event.MsgAudio:
@ -321,16 +304,7 @@ func (mc *MessageConverter) parseText(ctx context.Context, content *event.Messag
return return
} }
func (mc *MessageConverter) constructTextMessage( func (mc *MessageConverter) constructTextMessage(ctx context.Context, content *event.MessageEventContent, contextInfo *waE2E.ContextInfo) *waE2E.Message {
ctx context.Context,
content *event.MessageEventContent,
raw map[string]any,
contextInfo *waE2E.ContextInfo,
) (*waE2E.Message, error) {
groupInvite, ok := raw[GroupInviteMetaField].(map[string]any)
if ok {
return mc.constructGroupInviteMessage(ctx, content, groupInvite, contextInfo)
}
text, mentions := mc.parseText(ctx, content) text, mentions := mc.parseText(ctx, content)
if len(mentions) > 0 { if len(mentions) > 0 {
contextInfo.MentionedJID = mentions contextInfo.MentionedJID = mentions
@ -341,44 +315,7 @@ func (mc *MessageConverter) constructTextMessage(
} }
mc.convertURLPreviewToWhatsApp(ctx, content, etm) mc.convertURLPreviewToWhatsApp(ctx, content, etm)
return &waE2E.Message{ExtendedTextMessage: etm}, nil return &waE2E.Message{ExtendedTextMessage: etm}
}
func (mc *MessageConverter) constructGroupInviteMessage(
ctx context.Context,
content *event.MessageEventContent,
inviteMeta map[string]any,
contextInfo *waE2E.ContextInfo,
) (*waE2E.Message, error) {
payload, err := json.Marshal(inviteMeta)
if err != nil {
return nil, fmt.Errorf("failed to marshal invite meta: %w", err)
}
var parsedInviteMeta waid.GroupInviteMeta
err = json.Unmarshal(payload, &parsedInviteMeta)
if err != nil {
return nil, fmt.Errorf("failed to parse invite meta: %w", err)
}
text, mentions := mc.parseText(ctx, content)
if len(mentions) > 0 {
contextInfo.MentionedJID = mentions
}
groupType := waE2E.GroupInviteMessage_DEFAULT
if parsedInviteMeta.IsParentGroup {
groupType = waE2E.GroupInviteMessage_PARENT
}
return &waE2E.Message{
GroupInviteMessage: &waE2E.GroupInviteMessage{
GroupJID: proto.String(parsedInviteMeta.JID.String()),
InviteCode: proto.String(parsedInviteMeta.Code),
InviteExpiration: proto.Int64(parsedInviteMeta.Expiration),
GroupName: proto.String(parsedInviteMeta.GroupName),
JPEGThumbnail: nil,
Caption: proto.String(text),
ContextInfo: contextInfo,
GroupType: groupType.Enum(),
},
}, nil
} }
func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx format.Context) string { func (mc *MessageConverter) convertPill(displayname, mxid, eventID string, ctx format.Context) string {
@ -484,17 +421,6 @@ func (mc *MessageConverter) convertToWebP(img []byte) ([]byte, int, error) {
return webpBuffer.Bytes(), size, nil return webpBuffer.Bytes(), size, nil
} }
func (mc *MessageConverter) getOriginalBridgedSticker(ctx context.Context, info *event.BridgedSticker) (*types.StickerPackItem, error) {
if info == nil || info.Network != StickerSourceID || !strings.HasPrefix(info.PackURL, StickerPackURLPrefix) || info.ID == "" {
return nil, nil
}
fileHash, err := base64.StdEncoding.DecodeString(info.ID)
if err != nil {
return nil, nil
}
return mc.GetCachedSticker(ctx, getClient(ctx), strings.TrimPrefix(info.PackURL, StickerPackURLPrefix), fileHash)
}
func (mc *MessageConverter) reuploadFileToWhatsApp( func (mc *MessageConverter) reuploadFileToWhatsApp(
ctx context.Context, content *event.MessageEventContent, ctx context.Context, content *event.MessageEventContent,
) (*whatsmeow.UploadResponse, []byte, string, error) { ) (*whatsmeow.UploadResponse, []byte, string, error) {
@ -503,25 +429,7 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
if content.FileName != "" { if content.FileName != "" {
fileName = content.FileName fileName = content.FileName
} }
var data []byte data, err := mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
var err error
var sticker *types.StickerPackItem
if sticker, err = mc.getOriginalBridgedSticker(ctx, content.Info.BridgedSticker); err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Msg("Failed to get original bridged sticker, falling back to downloading from URL")
data, err = mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
} else if sticker != nil {
if sticker.MimeType == "application/was" {
data, err = getClient(ctx).Download(ctx, sticker)
mime = sticker.MimeType
} else {
data, err = mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
}
content.Info.Width = sticker.Width
content.Info.Height = sticker.Height
} else {
data, err = mc.Bridge.Bot.DownloadMedia(ctx, content.URL, content.File)
}
if err != nil { if err != nil {
return nil, nil, "", fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) return nil, nil, "", fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
} }
@ -539,14 +447,7 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
case event.MessageType(event.EventSticker.Type): case event.MessageType(event.EventSticker.Type):
isSticker = true isSticker = true
mediaType = whatsmeow.MediaImage mediaType = whatsmeow.MediaImage
if mime == "video/lottie+json" { if mime != "image/webp" || content.Info.Width != content.Info.Height {
// This likely won't work
data, err = PackAnimatedSticker(data)
if err != nil {
return nil, nil, mime, fmt.Errorf("%w (packing animated sticker): %w", bridgev2.ErrMediaConvertFailed, err)
}
mime = "application/was"
} else if (mime != "image/webp" || content.Info.Width != content.Info.Height) && mime != "application/was" {
var size int var size int
data, size, err = mc.convertToWebP(data) data, size, err = mc.convertToWebP(data)
if err != nil { if err != nil {

View file

@ -140,9 +140,6 @@ func (mc *MessageConverter) ToMatrix(
isBackfill bool, isBackfill bool,
previouslyConvertedPart *bridgev2.ConvertedMessagePart, previouslyConvertedPart *bridgev2.ConvertedMessagePart,
) *bridgev2.ConvertedMessage { ) *bridgev2.ConvertedMessage {
if waMsg == nil {
waMsg = &waE2E.Message{}
}
ctx = context.WithValue(ctx, contextKeyClient, client) ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyIntent, intent) ctx = context.WithValue(ctx, contextKeyIntent, intent)
ctx = context.WithValue(ctx, contextKeyPortal, portal) ctx = context.WithValue(ctx, contextKeyPortal, portal)
@ -237,9 +234,6 @@ func (mc *MessageConverter) ToMatrix(
part.Extra["fi.mau.whatsapp.source_broadcast_list"] = info.Chat.String() part.Extra["fi.mau.whatsapp.source_broadcast_list"] = info.Chat.String()
} }
mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content) mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content)
if contextInfo.GetNonJIDMentions() == 1 {
part.Content.Mentions.Room = true
}
cm := &bridgev2.ConvertedMessage{ cm := &bridgev2.ConvertedMessage{
Parts: []*bridgev2.ConvertedMessagePart{part}, Parts: []*bridgev2.ConvertedMessagePart{part},
@ -263,27 +257,6 @@ func (mc *MessageConverter) ToMatrix(
if chat.IsEmpty() { if chat.IsEmpty() {
chat, _ = waid.ParsePortalID(portal.ID) chat, _ = waid.ParsePortalID(portal.ID)
} }
// We reroute all DMs to the phone number JID, so reroute reply participants too
pcp = rerouteMessageKey(ctx, chat, pcp, getPortal(ctx).Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID)
if store := getClient(ctx).Store; store != nil && chat.Server == types.DefaultUserServer && pcp.Server == types.HiddenUserServer {
pcpPN, _ := store.LIDs.GetPNForLID(ctx, pcp)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", pcp).
Stringer("rerouted_participant", pcpPN).
Msg("Rerouting reply target (PN recipient in LID DM)")
if !pcpPN.IsEmpty() {
pcp = pcpPN
}
} else if store != nil && chat.Server == types.GroupServer && pcp.Server == types.DefaultUserServer && getPortal(ctx).Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID {
pcpLID, _ := store.LIDs.GetLIDForPN(ctx, pcp)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", pcp).
Stringer("rerouted_participant", pcpLID).
Msg("Rerouting reply target (PN recipient in LID group)")
if !pcpLID.IsEmpty() {
pcp = pcpLID
}
}
cm.ReplyTo = &networkid.MessageOptionalPartID{ cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()), MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()),
} }

View file

@ -71,7 +71,7 @@ func (mc *MessageConverter) PollStartToWhatsApp(
if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 { if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 {
maxAnswers = 0 maxAnswers = 0
} }
contextInfo := mc.generateContextInfo(ctx, replyTo, portal, nil, content.Mentions != nil && content.Mentions.Room) contextInfo := mc.generateContextInfo(ctx, replyTo, portal, nil)
var question string var question string
question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions) question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions)
if len(question) == 0 { if len(question) == 0 {

View file

@ -17,9 +17,6 @@
package msgconv package msgconv
import ( import (
"sync"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
@ -46,16 +43,12 @@ type MessageConverter struct {
DisableViewOnce bool DisableViewOnce bool
DirectMedia bool DirectMedia bool
OldMediaSuffix string OldMediaSuffix string
stickerPackCache map[string]*types.StickerPack
stickerPackCacheLock sync.Mutex
} }
func New(br *bridgev2.Bridge) *MessageConverter { func New(br *bridgev2.Bridge) *MessageConverter {
mc := &MessageConverter{ mc := &MessageConverter{
Bridge: br, Bridge: br,
MaxFileSize: 50 * 1024 * 1024, MaxFileSize: 50 * 1024 * 1024,
stickerPackCache: make(map[string]*types.StickerPack),
} }
mc.HTMLParser = &format.HTMLParser{ mc.HTMLParser = &format.HTMLParser{
PillConverter: mc.convertPill, PillConverter: mc.convertPill,

View file

@ -66,10 +66,10 @@ func (mc *MessageConverter) convertTemplateMessage(ctx context.Context, info *ty
if addButtonText { if addButtonText {
description += "\nUse the WhatsApp app to click buttons" description += "\nUse the WhatsApp app to click buttons"
} }
content = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", content, description)) content = fmt.Sprintf("%s\n\n%s", content, description)
} }
if footer := tpl.GetHydratedFooterText(); footer != "" { if footer := tpl.GetHydratedFooterText(); footer != "" {
content = strings.TrimSpace(fmt.Sprintf("%s\n\n%s", content, footer)) content = fmt.Sprintf("%s\n\n%s", content, footer)
} }
var convertedTitle *bridgev2.ConvertedMessagePart var convertedTitle *bridgev2.ConvertedMessagePart
@ -239,7 +239,7 @@ func (mc *MessageConverter) postProcessBusinessMessage(content string, headerMed
converted.Content.Body += content converted.Content.Body += content
contentHTML := parseWAFormattingToHTML(content, true) contentHTML := parseWAFormattingToHTML(content, true)
if contentHTML != event.TextToHTML(content) || converted.Content.FormattedBody != "" { if contentHTML != event.TextToHTML(content) || converted.Content.FormattedBody != "" {
converted.Content.Format = event.FormatHTML converted.Content.EnsureHasHTML()
if converted.Content.FormattedBody != "" { if converted.Content.FormattedBody != "" {
converted.Content.FormattedBody += "<br><br>" converted.Content.FormattedBody += "<br><br>"
} }

View file

@ -17,6 +17,8 @@
package msgconv package msgconv
import ( import (
"archive/zip"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -24,18 +26,21 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv"
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exmime" "go.mau.fi/util/exmime"
"go.mau.fi/util/exslices" "go.mau.fi/util/exslices"
"go.mau.fi/util/lottie"
"go.mau.fi/util/random"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -82,11 +87,11 @@ func (mc *MessageConverter) convertMediaMessage(
MimeType: msg.GetMimetype(), MimeType: msg.GetMimetype(),
} }
if mc.DirectMedia { if mc.DirectMedia {
preparedMedia.FillFileName()
if preparedMedia.Info.MimeType == "application/was" { if preparedMedia.Info.MimeType == "application/was" {
preparedMedia.Info.MimeType = "video/lottie+json" preparedMedia.Info.MimeType = "video/lottie+json"
preparedMedia.FileName = "sticker.json" preparedMedia.FileName = "sticker.json"
} }
preparedMedia.FillFileName()
var err error var err error
portal := getPortal(ctx) portal := getPortal(ctx)
idOverride := getEditTargetID(ctx) idOverride := getEditTargetID(ctx)
@ -193,9 +198,7 @@ type PreparedMedia struct {
} }
func (pm *PreparedMedia) FillFileName() *PreparedMedia { func (pm *PreparedMedia) FillFileName() *PreparedMedia {
if pm.Type == event.EventSticker { if pm.FileName == "" {
pm.FileName = ""
} else if pm.FileName == "" {
pm.FileName = strings.TrimPrefix(string(pm.MsgType), "m.") + exmime.ExtensionFromMimetype(pm.Info.MimeType) pm.FileName = strings.TrimPrefix(string(pm.MsgType), "m.") + exmime.ExtensionFromMimetype(pm.Info.MimeType)
} }
return pm return pm
@ -234,21 +237,6 @@ type MediaMessageWithDuration interface {
GetSeconds() uint32 GetSeconds() uint32
} }
const WhatsAppStickerSize = 190
func fixStickerDimensions(info *event.FileInfo) {
if info.Width == info.Height {
info.Width = WhatsAppStickerSize
info.Height = WhatsAppStickerSize
} else if info.Width > info.Height {
info.Height /= info.Width / WhatsAppStickerSize
info.Width = WhatsAppStickerSize
} else {
info.Width /= info.Height / WhatsAppStickerSize
info.Height = WhatsAppStickerSize
}
}
func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia { func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
extraInfo := map[string]any{} extraInfo := map[string]any{}
data := &PreparedMedia{ data := &PreparedMedia{
@ -260,22 +248,6 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
"info": extraInfo, "info": extraInfo,
}, },
} }
if durationMsg, ok := rawMsg.(MediaMessageWithDuration); ok {
data.Info.Duration = int(durationMsg.GetSeconds() * 1000)
}
if dimensionMsg, ok := rawMsg.(MediaMessageWithDimensions); ok {
data.Info.Width = int(dimensionMsg.GetWidth())
data.Info.Height = int(dimensionMsg.GetHeight())
}
if captionMsg, ok := rawMsg.(MediaMessageWithCaption); ok && captionMsg.GetCaption() != "" {
data.Body = captionMsg.GetCaption()
} else {
data.Body = data.FileName
}
data.Info.Size = int(rawMsg.GetFileLength())
data.Info.MimeType = rawMsg.GetMimetype()
data.ContextInfo = rawMsg.GetContextInfo()
switch msg := rawMsg.(type) { switch msg := rawMsg.(type) {
case *waE2E.ImageMessage: case *waE2E.ImageMessage:
data.MsgType = event.MsgImage data.MsgType = event.MsgImage
@ -297,11 +269,12 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
case *waE2E.StickerMessage: case *waE2E.StickerMessage:
data.Type = event.EventSticker data.Type = event.EventSticker
data.FileName = "sticker" + exmime.ExtensionFromMimetype(msg.GetMimetype()) data.FileName = "sticker" + exmime.ExtensionFromMimetype(msg.GetMimetype())
fixStickerDimensions(data.Info) if msg.GetMimetype() == "application/was" && data.FileName == "sticker" {
data.FileName = "sticker.json"
}
case *waE2E.VideoMessage: case *waE2E.VideoMessage:
data.MsgType = event.MsgVideo data.MsgType = event.MsgVideo
pairedMediaType := msg.GetContextInfo().GetPairedMediaType() if msg.GetGifPlayback() {
if msg.GetGifPlayback() || pairedMediaType == waE2E.ContextInfo_MOTION_PHOTO_PARENT || pairedMediaType == waE2E.ContextInfo_MOTION_PHOTO_CHILD {
extraInfo["fi.mau.gif"] = true extraInfo["fi.mau.gif"] = true
extraInfo["fi.mau.loop"] = true extraInfo["fi.mau.loop"] = true
extraInfo["fi.mau.autoplay"] = true extraInfo["fi.mau.autoplay"] = true
@ -312,7 +285,22 @@ func prepareMediaMessage(rawMsg MediaMessage) *PreparedMedia {
default: default:
panic(fmt.Errorf("unknown media message type %T", rawMsg)) panic(fmt.Errorf("unknown media message type %T", rawMsg))
} }
if durationMsg, ok := rawMsg.(MediaMessageWithDuration); ok {
data.Info.Duration = int(durationMsg.GetSeconds() * 1000)
}
if dimensionMsg, ok := rawMsg.(MediaMessageWithDimensions); ok {
data.Info.Width = int(dimensionMsg.GetWidth())
data.Info.Height = int(dimensionMsg.GetHeight())
}
if captionMsg, ok := rawMsg.(MediaMessageWithCaption); ok && captionMsg.GetCaption() != "" {
data.Body = captionMsg.GetCaption()
} else {
data.Body = data.FileName
}
data.Info.Size = int(rawMsg.GetFileLength())
data.Info.MimeType = rawMsg.GetMimetype()
data.ContextInfo = rawMsg.GetContextInfo()
return data return data
} }
@ -357,15 +345,12 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
) error { ) error {
client := getClient(ctx) client := getClient(ctx)
intent := getIntent(ctx) intent := getIntent(ctx)
var roomID id.RoomID portal := getPortal(ctx)
if portal := getPortal(ctx); portal != nil {
roomID = portal.MXID
}
var thumbnailData []byte var thumbnailData []byte
var thumbnailInfo *event.FileInfo var thumbnailInfo *event.FileInfo
if part.Info.Size > uploadFileThreshold { if part.Info.Size > uploadFileThreshold {
var err error var err error
part.URL, part.File, err = intent.UploadMediaStream(ctx, roomID, -1, true, func(file io.Writer) (*bridgev2.FileStreamResult, error) { part.URL, part.File, err = intent.UploadMediaStream(ctx, portal.MXID, -1, true, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
err := client.DownloadToFile(ctx, message, file.(*os.File)) err := client.DownloadToFile(ctx, message, file.(*os.File))
if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) { if errors.Is(err, whatsmeow.ErrFileLengthMismatch) || errors.Is(err, whatsmeow.ErrInvalidMediaSHA256) {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too") zerolog.Ctx(ctx).Warn().Err(err).Msg("Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too")
@ -398,14 +383,12 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
if err != nil { if err != nil {
return err return err
} }
} else if part.Type == event.EventSticker && part.Info.MimeType == "image/webp" {
mc.fillWebPStickerInfo(ctx, part, data)
} }
if part.Info.MimeType == "" { if part.Info.MimeType == "" {
part.Info.MimeType = http.DetectContentType(data) part.Info.MimeType = http.DetectContentType(data)
} }
part.FillFileName() part.FillFileName()
part.URL, part.File, err = intent.UploadMedia(ctx, roomID, data, part.FileName, part.Info.MimeType) part.URL, part.File, err = intent.UploadMedia(ctx, portal.MXID, data, part.FileName, part.Info.MimeType)
if err != nil { if err != nil {
return fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err) return fmt.Errorf("%w: %w", bridgev2.ErrMediaReuploadFailed, err)
} }
@ -414,7 +397,7 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
var err error var err error
part.Info.ThumbnailURL, part.Info.ThumbnailFile, err = intent.UploadMedia( part.Info.ThumbnailURL, part.Info.ThumbnailFile, err = intent.UploadMedia(
ctx, ctx,
roomID, portal.MXID,
thumbnailData, thumbnailData,
"thumbnail"+exmime.ExtensionFromMimetype(thumbnailInfo.MimeType), "thumbnail"+exmime.ExtensionFromMimetype(thumbnailInfo.MimeType),
thumbnailInfo.MimeType, thumbnailInfo.MimeType,
@ -428,6 +411,68 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
return nil return nil
} }
func (mc *MessageConverter) extractAnimatedSticker(fileInfo *PreparedMedia, data []byte) ([]byte, error) {
data, err := ExtractAnimatedSticker(data)
if err != nil {
return nil, err
}
fileInfo.Info.MimeType = "video/lottie+json"
fileInfo.FileName = "sticker.json"
return data, nil
}
func (mc *MessageConverter) convertAnimatedSticker(ctx context.Context, fileInfo *PreparedMedia, data []byte) ([]byte, []byte, *event.FileInfo, error) {
data, err := mc.extractAnimatedSticker(fileInfo, data)
if err != nil {
return nil, nil, nil, err
}
c := mc.AnimatedStickerConfig
if c.Target == "disable" {
return data, nil, nil, nil
} else if !lottie.Supported() {
zerolog.Ctx(ctx).Warn().Msg("Animated sticker conversion is enabled, but lottieconverter is not installed")
return data, nil, nil, nil
}
input := bytes.NewReader(data)
fileInfo.Info.MimeType = "image/" + c.Target
fileInfo.FileName = "sticker." + c.Target
switch c.Target {
case "png":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, "1")
return output.Bytes(), nil, nil, err
case "gif":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, strconv.Itoa(c.Args.FPS))
return output.Bytes(), nil, nil, err
case "webm", "webp":
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("mautrix-whatsapp-lottieconverter-%s.%s", random.String(10), c.Target))
defer func() {
_ = os.Remove(tmpFile)
}()
thumbnailData, err := lottie.FFmpegConvert(ctx, input, tmpFile, c.Args.Width, c.Args.Height, c.Args.FPS)
if err != nil {
return nil, nil, nil, err
}
data, err = os.ReadFile(tmpFile)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to read converted file: %w", err)
}
var thumbnailInfo *event.FileInfo
if thumbnailData != nil {
thumbnailInfo = &event.FileInfo{
MimeType: "image/png",
Width: c.Args.Width,
Height: c.Args.Height,
Size: len(thumbnailData),
}
}
return data, thumbnailData, thumbnailInfo, nil
default:
return nil, nil, nil, fmt.Errorf("unsupported target format %s", c.Target)
}
}
func (mc *MessageConverter) makeMediaFailure(ctx context.Context, mediaInfo *PreparedMedia, keys *FailedMediaKeys, err error) *bridgev2.ConvertedMessagePart { func (mc *MessageConverter) makeMediaFailure(ctx context.Context, mediaInfo *PreparedMedia, keys *FailedMediaKeys, err error) *bridgev2.ConvertedMessagePart {
logLevel := zerolog.ErrorLevel logLevel := zerolog.ErrorLevel
var extra map[string]any var extra map[string]any
@ -472,3 +517,28 @@ func (mc *MessageConverter) makeMediaFailure(ctx context.Context, mediaInfo *Pre
} }
return part return part
} }
func ExtractAnimatedSticker(data []byte) ([]byte, error) {
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("failed to read sticker zip: %w", err)
}
animationFile, err := zipReader.Open("animation/animation.json")
if err != nil {
return nil, fmt.Errorf("failed to open animation.json: %w", err)
}
animationFileInfo, err := animationFile.Stat()
if err != nil {
_ = animationFile.Close()
return nil, fmt.Errorf("failed to stat animation.json: %w", err)
} else if animationFileInfo.Size() > uploadFileThreshold {
_ = animationFile.Close()
return nil, fmt.Errorf("animation.json is too large (%.2f MiB)", float64(animationFileInfo.Size())/1024/1024)
}
data, err = io.ReadAll(animationFile)
_ = animationFile.Close()
if err != nil {
return nil, fmt.Errorf("failed to read animation.json: %w", err)
}
return data, nil
}

View file

@ -27,7 +27,7 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waAICommonDeprecated" "go.mau.fi/whatsmeow/proto/waAICommon"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -92,8 +92,6 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info
Code: msg.GetInviteCode(), Code: msg.GetInviteCode(),
Expiration: msg.GetInviteExpiration(), Expiration: msg.GetInviteExpiration(),
Inviter: info.Sender.ToNonAD(), Inviter: info.Sender.ToNonAD(),
GroupName: msg.GetGroupName(),
IsParentGroup: msg.GetGroupType() == waE2E.GroupInviteMessage_PARENT,
} }
extraAttrs = map[string]any{ extraAttrs = map[string]any{
GroupInviteMetaField: inviteMeta, GroupInviteMetaField: inviteMeta,
@ -266,9 +264,8 @@ func (mc *MessageConverter) convertKeepInChatMessage(ctx context.Context, msg *w
func (mc *MessageConverter) convertRichResponseMessage(ctx context.Context, msg *waE2E.AIRichResponseMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertRichResponseMessage(ctx context.Context, msg *waE2E.AIRichResponseMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
var body strings.Builder var body strings.Builder
// TODO switch to new format?
for i, submsg := range msg.GetSubmessages() { for i, submsg := range msg.GetSubmessages() {
if submsg.GetMessageType() == waAICommonDeprecated.AIRichResponseSubMessageType_AI_RICH_RESPONSE_TEXT { if submsg.GetMessageType() == waAICommon.AIRichResponseSubMessageType_AI_RICH_RESPONSE_TEXT {
if i > 0 { if i > 0 {
body.WriteString("\n") body.WriteString("\n")
} }

View file

@ -24,6 +24,7 @@ import (
"strings" "strings"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
@ -94,31 +95,7 @@ func (mc *MessageConverter) convertPollCreationMessage(ctx context.Context, msg
}, msg.GetContextInfo() }, msg.GetContextInfo()
} }
func rerouteMessageKey(ctx context.Context, chat, sender types.JID, groupLIDAddressing bool) types.JID { func KeyToMessageID(client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID {
if store := getClient(ctx).Store; store != nil && chat.Server == types.DefaultUserServer && sender.Server == types.HiddenUserServer {
senderPN, _ := store.LIDs.GetPNForLID(ctx, sender)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", sender).
Stringer("rerouted_participant", senderPN).
Msg("Rerouting message key (PN recipient in LID DM)")
if !senderPN.IsEmpty() {
return senderPN
}
} else if store != nil && chat.Server == types.GroupServer && sender.Server == types.DefaultUserServer && groupLIDAddressing {
senderLID, _ := store.LIDs.GetLIDForPN(ctx, sender)
zerolog.Ctx(ctx).Debug().
Stringer("orig_participant", sender).
Stringer("rerouted_participant", senderLID).
Msg("Rerouting message key (PN recipient in LID group)")
if !senderLID.IsEmpty() {
return senderLID
}
}
return sender
}
func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID {
groupLIDAddressing := sender.Server == types.HiddenUserServer
sender = sender.ToNonAD() sender = sender.ToNonAD()
var err error var err error
if !key.GetFromMe() { if !key.GetFromMe() {
@ -132,21 +109,14 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
sender.Server = types.DefaultUserServer sender.Server = types.DefaultUserServer
} }
} else if chat.Server == types.DefaultUserServer || chat.Server == types.BotServer { } else if chat.Server == types.DefaultUserServer || chat.Server == types.BotServer {
if sender.User == client.Store.GetJID().User || sender.User == client.Store.GetLID().User { ownID := ptr.Val(client.Store.ID).ToNonAD()
// Message key is not from the sender, but message sender (containing key) is me, if sender.User == ownID.User {
// so message key sender is the other user in the DM
sender = chat sender = chat
} else { } else {
// Message key is not from the sender, but message sender (containing key) is not me, sender = ownID
// so message key sender is me
sender = client.Store.GetJID().ToNonAD()
} }
} else { } else {
zerolog.Ctx(ctx).Warn(). // TODO log somehow?
Stringer("chat", chat).
Stringer("sender", sender).
Any("key", key).
Msg("Failed to get message ID from key")
return "" return ""
} }
} }
@ -157,10 +127,6 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
chat = remoteJID chat = remoteJID
} }
} }
sender = rerouteMessageKey(
context.WithValue(ctx, contextKeyClient, client),
chat, sender, groupLIDAddressing,
)
return waid.MakeMessageID(chat, sender, key.GetID()) return waid.MakeMessageID(chat, sender, key.GetID())
} }
@ -172,14 +138,11 @@ var failedPollUpdatePart = &bridgev2.ConvertedMessagePart{
func (mc *MessageConverter) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertPollUpdateMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.PollUpdateMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
pollMessageID := KeyToMessageID(ctx, getClient(ctx), info.Chat, info.Sender, msg.PollCreationMessageKey) pollMessageID := KeyToMessageID(getClient(ctx), info.Chat, info.Sender, msg.PollCreationMessageKey)
pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "") pollMessage, err := mc.Bridge.DB.Message.GetPartByID(ctx, getPortal(ctx).Receiver, pollMessageID, "")
if err != nil { if err != nil {
log.Err(err).Msg("Failed to get poll update target message") log.Err(err).Msg("Failed to get poll update target message")
return failedPollUpdatePart, nil return failedPollUpdatePart, nil
} else if pollMessage == nil {
log.Warn().Str("target_message_id", string(pollMessageID)).Msg("Poll update target message not found")
return failedPollUpdatePart, nil
} }
vote, err := getClient(ctx).DecryptPollVote(ctx, &events.Message{ vote, err := getClient(ctx).DecryptPollVote(ctx, &events.Message{
Info: *info, Info: *info,

View file

@ -1,455 +0,0 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2026 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 (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/rs/zerolog"
"github.com/tidwall/gjson"
"go.mau.fi/util/exstrings"
"go.mau.fi/util/lottie"
"go.mau.fi/util/random"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
func (mc *MessageConverter) GetCachedStickerPack(ctx context.Context, client *whatsmeow.Client, packID string) (*types.StickerPack, error) {
mc.stickerPackCacheLock.Lock()
defer mc.stickerPackCacheLock.Unlock()
cached, ok := mc.stickerPackCache[packID]
if ok {
if cached == nil {
return nil, bridgev2.RespError(mautrix.MNotFound.WithMessage("sticker pack not found (cached)"))
}
return cached, nil
}
pack, err := client.FetchStickerPack(ctx, packID)
if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) {
mc.stickerPackCache[packID] = nil
return nil, bridgev2.WrapRespErr(err, mautrix.MNotFound)
} else if err != nil {
return nil, err
}
mc.stickerPackCache[packID] = pack
if packID != pack.StickerPackID {
mc.stickerPackCache[pack.StickerPackID] = pack
}
return pack, nil
}
func (mc *MessageConverter) GetCachedSticker(ctx context.Context, client *whatsmeow.Client, packID string, hash []byte) (*types.StickerPackItem, error) {
pack, err := mc.GetCachedStickerPack(ctx, client, packID)
if err != nil {
return nil, err
}
for _, sticker := range pack.Stickers {
if bytes.Equal(sticker.FileHash, hash) {
return sticker, nil
}
}
return nil, nil
}
func (mc *MessageConverter) DownloadImagePack(ctx context.Context, userLoginID networkid.UserLoginID, client *whatsmeow.Client, inputURL string) (*bridgev2.ImportedImagePack, error) {
parsedURL, err := url.Parse(inputURL)
if err != nil {
return nil, bridgev2.WrapRespErr(err, mautrix.MNotFound)
} else if parsedURL.Host != "api.whatsapp.com" && parsedURL.Host != "wa.me" {
return nil, bridgev2.WrapRespErr(fmt.Errorf("invalid host %q", parsedURL.Host), mautrix.MNotFound)
} else if !strings.HasPrefix(parsedURL.Path, "/stickerpack/") {
return nil, bridgev2.WrapRespErr(fmt.Errorf("invalid path %q", parsedURL.Path), mautrix.MNotFound)
}
packName := strings.Split(strings.TrimPrefix(parsedURL.Path, "/stickerpack/"), "/")[0]
if packName == "" {
return nil, bridgev2.WrapRespErr(fmt.Errorf("empty pack name"), mautrix.MNotFound)
}
pack, err := mc.GetCachedStickerPack(ctx, client, packName)
if err != nil {
return nil, err
}
canonicalURL := "https://wa.me/stickerpack/" + pack.StickerPackID
topLevelExtra := map[string]any{
"fi.mau.whatsapp.stickerpack": map[string]any{
"id": pack.StickerPackID,
"name": pack.Name,
"description": pack.Description,
"publisher": pack.Publisher,
"animated": pack.Animated > 0,
"lottie": pack.Lottie > 0,
},
}
content := &event.ImagePackEventContent{
Images: make(map[string]*event.ImagePackImage, len(pack.Stickers)),
Metadata: event.ImagePackMetadata{
DisplayName: pack.Name,
AvatarURL: "",
Usage: []event.ImagePackUsage{event.ImagePackUsageSticker},
Attribution: fmt.Sprintf("By %s on WhatsApp %s", pack.Publisher, canonicalURL),
BridgedPack: &event.BridgedStickerPack{
Network: StickerSourceID,
URL: canonicalURL,
},
},
}
ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyIntent, mc.Bridge.Bot)
ctx = context.WithValue(ctx, contextKeyPortal, (*bridgev2.Portal)(nil))
for i, sticker := range pack.Stickers {
shortcode := sticker.PreviewWebpID
if shortcode == "" {
shortcode = fmt.Sprintf("%s_img%d", pack.StickerPackID, i+1)
}
body := sticker.AccessibilityText
var emoji string
if len(sticker.Emojis) > 0 {
emoji = sticker.Emojis[0]
if body == "" {
body = strings.Join(sticker.Emojis, " ")
}
}
part := &PreparedMedia{
Type: event.EventSticker,
MessageEventContent: &event.MessageEventContent{
Body: body,
Info: &event.FileInfo{
MimeType: sticker.MimeType,
Width: sticker.Width,
Height: sticker.Height,
Size: int(sticker.FileSize),
BridgedSticker: &event.BridgedSticker{
Network: StickerSourceID,
ID: base64.StdEncoding.EncodeToString(sticker.FileHash),
Emoji: emoji,
PackURL: canonicalURL,
},
},
},
TypeDescription: "sticker",
}
dbKey := database.Key(fmt.Sprintf("stickercache:%x", part.Info.BridgedSticker.ID))
fixStickerDimensions(part.Info)
var packed *event.ImagePackImage
if mc.DirectMedia {
dbKey = ""
if part.Info.MimeType == "application/was" {
part.Info.MimeType = "video/lottie+json"
}
part.URL, err = mc.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeStickerPackMediaID(pack.StickerPackID, sticker.FileHash, userLoginID))
if err != nil {
panic(fmt.Errorf("failed to generate content URI: %w", err))
}
} else if cached := mc.Bridge.DB.KV.Get(ctx, dbKey); cached != "" {
err = json.Unmarshal([]byte(cached), &packed)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal cached sticker data: %w", err)
}
} else {
err = mc.reuploadWhatsAppAttachment(ctx, sticker, part)
if err != nil {
return nil, fmt.Errorf("failed to reupload sticker %q: %w", sticker.GetDirectPath(), err)
}
}
if packed == nil {
packed = &event.ImagePackImage{
URL: part.URL,
Body: part.Body,
Info: part.Info,
}
if dbKey != "" {
data, _ := json.Marshal(packed)
if data != nil {
mc.Bridge.DB.KV.Set(ctx, dbKey, string(data))
}
}
}
content.Images[shortcode] = packed
}
return &bridgev2.ImportedImagePack{
Content: content,
Extra: topLevelExtra,
Shortcode: pack.StickerPackID,
}, nil
}
type StickerMetadata struct {
StickerPackID string `json:"sticker-pack-id"`
AccessibilityText string `json:"accessibility-text"`
Emojis []string `json:"emojis"`
IsFirstPartySticker int `json:"is-first-party-sticker"`
}
func (sm *StickerMetadata) ToMatrix(content *event.MessageEventContent) {
if sm == nil {
return
}
if sm.StickerPackID != "" && content.Info.BridgedSticker == nil {
content.Info.BridgedSticker = &event.BridgedSticker{
Network: StickerSourceID,
PackURL: StickerPackURLPrefix + sm.StickerPackID,
}
if len(sm.Emojis) > 0 {
content.Info.BridgedSticker.Emoji = sm.Emojis[0]
}
}
if sm.AccessibilityText != "" {
content.Body = sm.AccessibilityText
} else if len(sm.Emojis) > 0 {
content.Body = strings.Join(sm.Emojis, " ")
}
}
const StickerSourceID = "whatsapp"
const StickerPackURLPrefix = "https://wa.me/stickerpack/"
func PackAnimatedSticker(data []byte) ([]byte, error) {
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
f, err := zipWriter.Create("animation/animation.json")
if err != nil {
return nil, fmt.Errorf("failed to create zip entry: %w", err)
}
_, err = f.Write(data)
if err != nil {
return nil, fmt.Errorf("failed to write zip entry: %w", err)
}
err = zipWriter.Close()
if err != nil {
return nil, fmt.Errorf("failed to close zip writer: %w", err)
}
return buf.Bytes(), nil
}
func ExtractAnimatedSticker(data []byte) ([]byte, *StickerMetadata, error) {
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, fmt.Errorf("failed to read sticker zip: %w", err)
}
animationFile, err := zipReader.Open("animation/animation.json")
if err != nil {
return nil, nil, fmt.Errorf("failed to open animation.json: %w", err)
}
animationFileInfo, err := animationFile.Stat()
if err != nil {
_ = animationFile.Close()
return nil, nil, fmt.Errorf("failed to stat animation.json: %w", err)
} else if animationFileInfo.Size() > uploadFileThreshold {
_ = animationFile.Close()
return nil, nil, fmt.Errorf("animation.json is too large (%.2f MiB)", float64(animationFileInfo.Size())/1024/1024)
}
data, err = io.ReadAll(animationFile)
_ = animationFile.Close()
if err != nil {
return nil, nil, fmt.Errorf("failed to read animation.json: %w", err)
}
var meta StickerMetadata
metaFile, err := zipReader.Open("animation/animation.json.overridden_metadata")
if err == nil {
_ = json.NewDecoder(metaFile).Decode(&meta)
_ = metaFile.Close()
}
if meta.StickerPackID == "" {
res := gjson.GetBytes(data, "metadata.customProps")
if res.IsObject() {
_ = json.Unmarshal(exstrings.UnsafeBytes(res.Raw), &meta)
}
}
return data, &meta, nil
}
func (mc *MessageConverter) extractAnimatedSticker(fileInfo *PreparedMedia, data []byte) ([]byte, error) {
data, meta, err := ExtractAnimatedSticker(data)
if err != nil {
return nil, err
}
meta.ToMatrix(fileInfo.MessageEventContent)
fileInfo.Info.MimeType = "video/lottie+json"
fileInfo.FileName = "sticker.json"
return data, nil
}
func (mc *MessageConverter) convertAnimatedSticker(ctx context.Context, fileInfo *PreparedMedia, data []byte) ([]byte, []byte, *event.FileInfo, error) {
data, err := mc.extractAnimatedSticker(fileInfo, data)
if err != nil {
return nil, nil, nil, err
}
c := mc.AnimatedStickerConfig
if c.Target == "disable" {
return data, nil, nil, nil
} else if !lottie.Supported() {
zerolog.Ctx(ctx).Warn().Msg("Animated sticker conversion is enabled, but lottieconverter is not installed")
return data, nil, nil, nil
}
input := bytes.NewReader(data)
fileInfo.Info.MimeType = "image/" + c.Target
fileInfo.FileName = "sticker." + c.Target
switch c.Target {
case "png":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, "1")
return output.Bytes(), nil, nil, err
case "gif":
var output bytes.Buffer
err = lottie.Convert(ctx, input, "", &output, c.Target, c.Args.Width, c.Args.Height, strconv.Itoa(c.Args.FPS))
return output.Bytes(), nil, nil, err
case "webm", "webp":
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("mautrix-whatsapp-lottieconverter-%s.%s", random.String(10), c.Target))
defer func() {
_ = os.Remove(tmpFile)
}()
thumbnailData, err := lottie.FFmpegConvert(ctx, input, tmpFile, c.Args.Width, c.Args.Height, c.Args.FPS)
if err != nil {
return nil, nil, nil, err
}
data, err = os.ReadFile(tmpFile)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to read converted file: %w", err)
}
var thumbnailInfo *event.FileInfo
if thumbnailData != nil {
thumbnailInfo = &event.FileInfo{
MimeType: "image/png",
Width: c.Args.Width,
Height: c.Args.Height,
Size: len(thumbnailData),
}
}
return data, thumbnailData, thumbnailInfo, nil
default:
return nil, nil, nil, fmt.Errorf("unsupported target format %s", c.Target)
}
}
func (mc *MessageConverter) fillWebPStickerInfo(ctx context.Context, fileInfo *PreparedMedia, data []byte) {
meta, err := extractWebPStickerMetadata(data)
if err != nil {
zerolog.Ctx(ctx).Debug().Err(err).Msg("Failed to extract webp sticker metadata")
return
}
meta.ToMatrix(fileInfo.MessageEventContent)
}
// stickerMetadataEXIFTag is the custom EXIF tag WhatsApp uses to embed
// sticker pack metadata as a JSON object inside non-animated webp stickers.
const stickerMetadataEXIFTag = 0x5741
// extractWebPStickerMetadata parses the WhatsApp sticker pack metadata JSON
// embedded in EXIF tag 0x5741 of a non-animated webp sticker.
func extractWebPStickerMetadata(data []byte) (*StickerMetadata, error) {
exif, err := findWebPChunk(data, "EXIF")
if err != nil {
return nil, err
}
raw, err := findEXIFTagValue(exif, stickerMetadataEXIFTag)
if err != nil {
return nil, err
}
var meta StickerMetadata
err = json.Unmarshal(raw, &meta)
if err != nil {
return nil, fmt.Errorf("failed to parse sticker metadata JSON: %w", err)
}
return &meta, nil
}
func findWebPChunk(data []byte, chunkType string) ([]byte, error) {
if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, fmt.Errorf("not a webp file")
}
for pos := 12; pos+8 <= len(data); {
size := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
start := pos + 8
end := start + int(size)
if end > len(data) {
return nil, fmt.Errorf("webp chunk %q extends past end of file", data[pos:pos+4])
}
if string(data[pos:pos+4]) == chunkType {
return data[start:end], nil
}
pos = end
if pos%2 != 0 {
pos++
}
}
return nil, fmt.Errorf("webp chunk %q not found", chunkType)
}
func findEXIFTagValue(exif []byte, tag uint16) ([]byte, error) {
if len(exif) < 8 {
return nil, fmt.Errorf("exif data too short")
}
var bo binary.ByteOrder
switch string(exif[0:2]) {
case "II":
bo = binary.LittleEndian
case "MM":
bo = binary.BigEndian
default:
return nil, fmt.Errorf("invalid TIFF byte order %q", exif[0:2])
}
if bo.Uint16(exif[2:4]) != 0x002A {
return nil, fmt.Errorf("invalid TIFF magic")
}
ifdOffset := int(bo.Uint32(exif[4:8]))
if ifdOffset < 0 || ifdOffset+2 > len(exif) {
return nil, fmt.Errorf("IFD offset out of range")
}
count := int(bo.Uint16(exif[ifdOffset : ifdOffset+2]))
entries := ifdOffset + 2
if entries+count*12 > len(exif) {
return nil, fmt.Errorf("IFD entries out of range")
}
for i := 0; i < count; i++ {
entry := exif[entries+i*12 : entries+(i+1)*12]
if bo.Uint16(entry[0:2]) != tag {
continue
}
// Tag 0x5741 stores JSON as type 7 (UNDEFINED), where size == count bytes.
size := int(bo.Uint32(entry[4:8]))
if size <= 4 {
return entry[8 : 8+size], nil
}
offset := int(bo.Uint32(entry[8:12]))
if offset+size > len(exif) {
return nil, fmt.Errorf("exif tag value out of range")
}
return exif[offset : offset+size], nil
}
return nil, fmt.Errorf("exif tag 0x%04x not found", tag)
}

View file

@ -20,12 +20,10 @@ import (
"crypto/ecdh" "crypto/ecdh"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
"time"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/random" "go.mau.fi/util/random"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
) )
@ -39,11 +37,7 @@ type UserLoginMetadata struct {
APNSEncPrivKey []byte `json:"apns_enc_privkey,omitempty"` APNSEncPrivKey []byte `json:"apns_enc_privkey,omitempty"`
LoggedInAt jsontime.Unix `json:"logged_in_at,omitempty"` LoggedInAt jsontime.Unix `json:"logged_in_at,omitempty"`
AppStateRecoveryAttempted map[appstate.WAPatchName]time.Time `json:"app_state_recovery_attempted,omitempty"`
HistorySyncPortalsNeedCreating bool `json:"history_sync_portals_need_creating,omitempty"` HistorySyncPortalsNeedCreating bool `json:"history_sync_portals_need_creating,omitempty"`
MData json.RawMessage `json:"mdata,omitempty"`
} }
type PushKeys struct { type PushKeys struct {
@ -74,9 +68,6 @@ type GroupInviteMeta struct {
Code string `json:"code"` Code string `json:"code"`
Expiration int64 `json:"expiration,string"` Expiration int64 `json:"expiration,string"`
Inviter types.JID `json:"inviter"` Inviter types.JID `json:"inviter"`
GroupName string `json:"group_name,omitempty"`
IsParentGroup bool `json:"is_parent_group,omitempty"`
} }
type MessageMetadata struct { type MessageMetadata struct {

View file

@ -33,7 +33,6 @@ const (
mediaIDTypeMessage = 255 mediaIDTypeMessage = 255
mediaIDTypeAvatar = 254 mediaIDTypeAvatar = 254
mediaIDTypeCommunityAvatar = 253 mediaIDTypeCommunityAvatar = 253
mediaIDTypeStickerPackItem = 252
) )
func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, receiver networkid.UserLoginID) networkid.MediaID { func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, receiver networkid.UserLoginID) networkid.MediaID {
@ -83,28 +82,9 @@ type AvatarMediaInfo struct {
Community bool Community bool
} }
func MakeStickerPackMediaID(packID string, fileHash []byte, receiver networkid.UserLoginID) networkid.MediaID {
receiverID := compactJID(ParseUserLoginID(receiver, 0))
mediaID := make([]byte, 0, 4+len(packID)+len(fileHash)+len(receiverID))
mediaID = append(mediaID, mediaIDTypeStickerPackItem)
mediaID = append(mediaID, byte(len(packID)))
mediaID = append(mediaID, packID...)
mediaID = append(mediaID, byte(len(fileHash)))
mediaID = append(mediaID, fileHash...)
mediaID = append(mediaID, byte(len(receiverID)))
mediaID = append(mediaID, receiverID...)
return mediaID
}
type StickerPackMediaInfo struct {
PackID string
FileHash []byte
}
type ParsedMediaID struct { type ParsedMediaID struct {
Message *ParsedMessageID Message *ParsedMessageID
Avatar *AvatarMediaInfo Avatar *AvatarMediaInfo
Sticker *StickerPackMediaInfo
UserLogin networkid.UserLoginID UserLogin networkid.UserLoginID
} }
@ -158,24 +138,6 @@ func ParseMediaID(mediaID networkid.MediaID) (*ParsedMediaID, error) {
Community: mediaIDType == mediaIDTypeCommunityAvatar, Community: mediaIDType == mediaIDTypeCommunityAvatar,
} }
parsed.UserLogin = MakeUserLoginID(receiverID) parsed.UserLogin = MakeUserLoginID(receiverID)
case mediaIDTypeStickerPackItem:
packID, err := readCompact(&mediaID, parseString)
if err != nil {
return nil, fmt.Errorf("failed to parse sticker pack ID: %w", err)
}
fileHash, err := readCompact(&mediaID, rawBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse sticker file hash: %w", err)
}
receiverID, err := readCompact(&mediaID, parseCompactJID)
if err != nil {
return nil, fmt.Errorf("failed to parse receiver JID: %w", err)
}
parsed.Sticker = &StickerPackMediaInfo{
PackID: packID,
FileHash: fileHash,
}
parsed.UserLogin = MakeUserLoginID(receiverID)
default: default:
return nil, fmt.Errorf("unknown media ID type %d", mediaIDType) return nil, fmt.Errorf("unknown media ID type %d", mediaIDType)
} }
@ -284,10 +246,6 @@ func parseCompactJID(jid []byte) (types.JID, error) {
} }
} }
func rawBytes(data []byte) ([]byte, error) {
return data, nil
}
func readCompact[T any](data *networkid.MediaID, fn func(data []byte) (T, error)) (T, error) { func readCompact[T any](data *networkid.MediaID, fn func(data []byte) (T, error)) (T, error) {
var defVal T var defVal T
if len(*data) < 1 { if len(*data) < 1 {