Watch
1
0
Fork
You've already forked mautrix-whatsapp
0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-08-22 12:04:55 -04:00

Compare commits

..
Author SHA1 Message Date
Adam Van Ymeren
cd7f229ca6 resync: resync portals/ghosts immediately upon viewing
- refactor all the resync delays to a single per ghost/portal resync interval
- trigger resync immediately upon opening a room
- also resync portals if bridge capabilities has changed
2025-09-25 09:05:02 -07:00
51 changed files with 999 additions and 3147 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.26", "1.27"] go-version: ["1.24", "1.25"]
name: Lint ${{ matrix.go-version == '1.27' && '(latest)' || '(old)' }} name: Lint ${{ matrix.go-version == '1.25' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 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,94 +1,3 @@
# v26.08
* Switched direct chats to use LIDs instead of phone numbers.
# v26.07
* Updated Docker image to Alpine 3.24.
* Added support for the extra passkey authentication step when logging in.
* Added support for newer versions of poll messages from WhatsApp.
* Fixed poll votes in DMs.
# v26.06
* Added placeholder for group message history share notices.
* Fixed community spaces not being bridged properly in some cases.
* Fixed handling edits of HD media.
* Fixed duplicate message when a message from another own device is initially
undecryptable and later gets resolved.
# v26.05
* Added support for importing sticker packs from WhatsApp.
* Added support for WhatsApp's new message edit encryption scheme.
# 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.24 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.24 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.24
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

@ -0,0 +1,112 @@
package main
import (
"net/http"
"strings"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
type OtherUserInfo struct {
MXID id.UserID `json:"mxid"`
JID types.JID `json:"jid"`
Name string `json:"displayname"`
Avatar id.ContentURIString `json:"avatar_url"`
}
type PortalInfo struct {
RoomID id.RoomID `json:"room_id"`
OtherUser *OtherUserInfo `json:"other_user,omitempty"`
GroupInfo *types.GroupInfo `json:"group_info,omitempty"`
JustCreated bool `json:"just_created"`
}
type Error struct {
Success bool `json:"success"`
Error string `json:"error"`
ErrCode string `json:"errcode"`
}
func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
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")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, Error{
Error: "Internal server error while fetching contact list",
ErrCode: "failed to get contacts",
})
} else {
augmentedContacts := map[types.JID]any{}
for jid, contact := range contacts {
var avatarURL id.ContentURIString
if puppet, _ := m.Bridge.GetExistingGhostByID(r.Context(), waid.MakeUserID(jid)); puppet != nil {
avatarURL = puppet.AvatarMXC
}
augmentedContacts[jid] = map[string]interface{}{
"Found": contact.Found,
"FirstName": contact.FirstName,
"FullName": contact.FullName,
"PushName": contact.PushName,
"BusinessName": contact.BusinessName,
"AvatarURL": avatarURL,
}
}
exhttp.WriteJSONResponse(w, http.StatusOK, augmentedContacts)
}
}
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
number := r.PathValue("number")
userLogin := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if userLogin == nil {
return
}
startChat := strings.Contains(r.URL.Path, "/v1/pm/")
resp, err := userLogin.Client.(*connector.WhatsAppClient).ResolveIdentifier(r.Context(), number, startChat)
if err != nil {
hlog.FromRequest(r).Warn().Err(err).Str("identifier", number).Msg("Failed to resolve identifier")
matrix.RespondWithError(w, err, "Internal error resolving identifier")
return
}
var portal *bridgev2.Portal
if startChat {
portal, err = m.Bridge.GetPortalByKey(r.Context(), resp.Chat.PortalKey)
if err != nil {
hlog.FromRequest(r).Warn().Err(err).Stringer("portal_key", resp.Chat.PortalKey).Msg("Failed to get portal by key")
matrix.RespondWithError(w, err, "Internal error getting portal by key")
return
}
err = portal.CreateMatrixRoom(r.Context(), userLogin, nil)
if err != nil {
hlog.FromRequest(r).Warn().Err(err).Stringer("portal_key", resp.Chat.PortalKey).Msg("Failed to create matrix room for portal")
matrix.RespondWithError(w, err, "Internal error creating matrix room for portal")
return
}
} else {
portal, _ = m.Bridge.GetExistingPortalByKey(r.Context(), resp.Chat.PortalKey)
}
var roomID id.RoomID
if portal != nil {
roomID = portal.MXID
}
exhttp.WriteJSONResponse(w, http.StatusOK, PortalInfo{
RoomID: roomID,
OtherUser: &OtherUserInfo{
JID: waid.ParseUserID(resp.UserID),
MXID: resp.Ghost.Intent.GetMXID(),
Name: resp.Ghost.Name,
Avatar: resp.Ghost.AvatarMXC,
},
})
}

View file

@ -18,12 +18,18 @@ 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.08", Version: "0.12.5",
SemCalVer: true,
Connector: &connector.WhatsAppConnector{}, Connector: &connector.WhatsAppConnector{},
} }
func main() { func main() {
m.PostStart = func() {
if m.Matrix.Provisioning != nil {
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("POST /v1/pm/{number}", legacyProvResolveIdentifier)
}
}
m.InitVersion(Tag, Commit, BuildTime) m.InitVersion(Tag, Commit, BuildTime)
m.Run() m.Run()
} }

54
go.mod
View file

@ -1,53 +1,51 @@
module go.mau.fi/mautrix-whatsapp module go.mau.fi/mautrix-whatsapp
go 1.26.0 go 1.24.0
toolchain go1.27.0 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.19.0 go.mau.fi/util v0.9.1
go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde go.mau.fi/webp v0.2.0
go.mau.fi/webp v0.3.0 go.mau.fi/whatsmeow v0.0.0-20250919124702-c8bdfd36d05e
go.mau.fi/whatsmeow v0.0.0-20260821141805-33cfac511629 golang.org/x/image v0.31.0
golang.org/x/image v0.45.0 golang.org/x/net v0.44.0
golang.org/x/net v0.58.0 golang.org/x/sync v0.17.0
golang.org/x/sync v0.22.0 google.golang.org/protobuf v1.36.9
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.30.1-0.20260820142923-ae00f2c62e1e maunium.net/go/mautrix v0.25.2-0.20250919113047-b760023dcaa3
) )
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.15 // 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.49 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b // 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.5 // indirect github.com/yuin/goldmark v1.7.13 // indirect
go.mau.fi/libsignal v0.2.2 // indirect go.mau.fi/libsignal v0.2.0 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.29.0 // indirect
golang.org/x/text v0.41.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

106
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=
@ -8,19 +8,22 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= 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.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.15/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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b h1:sS7HLzwS+dO+gxATgQfeZDEdUZe2pKAB3nGoUwP5zU0= github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4=
github.com/petermattis/goid v0.0.0-20260816044145-ed329add6b1b/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=
@ -55,11 +62,10 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= 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.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= go.mau.fi/libsignal v0.2.0 h1:oRXj3OHhEJq51BFEM8/50UZblmWiTYH93hsNTPcbk90=
go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= go.mau.fi/libsignal v0.2.0/go.mod h1:tvjoDsMejgT38CXTXwqaYu8itBiY8O2Mb6biWvZBb9k=
go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde h1:eMHY9dMDkNuDMWhfTbMZHbbsxj7G6mfujjKei1HaFQM= go.mau.fi/util v0.9.1 h1:A+XKHRsjKkFi2qOm4RriR1HqY2hoOXNS3WFHaC89r2Y=
go.mau.fi/util v0.10.1-0.20260820140024-eb612d936fde/go.mod h1:z0ZZNt4hq3FZbUKnunexE/QscCx7VkLvQSvtggc/aE8= go.mau.fi/util v0.9.1/go.mod h1:M0bM9SyaOWJniaHs9hxEzz91r5ql6gYq6o1q5O1SsjQ=
go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/whatsmeow v0.0.0-20260821141805-33cfac511629 h1:99p9fTS1G73a1aifCVaV5MX4hDq0lVUKWGoUNoXyE3A= go.mau.fi/whatsmeow v0.0.0-20250919124702-c8bdfd36d05e h1:+o9+K5Qyo/490H4NyPVA0Es+aEzmmjaK37/437hV2LM=
go.mau.fi/whatsmeow v0.0.0-20260821141805-33cfac511629/go.mod h1:aMd13H2xFFGH9cskcvxo4Aae+TmyFN38yw+HvsrpwVg= go.mau.fi/whatsmeow v0.0.0-20250919124702-c8bdfd36d05e/go.mod h1:dvltpCF0rOHbbur25DHbQ3Ovi747z2Pm11S2M7p1T74=
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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.22.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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
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.30.1-0.20260820142923-ae00f2c62e1e h1:ATvERyBIUXJrnL7ezP2xCLLGCNmGClmfzoQq4N3jBcA= maunium.net/go/mautrix v0.25.2-0.20250919113047-b760023dcaa3 h1:MU7lyDtxVAsd1Mh+WGJOFD5PsGLRRl3Xv67zizxLkgc=
maunium.net/go/mautrix v0.30.1-0.20260820142923-ae00f2c62e1e/go.mod h1:Y02sBiAvfEVqK24bwVGCprmLATRZ7prWel3ZpB413e0= maunium.net/go/mautrix v0.25.2-0.20250919113047-b760023dcaa3/go.mod h1:iSueLJ/2fBaNrsTObGqi1j0cl/loxrtAjmjay1scYD8=

View file

@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exmaps"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
@ -64,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)
@ -116,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
} }
@ -151,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").
@ -197,64 +170,41 @@ 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())
} }
if jid.Server == types.DefaultUserServer { totalMessageCount += len(conv.GetMessages())
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid) log := log.With().
if err != nil { Stringer("chat_jid", jid).
log.Err(err).Stringer("pn", jid).Msg("Failed to get LID for phone number in history sync") Int("msg_count", len(conv.GetMessages())).
} else if lid.IsEmpty() { Logger()
log.Warn().Stringer("pn", jid).Msg("No LID found for phone number in history sync")
} else {
log.Debug().
Stringer("lid", lid).
Stringer("pn", jid).
Msg("Rerouting phone number DM to LID in history sync")
jid = lid
}
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("chat_jid", jid)
})
var firstItemTime, lastItemTime time.Time var minTime, maxTime time.Time
var minTimeIndex, maxTimeIndex int
ignoredTypes := 0 ignoredTypes := 0
rawMessages := conv.GetMessages() messages := make([]*wadb.HistorySyncMessageTuple, 0, len(conv.GetMessages()))
messages := make([]*wadb.HistorySyncMessageTuple, 0, len(rawMessages)) for i, rawMsg := range conv.GetMessages() {
allowClamp := conv.GetCommentsCount() == 0
var newerTS uint64
for i, rawMsg := range rawMessages {
// Don't store messages that will just be skipped. // Don't store messages that will just be skipped.
msgEvt, err := wa.Client.ParseWebMessage(jid, rawMsg.GetMessage()) msgEvt, err := wa.Client.ParseWebMessage(jid, rawMsg.GetMessage())
if err != nil { if err != nil {
@ -263,39 +213,22 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Str("msg_id", rawMsg.GetMessage().GetKey().GetID()). Str("msg_id", rawMsg.GetMessage().GetKey().GetID()).
Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()). Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()).
Msg("Dropping historical message due to parse error") Msg("Dropping historical message due to parse error")
log.Trace().
Any("web_message_info", rawMsg.GetMessage()).
Msg("Content of event that failed to parse")
continue continue
} }
if firstItemTime.IsZero() { if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) {
firstItemTime = msgEvt.Info.Timestamp minTime = msgEvt.Info.Timestamp
minTimeIndex = i
}
if maxTime.IsZero() || msgEvt.Info.Timestamp.After(maxTime) {
maxTime = msgEvt.Info.Timestamp
maxTimeIndex = i
} }
lastItemTime = msgEvt.Info.Timestamp
msgType := getMessageType(msgEvt.Message) msgType := getMessageType(msgEvt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") { if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
ignoredTypes++ ignoredTypes++
continue continue
} }
// Comments (replies) in announcement groups are not ordered by timestamp, so don't clamp them.
if rawMsg.GetMessage().GetCommentMetadata().GetCommentParentKey() != nil {
allowClamp = false
}
// WhatsApp has bugs where some random messages will have timestamps decades in the future.
// To ensure they don't mess up our ordering, require timestamps of older messages to be
// before the previous (newer) message.
if currentTS := rawMsg.GetMessage().GetMessageTimestamp(); newerTS > 0 && allowClamp && currentTS > newerTS {
log.Warn().
Time("current_ts", time.Unix(int64(currentTS), 0)).
Time("prev_ts", time.Unix(int64(newerTS), 0)).
Int("msg_index", i).
Str("msg_id", rawMsg.GetMessage().GetKey().GetID()).
Msg("Clamping message timestamp")
rawMsg.Message.MessageTimestamp = ptr.Ptr(newerTS)
msgEvt.Info.Timestamp = time.Unix(int64(newerTS), 0)
}
newerTS = rawMsg.GetMessage().GetMessageTimestamp()
marshaled, err := proto.Marshal(rawMsg) marshaled, err := proto.Marshal(rawMsg)
if err != nil { if err != nil {
log.Warn().Err(err). log.Warn().Err(err).
@ -310,8 +243,10 @@ func (wa *WhatsAppClient) handleWAHistorySync(
log.Debug(). log.Debug().
Int("wrapped_count", len(messages)). Int("wrapped_count", len(messages)).
Int("ignored_msg_type_count", ignoredTypes). Int("ignored_msg_type_count", ignoredTypes).
Time("first_item_time", firstItemTime). Time("lowest_time", minTime).
Time("last_item_time", lastItemTime). Int("lowest_time_index", minTimeIndex).
Time("highest_time", maxTime).
Int("highest_time_index", maxTimeIndex).
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()).
@ -320,17 +255,15 @@ 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")
if len(messages) > 0 { if len(messages) > 0 {
err = wa.Main.DB.Conversation.Put(ctx, wadb.NewConversation(wa.UserLogin.ID, jid, conv, firstItemTime)) 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
@ -338,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)
@ -348,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")
} }
@ -360,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) {
@ -407,9 +342,9 @@ 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) || errors.Is(err, whatsmeow.ErrGroupNotFound) { if errors.Is(err, whatsmeow.ErrNotInGroup) {
log.Debug().Err(err).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")
//err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, conv.ChatJID) //err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, conv.ChatJID)
//if err != nil { //if err != nil {
@ -482,72 +417,40 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
if err != nil { if err != nil {
return nil, err return nil, err
} }
if portalJID.Server == types.DefaultUserServer {
zerolog.Ctx(ctx).Warn().Stringer("portal_jid", portalJID).Msg("FetchMessages called for phone number portal")
}
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 { endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64)
endTime = ptr.Ptr(params.AnchorMessage.Timestamp) if err != nil {
return nil, fmt.Errorf("failed to parse cursor: %w", err)
} }
if params.Cursor != "" { endTime = ptr.Ptr(time.Unix(endTimeUnix, 0))
endTimeUnix, err := strconv.ParseInt(string(params.Cursor), 10, 64) } else if params.AnchorMessage != nil {
if err != nil { endTime = ptr.Ptr(params.AnchorMessage.Timestamp)
return nil, fmt.Errorf("failed to parse cursor: %w", err)
}
cursorTime := time.Unix(endTimeUnix, 0)
if endTime == nil || cursorTime.Before(*endTime) {
endTime = &cursorTime
}
}
}
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, portalJID, params)
}
return &bridgev2.FetchMessagesResponse{ return &bridgev2.FetchMessagesResponse{
HasMore: false, HasMore: false,
Forward: params.Forward, Forward: params.Forward,
}, nil }, nil
} }
hasMore := false
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
if len(messages) > params.Count { if len(messages) > params.Count {
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
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.
@ -558,90 +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
dups := make(exmaps.Set[networkid.MessageID])
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).
Int("msg_index", i).
Str("msg_id", msg.GetKey().GetID()).
Uint64("msg_time_seconds", msg.GetMessageTimestamp()).
Msg("Dropping historical message due to parse error")
zerolog.Ctx(ctx).Trace().
Any("web_message_info", msg.GetMessage()).
Msg("Content of event that failed to parse")
continue
}
if !explodeOnError {
msgType := getMessageType(evt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
continue
}
}
if !wa.ensureAltJIDs(ctx, &evt.Info.MessageSource, false) {
return nil, fmt.Errorf("failed to ensure alt JIDs for message %s", evt.Info.ID)
} }
var mediaReq *wadb.MediaRequest
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,
) )
// This is a hack to remove duplicates where the same message is inserted with both the LID and phone number sender
// TODO prevent those being inserted in the first place instead of hacking around it here
if !dups.Add(converted.ID) {
continue
}
convertedMessages = append(convertedMessages, converted)
if mediaReq != nil { if mediaReq != nil {
mediaRequests = append(mediaRequests, mediaReq) mediaRequests = append(mediaRequests, mediaReq)
} }
@ -650,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 {
@ -671,109 +517,6 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
}, nil }, nil
} }
func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID types.JID, 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: portalJID,
Sender: parsed.Sender,
IsFromMe: wa.IsOwnJID(parsed.Sender),
IsGroup: parsed.Chat.Server == types.GroupServer,
},
ID: parsed.ID,
Timestamp: params.AnchorMessage.Timestamp,
}, 50)
zerolog.Ctx(ctx).Debug().
Str("request_msg_id", msgID).
Stringer("portal_jid", portalJID).
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
}
if portalJID.Server == types.DefaultUserServer {
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, portalJID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("lid", portalJID).Msg("Failed to get LID for phone number in on-demand history sync")
} else if lid.IsEmpty() {
zerolog.Ctx(ctx).Warn().Stringer("lid", portalJID).Msg("No LID found for phone number in on-demand history sync")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("lid", lid).
Stringer("pn", portalJID).
Msg("Rerouting phone number DM to LID in on-demand history sync")
portalJID = lid
}
}
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) {
@ -784,35 +527,34 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
} }
// TODO use proper intent // TODO use proper intent
intent := wa.Main.Bridge.Bot intent := wa.Main.Bridge.Bot
msgID := waid.MakeMessageIDWithAltSender(info.Chat, info.Sender, info.SenderAlt, info.ID)
wrapped := &bridgev2.BackfillMessage{ wrapped := &bridgev2.BackfillMessage{
ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, rawMsg, info, isViewOnce, true, nil), ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, rawMsg, info, isViewOnce, true, nil),
Sender: wa.makeEventSender(ctx, pickLID(info.Sender, info.SenderAlt)), Sender: wa.makeEventSender(ctx, info.Sender),
ID: msgID, ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID),
TxnID: networkid.TransactionID(msgID), 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.GetLID() sender = wa.JID
} else if reaction.GetKey().GetParticipant() != "" { } else if reaction.GetKey().GetParticipant() != "" {
sender, _ = types.ParseJID(*reaction.Key.Participant) sender, _ = types.ParseJID(*reaction.Key.Participant)
} else if info.Chat.Server == types.DefaultUserServer || info.Chat.Server == types.HiddenUserServer || info.Chat.Server == types.BotServer { } else if info.Chat.Server == types.DefaultUserServer || info.Chat.Server == types.BotServer {
sender = info.Chat sender = info.Chat
} }
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, 9 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_07_22" 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,49 +161,26 @@ var whatsappCaps = &event.RoomFeatures{
MaxSize: WAMaxFileSize, MaxSize: WAMaxFileSize,
}, },
}, },
State: event.StateFeatureMap{ MaxTextLength: MaxTextLength,
event.StateRoomName.Type: {Level: event.CapLevelFullySupported}, LocationMessage: event.CapLevelFullySupported,
event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported}, Poll: event.CapLevelFullySupported,
event.StateTopic.Type: {Level: event.CapLevelFullySupported}, Reply: event.CapLevelFullySupported,
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported}, Edit: event.CapLevelFullySupported,
}, EditMaxCount: 10,
MemberActions: event.MemberFeatureMap{ EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)),
event.MemberActionInvite: event.CapLevelFullySupported, Delete: event.CapLevelFullySupported,
event.MemberActionKick: event.CapLevelFullySupported, DeleteForMe: false,
event.MemberActionLeave: event.CapLevelFullySupported, DeleteMaxAge: ptr.Ptr(jsontime.S(2 * 24 * time.Hour)),
}, Reaction: event.CapLevelFullySupported,
MaxTextLength: MaxTextLength, ReactionCount: 1,
LocationMessage: event.CapLevelFullySupported, ReadReceipts: true,
Poll: event.CapLevelFullySupported, TypingNotifications: true,
PollEnd: event.CapLevelUnsupported, DisappearingTimer: waDisappearingCap,
PollHiddenVotes: event.CapLevelUnsupported,
PollDuplicateOptions: event.CapLevelUnsupported,
PollMaxOptions: 12,
PollOptionMaxLength: 100,
Reply: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported,
EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)),
Delete: event.CapLevelFullySupported,
DeleteForMe: false,
DeleteMaxAge: ptr.Ptr(jsontime.S(2 * 24 * time.Hour)),
Reaction: event.CapLevelFullySupported,
ReactionCount: 1,
ReadReceipts: true,
TypingNotifications: true,
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
@ -215,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,13 @@ 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)
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,31 +54,36 @@ 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 { _, capVer := wa.Main.GetBridgeInfoVersion()
if conv == nil { wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalSyncMeta(capVer))
var err error
conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID) if conv == nil {
if err != nil { var err error
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get history sync conversation info") conv, err = wa.Main.DB.Conversation.Get(ctx, wa.UserLogin.ID, portalJID)
} if err != nil {
} zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get history sync conversation info")
if conv != nil {
wa.applyHistoryInfo(wrapped, conv)
} }
} }
if conv != nil {
wa.applyHistoryInfo(wrapped, conv)
}
wa.applyChatSettings(ctx, portalJID, wrapped) wa.applyChatSettings(ctx, portalJID, wrapped)
} }
func updatePortalLastSyncAt(_ context.Context, portal *bridgev2.Portal) bool { func updatePortalSyncMeta(expectedCapVer int) bridgev2.ExtraUpdater[*bridgev2.Portal] {
meta := portal.Metadata.(*waid.PortalMetadata) return func(_ context.Context, portal *bridgev2.Portal) bool {
forceSave := ResyncMinInterval < 24*time.Hour || time.Since(meta.LastSync.Time) > 24*time.Hour meta := portal.Metadata.(*waid.PortalMetadata)
meta.LastSync = jsontime.UnixNow() meta.LastSync = jsontime.UnixNow()
return forceSave if meta.BridgeCapsVersion != expectedCapVer {
meta.BridgeCapsVersion = expectedCapVer
}
return true
}
} }
func updateDisappearingTimerSetAt(ts int64) bridgev2.ExtraUpdater[*bridgev2.Portal] { func updateDisappearingTimerSetAt(ts int64) bridgev2.ExtraUpdater[*bridgev2.Portal] {
@ -99,18 +103,6 @@ func (wa *WhatsAppClient) applyChatSettings(ctx context.Context, chatID types.JI
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings") zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings")
return return
} }
if !chat.Found {
chatID, err = wa.GetStore().GetAltJID(ctx, chatID)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get alternate JID to get chat settings")
return
}
chat, err = wa.GetStore().ChatSettings.GetChatSettings(ctx, chatID)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings with alternate JID")
return
}
}
info.UserLocal = &bridgev2.UserLocalPortalInfo{ info.UserLocal = &bridgev2.UserLocalPortalInfo{
MutedUntil: ptr.Ptr(chat.MutedUntil), MutedUntil: ptr.Ptr(chat.MutedUntil),
} }
@ -153,20 +145,15 @@ const PrivateChatTopic = "WhatsApp private chat"
const BotChatTopic = "WhatsApp chat with a bot" const BotChatTopic = "WhatsApp chat with a bot"
func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridgev2.ChatInfo { func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridgev2.ChatInfo {
ownID := wa.JID
if jid.Server == types.HiddenUserServer {
ownID = wa.GetLID()
}
info := &bridgev2.ChatInfo{ info := &bridgev2.ChatInfo{
Type: ptr.Ptr(database.RoomTypeDM),
Topic: ptr.Ptr(PrivateChatTopic), Topic: ptr.Ptr(PrivateChatTopic),
Members: &bridgev2.ChatMemberList{ Members: &bridgev2.ChatMemberList{
IsFull: true, IsFull: true,
TotalMemberCount: 2, TotalMemberCount: 2,
OtherUserID: waid.MakeUserID(jid), OtherUserID: waid.MakeUserID(jid),
MemberMap: map[networkid.UserID]bridgev2.ChatMember{ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(jid): {EventSender: wa.makeEventSender(ctx, jid)}, waid.MakeUserID(jid): {EventSender: wa.makeEventSender(ctx, jid)},
waid.MakeUserID(ownID): {EventSender: wa.makeEventSender(ctx, ownID)}, waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(ctx, wa.JID)},
}, },
PowerLevels: &bridgev2.PowerLevelOverrides{ PowerLevels: &bridgev2.PowerLevelOverrides{
Events: map[event.Type]int{ Events: map[event.Type]int{
@ -176,14 +163,13 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge
event.StateBeeperDisappearingTimer: 0, event.StateBeeperDisappearingTimer: 0,
}, },
}, },
ExcludeChangesFromTimeline: true,
}, },
ExcludeChangesFromTimeline: true, Type: ptr.Ptr(database.RoomTypeDM),
} }
if jid.Server == types.BotServer { if jid.Server == types.BotServer {
info.Topic = ptr.Ptr(BotChatTopic) info.Topic = ptr.Ptr(BotChatTopic)
} }
if wa.IsOwnJID(jid) { if jid == wa.JID.ToNonAD() {
// For chats with self, force-split the members so the user's own ghost is always in the room. // For chats with self, force-split the members so the user's own ghost is always in the room.
info.Members.MemberMap = map[networkid.UserID]bridgev2.ChatMember{ info.Members.MemberMap = map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(jid): {EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(jid)}}, waid.MakeUserID(jid): {EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(jid)}},
@ -207,7 +193,7 @@ func (wa *WhatsAppClient) wrapStatusBroadcastInfo(ctx context.Context) *bridgev2
Members: &bridgev2.ChatMemberList{ Members: &bridgev2.ChatMemberList{
IsFull: false, IsFull: false,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(wa.GetLID()): {EventSender: wa.makeEventSender(ctx, wa.GetLID())}, waid.MakeUserID(wa.JID): {EventSender: wa.makeEventSender(ctx, wa.JID)},
}, },
}, },
Type: ptr.Ptr(database.RoomTypeDefault), Type: ptr.Ptr(database.RoomTypeDefault),
@ -275,7 +261,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
Name: ptr.Ptr(info.Name), Name: ptr.Ptr(info.Name),
Topic: ptr.Ptr(info.Topic), Topic: ptr.Ptr(info.Topic),
Members: &bridgev2.ChatMemberList{ Members: &bridgev2.ChatMemberList{
IsFull: !info.IsIncognito && !info.IsParent, IsFull: !info.IsIncognito,
TotalMemberCount: len(info.Participants), TotalMemberCount: len(info.Participants),
MemberMap: make(map[networkid.UserID]bridgev2.ChatMember, len(info.Participants)), MemberMap: make(map[networkid.UserID]bridgev2.ChatMember, len(info.Participants)),
PowerLevels: &bridgev2.PowerLevelOverrides{ PowerLevels: &bridgev2.PowerLevelOverrides{
@ -284,32 +270,28 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
Ban: ptr.Ptr(nobodyPL), Ban: ptr.Ptr(nobodyPL),
// TODO allow invites if bridge config says to allow them, or maybe if relay mode is enabled? // TODO allow invites if bridge config says to allow them, or maybe if relay mode is enabled?
Events: map[event.Type]int{ Events: map[event.Type]int{
event.StateRoomName: metaChangePL, event.StateRoomName: metaChangePL,
event.StateRoomAvatar: metaChangePL, event.StateRoomAvatar: metaChangePL,
event.StateTopic: metaChangePL, event.StateTopic: metaChangePL,
event.EventReaction: defaultPL, event.EventReaction: defaultPL,
event.EventRedaction: defaultPL, event.EventRedaction: defaultPL,
event.EventUnstablePollResponse: defaultPL,
event.StateBeeperDisappearingTimer: metaChangePL, event.StateBeeperDisappearingTimer: metaChangePL,
// TODO always allow poll responses
}, },
}, },
}, },
ExcludeChangesFromTimeline: true,
Disappear: &database.DisappearingSetting{ Disappear: &database.DisappearingSetting{
Type: event.DisappearingTypeAfterSend, Type: event.DisappearingTypeAfterSend,
Timer: time.Duration(info.DisappearingTimer) * time.Second, Timer: time.Duration(info.DisappearingTimer) * time.Second,
}, },
ExtraUpdates: extraUpdater, ExtraUpdates: extraUpdater,
} }
var hasSelf bool
for _, pcp := range info.Participants { for _, pcp := range info.Participants {
member := bridgev2.ChatMember{ member := bridgev2.ChatMember{
EventSender: wa.makeEventSender(ctx, pcp.JID), EventSender: wa.makeEventSender(ctx, pcp.JID),
Membership: event.MembershipJoin, Membership: event.MembershipJoin,
} }
if member.EventSender.IsFromMe {
hasSelf = true
}
if pcp.IsSuperAdmin { if pcp.IsSuperAdmin {
member.PowerLevel = ptr.Ptr(superAdminPL) member.PowerLevel = ptr.Ptr(superAdminPL)
} else if pcp.IsAdmin { } else if pcp.IsAdmin {
@ -320,21 +302,18 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
member.MemberEventExtra = map[string]any{ member.MemberEventExtra = map[string]any{
"com.beeper.exclude_from_timeline": true, "com.beeper.exclude_from_timeline": true,
} }
wrapped.Members.MemberMap.Set(member) wrapped.Members.MemberMap[waid.MakeUserID(pcp.JID)] = member
if pcp.JID.Server == types.HiddenUserServer && !pcp.PhoneNumber.IsEmpty() { if pcp.JID.Server == types.HiddenUserServer && !pcp.PhoneNumber.IsEmpty() {
wrapped.Members.MemberMap.Add(bridgev2.ChatMember{ wrapped.Members.MemberMap[waid.MakeUserID(pcp.PhoneNumber)] = bridgev2.ChatMember{
EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(pcp.PhoneNumber)}, EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(pcp.PhoneNumber)},
Membership: event.MembershipLeave, Membership: event.MembershipLeave,
PrevMembership: event.MembershipJoin, PrevMembership: event.MembershipJoin,
MemberEventExtra: map[string]any{ MemberEventExtra: map[string]any{
"com.beeper.exclude_from_timeline": true, "com.beeper.exclude_from_timeline": true,
}, },
}) }
} }
} }
if info.IsParent && !hasSelf && info.AddressingMode == types.AddressingModeLID {
wrapped.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: wa.makeEventSender(ctx, wa.GetLID())})
}
if !info.LinkedParentJID.IsEmpty() { if !info.LinkedParentJID.IsEmpty() {
wrapped.ParentID = ptr.Ptr(waid.MakePortalID(info.LinkedParentJID)) wrapped.ParentID = ptr.Ptr(waid.MakePortalID(info.LinkedParentJID))
@ -445,7 +424,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,
}) })
@ -474,7 +453,7 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types.
wrappedAvatar = &bridgev2.Avatar{ wrappedAvatar = &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID), ID: networkid.AvatarID(avatar.ID),
Get: func(ctx context.Context) ([]byte, error) { Get: func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithOnlyPath(ctx, avatar.DirectPath) return wa.Client.DownloadMediaWithPath(ctx, avatar.DirectPath, nil, nil, nil, 0, "", "")
}, },
} }
} }
@ -487,7 +466,7 @@ func (wa *WhatsAppClient) makePortalAvatarFetcher(avatarID string, sender types.
return false return false
} }
//lint:ignore SA1019 TODO invent a cleaner way to fetch avatar metadata before updating? //lint:ignore SA1019 TODO invent a cleaner way to fetch avatar metadata before updating?
return portal.Internal().UpdateAvatar(ctx, wrappedAvatar, senderIntent, ts, false) return portal.Internal().UpdateAvatar(ctx, wrappedAvatar, senderIntent, ts)
} }
} }
@ -513,18 +492,18 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
if info.ThreadMeta.Picture != nil { if info.ThreadMeta.Picture != nil {
avatar.ID = networkid.AvatarID(info.ThreadMeta.Picture.ID) avatar.ID = networkid.AvatarID(info.ThreadMeta.Picture.ID)
avatar.Get = func(ctx context.Context) ([]byte, error) { avatar.Get = func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithOnlyPath(ctx, info.ThreadMeta.Picture.DirectPath) return wa.Client.DownloadMediaWithPath(ctx, info.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "")
} }
} 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 {
return nil, fmt.Errorf("full res avatar info is missing") return nil, fmt.Errorf("full res avatar info is missing")
} }
return wa.Client.DownloadMediaWithOnlyPath(ctx, meta.ThreadMeta.Picture.DirectPath) return wa.Client.DownloadMediaWithPath(ctx, meta.ThreadMeta.Picture.DirectPath, nil, nil, nil, 0, "", "")
} }
} else { } else {
avatar.ID = "remove" avatar.ID = "remove"
@ -540,8 +519,8 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
Members: &bridgev2.ChatMemberList{ Members: &bridgev2.ChatMemberList{
TotalMemberCount: info.ThreadMeta.SubscriberCount, TotalMemberCount: info.ThreadMeta.SubscriberCount,
MemberMap: map[networkid.UserID]bridgev2.ChatMember{ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(wa.GetLID()): { waid.MakeUserID(wa.JID): {
EventSender: wa.makeEventSender(ctx, wa.GetLID()), EventSender: wa.makeEventSender(ctx, wa.JID),
PowerLevel: &ownPowerLevel, PowerLevel: &ownPowerLevel,
}, },
}, },
@ -550,12 +529,12 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
StateDefault: ptr.Ptr(nobodyPL), StateDefault: ptr.Ptr(nobodyPL),
Ban: ptr.Ptr(nobodyPL), Ban: ptr.Ptr(nobodyPL),
Events: map[event.Type]int{ Events: map[event.Type]int{
event.StateRoomName: adminPL, event.StateRoomName: adminPL,
event.StateRoomAvatar: adminPL, event.StateRoomAvatar: adminPL,
event.StateTopic: adminPL, event.StateTopic: adminPL,
event.EventReaction: defaultPL, event.EventReaction: defaultPL,
event.EventRedaction: defaultPL, event.EventRedaction: defaultPL,
event.EventUnstablePollResponse: defaultPL, // TODO always allow poll responses
}, },
}, },
}, },

View file

@ -22,13 +22,12 @@ import (
"fmt" "fmt"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"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 +37,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,16 +45,14 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
w := &WhatsAppClient{ w := &WhatsAppClient{
Main: wa, Main: wa,
UserLogin: login, UserLogin: login,
MC: noopMCInstance,
historySyncWakeup: make(chan struct{}, 1), historySyncs: make(chan *waHistorySync.HistorySync, 64),
resyncQueue: make(map[types.JID]resyncQueueItem), historySyncWakeup: make(chan struct{}, 1),
directMediaRetries: make(map[networkid.MessageID]*directMediaRetry), resyncQueueCh: make(chan resyncQueueItem, 256),
mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle), directMediaRetries: make(map[networkid.MessageID]*directMediaRetry),
pushNamesSynced: exsync.NewEvent(), mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle),
appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time), pushNamesSynced: exsync.NewEvent(),
createDedup: exsync.NewSet[types.MessageID](),
disableNewsletter: store.BaseClientPayload.GetUserAgent().GetPlatform() == waWa6.ClientPayload_UserAgent_MACOS,
} }
login.Client = w login.Client = w
@ -71,15 +67,16 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
if err != nil { if err != nil {
return err return err
} }
w.LID = w.Device.GetLID()
if w.Device != nil { if w.Device != nil {
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)
w.Client.SynchronousAck = true if bridgev2.PortalEventBuffer == 0 {
w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0 w.Client.SynchronousAck = true
w.Client.ManualHistorySyncDownload = true w.Client.EnableDecryptedEventBuffer = 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
@ -87,7 +84,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")
} }
@ -106,14 +102,12 @@ type WhatsAppClient struct {
Client *whatsmeow.Client Client *whatsmeow.Client
Device *store.Device Device *store.Device
JID types.JID JID types.JID
LID 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 resyncQueueCh chan resyncQueueItem
resyncQueueLock sync.Mutex resyncQueueLock sync.Mutex
nextResync time.Time
directMediaRetries map[networkid.MessageID]*directMediaRetry directMediaRetries map[networkid.MessageID]*directMediaRetry
directMediaLock sync.Mutex directMediaLock sync.Mutex
mediaRetryLock *semaphore.Weighted mediaRetryLock *semaphore.Weighted
@ -121,11 +115,7 @@ type WhatsAppClient struct {
isNewLogin bool isNewLogin bool
pushNamesSynced *exsync.Event pushNamesSynced *exsync.Event
lastPresence types.Presence lastPresence types.Presence
createDedup *exsync.Set[types.MessageID]
disableNewsletter bool
appStateRecoveryLock sync.Mutex
appStateFullSyncAttempted map[appstate.WAPatchName]time.Time
} }
var ( var (
@ -133,7 +123,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{
@ -189,19 +178,7 @@ func (wa *WhatsAppClient) RegisterPushNotifications(ctx context.Context, pushTyp
} }
func (wa *WhatsAppClient) IsThisUser(_ context.Context, userID networkid.UserID) bool { func (wa *WhatsAppClient) IsThisUser(_ context.Context, userID networkid.UserID) bool {
return userID == waid.MakeUserID(wa.JID) || userID == waid.MakeUserID(wa.GetLID()) return userID == waid.MakeUserID(wa.JID)
}
func (wa *WhatsAppClient) IsOwnJID(jid types.JID) bool {
return (jid.Server == types.DefaultUserServer && jid.User == wa.JID.User) ||
(jid.Server == types.HiddenUserServer && jid.User == wa.GetLID().User)
}
func (wa *WhatsAppClient) GetLID() types.JID {
if wa.LID.IsEmpty() && !wa.JID.IsEmpty() {
wa.LID = wa.GetStore().GetLID()
}
return wa.LID
} }
func (wa *WhatsAppClient) Connect(ctx context.Context) { func (wa *WhatsAppClient) Connect(ctx context.Context) {
@ -213,20 +190,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.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,
@ -282,11 +252,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
} }
@ -311,7 +279,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,
@ -319,6 +287,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)
@ -333,7 +302,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",
@ -348,14 +317,13 @@ 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.resyncLoop(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 {
go wa.mediaRequestLoop(ctx) go wa.mediaRequestLoop(ctx)
} }
@ -371,14 +339,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()
} }
@ -443,21 +407,18 @@ 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")
} }
} }
if msg.Portal == nil || msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Add(5*time.Minute).After(time.Now()) { if msg.Portal == nil {
// If we resynced this portal within the last 5 minutes, don't do it again
return nil return nil
} }
// Reset, but don't save, portal last sync time for immediate sync now // Ask the queue to resync if needed (old or version mismatch). No-op otherwise.
msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Time = time.Time{} wa.EnqueuePortalResync(msg.Portal)
// Enqueue for the sync, don't block on it completing
wa.EnqueuePortalResync(msg.Portal, true)
if msg.Portal.OtherUserID != "" { if msg.Portal.OtherUserID != "" {
// If this is a DM, also sync the ghost of the other user immediately // If this is a DM, also sync the ghost of the other user immediately
@ -469,8 +430,6 @@ func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *brid
Str("other_user_id", string(msg.Portal.OtherUserID)). Str("other_user_id", string(msg.Portal.OtherUserID)).
Msg("No ghost found for other user in portal") Msg("No ghost found for other user in portal")
} else { } else {
// Reset, but don't save, portal last sync time for immediate sync now
ghost.Metadata.(*waid.GhostMetadata).LastSync.Time = time.Time{}
wa.EnqueueGhostResync(ghost) wa.EnqueueGhostResync(ghost)
} }
} }
@ -478,19 +437,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 {
@ -122,7 +119,11 @@ func fnSync(ce *commands.Event) {
ce.Reply("Failed to get joined groups: %v", err) ce.Reply("Failed to get joined groups: %v", err)
return return
} }
_, capVer := wa.Main.GetBridgeInfoVersion()
for _, group := range groups { for _, group := range groups {
wrapped := wa.wrapGroupInfo(ce.Ctx, group)
wrapped.ExtraUpdates = bridgev2.MergeExtraUpdaters(wrapped.ExtraUpdates, updatePortalSyncMeta(capVer))
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 +131,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 +142,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 +190,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 +220,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 +242,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 +281,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 +289,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

@ -2,7 +2,6 @@ package connector
import ( import (
_ "embed" _ "embed"
"fmt"
"strings" "strings"
"text/template" "text/template"
"time" "time"
@ -51,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"`
@ -71,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:"-"`
@ -91,15 +87,7 @@ func (c *Config) UnmarshalYAML(node *yaml.Node) error {
func (c *Config) PostProcess() error { func (c *Config) PostProcess() error {
var err error var err error
c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate) c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate)
if err != nil { return err
return err
}
// Try to execute template to make sure it's valid
_, err = c.formatDisplayname(types.PSAJID, "", types.ContactInfo{})
if err != nil {
return fmt.Errorf("failed to execute displayname template: %w", err)
}
return nil
} }
func upgradeConfig(helper up.Helper) { func upgradeConfig(helper up.Helper) {
@ -128,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")
@ -145,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 {
@ -160,7 +146,7 @@ type DisplaynameParams struct {
Short string Short string
} }
func (c *Config) formatDisplayname(jid types.JID, phone string, contact types.ContactInfo) (string, error) { func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.ContactInfo) string {
var nameBuf strings.Builder var nameBuf strings.Builder
if phone == "" && jid.Server == types.DefaultUserServer { if phone == "" && jid.Server == types.DefaultUserServer {
phone = "+" + jid.User phone = "+" + jid.User
@ -179,21 +165,13 @@ func (c *Config) formatDisplayname(jid types.JID, phone string, contact types.Co
Name: contact.FullName, Name: contact.FullName,
Short: contact.FirstName, Short: contact.FirstName,
}) })
return nameBuf.String(), err
}
func (c *Config) FormatDisplayname(jid types.JID, phone string, contact types.ContactInfo) string {
name, err := c.formatDisplayname(jid, phone, contact)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return name return nameBuf.String()
} }
func redactPhone(phone string) string { func redactPhone(phone string) string {
if len(phone) <= 4 {
return phone
}
// This doesn't keep 2+ digit country codes properly, but whatever // This doesn't keep 2+ digit country codes properly, but whatever
return phone[:2] + strings.Repeat("∙", len(phone)-4) + phone[len(phone)-2:] return phone[:2] + strings.Repeat("∙", len(phone)-4) + phone[len(phone)-2:]
} }

View file

@ -20,16 +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"
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
"go.mau.fi/util/exsync"
"go.mau.fi/util/random" "go.mau.fi/util/random"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg" "go.mau.fi/whatsmeow/proto/waCompanionReg"
@ -61,19 +56,12 @@ type WhatsAppConnector struct {
mediaEditCache MediaEditCache mediaEditCache MediaEditCache
mediaEditCacheLock sync.RWMutex mediaEditCacheLock sync.RWMutex
stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc] stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
unmigratedDMs *exsync.Set[networkid.PortalKey]
}
func init() {
sqlstore.PostgresArrayWrapper = pq.Array
} }
var ( 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) {
@ -113,7 +101,6 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin, cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin,
) )
wa.mediaEditCache = make(MediaEditCache) wa.mediaEditCache = make(MediaEditCache)
wa.unmigratedDMs = exsync.NewSet[networkid.PortalKey]()
whatsmeowDBLog := bridge.Log.With().Str("db_section", "whatsmeow").Logger() whatsmeowDBLog := bridge.Log.With().Str("db_section", "whatsmeow").Logger()
wa.DeviceStore = sqlstore.NewWithWrappedDB( wa.DeviceStore = sqlstore.NewWithWrappedDB(
@ -128,12 +115,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,7 +143,7 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"} return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"}
} }
return wa.migrateToLIDDMs(ctx) return nil
} }
func (wa *WhatsAppConnector) Stop() { func (wa *WhatsAppConnector) Stop() {
@ -168,8 +154,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 == "" {
@ -182,29 +166,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)
@ -220,13 +195,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

@ -17,7 +17,6 @@
package connector package connector
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -30,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"
@ -52,7 +50,7 @@ func (wa *WhatsAppConnector) SetUseDirectMedia() {
} }
var ErrReloadNeeded = mautrix.RespError{ var ErrReloadNeeded = mautrix.RespError{
ErrCode: "COM.BEEPER.MEDIA_RELOAD_NEEDED", ErrCode: "FI.MAU.WHATSAPP_RELOAD_NEEDED",
Err: "Media is no longer available on WhatsApp servers and must be re-requested from your phone", Err: "Media is no longer available on WhatsApp servers and must be re-requested from your phone",
StatusCode: http.StatusNotFound, StatusCode: http.StatusNotFound,
} }
@ -68,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")
} }
@ -89,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,
@ -119,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)
@ -130,31 +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.DownloadMediaWithOnlyPathToFile(ctx, cachedInfo.DirectPath, w) return waClient.Client.DownloadMediaWithPathToFile(
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)
@ -170,9 +145,6 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to unmarshal media keys: %w", err) return nil, fmt.Errorf("failed to unmarshal media keys: %w", err)
} }
if version := parsedID.Message.Version; len(version) > 0 && !bytes.Equal(version, keys.EncSHA256) && !bytes.Equal(version, keys.SHA256) {
return nil, mautrix.MNotFound.WithMessage("Version mismatch, media was likely replaced")
}
var ul *bridgev2.UserLogin var ul *bridgev2.UserLogin
if parsedID.UserLogin != "" { if parsedID.UserLogin != "" {
ul = wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin) ul = wa.Bridge.GetCachedUserLoginByID(parsedID.UserLogin)
@ -189,67 +161,53 @@ 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["com.beeper.interactive_download_request"]
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)
} }
if 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
} }
@ -287,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()
} }
@ -308,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

@ -39,6 +39,18 @@ import (
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
func (wa *WhatsAppClient) getPortalKeyByMessageSource(ms types.MessageSource) networkid.PortalKey {
jid := ms.Chat
if ms.IsIncomingBroadcast() {
if ms.IsFromMe {
jid = ms.BroadcastListOwner.ToNonAD()
} else {
jid = ms.Sender.ToNonAD()
}
}
return wa.makeWAPortalKey(jid)
}
type MessageInfoWrapper struct { type MessageInfoWrapper struct {
Info types.MessageInfo Info types.MessageInfo
wa *WhatsAppClient wa *WhatsAppClient
@ -49,26 +61,7 @@ func (evt *MessageInfoWrapper) ShouldCreatePortal() bool {
} }
func (evt *MessageInfoWrapper) GetPortalKey() networkid.PortalKey { func (evt *MessageInfoWrapper) GetPortalKey() networkid.PortalKey {
ms := evt.Info.MessageSource return evt.wa.getPortalKeyByMessageSource(evt.Info.MessageSource)
jid := ms.Chat
if ms.IsIncomingBroadcast() {
if ms.IsFromMe {
// TODO can this still be a phone number?
jid = ms.BroadcastListOwner.ToNonAD()
} else {
jid = ms.Sender.ToNonAD()
if jid.Server == types.DefaultUserServer && !ms.SenderAlt.IsEmpty() {
jid = ms.SenderAlt.ToNonAD()
}
}
} else if jid.Server == types.DefaultUserServer {
if !ms.IsFromMe && ms.Chat.ToNonAD() == ms.Sender.ToNonAD() && !ms.SenderAlt.IsEmpty() {
jid = ms.SenderAlt.ToNonAD()
} else if !ms.RecipientAlt.IsEmpty() {
jid = ms.RecipientAlt.ToNonAD()
}
}
return evt.wa.makeWAPortalKey(jid)
} }
func (evt *MessageInfoWrapper) AddLogContext(c zerolog.Context) zerolog.Context { func (evt *MessageInfoWrapper) AddLogContext(c zerolog.Context) zerolog.Context {
@ -79,19 +72,12 @@ func (evt *MessageInfoWrapper) GetTimestamp() time.Time {
return evt.Info.Timestamp return evt.Info.Timestamp
} }
func pickLID(main, alt types.JID) types.JID {
if main.Server == types.DefaultUserServer && alt.Server == types.HiddenUserServer {
return alt
}
return main
}
func (evt *MessageInfoWrapper) GetSender() bridgev2.EventSender { func (evt *MessageInfoWrapper) GetSender() bridgev2.EventSender {
return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, pickLID(evt.Info.Sender, evt.Info.SenderAlt)) return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, evt.Info.Sender)
} }
func (evt *MessageInfoWrapper) GetID() networkid.MessageID { func (evt *MessageInfoWrapper) GetID() networkid.MessageID {
return waid.MakeMessageIDWithAltSender(evt.Info.Chat, evt.Info.Sender, evt.Info.SenderAlt, evt.Info.ID) return waid.MakeMessageID(evt.Info.Chat, evt.Info.Sender, evt.Info.ID)
} }
func (evt *MessageInfoWrapper) GetTransactionID() networkid.TransactionID { func (evt *MessageInfoWrapper) GetTransactionID() networkid.TransactionID {
@ -105,7 +91,6 @@ type WAMessageEvent struct {
parsedMessageType string parsedMessageType string
isUndecryptableUpsertSubEvent bool isUndecryptableUpsertSubEvent bool
dontRenderEdited bool
postHandle func() postHandle func()
} }
@ -154,7 +139,7 @@ func (evt *WAMessageEvent) PreHandle(ctx context.Context, portal *bridgev2.Porta
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
@ -166,6 +151,13 @@ func (evt *WAMessageEvent) PreHandle(ctx context.Context, portal *bridgev2.Porta
log.Info().Msg("Resyncing group members as it appears to have switched to LID addressing mode") log.Info().Msg("Resyncing group members as it appears to have switched to LID addressing mode")
portal.UpdateInfo(ctx, evt.wa.wrapGroupInfo(ctx, info), evt.wa.UserLogin, nil, time.Time{}) portal.UpdateInfo(ctx, evt.wa.wrapGroupInfo(ctx, info), evt.wa.UserLogin, nil, time.Time{})
log.Debug().Msg("Finished resyncing after LID change") log.Debug().Msg("Finished resyncing after LID change")
if evt.Info.Sender.Server == types.DefaultUserServer && evt.Info.SenderAlt.Server == types.HiddenUserServer {
evt.Info.Sender, evt.Info.SenderAlt = evt.Info.SenderAlt, evt.Info.Sender
log.Debug().
Stringer("new_sender", evt.Info.Sender).
Stringer("new_sender_alt", evt.Info.SenderAlt).
Msg("Overriding sender to LID after resyncing group members")
}
} }
func (evt *WAMessageEvent) PostHandle(ctx context.Context, portal *bridgev2.Portal) { func (evt *WAMessageEvent) PostHandle(ctx context.Context, portal *bridgev2.Portal) {
@ -181,15 +173,12 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
} }
var editedMsg *waE2E.Message var editedMsg *waE2E.Message
var previouslyConvertedPart *bridgev2.ConvertedMessagePart var previouslyConvertedPart *bridgev2.ConvertedMessagePart
targetMessage := evt.GetTargetMessage()
cacheMessage := targetMessage
if evt.isUndecryptableUpsertSubEvent { if evt.isUndecryptableUpsertSubEvent {
// TODO db metadata needs to be updated in this case to remove the error // TODO db metadata needs to be updated in this case to remove the error
editedMsg = evt.Message editedMsg = evt.Message
cacheMessage = evt.GetID()
} else { } else {
editedMsg = evt.Message.GetProtocolMessage().GetEditedMessage() editedMsg = evt.Message.GetProtocolMessage().GetEditedMessage()
previouslyConvertedPart = evt.wa.Main.GetMediaEditCache(portal, targetMessage) previouslyConvertedPart = evt.wa.Main.GetMediaEditCache(portal, evt.GetTargetMessage())
meta := existing[0].Metadata.(*waid.MessageMetadata) meta := existing[0].Metadata.(*waid.MessageMetadata)
if slices.Contains(meta.Edits, evt.Info.ID) { if slices.Contains(meta.Edits, evt.Info.ID) {
return nil, fmt.Errorf("%w: edit already handled", bridgev2.ErrIgnoringRemoteEvent) return nil, fmt.Errorf("%w: edit already handled", bridgev2.ErrIgnoringRemoteEvent)
@ -205,11 +194,9 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
evt.postHandle = func() { evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), cm, false) evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), cm, false)
} }
} else if len(cm.Parts) > 0 && cacheMessage != "" {
evt.wa.Main.AddMediaEditCache(portal, cacheMessage, cm.Parts[0])
} }
editPart := cm.Parts[0].ToEditPart(existing[0]) editPart := cm.Parts[0].ToEditPart(existing[0])
if evt.isUndecryptableUpsertSubEvent || evt.dontRenderEdited { if evt.isUndecryptableUpsertSubEvent {
if editPart.TopLevelExtra == nil { if editPart.TopLevelExtra == nil {
editPart.TopLevelExtra = make(map[string]any) editPart.TopLevelExtra = make(map[string]any)
} }
@ -222,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 ""
} }
@ -282,7 +263,7 @@ func (evt *WAMessageEvent) HandleExisting(ctx context.Context, portal *bridgev2.
} }
func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) { func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
evt.wa.EnqueuePortalResync(portal, false) evt.wa.EnqueuePortalResync(portal)
converted := evt.wa.Main.MsgConv.ToMatrix( converted := evt.wa.Main.MsgConv.ToMatrix(
ctx, portal, evt.wa.Client, intent, evt.Message, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, nil, ctx, portal, evt.wa.Client, intent, evt.Message, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, nil,
) )
@ -359,13 +340,9 @@ func (evt *WAUndecryptableMessage) ConvertMessage(ctx context.Context, portal *b
} }
content := &undecryptableMessageContent content := &undecryptableMessageContent
if evt.Type == events.UnavailableTypeViewOnce { if evt.Type == events.UnavailableTypeViewOnce {
body := "You received a view once message. For added privacy, you can only open it on the WhatsApp app."
if evt.Info.IsFromMe {
body = "You sent a view once message from another device."
}
content = &event.MessageEventContent{ content = &event.MessageEventContent{
MsgType: event.MsgNotice, MsgType: event.MsgNotice,
Body: body, Body: "You received a view once message. For added privacy, you can only open it on the WhatsApp app.",
} }
} }
// TODO thread root for comments // TODO thread root for comments
@ -390,9 +367,7 @@ func (evt *WAUndecryptableMessage) GetStreamOrder() int64 {
type WAMediaRetry struct { type WAMediaRetry struct {
*events.MediaRetry *events.MediaRetry
wa *WhatsAppClient wa *WhatsAppClient
senderLID types.JID
chatLID types.JID
} }
func (evt *WAMediaRetry) GetType() bridgev2.RemoteEventType { func (evt *WAMediaRetry) GetType() bridgev2.RemoteEventType {
@ -400,7 +375,7 @@ func (evt *WAMediaRetry) GetType() bridgev2.RemoteEventType {
} }
func (evt *WAMediaRetry) GetPortalKey() networkid.PortalKey { func (evt *WAMediaRetry) GetPortalKey() networkid.PortalKey {
return evt.wa.makeWAPortalKey(pickLID(evt.ChatID, evt.chatLID)) return evt.wa.makeWAPortalKey(evt.ChatID)
} }
func (evt *WAMediaRetry) AddLogContext(c zerolog.Context) zerolog.Context { func (evt *WAMediaRetry) AddLogContext(c zerolog.Context) zerolog.Context {
@ -414,23 +389,16 @@ func (evt *WAMediaRetry) AddLogContext(c zerolog.Context) zerolog.Context {
func (evt *WAMediaRetry) getRealSender() types.JID { func (evt *WAMediaRetry) getRealSender() types.JID {
sender := evt.SenderID sender := evt.SenderID
if sender.IsEmpty() { if evt.FromMe {
if evt.FromMe { sender = evt.wa.JID.ToNonAD()
if evt.ChatID.Server == types.HiddenUserServer { } else if sender.IsEmpty() && (evt.ChatID.Server == types.DefaultUserServer || evt.ChatID.Server == types.BotServer) {
sender = evt.wa.GetLID().ToNonAD() sender = evt.ChatID.ToNonAD()
} else {
sender = evt.wa.JID.ToNonAD()
}
} else if evt.ChatID.Server == types.DefaultUserServer || evt.ChatID.Server == types.HiddenUserServer || evt.ChatID.Server == types.BotServer {
sender = evt.ChatID.ToNonAD()
}
} }
return sender return sender
} }
func (evt *WAMediaRetry) GetSender() bridgev2.EventSender { func (evt *WAMediaRetry) GetSender() bridgev2.EventSender {
realSender := pickLID(evt.getRealSender(), evt.senderLID) return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, evt.getRealSender())
return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, realSender)
} }
func (evt *WAMediaRetry) GetTargetMessage() networkid.MessageID { func (evt *WAMediaRetry) GetTargetMessage() networkid.MessageID {

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

@ -15,8 +15,6 @@ import (
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/util/variationselector" "go.mau.fi/util/variationselector"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
"golang.org/x/image/draw" "golang.org/x/image/draw"
@ -42,18 +40,13 @@ var (
_ bridgev2.RoomNameHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.RoomNameHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.RoomTopicHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.RoomTopicHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.RoomAvatarHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MuteHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.TagHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MarkedUnreadHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.DeleteChatHandlingNetworkAPI = (*WhatsAppClient)(nil)
) )
func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (result *bridgev2.MatrixMessageResponse, retErr error) { func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) {
waMsg, optionMap, err := wa.Main.MsgConv.PollStartToWhatsApp(ctx, msg.Content, msg.ReplyTo, msg.Portal) waMsg, optionMap, err := wa.Main.MsgConv.PollStartToWhatsApp(ctx, msg.Content, msg.ReplyTo, msg.Portal)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert poll vote: %w", err) return nil, fmt.Errorf("failed to convert poll vote: %w", err)
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
resp, err := wa.handleConvertedMatrixMessage(ctx, &msg.MatrixMessage, waMsg, nil) resp, err := wa.handleConvertedMatrixMessage(ctx, &msg.MatrixMessage, waMsg, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -68,21 +61,19 @@ func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridge
return resp, nil return resp, nil
} }
func (wa *WhatsAppClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.MatrixPollVote) (result *bridgev2.MatrixMessageResponse, retErr error) { func (wa *WhatsAppClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.MatrixPollVote) (*bridgev2.MatrixMessageResponse, error) {
waMsg, err := wa.Main.MsgConv.PollVoteToWhatsApp(ctx, wa.Client, msg.Content, msg.VoteTo) waMsg, err := wa.Main.MsgConv.PollVoteToWhatsApp(ctx, wa.Client, msg.Content, msg.VoteTo)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert poll vote: %w", err) return nil, fmt.Errorf("failed to convert poll vote: %w", err)
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
return wa.handleConvertedMatrixMessage(ctx, &msg.MatrixMessage, waMsg, nil) return wa.handleConvertedMatrixMessage(ctx, &msg.MatrixMessage, waMsg, nil)
} }
func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (result *bridgev2.MatrixMessageResponse, retErr error) { func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
waMsg, req, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, msg.Event, msg.Content, msg.ReplyTo, msg.ThreadRoot, msg.Portal) waMsg, req, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, msg.Event, msg.Content, msg.ReplyTo, msg.ThreadRoot, msg.Portal)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert message: %w", err) return nil, fmt.Errorf("failed to convert message: %w", err)
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
return wa.handleConvertedMatrixMessage(ctx, msg, waMsg, req) return wa.handleConvertedMatrixMessage(ctx, msg, waMsg, req)
} }
@ -103,23 +94,19 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
if err != nil { if err != nil {
return nil, err return nil, err
} }
if chatJID.Server == types.DefaultUserServer {
zerolog.Ctx(ctx).Warn().Stringer("portal_jid", chatJID).Msg("Matrix message received in phone number portal")
}
if chatJID == types.StatusBroadcastJID && wa.Main.Config.DisableStatusBroadcastSend { if chatJID == types.StatusBroadcastJID && wa.Main.Config.DisableStatusBroadcastSend {
return nil, ErrBroadcastSendDisabled return nil, ErrBroadcastSendDisabled
} }
wrappedMsgID := waid.MakeMessageID(chatJID, wa.JID, req.ID) wrappedMsgID := waid.MakeMessageID(chatJID, wa.JID, req.ID)
wrappedMsgID2 := waid.MakeMessageID(chatJID, wa.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.GetLID() { if resp.Sender == wa.GetStore().GetLID() {
pickedMessageID = wrappedMsgID2 pickedMessageID = wrappedMsgID2
msg.RemovePending(networkid.TransactionID(wrappedMsgID)) msg.RemovePending(networkid.TransactionID(wrappedMsgID))
} else { } else {
@ -140,17 +127,18 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg
}, nil }, nil
} }
func (wa *WhatsAppClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) { func (wa *WhatsAppClient) PreHandleMatrixReaction(_ context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("failed to parse portal ID: %w", err) return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("failed to parse portal ID: %w", err)
} else if portalJID == types.StatusBroadcastJID { } else if portalJID == types.StatusBroadcastJID {
return bridgev2.MatrixReactionPreResponse{}, ErrBroadcastReactionUnsupported return bridgev2.MatrixReactionPreResponse{}, ErrBroadcastReactionUnsupported
} }
sender := wa.GetLID() sender := wa.JID
if portalJID.Server == types.DefaultUserServer { if portalJID.Server == types.HiddenUserServer ||
zerolog.Ctx(ctx).Warn().Stringer("portal_jid", portalJID).Msg("Matrix reaction received in phone number portal") msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup ||
sender = wa.JID msg.Portal.Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID {
sender = wa.GetStore().GetLID()
} }
return bridgev2.MatrixReactionPreResponse{ return bridgev2.MatrixReactionPreResponse{
SenderID: waid.MakeUserID(sender), SenderID: waid.MakeUserID(sender),
@ -159,7 +147,7 @@ func (wa *WhatsAppClient) PreHandleMatrixReaction(ctx context.Context, msg *brid
}, nil }, nil
} }
func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (result *database.Reaction, retErr error) { func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error) {
messageID, err := waid.ParseMessageID(msg.TargetMessage.ID) messageID, err := waid.ParseMessageID(msg.TargetMessage.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse target message ID: %w", err) return nil, fmt.Errorf("failed to parse target message ID: %w", err)
@ -176,10 +164,9 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev
SenderTimestampMS: proto.Int64(msg.Event.Timestamp), SenderTimestampMS: proto.Int64(msg.Event.Timestamp),
}, },
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
var req whatsmeow.SendRequestExtra var req whatsmeow.SendRequestExtra
if msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup { if msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup {
reactionMsg.EncReactionMessage, err = wa.Client.EncryptReaction(ctx, msgconv.MessageIDToInfo(ctx, wa.Client, messageID), reactionMsg.ReactionMessage) reactionMsg.EncReactionMessage, err = wa.Client.EncryptReaction(ctx, msgconv.MessageIDToInfo(wa.Client, messageID), reactionMsg.ReactionMessage)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt reaction: %w", err) return nil, fmt.Errorf("failed to encrypt reaction: %w", err)
} }
@ -198,7 +185,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev
}, err }, err
} }
func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) (retErr error) { func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
messageID, err := waid.ParseMessageID(msg.TargetReaction.MessageID) messageID, err := waid.ParseMessageID(msg.TargetReaction.MessageID)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse target message ID: %w", err) return fmt.Errorf("failed to parse target message ID: %w", err)
@ -222,13 +209,12 @@ func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *b
extra.ID = types.MessageID(msg.InputTransactionID) extra.ID = types.MessageID(msg.InputTransactionID)
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg, extra) resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg, extra)
zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response") zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response")
return err return err
} }
func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) (retErr error) { func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
var editID types.MessageID var editID types.MessageID
@ -252,8 +238,6 @@ func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.M
if err != nil { if err != nil {
return fmt.Errorf("failed to convert message: %w", err) return fmt.Errorf("failed to convert message: %w", err)
} }
defer wa.mcTrack(edit, time.Now(), &retErr)
convertedEdit := wa.Client.BuildEdit(messageID.Chat, messageID.ID, waMsg) convertedEdit := wa.Client.BuildEdit(messageID.Chat, messageID.ID, waMsg)
if edit.OrigSender == nil { if edit.OrigSender == nil {
convertedEdit.EditedMessage.Message.ProtocolMessage.TimestampMS = proto.Int64(edit.Event.Timestamp) convertedEdit.EditedMessage.Message.ProtocolMessage.TimestampMS = proto.Int64(edit.Event.Timestamp)
@ -268,7 +252,7 @@ func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.M
return err return err
} }
func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) (retErr error) { func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
messageID, err := waid.ParseMessageID(msg.TargetMessage.ID) messageID, err := waid.ParseMessageID(msg.TargetMessage.ID)
if err != nil { if err != nil {
@ -280,7 +264,6 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br
return fmt.Errorf("failed to parse portal ID: %w", err) return fmt.Errorf("failed to parse portal ID: %w", err)
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
revokeMessage := wa.Client.BuildRevoke(messageID.Chat, messageID.Sender, messageID.ID) revokeMessage := wa.Client.BuildRevoke(messageID.Chat, messageID.Sender, messageID.ID)
extra := whatsmeow.SendRequestExtra{} extra := whatsmeow.SendRequestExtra{}
@ -293,7 +276,7 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br
return err return err
} }
func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) (retErr error) { func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) error {
if !receipt.ReadUpTo.After(receipt.LastRead) { if !receipt.ReadUpTo.After(receipt.LastRead) {
return nil return nil
} }
@ -322,7 +305,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
if err != nil { if err != nil {
continue continue
} }
if wa.IsOwnJID(parsed.Sender) { if parsed.Sender.User == wa.GetStore().GetLID().User || parsed.Sender.User == wa.JID.User {
continue continue
} }
var key types.JID var key types.JID
@ -332,9 +315,8 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
} }
messagesToRead[key] = append(messagesToRead[key], parsed.ID) messagesToRead[key] = append(messagesToRead[key], parsed.ID)
} }
defer wa.mcTrack(receipt, time.Now(), &retErr)
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")
} }
@ -342,7 +324,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *
return err return err
} }
func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) (retErr error) { func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return err return err
@ -363,19 +345,18 @@ func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.
return nil return nil
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
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)
func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (ok bool, retErr error) { func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return false, err return false, err
@ -387,9 +368,8 @@ func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg
return false, fmt.Errorf("%w (%s)", errUnsupportedDisappearingTimer, msg.Content.Timer.Duration) return false, fmt.Errorf("%w (%s)", errUnsupportedDisappearingTimer, msg.Content.Timer.Duration)
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
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
} }
@ -404,22 +384,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) (result *bridgev2.MatrixMembershipResult, retErr 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
} }
} }
@ -432,7 +408,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) {
@ -441,30 +417,22 @@ 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)
} }
defer wa.mcTrack(msg, time.Now(), &retErr) _, err = wa.Client.UpdateGroupParticipants(portalJID, changes, action)
resp, err := wa.Client.UpdateGroupParticipants(ctx, 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) (ok bool, retErr error) { func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return false, err return false, err
@ -474,8 +442,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")
} }
defer wa.mcTrack(msg, time.Now(), &retErr) err = wa.Client.SetGroupName(portalJID, msg.Content.Name)
err = wa.Client.SetGroupName(ctx, portalJID, msg.Content.Name)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -486,7 +453,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev
return true, nil return true, nil
} }
func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridgev2.MatrixRoomTopic) (ok bool, retErr error) { func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridgev2.MatrixRoomTopic) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return false, err return false, err
@ -496,10 +463,9 @@ func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridge
return false, fmt.Errorf("cannot set room topic for DM") return false, fmt.Errorf("cannot set room topic for DM")
} }
defer wa.mcTrack(msg, time.Now(), &retErr)
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
} }
@ -511,7 +477,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridge
return true, nil return true, nil
} }
func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (ok bool, retErr error) { func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
portalJID, err := waid.ParsePortalID(msg.Portal.ID) portalJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil { if err != nil {
return false, err return false, err
@ -522,8 +488,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)
} }
@ -534,8 +500,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridg
} }
} }
defer wa.mcTrack(msg, time.Now(), &retErr) avatarID, err := wa.Client.SetGroupPhoto(portalJID, data)
avatarID, err := wa.Client.SetGroupPhoto(ctx, portalJID, data)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -601,93 +566,3 @@ func convertRoomAvatar(data []byte) ([]byte, error) {
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func (wa *WhatsAppClient) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) (retErr error) {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
mutedUntil := msg.Content.GetMutedUntilTime()
muted := mutedUntil.After(time.Now())
muteTS := ptr.Ptr(mutedUntil.UnixMilli())
if !muted || mutedUntil == event.MutedForever {
muteTS = nil
}
defer wa.mcTrack(msg, time.Now(), &retErr)
return wa.Client.SendAppState(ctx, appstate.BuildMuteAbs(chatJID, muted, muteTS))
}
func (wa *WhatsAppClient) HandleRoomTag(ctx context.Context, msg *bridgev2.MatrixRoomTag) (retErr error) {
chatJID, err := waid.ParsePortalID(msg.Portal.ID)
if err != nil {
return err
}
defer wa.mcTrack(msg, time.Now(), &retErr)
_, isFavorite := msg.Content.Tags[event.RoomTagFavourite]
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) {
msgs, err := wa.Main.Bridge.DB.Message.GetLastNInPortal(ctx, portalKey, 1)
if err != nil {
return time.Time{}, nil, fmt.Errorf("failed to get last message in portal: %w", err)
}
var lastTS time.Time
var lastKey *waCommon.MessageKey
if len(msgs) == 1 {
lastTS = msgs[0].Timestamp
parsed, _ := waid.ParseMessageID(msgs[0].ID)
if parsed != nil {
fromMe := wa.IsOwnJID(parsed.Sender)
var participant *string
if chatJID.Server == types.GroupServer {
participant = ptr.Ptr(parsed.Sender.String())
}
lastKey = &waCommon.MessageKey{
RemoteJID: ptr.Ptr(chatJID.String()),
FromMe: &fromMe,
ID: &parsed.ID,
Participant: participant,
}
}
}
return lastTS, lastKey, nil
}
func (wa *WhatsAppClient) HandleMarkedUnread(ctx context.Context, msg *bridgev2.MatrixMarkedUnread) (retErr 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
}
defer wa.mcTrack(msg, time.Now(), &retErr)
return wa.Client.SendAppState(ctx, appstate.BuildMarkChatAsRead(chatJID, msg.Content.Unread, lastTS, lastKey))
}
func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) (retErr 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
}
defer wa.mcTrack(msg, time.Now(), &retErr)
return wa.Client.SendAppState(ctx, appstate.BuildDeleteChat(chatJID, lastTS, lastKey, true))
}

View file

@ -25,7 +25,6 @@ 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/types" "go.mau.fi/whatsmeow/types"
@ -76,7 +75,6 @@ func init() {
func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
log := wa.UserLogin.Log log := wa.UserLogin.Log
ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx)
wa.MC.OnWhatsAppEvent(rawEvt)
success = true success = true
switch evt := rawEvt.(type) { switch evt := rawEvt.(type) {
@ -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,9 +109,12 @@ 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:
success = wa.handleWAMediaRetry(ctx, evt) wa.phoneSeen(evt.Timestamp)
success = wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{MediaRetry: evt, wa: wa}).Success
case *events.GroupInfo: case *events.GroupInfo:
success = wa.handleWAGroupInfoChange(ctx, evt) success = wa.handleWAGroupInfoChange(ctx, evt)
@ -125,31 +126,23 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
success = wa.handleWANewsletterLeave(evt) success = wa.handleWANewsletterLeave(evt)
case *events.Picture: case *events.Picture:
success = wa.handleWAPictureUpdate(ctx, evt) success = wa.handleWAPictureUpdate(ctx, evt)
case *events.NotifyAccountReachoutTimelock:
wa.UserLogin.TrackAnalytics("WhatsApp Account Reachout Timelock", map[string]any{
"enforcement_type": evt.EnforcementType,
"is_active": evt.IsActive,
"time_enforcement_ends": evt.TimeEnforcementEnds.Time,
})
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).ReachoutTimelockUntil = evt.TimeEnforcementEnds.Time
if wa.UserLogin.BridgeState.GetPrevUnsent().StateEvent == status.StateConnected {
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
}
err := wa.UserLogin.Save(ctx)
if err != nil {
log.Err(err).Msg("Failed to save user login metadata after reachout timelock update")
}
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")
} }
@ -157,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.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)
@ -174,12 +163,12 @@ 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)
} }
case *events.OfflineSyncPreview: case *events.OfflineSyncPreview:
log.Info(). log.Info().
@ -258,40 +247,81 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
return return
} }
func (wa *WhatsAppClient) ensureAltJIDs(ctx context.Context, info *types.MessageSource, checkPhones bool) bool { func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, info *types.MessageInfo) {
var err error if info.Chat.Server == types.HiddenUserServer && info.Sender.ToNonAD() == info.Chat && info.SenderAlt.Server == types.DefaultUserServer {
if info.Sender.Server == types.DefaultUserServer && info.SenderAlt.IsEmpty() { wa.UserLogin.Log.Debug().
info.SenderAlt, err = wa.GetStore().LIDs.GetLIDForPN(ctx, info.Sender) Stringer("lid", info.Sender).
Stringer("pn", info.SenderAlt).
Str("message_id", info.ID).
Msg("Forced LID DM sender to phone number in incoming message")
info.Sender, info.SenderAlt = info.SenderAlt, info.Sender
info.Chat = info.Sender.ToNonAD()
} else if info.Chat.Server == types.HiddenUserServer && info.IsFromMe && info.RecipientAlt.Server == types.DefaultUserServer {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Chat).
Stringer("pn", info.RecipientAlt).
Str("message_id", info.ID).
Msg("Forced LID DM sender to phone number in own message sent from another device")
info.Chat = info.RecipientAlt.ToNonAD()
} else if info.Sender.Server == types.BotServer && info.Chat.Server == types.HiddenUserServer {
chatPN, err := wa.Device.LIDs.GetPNForLID(ctx, info.Chat)
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("sender", info.Sender).Msg("Failed to get LID for sender") wa.UserLogin.Log.Err(err).
return false Str("message_id", info.ID).
Stringer("lid", info.Chat).
Msg("Failed to get phone number of DM for incoming bot message")
} else if !chatPN.IsEmpty() {
wa.UserLogin.Log.Debug().
Stringer("lid", info.Chat).
Stringer("pn", chatPN).
Str("message_id", info.ID).
Msg("Forced LID chat to phone number in bot message")
info.Chat = chatPN
} }
} }
if info.Chat.Server == types.DefaultUserServer && info.IsFromMe && info.RecipientAlt.IsEmpty() {
info.RecipientAlt, err = wa.GetStore().LIDs.GetLIDForPN(ctx, info.Chat)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("chat", info.Chat).Msg("Failed to get LID for chat")
return false
}
}
if checkPhones {
return wa.checkAllPhonesInMessage(ctx, info)
}
return true
} }
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.Chat.Server == types.NewsletterServer && wa.disableNewsletter { 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)
if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") {
return return
} }
if !wa.ensureAltJIDs(ctx, &evt.Info.MessageSource, true) {
return false
}
parsedMessageType := getMessageType(evt.Message)
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 {
@ -310,68 +340,6 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
evt.Message = decrypted evt.Message = decrypted
} }
} }
if encMessage := evt.Message.GetSecretEncryptedMessage(); encMessage != nil {
decrypted, err := wa.Client.DecryptSecretEncryptedMessage(ctx, evt)
if err != nil {
wa.UserLogin.Log.Err(err).
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
}
evt.RawMessage = decrypted
evt.UnwrapRaw()
parsedMessageType = getMessageType(evt.Message)
}
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
}
dontRenderEdited := false
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()
protocolMsg := evt.Message.GetProtocolMessage()
if protocolMsg.GetType() != waE2E.ProtocolMessage_MESSAGE_EDIT || protocolMsg.GetKey() == nil {
protocolMsg = &waE2E.ProtocolMessage{
Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(),
Key: parentKey,
EditedMessage: evt.Message.GetAssociatedChildMessage().GetMessage(),
}
dontRenderEdited = true
} else if child := protocolMsg.GetEditedMessage().GetAssociatedChildMessage().GetMessage(); child != nil {
protocolMsg.EditedMessage = child
protocolMsg.Key = parentKey
}
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")
evt.Message = &waE2E.Message{
ProtocolMessage: protocolMsg,
}
parsedMessageType = getMessageType(evt.Message)
} 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,
@ -381,15 +349,12 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
MsgEvent: evt, MsgEvent: evt,
parsedMessageType: parsedMessageType, parsedMessageType: parsedMessageType,
dontRenderEdited: dontRenderEdited,
}) })
return res.Success return res.Success
} }
func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool { func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool {
if !wa.ensureAltJIDs(ctx, &evt.Info.MessageSource, true) { wa.rerouteWAMessage(ctx, &evt.Info)
return false
}
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Any("info", evt.Info). Any("info", evt.Info).
Bool("unavailable", evt.IsUnavailable). Bool("unavailable", evt.IsUnavailable).
@ -412,44 +377,26 @@ func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt
return res.Success return res.Success
} }
func (wa *WhatsAppClient) handleWAMediaRetry(ctx context.Context, evt *events.MediaRetry) bool {
wa.phoneSeen(evt.Timestamp)
var senderLID, chatLID types.JID
var err error
if evt.SenderID.Server == types.DefaultUserServer {
senderLID, err = wa.GetStore().LIDs.GetLIDForPN(ctx, evt.SenderID)
if err != nil {
wa.UserLogin.Log.Err(err).
Stringer("sender_id", evt.SenderID).
Msg("Failed to get LID for media retry sender")
return false
}
}
if evt.ChatID.Server == types.DefaultUserServer {
chatLID, err = wa.GetStore().LIDs.GetLIDForPN(ctx, evt.ChatID)
if err != nil {
wa.UserLogin.Log.Err(err).
Stringer("chat_id", evt.ChatID).
Msg("Failed to get LID for media retry chat")
return false
}
}
res := wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{
MediaRetry: evt,
wa: wa,
senderLID: senderLID,
chatLID: chatLID,
})
return res.Success
}
func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) { func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) {
if evt.Chat.Server == types.HiddenUserServer && evt.Sender.ToNonAD() == evt.Chat && evt.SenderAlt.Server == types.DefaultUserServer {
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)
} }
if !wa.ensureAltJIDs(ctx, &evt.MessageSource, true) {
return false
}
var evtType bridgev2.RemoteEventType var evtType bridgev2.RemoteEventType
switch evt.Type { switch evt.Type {
case types.ReceiptTypeRead, types.ReceiptTypeReadSelf: case types.ReceiptTypeRead, types.ReceiptTypeReadSelf:
@ -461,34 +408,24 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei
default: default:
return true return true
} }
targets := make([]networkid.MessageID, 0, len(evt.MessageIDs)) targets := make([]networkid.MessageID, len(evt.MessageIDs))
messageSender := wa.GetLID() messageSender := wa.JID
if !evt.MessageSender.IsEmpty() { if !evt.MessageSender.IsEmpty() {
messageSender = evt.MessageSender messageSender = evt.MessageSender
} } else if evt.Chat.Server == types.GroupServer && evt.Sender.Server == types.HiddenUserServer {
var chatAlt types.JID lid := wa.Device.GetLID()
if evt.Chat.Server == types.DefaultUserServer { if !lid.IsEmpty() {
chatLID, _ := wa.GetStore().LIDs.GetLIDForPN(ctx, evt.Chat) messageSender = lid
if !chatLID.IsEmpty() {
chatAlt = evt.Chat
evt.Chat = chatLID
} }
} }
for _, id := range evt.MessageIDs { for i, id := range evt.MessageIDs {
targets = append(targets, waid.MakeMessageID(evt.Chat, messageSender, id)) targets[i] = waid.MakeMessageID(evt.Chat, messageSender, id)
if !chatAlt.IsEmpty() {
targets = append(targets, waid.MakeMessageID(chatAlt, messageSender, id))
}
}
senderLID := evt.Sender
if senderLID.Server == types.DefaultUserServer && !evt.SenderAlt.IsEmpty() {
senderLID = evt.SenderAlt
} }
res := wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ res := wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: evtType, Type: evtType,
PortalKey: wa.makeWAPortalKey(evt.Chat), PortalKey: wa.makeWAPortalKey(evt.Chat),
Sender: wa.makeEventSender(ctx, senderLID), Sender: wa.makeEventSender(ctx, evt.Sender),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
Targets: targets, Targets: targets,
@ -497,15 +434,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.DefaultUserServer && evt.Sender.ToNonAD() == evt.Chat {
if evt.SenderAlt.IsEmpty() {
evt.SenderAlt, _ = wa.GetStore().LIDs.GetLIDForPN(ctx, evt.Sender)
}
if evt.SenderAlt.Server == types.HiddenUserServer {
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 {
@ -535,10 +463,9 @@ 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.LID = types.EmptyJID
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0 wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0
wa.UserLogin.BridgeState.Send(status.BridgeState{ wa.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials, StateEvent: status.StateBadCredentials,
@ -552,15 +479,12 @@ func (wa *WhatsAppClient) handleWACallStart(ctx context.Context, group, sender,
if !wa.Main.Config.CallStartNotices || time.Since(ts) > callEventMaxAge { if !wa.Main.Config.CallStartNotices || time.Since(ts) > callEventMaxAge {
return true return true
} }
if sender.Server == types.DefaultUserServer && senderAlt.IsEmpty() { if sender.Server == types.HiddenUserServer && senderAlt.Server == types.DefaultUserServer {
senderAlt, _ = wa.GetStore().LIDs.GetLIDForPN(ctx, sender)
}
if sender.Server == types.DefaultUserServer && senderAlt.Server == types.HiddenUserServer {
wa.UserLogin.Log.Debug(). wa.UserLogin.Log.Debug().
Stringer("lid", senderAlt). Stringer("lid", sender).
Stringer("pn", sender). Stringer("pn", senderAlt).
Str("call_id", id). Str("call_id", id).
Msg("Forced phone number caller to LID in incoming call") Msg("Forced LID caller to phone number in incoming call")
sender, senderAlt = senderAlt, sender sender, senderAlt = senderAlt, sender
} }
chat := group chat := group
@ -575,7 +499,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),
@ -594,10 +517,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
@ -607,12 +526,6 @@ func (wa *WhatsAppClient) handleWAIdentityChange(ctx context.Context, evt *event
if !wa.Main.Config.IdentityChangeNotices { if !wa.Main.Config.IdentityChangeNotices {
return return
} }
if evt.JID.Server == types.DefaultUserServer {
lid, _ := wa.GetStore().LIDs.GetLIDForPN(ctx, evt.JID)
if !lid.IsEmpty() {
evt.JID = lid
}
}
wa.UserLogin.QueueRemoteEvent(&simplevent.Message[*events.IdentityChange]{ wa.UserLogin.QueueRemoteEvent(&simplevent.Message[*events.IdentityChange]{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage, Type: bridgev2.RemoteEventMessage,
@ -648,40 +561,35 @@ 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)
senderJID := wa.maybeConvertJIDToLID(ctx, evt.SenderJID)
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, 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.GetLID()), Sender: wa.makeEventSender(ctx, wa.JID),
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
}, },
ReadUpTo: evt.Timestamp, ReadUpTo: evt.Timestamp,
@ -770,6 +678,9 @@ func (wa *WhatsAppClient) handleWAGroupInfoChange(ctx context.Context, evt *even
} }
func (wa *WhatsAppClient) handleWAJoinedGroup(ctx context.Context, evt *events.JoinedGroup) bool { func (wa *WhatsAppClient) handleWAJoinedGroup(ctx context.Context, evt *events.JoinedGroup) bool {
if wa.createDedup.Pop(evt.CreateKey) {
return true
}
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
@ -782,9 +693,6 @@ func (wa *WhatsAppClient) handleWAJoinedGroup(ctx context.Context, evt *events.J
} }
func (wa *WhatsAppClient) handleWANewsletterJoin(ctx context.Context, evt *events.NewsletterJoin) bool { func (wa *WhatsAppClient) handleWANewsletterJoin(ctx context.Context, evt *events.NewsletterJoin) bool {
if wa.disableNewsletter {
return true
}
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
@ -826,7 +734,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 {
@ -856,107 +764,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).
Bool("recovery_evt", evt.Recovery).
Msg("Unmarked app state recovery as attempted after successful full sync")
wa.UserLogin.TrackAnalytics("WhatsApp Appstate Recovery Success", map[string]any{
"patch_name": evt.Name,
"from_recovery": evt.Recovery,
})
}
} 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")
}
wa.UserLogin.TrackAnalytics("WhatsApp Appstate Recovery Request", map[string]any{
"patch_name": evt.Name,
})
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).
@ -49,7 +49,7 @@ func (wa *WhatsAppClient) makeEventSender(ctx context.Context, id types.JID) bri
} }
} }
return bridgev2.EventSender{ return bridgev2.EventSender{
IsFromMe: wa.IsOwnJID(id), IsFromMe: id.User == wa.GetStore().GetJID().User || id.User == wa.GetStore().GetLID().User,
Sender: waid.MakeUserID(id), Sender: waid.MakeUserID(id),
SenderLogin: waid.MakeUserLoginID(senderLoginJID), SenderLogin: waid.MakeUserLoginID(senderLoginJID),
} }
@ -60,25 +60,11 @@ func (wa *WhatsAppClient) messageIDToKey(id *waid.ParsedMessageID) *waCommon.Mes
RemoteJID: ptr.Ptr(id.Chat.String()), RemoteJID: ptr.Ptr(id.Chat.String()),
ID: ptr.Ptr(id.ID), ID: ptr.Ptr(id.ID),
} }
if wa.IsOwnJID(id.Sender) { if id.Sender.User == wa.GetStore().GetJID().User || id.Sender.User == wa.GetStore().GetLID().User {
key.FromMe = ptr.Ptr(true) key.FromMe = ptr.Ptr(true)
} }
if id.Chat.Server != types.MessengerServer && id.Chat.Server != types.DefaultUserServer && if id.Chat.Server != types.MessengerServer && id.Chat.Server != types.DefaultUserServer && id.Chat.Server != types.HiddenUserServer && id.Chat.Server != types.BotServer {
id.Chat.Server != types.HiddenUserServer && id.Chat.Server != types.BotServer {
key.Participant = ptr.Ptr(id.Sender.String()) key.Participant = ptr.Ptr(id.Sender.String())
} }
return key return key
} }
func (wa *WhatsAppClient) maybeConvertJIDToLID(ctx context.Context, jid types.JID) types.JID {
if jid.Server == types.DefaultUserServer {
if lidForPN, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid); err != nil {
wa.UserLogin.Log.Err(err).
Stringer("pn", jid).
Msg("Failed to get LID for phone number chat")
} else if !lidForPN.IsEmpty() {
return lidForPN
}
}
return jid
}

View file

@ -1,212 +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"
"fmt"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
func (wa *WhatsAppClient) FindAltTargetMessage(ctx context.Context, targetMsg networkid.MessageID, evt bridgev2.RemoteEventWithTargetMessage) (alts []networkid.MessageID, err error) {
parsed, err := waid.ParseMessageID(targetMsg)
if err != nil {
return nil, fmt.Errorf("failed to parse target message ID: %w", err)
}
altSender, err := wa.GetStore().GetAltJID(ctx, parsed.Sender)
if err != nil {
return nil, err
}
var altChat types.JID
if parsed.Chat.Server == types.HiddenUserServer {
altChat, err = wa.GetStore().LIDs.GetPNForLID(ctx, parsed.Chat)
if err != nil {
return nil, err
}
}
if !altSender.IsEmpty() {
altSenderID := *parsed
altSenderID.Sender = altSender
alts = append(alts, altSenderID.String())
}
if !altChat.IsEmpty() {
altChatID := *parsed
altChatID.Chat = altChat
if altSender.Server == types.DefaultUserServer {
altChatID.Sender = altSender
}
alts = append(alts, altChatID.String())
}
return
}
func (wa *WhatsAppClient) checkAllPhonesInMessage(ctx context.Context, info *types.MessageSource) (ok bool) {
for _, jid := range []types.JID{info.Sender, info.SenderAlt, info.Chat, info.RecipientAlt, info.BroadcastListOwner} {
if !wa.reIDPhoneDMToLIDIfNeeded(ctx, jid) {
return false
}
}
return true
}
func (wa *WhatsAppClient) reIDPhoneDMToLIDIfNeeded(ctx context.Context, pn types.JID) (ok bool) {
if pn.Server != types.DefaultUserServer {
return true
}
portalKey := wa.makeWAPortalKey(pn)
if wa.Main.unmigratedDMs.Has(portalKey) {
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, pn)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("pn", pn).Msg("Failed to get LID for PN")
return false
} else if lid.IsEmpty() {
zerolog.Ctx(ctx).Warn().Stringer("pn", pn).Msg("No found LID for phone number")
return true
}
zerolog.Ctx(ctx).Info().
Object("portal_key", portalKey).
Stringer("pn", pn).
Stringer("lid", lid).
Msg("Received event for portal in unmigrated DMs list, trying migration")
_, err = wa.Main.reIDPhoneDMToLID(ctx, pn, lid, wa.UserLogin.ID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to re-ID phone DM to LID")
return false
}
}
return true
}
func (wa *WhatsAppConnector) reIDPhoneDMToLID(ctx context.Context, pn, lid types.JID, receiver networkid.UserLoginID) (bridgev2.ReIDResult, error) {
pnKey := networkid.PortalKey{
ID: waid.MakePortalID(pn),
Receiver: receiver,
}
lidKey := networkid.PortalKey{
ID: waid.MakePortalID(lid),
Receiver: receiver,
}
result, portal, err := wa.Bridge.ReIDPortal(ctx, pnKey, lidKey)
if err != nil {
return result, err
}
if result == bridgev2.ReIDResultSourceReIDd || result == bridgev2.ReIDResultTargetDeletedAndSourceReIDd {
var pnGhost, lidGhost *bridgev2.Ghost
pnGhost, err = wa.Bridge.GetGhostByID(ctx, waid.MakeUserID(pn))
if err != nil {
return result, fmt.Errorf("failed to get PN ghost: %w", err)
}
lidGhost, err = wa.Bridge.GetGhostByID(ctx, waid.MakeUserID(lid))
if err != nil {
return result, fmt.Errorf("failed to get LID ghost: %w", err)
}
_, err = pnGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, pnGhost.Intent.GetMXID().String(), &event.Content{
Parsed: &event.MemberEventContent{Membership: event.MembershipLeave, Reason: "Migrating to LIDs"},
Raw: map[string]any{"com.beeper.exclude_from_timeline": true},
}, time.Time{})
if err != nil {
return result, fmt.Errorf("failed to send leave event for PN ghost: %w", err)
}
_, err = wa.Bridge.Bot.SendState(ctx, portal.MXID, event.StateMember, lidGhost.Intent.GetMXID().String(), &event.Content{
Parsed: &event.MemberEventContent{Membership: event.MembershipInvite, Reason: "Migrating to LIDs"},
Raw: map[string]any{"com.beeper.exclude_from_timeline": true},
}, time.Time{})
if err != nil {
return result, fmt.Errorf("failed to send invite event for LID ghost: %w", err)
}
_, err = lidGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, lidGhost.Intent.GetMXID().String(), &event.Content{
Parsed: &event.MemberEventContent{Membership: event.MembershipJoin, Reason: "Migrating to LIDs"},
Raw: map[string]any{"com.beeper.exclude_from_timeline": true},
}, time.Time{})
if err != nil {
return result, fmt.Errorf("failed to send join event for LID ghost: %w", err)
}
portal.OtherUserID = lidGhost.ID
err = portal.Save(ctx)
if err != nil {
return result, fmt.Errorf("failed to save portal after re-ID: %w", err)
}
portal.UpdateBridgeInfo(ctx)
}
return result, nil
}
var scanPortalKey = dbutil.ConvertRowFn[networkid.PortalKey](func(row dbutil.Scannable) (key networkid.PortalKey, err error) {
err = row.Scan(&key.ID, &key.Receiver)
return
})
func (wa *WhatsAppConnector) migrateToLIDDMs(ctx context.Context) error {
if wa.Bridge.Background {
if wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dms_migrated") == "true" {
return nil
}
return fmt.Errorf("can't migrate to LID DMs in background mode")
}
log := zerolog.Ctx(ctx).With().Str("action", "migrate to lid dms").Logger()
const findPNPortals = "SELECT id, receiver FROM portal WHERE bridge_id=$1 AND room_type='dm' AND id LIKE '%@s.whatsapp.net'"
pnPortalKeys, err := scanPortalKey.NewRowIter(wa.Bridge.DB.Query(ctx, findPNPortals, wa.Bridge.ID)).AsList()
if err != nil {
return fmt.Errorf("failed to get phone number portals: %w", err)
}
var updatedPortals, missingLID int
for _, key := range pnPortalKeys {
pnJID, 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 pnJID.Server != types.DefaultUserServer {
continue
}
lid, err := wa.DeviceStore.LIDMap.GetLIDForPN(ctx, pnJID)
if err != nil {
return fmt.Errorf("failed to get LID for PN portal %s: %w", key.ID, err)
} else if lid.IsEmpty() {
log.Warn().Stringer("pn", pnJID).Msg("No LID for PN portal")
wa.unmigratedDMs.Add(key)
missingLID++
continue
}
res, err := wa.reIDPhoneDMToLID(ctx, pnJID, lid, key.Receiver)
if err != nil {
return fmt.Errorf("failed to re-ID %s to %s: %w", pnJID, lid, err)
}
updatedPortals++
log.Info().
Stringer("pn", pnJID).
Stringer("lid", lid).
Stringer("result", res).
Msg("Re-ID'd phone number DM portal")
}
log.Info().
Int("updated_portals", updatedPortals).
Int("total_pn_portals", len(pnPortalKeys)).
Int("missing_lid", missingLID).
Msg("Finished re-IDing phone number DM portals")
wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dms_migrated", "true")
return nil
}

View file

@ -2,7 +2,6 @@ package connector
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@ -13,7 +12,6 @@ import (
"go.mau.fi/util/exsync" "go.mau.fi/util/exsync"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events" "go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log" waLog "go.mau.fi/whatsmeow/util/log"
"maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2"
@ -27,7 +25,6 @@ const (
LoginStepIDQR = "fi.mau.whatsapp.login.qr" LoginStepIDQR = "fi.mau.whatsapp.login.qr"
LoginStepIDPhoneNumber = "fi.mau.whatsapp.login.phone" LoginStepIDPhoneNumber = "fi.mau.whatsapp.login.phone"
LoginStepIDCode = "fi.mau.whatsapp.login.code" LoginStepIDCode = "fi.mau.whatsapp.login.code"
LoginStepIDPasskey = "fi.mau.whatsapp.login.passkey"
LoginStepIDComplete = "fi.mau.whatsapp.login.complete" LoginStepIDComplete = "fi.mau.whatsapp.login.complete"
LoginFlowIDQR = "qr" LoginFlowIDQR = "qr"
@ -77,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. Try again later.",
StatusCode: http.StatusBadRequest,
}
) )
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) {
@ -94,11 +86,9 @@ func (wa *WhatsAppConnector) CreateLogin(_ context.Context, user *bridgev2.User,
Bool("phone_code", flowID == LoginFlowIDPhone). Bool("phone_code", flowID == LoginFlowIDPhone).
Logger(), Logger(),
WaitForQRs: exsync.NewEvent(), WaitForQRs: exsync.NewEvent(),
LoginComplete: exsync.NewEvent(), LoginComplete: exsync.NewEvent(),
PasskeyRequest: exsync.NewEvent(), Received515: exsync.NewEvent(),
PasskeyConfirmation: exsync.NewEvent(),
Received515: exsync.NewEvent(),
}, nil }, nil
} }
@ -119,11 +109,6 @@ type WALogin struct {
Received515 *exsync.Event Received515 *exsync.Event
PrevQRIndex atomic.Int32 PrevQRIndex atomic.Int32
PasskeyRequest *exsync.Event
PasskeyRequestData *events.PairPasskeyRequest
PasskeyConfirmation *exsync.Event
PasskeyConfirmationData *events.PairPasskeyConfirmation
Closed atomic.Bool Closed atomic.Bool
EventHandlerID uint32 EventHandlerID uint32
} }
@ -132,11 +117,9 @@ var (
_ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil) _ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil)
_ bridgev2.LoginProcessUserInput = (*WALogin)(nil) _ bridgev2.LoginProcessUserInput = (*WALogin)(nil)
_ bridgev2.LoginProcessWithOverride = (*WALogin)(nil) _ bridgev2.LoginProcessWithOverride = (*WALogin)(nil)
_ bridgev2.LoginProcessWebAuthn = (*WALogin)(nil)
) )
const LoginConnectWait = 30 * time.Second const LoginConnectWait = 15 * time.Second
const LoginPairPhoneWait = 30 * time.Second
func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
wl.Main.firstClientConnectOnce.Do(wl.Main.onFirstClientConnect) wl.Main.firstClientConnectOnce.Do(wl.Main.onFirstClientConnect)
@ -194,29 +177,25 @@ func (wl *WALogin) StartWithOverride(ctx context.Context, old *bridgev2.UserLogi
} }
func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
ctx, cancel := context.WithTimeout(ctx, LoginConnectWait)
defer cancel()
err := wl.Client.Connect() err := wl.Client.Connect()
if err != nil { if err != nil {
wl.Log.Err(err).Msg("Failed to connect to WhatsApp for phone code login") wl.Log.Err(err).Msg("Failed to connect to WhatsApp for phone code login")
return nil, err return nil, err
} }
connectCtx, cancelConnect := context.WithTimeout(ctx, LoginConnectWait) err = wl.WaitForQRs.Wait(ctx)
err = wl.WaitForQRs.Wait(connectCtx)
cancelConnect()
if err != nil { if err != nil {
wl.Log.Warn().Err(err).Msg("Timed out waiting for connection") wl.Log.Warn().Err(err).Msg("Timed out waiting for connection")
return nil, fmt.Errorf("failed to wait for connection: %w", err) return nil, fmt.Errorf("failed to wait for connection: %w", err)
} }
pairCtx, cancelPair := context.WithTimeout(ctx, LoginPairPhoneWait) pairingCode, err := wl.Client.PairPhone(ctx, input["phone_number"], true, whatsmeow.PairClientChrome, "Chrome (Linux)")
defer cancelPair()
pairingCode, err := wl.Client.PairPhone(pairCtx, input["phone_number"], true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil { if err != nil {
wl.Log.Err(err).Msg("Failed to request phone code login") wl.Log.Err(err).Msg("Failed to request phone code login")
if errors.Is(err, whatsmeow.ErrPhoneNumberTooShort) { if errors.Is(err, whatsmeow.ErrPhoneNumberTooShort) {
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
} }
@ -276,17 +255,6 @@ func (wl *WALogin) handleEvent(rawEvt any) {
case *events.ClientOutdated: case *events.ClientOutdated:
wl.Log.Error().Msg("Got client outdated error") wl.Log.Error().Msg("Got client outdated error")
wl.LoginError = ErrLoginClientOutdated wl.LoginError = ErrLoginClientOutdated
case *events.PairPasskeyRequest:
wl.PasskeyRequestData = evt
wl.PasskeyRequest.Set()
return
case *events.PairPasskeyConfirmation:
wl.PasskeyConfirmationData = evt
wl.PasskeyConfirmation.Set()
return
case *events.PairPasskeyError:
wl.Log.Err(evt.Error).Msg("Got passkey error")
wl.LoginError = evt.Error
case *events.PairSuccess: case *events.PairSuccess:
wl.Log.Info().Any("event_data", evt).Msg("Got pair successful event") wl.Log.Info().Any("event_data", evt).Msg("Got pair successful event")
wl.LoginSuccess = evt wl.LoginSuccess = evt
@ -314,14 +282,10 @@ func (wl *WALogin) handleEvent(rawEvt any) {
func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
if wl.PhoneCode { if wl.PhoneCode {
select { err := wl.LoginComplete.Wait(ctx)
case <-ctx.Done(): if err != nil {
wl.Cancel() wl.Cancel()
return nil, ctx.Err() return nil, err
case <-wl.PasskeyRequest.GetChan():
return wl.makePasskeyStep()
case <-wl.LoginComplete.GetChan():
// continue
} }
} else { } else {
prevIndex := int(wl.PrevQRIndex.Load()) prevIndex := int(wl.PrevQRIndex.Load())
@ -348,16 +312,9 @@ func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
case <-ctx.Done(): case <-ctx.Done():
wl.Cancel() wl.Cancel()
return nil, ctx.Err() return nil, ctx.Err()
case <-wl.PasskeyRequest.GetChan():
return wl.makePasskeyStep()
case <-wl.LoginComplete.GetChan(): case <-wl.LoginComplete.GetChan():
// continue
} }
} }
return wl.onLoginComplete(ctx)
}
func (wl *WALogin) onLoginComplete(ctx context.Context) (*bridgev2.LoginStep, error) {
if wl.LoginError != nil { if wl.LoginError != nil {
wl.Log.Debug().Err(wl.LoginError).Msg("Login completed with error") wl.Log.Debug().Err(wl.LoginError).Msg("Login completed with error")
wl.Cancel() wl.Cancel()
@ -395,7 +352,7 @@ func (wl *WALogin) onLoginComplete(ctx context.Context) (*bridgev2.LoginStep, er
} }
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,
@ -408,66 +365,6 @@ func (wl *WALogin) onLoginComplete(ctx context.Context) (*bridgev2.LoginStep, er
}, nil }, nil
} }
func (wl *WALogin) makePasskeyStep() (*bridgev2.LoginStep, error) {
pubkeyData, err := json.Marshal(wl.PasskeyRequestData.PublicKey)
if err != nil {
wl.Log.Err(err).Msg("Failed to marshal public key data")
return nil, err
}
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeWebAuthn,
StepID: LoginStepIDPasskey,
WebAuthnParams: &bridgev2.LoginWebAuthnParams{
URL: "https://web.whatsapp.com",
PublicKey: pubkeyData,
},
}, nil
}
func (wl *WALogin) SubmitWebAuthnResponse(ctx context.Context, rawResp json.RawMessage) (*bridgev2.LoginStep, error) {
var resp types.WebAuthnResponse
err := json.Unmarshal(rawResp, &resp)
if err != nil {
wl.Log.Err(err).Msg("Failed to unmarshal WebAuthn response")
wl.Cancel()
return nil, err
}
err = wl.Client.SendPasskeyResponse(ctx, &resp)
if err != nil {
wl.Log.Err(err).Msg("Failed to send WebAuthn response")
wl.Cancel()
return nil, err
}
select {
case <-ctx.Done():
wl.Cancel()
return nil, ctx.Err()
case <-wl.PasskeyConfirmation.GetChan():
if !wl.PasskeyConfirmationData.SkipHandoffUX {
wl.Cancel()
return nil, bridgev2.RespError{
ErrCode: "FI.MAU.WHATSAPP.NOT_IMPLEMENTED",
Err: "Displaying WebAuthn pairing confirmation codes is not yet implemented",
StatusCode: http.StatusBadRequest,
}
}
err = wl.Client.SendPasskeyConfirmation(ctx)
if err != nil {
wl.Log.Err(err).Msg("Failed to send WebAuthn confirmation")
wl.Cancel()
return nil, err
}
case <-wl.LoginComplete.GetChan():
}
select {
case <-ctx.Done():
wl.Cancel()
return nil, ctx.Err()
case <-wl.LoginComplete.GetChan():
}
return wl.onLoginComplete(ctx)
}
func (wl *WALogin) Cancel() { func (wl *WALogin) Cancel() {
wl.Closed.Store(true) wl.Closed.Store(true)
wl.Client.RemoveEventHandler(wl.EventHandlerID) wl.Client.RemoveEventHandler(wl.EventHandlerID)

View file

@ -1,88 +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/util/ptr"
"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 {
OnMatrixEvent(any, time.Duration, error)
OnWhatsAppEvent(any)
}
type noopMC struct{}
var noopMCInstance mClient = &noopMC{}
func (n *noopMC) OnMatrixEvent(any, time.Duration, error) {}
func (n *noopMC) OnWhatsAppEvent(any) {}
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")
}
}
func (wa *WhatsAppClient) mcTrack(evt any, start time.Time, err *error) {
wa.MC.OnMatrixEvent(evt, time.Since(start), ptr.Val(err))
}

View file

@ -70,9 +70,10 @@ func (wa *WhatsAppClient) processFailedMedia(ctx context.Context, portalKey netw
func (wa *WhatsAppClient) mediaRequestLoop(ctx context.Context) { func (wa *WhatsAppClient) mediaRequestLoop(ctx context.Context) {
log := wa.UserLogin.Log.With().Str("loop", "media requests").Logger() log := wa.UserLogin.Log.With().Str("loop", "media requests").Logger()
ctx = log.WithContext(ctx) ctx = log.WithContext(ctx)
userTz, err := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).LoadTimezone() tzName := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).Timezone
userTz, err := time.LoadLocation(tzName)
var startIn time.Duration var startIn time.Duration
if err == nil && userTz != nil { if tzName != "" && err == nil && userTz != nil {
now := time.Now() now := time.Now()
startAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, userTz) startAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, userTz)
startAt = startAt.Add(time.Duration(wa.Main.Config.HistorySync.MediaRequests.RequestLocalTime) * time.Minute) startAt = startAt.Add(time.Duration(wa.Main.Config.HistorySync.MediaRequests.RequestLocalTime) * time.Minute)
@ -136,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
@ -147,16 +148,16 @@ 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: wa.IsOwnJID(msgID.Sender), IsFromMe: msgID.Sender.User == wa.JID.User,
IsGroup: msgID.Chat.Server != types.DefaultUserServer && msgID.Chat.Server != types.HiddenUserServer && msgID.Chat.Server != types.BotServer, IsGroup: msgID.Chat.Server != types.DefaultUserServer && msgID.Chat.Server != types.BotServer,
Sender: msgID.Sender, Sender: msgID.Sender,
Chat: msgID.Chat, Chat: msgID.Chat,
}, },

View file

@ -37,13 +37,6 @@ func (wa *WhatsAppClient) FillBridgeState(state status.BridgeState) status.Bridg
state.Error = WAPhoneOffline state.Error = WAPhoneOffline
state.UserAction = status.UserActionOpenNative state.UserAction = status.UserActionOpenNative
} }
rtu := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).ReachoutTimelockUntil
if !rtu.IsZero() {
if state.Info == nil {
state.Info = make(map[string]any)
}
state.Info["reachout_timelock_until"] = rtu
}
return state return state
} }

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

@ -25,8 +25,6 @@ import (
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exmaps"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types"
@ -37,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"
) )
@ -64,14 +61,7 @@ func looksEmaily(str string) bool {
return false return false
} }
type cacheEntry struct { func (wa *WhatsAppClient) validateIdentifer(number string) (types.JID, error) {
jid types.JID
ts time.Time
}
var isOnWhatsappCache = exsync.NewMap[string, cacheEntry]()
func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, 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) {
@ -85,16 +75,13 @@ 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 entry, ok := isOnWhatsappCache.Get(number); ok && time.Since(entry.ts) < 4*time.Hour { } else if resp, err := wa.Client.IsOnWhatsApp([]string{number}); err != nil {
return entry.jid, nil
} else if resp, err := wa.Client.IsOnWhatsApp(ctx, []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")
} else if !resp[0].IsIn { } else if !resp[0].IsIn {
return types.EmptyJID, bridgev2.WrapRespErr(fmt.Errorf("the server said +%s is not on WhatsApp", resp[0].JID.User), mautrix.MNotFound) return types.EmptyJID, bridgev2.WrapRespErr(fmt.Errorf("the server said +%s is not on WhatsApp", resp[0].JID.User), mautrix.MNotFound)
} else { } else {
isOnWhatsappCache.Set(number, cacheEntry{resp[0].JID, time.Now()})
return resp[0].JID, nil return resp[0].JID, nil
} }
} }
@ -120,24 +107,16 @@ func (wa *WhatsAppConnector) ValidateUserID(id networkid.UserID) bool {
} }
} }
func (wa *WhatsAppClient) startChatPNToLID(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.DefaultUserServer { if jid.Server == types.HiddenUserServer {
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid) pn, err := wa.Device.LIDs.GetPNForLID(ctx, jid)
if err != nil { if err != nil {
return jid, fmt.Errorf("failed to get lid for phone number: %w", err) return jid, fmt.Errorf("failed to get phone number for lid: %w", err)
} else if lid.IsEmpty() { } else if pn.IsEmpty() {
resp, err := wa.Client.GetUserInfo(ctx, []types.JID{jid}) // Don't allow starting chats with LIDs for now
if err != nil { return jid, fmt.Errorf("phone number not found")
return jid, fmt.Errorf("failed to get user info for phone number: %w", err)
} else if info, ok := resp[jid]; !ok {
return jid, fmt.Errorf("server didn't return user info for phone number")
} else if info.LID.IsEmpty() {
return jid, fmt.Errorf("server didn't return lid for phone number")
} else {
return info.LID, nil
}
} }
return lid, nil return pn, nil
} }
return jid, nil return jid, nil
} }
@ -156,7 +135,7 @@ func (wa *WhatsAppClient) makeCreateChatResponse(ctx context.Context, jid, origJ
func (wa *WhatsAppClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error) { func (wa *WhatsAppClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error) {
origJID := waid.ParseUserID(ghost.ID) origJID := waid.ParseUserID(ghost.ID)
jid, err := wa.startChatPNToLID(ctx, origJID) jid, err := wa.startChatLIDToPN(ctx, origJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -164,11 +143,11 @@ 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
} }
jid, err := wa.startChatPNToLID(ctx, origJID) jid, err := wa.startChatLIDToPN(ctx, origJID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -185,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 {
@ -199,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")
} }
@ -208,55 +187,33 @@ func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onl
return nil, err return nil, err
} }
resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(contacts)) resp := make([]*bridgev2.ResolveIdentifierResponse, 0, len(contacts))
addedIDs := make(exmaps.Set[types.JID])
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
} }
var lid types.JID
if jid.Server == types.HiddenUserServer {
lid = jid
} else if jid.Server == types.DefaultUserServer {
lid, err = wa.GetStore().LIDs.GetLIDForPN(ctx, jid)
if err != nil {
return nil, fmt.Errorf("failed to get lid for phone number %s: %w", jid, err)
} else if !lid.IsEmpty() {
jid = lid
}
}
if !addedIDs.Add(jid) {
continue
}
var chatResp *bridgev2.CreateChatResponse
if !lid.IsEmpty() {
chatResp = &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(lid)}
}
ghost, _ := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid)) ghost, _ := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
resp = append(resp, &bridgev2.ResolveIdentifierResponse{ resp = append(resp, &bridgev2.ResolveIdentifierResponse{
Ghost: ghost, Ghost: ghost,
UserID: waid.MakeUserID(jid), UserID: waid.MakeUserID(jid),
UserInfo: wa.contactToUserInfo(ctx, jid, contactInfo, false), UserInfo: wa.contactToUserInfo(ctx, jid, contactInfo, false),
Chat: chatResp, Chat: &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(jid)},
}) })
} }
return resp, nil return resp, nil
} }
func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) { func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCreateParams) (*bridgev2.CreateChatResponse, error) {
createKey := wa.Client.GenerateMessageID()
if params.RoomID != "" {
wa.createDedup.Add(createKey)
}
req := whatsmeow.ReqCreateGroup{ req := whatsmeow.ReqCreateGroup{
Name: ptr.Val(params.Name).Name, Name: ptr.Val(params.Name).Name,
Participants: make([]types.JID, len(params.Participants)), Participants: make([]types.JID, len(params.Participants)),
CreateKey: createKey,
} }
for i, participant := range params.Participants { for i, participant := range params.Participants {
jid := waid.ParseUserID(participant) req.Participants[i] = waid.ParseUserID(participant)
jid, err := wa.startChatPNToLID(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
@ -273,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)
} }
@ -289,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)
@ -357,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,
@ -375,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 {
@ -396,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

@ -5,13 +5,11 @@ import (
"crypto/sha256" "crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2"
"regexp" "regexp"
"strconv" "strconv"
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/jsontime" "go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
@ -29,148 +27,100 @@ var ResyncLoopInterval = 4 * time.Hour
var ResyncJitterSeconds = 3600 var ResyncJitterSeconds = 3600
func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) { func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) {
lastSync := ghost.Metadata.(*waid.GhostMetadata).LastSync.Time if ghost.Metadata.(*waid.GhostMetadata).LastSync.Add(ResyncMinInterval).After(time.Now()) {
if lastSync.Add(ResyncMinInterval).After(time.Now()) {
return return
} }
wa.resyncQueueLock.Lock()
jid := waid.ParseUserID(ghost.ID) jid := waid.ParseUserID(ghost.ID)
if _, exists := wa.resyncQueue[jid]; !exists { wa.UserLogin.Log.Debug().Stringer("jid", jid).Msg("Enqueued resync for ghost")
wa.resyncQueue[jid] = resyncQueueItem{ghost: ghost} select {
nextResyncIn := time.Until(wa.nextResync).String() case wa.resyncQueueCh <- resyncQueueItem{ghost: ghost}:
if wa.nextResync.IsZero() { default:
nextResyncIn = "never" wa.UserLogin.Log.Warn().Stringer("jid", jid).Msg("Resync queue channel full, dropping ghost resync")
}
wa.UserLogin.Log.Debug().
Stringer("jid", jid).
Str("next_resync_in", nextResyncIn).
Time("last_ghost_resync", lastSync).
Msg("Enqueued resync for ghost")
} }
wa.resyncQueueLock.Unlock()
} }
func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal, allowDM bool) { func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal) {
jid, _ := waid.ParsePortalID(portal.ID) jid, _ := waid.ParsePortalID(portal.ID)
lastSync := portal.Metadata.(*waid.PortalMetadata).LastSync.Time meta := portal.Metadata.(*waid.PortalMetadata)
if lastSync.Add(ResyncMinInterval).After(time.Now()) { _, capVer := wa.Main.GetBridgeInfoVersion()
return isOld := meta.LastSync.Add(ResyncMinInterval).Before(time.Now())
} else if !allowDM && jid.Server != types.GroupServer { versionMismatch := meta.BridgeCapsVersion != capVer
if !isOld && !versionMismatch {
return return
} }
wa.resyncQueueLock.Lock() wa.UserLogin.Log.Debug().Stringer("jid", jid).Msg("Enqueued resync for portal")
if _, exists := wa.resyncQueue[jid]; !exists { select {
wa.resyncQueue[jid] = resyncQueueItem{portal: portal} case wa.resyncQueueCh <- resyncQueueItem{portal: portal}:
wa.UserLogin.Log.Debug(). default:
Stringer("jid", jid). wa.UserLogin.Log.Warn().Stringer("jid", jid).Msg("Resync queue channel full, dropping portal resync")
Stringer("next_resync_in", time.Until(wa.nextResync)).
Time("last_portal_resync", lastSync).
Msg("Enqueued resync for portal")
} }
wa.resyncQueueLock.Unlock()
} }
func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) { func (wa *WhatsAppClient) resyncLoop(ctx context.Context) {
log := wa.UserLogin.Log.With().Str("action", "ghost resync loop").Logger() log := wa.UserLogin.Log.With().Str("action", "resync loop").Logger()
ctx = log.WithContext(ctx) ctx = log.WithContext(ctx)
wa.nextResync = time.Now().Add(ResyncLoopInterval).Add(-time.Duration(rand.IntN(ResyncJitterSeconds)) * time.Second) log.Info().Msg("Resync queue starting")
timer := time.NewTimer(time.Until(wa.nextResync))
log.Info().Time("first_resync", wa.nextResync).Msg("Ghost resync queue starting")
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
timer.Stop()
return return
case <-timer.C: case item := <-wa.resyncQueueCh:
} // Re-check gating after dequeue
queue := wa.rotateResyncQueue() if item.ghost != nil {
timer.Reset(time.Until(wa.nextResync)) lastSync := item.ghost.Metadata.(*waid.GhostMetadata).LastSync.Time
if len(queue) > 0 { if time.Since(lastSync) < ResyncMinInterval {
wa.doGhostResync(ctx, queue) continue
} else { }
log.Trace().Msg("Nothing in background resync queue") } else if item.portal != nil {
meta := item.portal.Metadata.(*waid.PortalMetadata)
_, capVer := wa.Main.GetBridgeInfoVersion()
if time.Since(meta.LastSync.Time) < ResyncMinInterval && meta.BridgeCapsVersion == capVer {
continue
}
}
wa.doResync(ctx, item)
} }
} }
} }
func (wa *WhatsAppClient) rotateResyncQueue() map[types.JID]resyncQueueItem { func (wa *WhatsAppClient) doResync(ctx context.Context, item resyncQueueItem) {
wa.resyncQueueLock.Lock()
defer wa.resyncQueueLock.Unlock()
wa.nextResync = time.Now().Add(ResyncLoopInterval)
if len(wa.resyncQueue) == 0 {
return nil
}
queue := wa.resyncQueue
wa.resyncQueue = make(map[types.JID]resyncQueueItem)
return queue
}
func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID]resyncQueueItem) {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
if !wa.IsLoggedIn() { if !wa.IsLoggedIn() {
log.Warn().Msg("Not logged in, skipping background resyncs") log.Warn().Msg("Not logged in, skipping background resync")
return return
} }
log.Debug().Msg("Starting background resyncs") log.Debug().Msg("Starting background resync")
defer log.Debug().Msg("Background resyncs finished") defer log.Debug().Msg("Background resync finished")
var ghostJIDs []types.JID if item.portal != nil {
var ghosts []*bridgev2.Ghost portal := item.portal
var portals []*bridgev2.Portal
for jid, item := range queue {
var lastSync time.Time
if item.ghost != nil {
lastSync = item.ghost.Metadata.(*waid.GhostMetadata).LastSync.Time
} else if item.portal != nil {
lastSync = item.portal.Metadata.(*waid.PortalMetadata).LastSync.Time
}
if lastSync.Add(ResyncMinInterval).After(time.Now()) {
log.Debug().
Stringer("jid", jid).
Time("last_sync", lastSync).
Msg("Not resyncing, last sync was too recent")
continue
}
if item.ghost != nil {
ghosts = append(ghosts, item.ghost)
ghostJIDs = append(ghostJIDs, jid)
} else if item.portal != nil {
portals = append(portals, item.portal)
}
}
for _, portal := range portals {
wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
EventMeta: simplevent.EventMeta{ EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatResync, Type: bridgev2.RemoteEventChatResync,
LogContext: func(c zerolog.Context) zerolog.Context { LogContext: func(c zerolog.Context) zerolog.Context { return c.Str("sync_reason", "resync_queue") },
return c.Str("sync_reason", "queue") PortalKey: portal.PortalKey,
},
PortalKey: portal.PortalKey,
}, },
GetChatInfoFunc: wa.GetChatInfo, GetChatInfoFunc: wa.GetChatInfo,
}) })
}
if len(ghostJIDs) == 0 {
return return
} }
log.Debug().Array("jids", exzerolog.ArrayOfStringers(ghostJIDs)).Msg("Doing background sync for users") if item.ghost != nil {
infos, err := wa.Client.GetUserInfo(ctx, ghostJIDs) jid := waid.ParseUserID(item.ghost.ID)
if err != nil { infos, err := wa.Client.GetUserInfo([]types.JID{jid})
log.Err(err).Msg("Failed to get user info for background sync") if err != nil {
return log.Err(err).Stringer("jid", jid).Msg("Failed to get user info for background sync")
} return
for _, ghost := range ghosts { }
jid := waid.ParseUserID(ghost.ID)
info, ok := infos[jid] info, ok := infos[jid]
if !ok { if !ok {
log.Warn().Stringer("jid", jid).Msg("Didn't get info for puppet in background sync") log.Warn().Stringer("jid", jid).Msg("Didn't get info for puppet in background sync")
continue return
} }
userInfo, err := wa.getUserInfo(ctx, jid, info.PictureID != "" && string(ghost.AvatarID) != info.PictureID) userInfo, err := wa.getUserInfo(ctx, jid, info.PictureID != "" && string(item.ghost.AvatarID) != info.PictureID)
if err != nil { if err != nil {
log.Err(err).Stringer("jid", jid).Msg("Failed to get user info for puppet in background sync") log.Err(err).Stringer("jid", jid).Msg("Failed to get user info for puppet in background sync")
continue return
} }
ghost.UpdateInfo(ctx, userInfo) item.ghost.UpdateInfo(ctx, userInfo)
wa.syncAltGhostWithInfo(ctx, jid, userInfo) wa.syncAltGhostWithInfo(ctx, jid, userInfo)
} }
} }
@ -198,30 +148,29 @@ 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 contact.FirstName == "" {
if altJID.Server == types.DefaultUserServer { contact.FirstName = extraContact.FirstName
if contact.FirstName == "" { }
contact.FirstName = extraContact.FirstName if contact.FullName == "" {
} contact.FullName = extraContact.FullName
if contact.FullName == "" {
contact.FullName = extraContact.FullName
}
} }
if contact.PushName == "" { if contact.PushName == "" {
contact.PushName = extraContact.PushName contact.PushName = extraContact.PushName
@ -229,37 +178,9 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID,
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)),
IsBot: ptr.Ptr(jid.IsBot()), IsBot: ptr.Ptr(jid.IsBot()),
@ -278,9 +199,8 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID,
func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool { func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool {
meta := ghost.Metadata.(*waid.GhostMetadata) meta := ghost.Metadata.(*waid.GhostMetadata)
forceSave := ResyncMinInterval < 24*time.Hour || time.Since(meta.LastSync.Time) > 24*time.Hour
meta.LastSync = jsontime.UnixNow() meta.LastSync = jsontime.UnixNow()
return forceSave return true
} }
var expiryRegex = regexp.MustCompile("oe=([0-9A-Fa-f]+)") var expiryRegex = regexp.MustCompile("oe=([0-9A-Fa-f]+)")
@ -335,7 +255,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",
@ -361,7 +281,7 @@ func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.
wrappedAvatar = &bridgev2.Avatar{ wrappedAvatar = &bridgev2.Avatar{
ID: networkid.AvatarID(avatar.ID), ID: networkid.AvatarID(avatar.ID),
Get: func(ctx context.Context) ([]byte, error) { Get: func(ctx context.Context) ([]byte, error) {
return wa.Client.DownloadMediaWithOnlyPath(ctx, avatar.DirectPath) return wa.Client.DownloadMediaWithPath(ctx, avatar.DirectPath, nil, nil, nil, 0, "", "")
}, },
} }
} }
@ -411,9 +331,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

@ -96,12 +96,12 @@ func (mq *MessageQuery) GetBetween(ctx context.Context, loginID networkid.UserLo
args := []any{mq.BridgeID, loginID, chatJID} args := []any{mq.BridgeID, loginID, chatJID}
argNum := 4 argNum := 4
if startTime != nil { if startTime != nil {
whereClauses += fmt.Sprintf(" AND timestamp > $%d", argNum) whereClauses += fmt.Sprintf(" AND timestamp >= $%d", argNum)
args = append(args, startTime.Unix()) args = append(args, startTime.Unix())
argNum++ argNum++
} }
if endTime != nil { if endTime != nil {
whereClauses += fmt.Sprintf(" AND timestamp < $%d", argNum) whereClauses += fmt.Sprintf(" AND timestamp <= $%d", argNum)
args = append(args, endTime.Unix()) args = append(args, endTime.Unix())
} }
@ -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 -> v10 (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,22 +0,0 @@
-- v10 (compatible with v3+): Move history sync conversations to LIDs
-- Delete history sync conversations where a @lid conversation already exists
DELETE FROM whatsapp_history_sync_conversation
WHERE chat_jid LIKE '%@lid' AND EXISTS (
SELECT 1
FROM whatsapp_history_sync_conversation pnconv
WHERE pnconv.chat_jid=(
SELECT pn || '@s.whatsapp.net'
FROM whatsmeow_lid_map
WHERE lid=replace(whatsapp_history_sync_conversation.chat_jid, '@lid', '')
)
);
-- Update all phone number conversations to lids if the lid is known
UPDATE whatsapp_history_sync_conversation
SET chat_jid=(SELECT lid || '@lid' FROM whatsmeow_lid_map WHERE pn=replace(chat_jid, '@s.whatsapp.net', ''))
WHERE chat_jid LIKE '%@s.whatsapp.net'
AND EXISTS (SELECT 1 FROM whatsmeow_lid_map WHERE pn=replace(chat_jid, '@s.whatsapp.net', ''));
-- Delete blank phone number portals
DELETE FROM portal WHERE id LIKE '%@s.whatsapp.net' AND (mxid IS NULL OR mxid='') AND room_type='';

View file

@ -6,9 +6,11 @@ import (
"go.mau.fi/util/dbutil" "go.mau.fi/util/dbutil"
) )
var Table dbutil.UpgradeTable
//go:embed *.sql //go:embed *.sql
var rawUpgrades embed.FS var rawUpgrades embed.FS
var Table = dbutil.BuildUpgradeTable(). func init() {
WithFS(rawUpgrades). Table.RegisterFS(rawUpgrades)
Finish() }

View file

@ -67,11 +67,10 @@ func getMessageType(waMsg *waE2E.Message) string {
return "encrypted comment" return "encrypted comment"
case waMsg.CommentMessage != nil: case waMsg.CommentMessage != nil:
return "comment" return "comment"
case waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil || waMsg.PollCreationMessageV3 != nil || case waMsg.PollCreationMessage != nil || waMsg.PollCreationMessageV2 != nil || waMsg.PollCreationMessageV3 != nil:
waMsg.PollCreationMessageV5 != nil || waMsg.PollCreationMessageV6 != nil:
return "poll create" return "poll create"
case waMsg.PollCreationMessageV4 != nil: case waMsg.PollCreationMessageV4 != nil || waMsg.PollCreationMessageV5 != nil:
return "poll create (v4)" return "poll create (vNext)"
case waMsg.PollUpdateMessage != nil: case waMsg.PollUpdateMessage != nil:
return "poll update" return "poll update"
case waMsg.ProtocolMessage != nil: case waMsg.ProtocolMessage != nil:
@ -131,8 +130,8 @@ func getMessageType(waMsg *waE2E.Message) string {
return "secret encrypted" return "secret encrypted"
case waMsg.PollResultSnapshotMessage != nil: case waMsg.PollResultSnapshotMessage != nil:
return "poll result snapshot" return "poll result snapshot"
case waMsg.MessageHistoryNotice != nil: case waMsg.MessageHistoryBundle != nil:
return "message history notice" return "message history bundle"
case waMsg.RequestPhoneNumberMessage != nil: case waMsg.RequestPhoneNumberMessage != nil:
return "request phone number" return "request phone number"
case waMsg.KeepInChatMessage != nil: case waMsg.KeepInChatMessage != nil:
@ -153,7 +152,7 @@ func getMessageType(waMsg *waE2E.Message) string {
return "chat" return "chat"
case waMsg.PlaceholderMessage != nil: case waMsg.PlaceholderMessage != nil:
return "placeholder" return "placeholder"
case waMsg.SenderKeyDistributionMessage != nil, waMsg.StickerSyncRmrMessage != nil, waMsg.MessageHistoryBundle != nil: case waMsg.SenderKeyDistributionMessage != nil, waMsg.StickerSyncRmrMessage != nil:
return "ignore" return "ignore"
default: default:
return "unknown" return "unknown"

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 {
@ -143,14 +127,14 @@ func (mc *MessageConverter) ToWhatsApp(
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to parse message ID: %w", err) return nil, nil, fmt.Errorf("failed to parse message ID: %w", err)
} }
rootMsgInfo := MessageIDToInfo(ctx, client, parsedID) rootMsgInfo := MessageIDToInfo(client, parsedID)
message, err = client.EncryptComment(ctx, rootMsgInfo, message) message, err = client.EncryptComment(ctx, rootMsgInfo, message)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to encrypt comment: %w", err) return nil, nil, fmt.Errorf("failed to encrypt comment: %w", err)
} }
lid := parsedID.Sender lid := parsedID.Sender
if lid.Server == types.DefaultUserServer { if lid.Server == types.DefaultUserServer {
lid, err = client.Store.LIDs.GetLIDForPN(ctx, lid) lid, err = client.Store.LIDs.GetLIDForPN(ctx, parsedID.Sender)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to get LID for PN: %w", err) return nil, nil, fmt.Errorf("failed to get LID for PN: %w", err)
} }
@ -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:
@ -272,14 +255,9 @@ func (mc *MessageConverter) constructMediaMessage(
}, },
} }
case event.MsgFile: case event.MsgFile:
fileName := content.FileName
if fileName == "" {
fileName = content.Body
}
msg := &waE2E.Message{ msg := &waE2E.Message{
DocumentMessage: &waE2E.DocumentMessage{ DocumentMessage: &waE2E.DocumentMessage{
FileName: proto.String(fileName), FileName: proto.String(content.FileName),
Caption: proto.String(caption), Caption: proto.String(caption),
JPEGThumbnail: thumbnail, JPEGThumbnail: thumbnail,
@ -321,16 +299,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 +310,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 +416,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 +424,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 +442,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)
@ -175,12 +172,6 @@ func (mc *MessageConverter) ToMatrix(
part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV2) part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV2)
case waMsg.PollCreationMessageV3 != nil: case waMsg.PollCreationMessageV3 != nil:
part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV3) part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV3)
//case waMsg.PollCreationMessageV4 != nil:
// part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV4)
case waMsg.PollCreationMessageV5 != nil:
part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV5)
case waMsg.PollCreationMessageV6 != nil:
part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV6)
case waMsg.PollUpdateMessage != nil: case waMsg.PollUpdateMessage != nil:
part, contextInfo = mc.convertPollUpdateMessage(ctx, info, waMsg.PollUpdateMessage) part, contextInfo = mc.convertPollUpdateMessage(ctx, info, waMsg.PollUpdateMessage)
case waMsg.EventMessage != nil: case waMsg.EventMessage != nil:
@ -221,8 +212,6 @@ func (mc *MessageConverter) ToMatrix(
part, contextInfo = mc.convertPlaceholderMessage(ctx, waMsg) part, contextInfo = mc.convertPlaceholderMessage(ctx, waMsg)
case waMsg.GroupInviteMessage != nil: case waMsg.GroupInviteMessage != nil:
part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage) part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage)
case waMsg.MessageHistoryNotice != nil:
part, contextInfo = mc.convertMessageHistoryNotice(ctx, info, waMsg.MessageHistoryNotice)
case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waE2E.ProtocolMessage_EPHEMERAL_SETTING: case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waE2E.ProtocolMessage_EPHEMERAL_SETTING:
part, contextInfo = mc.convertEphemeralSettingMessage(ctx, waMsg.ProtocolMessage, info.Timestamp, isBackfill) part, contextInfo = mc.convertEphemeralSettingMessage(ctx, waMsg.ProtocolMessage, info.Timestamp, isBackfill)
case waMsg.EncCommentMessage != nil: case waMsg.EncCommentMessage != nil:
@ -245,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},
@ -274,23 +260,6 @@ func (mc *MessageConverter) ToMatrix(
cm.ReplyTo = &networkid.MessageOptionalPartID{ cm.ReplyTo = &networkid.MessageOptionalPartID{
MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()), MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()),
} }
var pn, lid types.JID
if pcp.Server == types.DefaultUserServer {
pn = pcp
lid, _ = client.Store.LIDs.GetLIDForPN(ctx, pcp)
} else if pcp.Server == types.HiddenUserServer {
lid = pcp
pn, _ = client.Store.LIDs.GetPNForLID(ctx, pcp)
} else if pcp.Server == types.BotServer {
lid = pcp
}
if !pn.IsEmpty() {
cm.ReplyToLogin = waid.MakeUserLoginID(pn)
}
if !lid.IsEmpty() {
cm.ReplyToUser = waid.MakeUserID(lid)
}
// TODO set reply to room
} }
if contextInfo.GetIsForwarded() { if contextInfo.GetIsForwarded() {
hasCaption := part.Content.FileName != "" && part.Content.FileName != part.Content.Body hasCaption := part.Content.FileName != "" && part.Content.FileName != part.Content.Body

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 {
@ -115,7 +115,7 @@ func (mc *MessageConverter) PollVoteToWhatsApp(
zerolog.Ctx(ctx).Err(err).Msg("Failed to parse message ID") zerolog.Ctx(ctx).Err(err).Msg("Failed to parse message ID")
return nil, fmt.Errorf("failed to parse message ID") return nil, fmt.Errorf("failed to parse message ID")
} }
pollMsgInfo := MessageIDToInfo(ctx, client, parsedMsgID) pollMsgInfo := MessageIDToInfo(client, parsedMsgID)
pollMsgInfo.Type = "poll" pollMsgInfo.Type = "poll"
optionHashes := make([][]byte, 0, len(content.Response.Answers)) optionHashes := make([][]byte, 0, len(content.Response.Answers))
if pollMsg.Metadata.(*waid.MessageMetadata).IsMatrixPoll { if pollMsg.Metadata.(*waid.MessageMetadata).IsMatrixPoll {
@ -146,23 +146,13 @@ func (mc *MessageConverter) PollVoteToWhatsApp(
return &waE2E.Message{PollUpdateMessage: pollUpdate}, err return &waE2E.Message{PollUpdateMessage: pollUpdate}, err
} }
func MessageIDToInfo(ctx context.Context, client *whatsmeow.Client, parsedMsgID *waid.ParsedMessageID) *types.MessageInfo { func MessageIDToInfo(client *whatsmeow.Client, parsedMsgID *waid.ParsedMessageID) *types.MessageInfo {
chat := parsedMsgID.Chat
sender := parsedMsgID.Sender
if chat.Server == types.DefaultUserServer {
chatLID, _ := client.Store.LIDs.GetLIDForPN(ctx, chat)
senderLID, _ := client.Store.LIDs.GetLIDForPN(ctx, sender)
if !chatLID.IsEmpty() && !senderLID.IsEmpty() {
chat = chatLID
sender = senderLID
}
}
return &types.MessageInfo{ return &types.MessageInfo{
MessageSource: types.MessageSource{ MessageSource: types.MessageSource{
Chat: chat, Chat: parsedMsgID.Chat,
Sender: sender, Sender: parsedMsgID.Sender,
IsFromMe: sender.User == client.Store.GetLID().User || sender.User == client.Store.GetJID().User, IsFromMe: parsedMsgID.Sender.User == client.Store.GetLID().User || parsedMsgID.Sender.User == client.Store.GetJID().User,
IsGroup: chat.Server == types.GroupServer, IsGroup: parsedMsgID.Chat.Server == types.GroupServer,
}, },
ID: parsedMsgID.ID, ID: parsedMsgID.ID,
} }

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

@ -20,7 +20,6 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"html"
"image" "image"
"math" "math"
"net/http" "net/http"
@ -52,7 +51,7 @@ func (mc *MessageConverter) convertLocationMessage(ctx context.Context, msg *waE
MsgType: event.MsgLocation, MsgType: event.MsgLocation,
Body: fmt.Sprintf("Location: %s\n%s\n%s", name, msg.GetAddress(), url), Body: fmt.Sprintf("Location: %s\n%s\n%s", name, msg.GetAddress(), url),
Format: event.FormatHTML, Format: event.FormatHTML,
FormattedBody: fmt.Sprintf(`Location: <a href="%s">%s</a><br>%s`, html.EscapeString(url), html.EscapeString(name), html.EscapeString(msg.GetAddress())), FormattedBody: fmt.Sprintf("Location: <a href='%s'>%s</a><br>%s", url, name, msg.GetAddress()),
GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()), GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()),
} }

View file

@ -17,6 +17,8 @@
package msgconv package msgconv
import ( import (
"archive/zip"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@ -24,19 +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/exfmt"
"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"
) )
@ -50,15 +54,11 @@ func (mc *MessageConverter) convertMediaMessage(
cachedPart *bridgev2.ConvertedMessagePart, cachedPart *bridgev2.ConvertedMessagePart,
) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) { ) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) {
if mc.DisableViewOnce && isViewOnce { if mc.DisableViewOnce && isViewOnce {
body := "You received a view once message. For added privacy, you can only open it on the WhatsApp app."
if messageInfo.IsFromMe {
body = "You sent a view once message from another device."
}
return &bridgev2.ConvertedMessagePart{ return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage, Type: event.EventMessage,
Content: &event.MessageEventContent{ Content: &event.MessageEventContent{
MsgType: event.MsgNotice, MsgType: event.MsgNotice,
Body: body, Body: fmt.Sprintf("You received a view once %s. For added privacy, you can only open it on the WhatsApp app.", typeName),
}, },
}, nil }, nil
} }
@ -83,15 +83,15 @@ 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)
preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, idOverride, portal.Receiver, getMediaIDVersion(msg))) preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, idOverride, portal.Receiver))
if err != nil { if err != nil {
panic(fmt.Errorf("failed to generate content URI: %w", err)) panic(fmt.Errorf("failed to generate content URI: %w", err))
} }
@ -120,20 +120,13 @@ func (mc *MessageConverter) convertMediaMessage(
return return
} }
func getMediaIDVersion(msg MediaMessage) []byte {
if encSHA256 := msg.GetFileEncSHA256(); len(encSHA256) > 0 {
return encSHA256
}
return msg.GetFileSHA256()
}
func (mc *MessageConverter) convertAlbumMessage(ctx context.Context, msg *waE2E.AlbumMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertAlbumMessage(ctx context.Context, msg *waE2E.AlbumMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
parts := make([]string, 0, 2) parts := make([]string, 0, 2)
if msg.GetExpectedImageCount() > 0 { if msg.GetExpectedImageCount() > 0 {
parts = append(parts, exfmt.Pluralizable("image")(int(msg.GetExpectedImageCount()))) parts = append(parts, fmt.Sprintf("%d images", msg.GetExpectedImageCount()))
} }
if msg.GetExpectedVideoCount() > 0 { if msg.GetExpectedVideoCount() > 0 {
parts = append(parts, exfmt.Pluralizable("video")(int(msg.GetExpectedVideoCount()))) parts = append(parts, fmt.Sprintf("%d videos", msg.GetExpectedVideoCount()))
} }
var partDesc string var partDesc string
if len(parts) > 0 { if len(parts) > 0 {
@ -201,9 +194,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
@ -242,21 +233,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{
@ -268,22 +244,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
@ -305,11 +265,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
@ -320,7 +281,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
} }
@ -365,17 +341,14 @@ 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.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, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
@ -396,7 +369,7 @@ func (mc *MessageConverter) reuploadWhatsAppAttachment(
} }
} else { } else {
data, err := client.Download(ctx, message) data, err := client.Download(ctx, message)
if 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 fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) return fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err)
@ -406,14 +379,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)
} }
@ -422,7 +393,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,
@ -436,6 +407,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
@ -480,3 +513,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

@ -20,16 +20,13 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"html"
"html/template" "html/template"
"strings" "strings"
"time" "time"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exfmt"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waAICommonDeprecated"
"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"
@ -90,19 +87,17 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info
template = inviteMsgBroken template = inviteMsgBroken
} else { } else {
inviteMeta = &waid.GroupInviteMeta{ inviteMeta = &waid.GroupInviteMeta{
JID: groupJID, JID: groupJID,
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,
} }
} }
htmlMessage := fmt.Sprintf(template, event.TextToHTML(msg.GetCaption()), html.EscapeString(msg.GetGroupName()), expiry, mc.Bridge.Config.CommandPrefix) htmlMessage := fmt.Sprintf(template, event.TextToHTML(msg.GetCaption()), msg.GetGroupName(), expiry, mc.Bridge.Config.CommandPrefix)
content := &event.MessageEventContent{ content := &event.MessageEventContent{
MsgType: event.MsgText, MsgType: event.MsgText,
Body: format.HTMLToText(htmlMessage), Body: format.HTMLToText(htmlMessage),
@ -119,92 +114,6 @@ func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info
}, msg.GetContextInfo() }, msg.GetContextInfo()
} }
func (mc *MessageConverter) formatMessageHistoryNoticeJID(ctx context.Context, jid types.JID) string {
_, displayName, err := mc.getBasicUserInfo(ctx, jid)
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("jid", jid).Msg("Failed to get user info for message history notice")
} else if displayName != "" {
return displayName
}
switch jid.Server {
case types.DefaultUserServer:
return "+" + jid.User
default:
return "Unknown user " + jid.String()
}
}
const maxMessageHistoryNoticeReceivers = 5
var others = exfmt.Pluralizable("other")
func (mc *MessageConverter) formatMessageHistoryNoticeReceivers(ctx context.Context, receivers []string) string {
receiverLimit := min(len(receivers), maxMessageHistoryNoticeReceivers)
receiverNames := make([]string, 0, receiverLimit)
for _, receiver := range receivers[:receiverLimit] {
jid, err := types.ParseJID(receiver)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("receiver", receiver).Msg("Failed to parse message history receiver JID")
receiverNames = append(receiverNames, receiver)
} else {
receiverNames = append(receiverNames, mc.formatMessageHistoryNoticeJID(ctx, jid))
}
}
receiverText := strings.Join(receiverNames, ", ")
if len(receivers) > receiverLimit {
receiverText = fmt.Sprintf("%s + %s", receiverText, others(len(receivers)-receiverLimit))
}
return receiverText
}
func (mc *MessageConverter) messageHistoryNoticeLocation(ctx context.Context) *time.Location {
portal := getPortal(ctx)
loginID := portal.Receiver
if loginID == "" {
loginID = waid.MakeUserLoginID(getClient(ctx).Store.GetJID().ToNonAD())
}
if login := mc.Bridge.GetCachedUserLoginByID(loginID); login != nil {
meta, _ := login.Metadata.(*waid.UserLoginMetadata)
loc, err := meta.LoadTimezone()
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("timezone", meta.Timezone).Msg("Failed to load user timezone for message history notice")
} else if loc != nil {
return loc
}
}
return time.Local
}
func (mc *MessageConverter) convertMessageHistoryNotice(ctx context.Context, info *types.MessageInfo, msg *waE2E.MessageHistoryNotice) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
metadata := msg.GetMessageHistoryMetadata()
sender := mc.formatMessageHistoryNoticeJID(ctx, info.Sender)
body := fmt.Sprintf("%s sent message history", sender)
if receiverText := mc.formatMessageHistoryNoticeReceivers(ctx, metadata.GetHistoryReceivers()); receiverText != "" {
body = fmt.Sprintf("%s to %s", body, receiverText)
}
if count := metadata.GetMessageCount(); count > 0 {
messageWord := "messages"
if count == 1 {
messageWord = "message"
}
body = fmt.Sprintf("%s (%d %s)", body, count, messageWord)
}
if metadata != nil && metadata.OldestMessageTimestampInWindow != nil {
oldestTS := time.Unix(metadata.GetOldestMessageTimestampInWindow(), 0).In(mc.messageHistoryNoticeLocation(ctx))
body = fmt.Sprintf("%s, starting %s", body, oldestTS.Format("Jan 2, 2006 at 3:04 PM"))
}
body += "."
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: body,
},
}, msg.GetContextInfo()
}
func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context, msg *waE2E.ProtocolMessage, ts time.Time, isBackfill bool) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context, msg *waE2E.ProtocolMessage, ts time.Time, isBackfill bool) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
portal := getPortal(ctx) portal := getPortal(ctx)
portalMeta := portal.Metadata.(*waid.PortalMetadata) portalMeta := portal.Metadata.(*waid.PortalMetadata)
@ -249,7 +158,7 @@ func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context,
const eventMessageTemplate = ` const eventMessageTemplate = `
{{- if .Name -}} {{- if .Name -}}
<h4>{{ .Name }} {{- if .IsCanceled -}}<span> (Canceled)</span>{{- end -}}</h4> <h4>{{ .Name }}</h4>
{{- end -}} {{- end -}}
{{- if .StartTime -}} {{- if .StartTime -}}
<p> <p>
@ -275,7 +184,6 @@ var eventMessageTplParsed = exerrors.Must(template.New("eventmessage").Parse(str
type eventMessageParams struct { type eventMessageParams struct {
Name string Name string
IsCanceled bool
JoinLink string JoinLink string
StartTimeISO string StartTimeISO string
StartTime string StartTime string
@ -288,7 +196,6 @@ type eventMessageParams struct {
func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.EventMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.EventMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
params := &eventMessageParams{ params := &eventMessageParams{
Name: msg.GetName(), Name: msg.GetName(),
IsCanceled: msg.GetIsCanceled(),
JoinLink: msg.GetJoinLink(), JoinLink: msg.GetJoinLink(),
Location: msg.GetLocation().GetName(), Location: msg.GetLocation().GetName(),
DescriptionHTML: template.HTML(parseWAFormattingToHTML(msg.GetDescription(), false)), DescriptionHTML: template.HTML(parseWAFormattingToHTML(msg.GetDescription(), false)),
@ -354,9 +261,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() == waE2E.AIRichResponseMessage_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,7 +95,7 @@ func (mc *MessageConverter) convertPollCreationMessage(ctx context.Context, msg
}, msg.GetContextInfo() }, msg.GetContextInfo()
} }
func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID { func KeyToMessageID(client *whatsmeow.Client, chat, sender types.JID, key *waCommon.MessageKey) networkid.MessageID {
sender = sender.ToNonAD() sender = sender.ToNonAD()
var err error var err error
if !key.GetFromMe() { if !key.GetFromMe() {
@ -107,26 +108,15 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
if sender.Server == types.LegacyUserServer { if sender.Server == types.LegacyUserServer {
sender.Server = types.DefaultUserServer sender.Server = types.DefaultUserServer
} }
} else if chat.Server == types.DefaultUserServer || chat.Server == types.HiddenUserServer || 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
if chat.Server == types.HiddenUserServer {
sender = client.Store.GetLID().ToNonAD()
} else {
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 ""
} }
} }
@ -148,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,12 +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"`
ReachoutTimelockUntil time.Time `json:"reachout_timelock_until,omitempty"`
MData json.RawMessage `json:"mdata,omitempty"`
} }
type PushKeys struct { type PushKeys struct {
@ -62,13 +55,6 @@ func (m *UserLoginMetadata) GeneratePushKeys() {
} }
} }
func (m *UserLoginMetadata) LoadTimezone() (*time.Location, error) {
if m == nil || m.Timezone == "" {
return nil, nil
}
return time.LoadLocation(m.Timezone)
}
type MessageErrorType string type MessageErrorType string
const ( const (
@ -82,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 {
@ -128,6 +111,9 @@ type PortalMetadata struct {
CommunityAnnouncementGroup bool `json:"is_cag,omitempty"` CommunityAnnouncementGroup bool `json:"is_cag,omitempty"`
AddressingMode types.AddressingMode `json:"addressing_mode,omitempty"` AddressingMode types.AddressingMode `json:"addressing_mode,omitempty"`
LIDMigrationAttempted bool `json:"lid_migration_attempted,omitempty"` LIDMigrationAttempted bool `json:"lid_migration_attempted,omitempty"`
// BridgeCapsVersion stores the last seen capabilities version for this portal,
// used to decide if a lazy resync-on-view is needed when the bridge version changes.
BridgeCapsVersion int `json:"bridge_caps_version,omitempty"`
} }
type GhostMetadata struct { type GhostMetadata struct {

View file

@ -83,22 +83,14 @@ func MakeMessageID(chat, sender types.JID, id types.MessageID) networkid.Message
return networkid.MessageID(fmt.Sprintf("%s:%s:%s", chat.ToNonAD().String(), sender.ToNonAD().String(), id)) return networkid.MessageID(fmt.Sprintf("%s:%s:%s", chat.ToNonAD().String(), sender.ToNonAD().String(), id))
} }
func MakeMessageIDWithAltSender(chat, sender, altSender types.JID, id types.MessageID) networkid.MessageID {
if chat.Server == types.HiddenUserServer && sender.Server == types.DefaultUserServer && altSender.Server == types.HiddenUserServer {
sender = altSender
}
return MakeMessageID(chat, sender, id)
}
func MakeFakeMessageID(chat, sender types.JID, data string) networkid.MessageID { func MakeFakeMessageID(chat, sender types.JID, data string) networkid.MessageID {
return networkid.MessageID(fmt.Sprintf("fake:%s:%s:%s", chat.ToNonAD().String(), sender.ToNonAD().String(), data)) return networkid.MessageID(fmt.Sprintf("fake:%s:%s:%s", chat.ToNonAD().String(), sender.ToNonAD().String(), data))
} }
type ParsedMessageID struct { type ParsedMessageID struct {
Chat types.JID Chat types.JID
Sender types.JID Sender types.JID
ID types.MessageID ID types.MessageID
Version []byte
} }
func (pmi *ParsedMessageID) String() networkid.MessageID { func (pmi *ParsedMessageID) String() networkid.MessageID {

View file

@ -33,10 +33,9 @@ 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, version []byte) networkid.MediaID { func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, receiver networkid.UserLoginID) networkid.MediaID {
compactChat := compactJID(messageInfo.Chat.ToNonAD()) compactChat := compactJID(messageInfo.Chat.ToNonAD())
compactSender := compactJID(messageInfo.Sender.ToNonAD()) compactSender := compactJID(messageInfo.Sender.ToNonAD())
receiverID := compactJID(ParseUserLoginID(receiver, 0)) receiverID := compactJID(ParseUserLoginID(receiver, 0))
@ -46,7 +45,7 @@ func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, rec
} else { } else {
compactID = compactMsgID(messageInfo.ID) compactID = compactMsgID(messageInfo.ID)
} }
mediaID := make([]byte, 0, 6+len(compactChat)+len(compactSender)+len(receiverID)+len(compactID)+len(version)) mediaID := make([]byte, 0, 5+len(compactChat)+len(compactSender)+len(receiverID)+len(compactID))
mediaID = append(mediaID, mediaIDTypeMessage) mediaID = append(mediaID, mediaIDTypeMessage)
mediaID = append(mediaID, byte(len(compactChat))) mediaID = append(mediaID, byte(len(compactChat)))
mediaID = append(mediaID, compactChat...) mediaID = append(mediaID, compactChat...)
@ -56,8 +55,6 @@ func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, rec
mediaID = append(mediaID, receiverID...) mediaID = append(mediaID, receiverID...)
mediaID = append(mediaID, byte(len(compactID))) mediaID = append(mediaID, byte(len(compactID)))
mediaID = append(mediaID, compactID...) mediaID = append(mediaID, compactID...)
mediaID = append(mediaID, byte(len(version)))
mediaID = append(mediaID, version...)
return mediaID return mediaID
} }
@ -85,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
} }
@ -140,12 +118,6 @@ func ParseMediaID(mediaID networkid.MediaID) (*ParsedMediaID, error) {
Sender: senderJID, Sender: senderJID,
ID: id, ID: id,
} }
if len(mediaID) > 0 {
parsed.Message.Version, err = readCompact(&mediaID, rawBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse version: %w", err)
}
}
parsed.UserLogin = MakeUserLoginID(receiverID) parsed.UserLogin = MakeUserLoginID(receiverID)
case mediaIDTypeAvatar, mediaIDTypeCommunityAvatar: case mediaIDTypeAvatar, mediaIDTypeCommunityAvatar:
targetJID, err := readCompact(&mediaID, parseCompactJID) targetJID, err := readCompact(&mediaID, parseCompactJID)
@ -166,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)
} }
@ -292,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 {