Watch
1
0
Fork
You've already forked mautrix-whatsapp
0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-11-09 23:59:17 -05:00

Compare commits

..
Author SHA1 Message Date
Rajeh Taher
3f8460006f matrixrtc: announce call link waiting rooms 2026-07-30 05:20:09 +02:00
Rajeh Taher
36bd890886 go.mod: tidy group call dependencies 2026-07-30 05:18:29 +02:00
Rajeh Taher
aa70741f50 matrixrtc: serialize call starts and recover state 2026-07-30 05:16:20 +02:00
Rajeh Taher
35720a37f2 matrixrtc: suppress local reaction echoes 2026-07-30 05:14:00 +02:00
Rajeh Taher
326a47af7a matrixrtc: restrict calls to login owners 2026-07-30 05:12:34 +02:00
Rajeh Taher
8521def8da matrixrtc: route incoming group calls 2026-07-30 05:07:43 +02:00
Rajeh Taher
91eff85012 voip: select group camera participants 2026-07-30 05:03:26 +02:00
Rajeh Taher
aae11aba36 matrixrtc: bridge WhatsApp call links 2026-07-30 04:59:42 +02:00
Rajeh Taher
84b08c2519 commands: add group call participant controls 2026-07-30 04:55:21 +02:00
Rajeh Taher
86573da9eb matrixrtc: bridge call reactions and hand state 2026-07-30 04:47:36 +02:00
Rajeh Taher
989464a146 voip: bridge group video and screen sharing 2026-07-30 04:34:36 +02:00
Rajeh Taher
9b5c322351 matrixrtc: dial WhatsApp group calls 2026-07-30 04:13:45 +02:00
Rajeh Taher
2be1320084 Merge remote-tracking branch 'origin/main' into rajeh/calls
# Conflicts:
#	go.mod
#	go.sum
2026-07-23 11:27:05 +02:00
Rajeh Taher
9810204dfe go.mod: update meowcaller peer video upgrades 2026-07-22 16:58:32 +02:00
Rajeh Taher
aed702e5a1 manager.go: accept peer video upgrades 2026-07-22 16:29:32 +02:00
Rajeh Taher
7e9cdabfc8 video.go: remove unsupported orientation forwarding 2026-07-22 16:29:32 +02:00
Rajeh Taher
a2dfba2473 go.mod: update meowcaller video orientation 2026-07-22 15:41:23 +02:00
Rajeh Taher
d887137a81 pkg/connector/voip/video.go: forward WhatsApp orientation to LiveKit 2026-07-22 15:30:35 +02:00
Rajeh Taher
830003be9b go.mod: update meowcaller low-rate audio 2026-07-22 15:13:10 +02:00
Rajeh Taher
6f23905640 go.mod: update meowcaller audio playout 2026-07-22 14:18:25 +02:00
Rajeh Taher
b91ed6f5b6 go.mod: update meowcaller audio playout 2026-07-22 14:00:54 +02:00
Rajeh Taher
6f0e9ba9ff go.mod: update meowcaller low-rate audio 2026-07-22 13:40:25 +02:00
Rajeh Taher
638205226a go.mod: update meowcaller audio feedback 2026-07-22 13:20:11 +02:00
Rajeh Taher
4c45a21919 go.mod: update meowcaller video packetization 2026-07-22 12:56:04 +02:00
Rajeh Taher
d49b20735d go.mod: update meowcaller diagnostics 2026-07-22 12:47:02 +02:00
Rajeh Taher
1229760bf1 go.mod: update meowcaller video metadata 2026-07-22 12:37:11 +02:00
Rajeh Taher
d29d808ebe go.mod: update meowcaller video startup 2026-07-22 12:11:41 +02:00
Rajeh Taher
666fc78609 manager.go: keep video toggles directional 2026-07-21 23:02:46 +02:00
Rajeh Taher
be0ce2e49c go.mod: update meowcaller web video state 2026-07-17 12:57:22 +03:00
Rajeh Taher
4b3aaac41c manager.go: separate video flow state 2026-07-17 12:48:07 +03:00
Rajeh Taher
7466d5e7ff go.mod: update meowcaller directional video 2026-07-17 12:48:07 +03:00
Rajeh Taher
116012b27d go.mod: update meowcaller revision 2026-07-17 12:36:37 +03:00
Rajeh Taher
e510baa1f4 manager.go: forward video keyframe requests 2026-07-17 12:33:11 +03:00
Rajeh Taher
07e19a826d go.mod: update meowcaller video lifecycle 2026-07-17 12:33:11 +03:00
Rajeh Taher
65571f5d6c (video.go): repeat H264 parameter sets 2026-07-17 10:47:52 +03:00
Rajeh Taher
0c424dcbcf livekit.go: request keyframe after peer acceptance 2026-07-17 10:10:07 +03:00
Rajeh Taher
b3784f075d go.mod: restore compatible video offer 2026-07-17 09:17:35 +03:00
Rajeh Taher
8b83612207 go.mod: update meowcaller video negotiation 2026-07-17 00:28:45 +03:00
Rajeh Taher
738e24e41f video.go: propagate device orientation 2026-07-17 00:25:27 +03:00
Rajeh Taher
a492582892 go.mod: update meowcaller video capability 2026-07-17 00:08:32 +03:00
Rajeh Taher
929eb6cefd go.mod: update meowcaller video transport 2026-07-16 23:58:58 +03:00
Rajeh Taher
5341e0b0d2 go.mod: refresh meowcaller module metadata 2026-07-16 23:21:05 +03:00
Rajeh Taher
f469f2c721 pkg/connector: bridge WhatsApp calls over MatrixRTC 2026-07-16 21:04:15 +03:00
Rajeh Taher
4833b867c2 go.mod: add MatrixRTC call dependencies 2026-07-16 21:04:11 +03:00
Rajeh Taher
b97a973b7c pkg/connector/wadb: store MatrixRTC call state 2026-07-16 20:55:34 +03:00
82 changed files with 8772 additions and 2841 deletions

View file

@ -1 +0,0 @@
code_format_allowlist: ["wa-connect-failure-%d"]

View file

@ -11,14 +11,14 @@ jobs:
strategy:
fail-fast: false
matrix:
go-version: ["1.26", "1.27"]
name: Lint ${{ matrix.go-version == '1.27' && '(latest)' || '(old)' }}
go-version: ["1.25", "1.26"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
@ -30,7 +30,6 @@ jobs:
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/beeper/pre-commit-go/bridgelint@latest
export PATH="$HOME/go/bin:$PATH"
- name: Run pre-commit

View file

@ -21,12 +21,9 @@ repos:
- id: go-mod-tidy
- repo: https://github.com/beeper/pre-commit-go
rev: v0.5.0
rev: v0.4.2
hooks:
- id: zerolog-ban-msgf
- id: zerolog-use-stringer
- id: prevent-literal-http-methods
- id: zerolog-ban-global-log
- id: bridgelint
language: system
verbose: true

View file

@ -1,50 +1,3 @@
# v26.10
* Added support for new rich response messages used by the Muse AI bot.
* Added support for bridging custom profile set for Muse.
* Added automatic one-time resync of keys needed to decrypt Muse messages.
* Added support for blocking and unblocking users on WhatsApp.
* Added support for contact array messages (thanks to [@voska] in [#961]).
* Added partial support for filling quoted message when sending replies to
WhatsApp.
* Changed undecryptable message resends to only bridge as edits and never as new
messages, to avoid races if the message was deleted before the resend.
* Changed login to wait for OTKs to be successfully uploaded, as the login will
fail if the bridge is stopped before that point.
* Changed chat info syncing to not bridge mute status if app state sync is
failing.
* Changed power levels to allow poll votes in read-only groups even if the room
is encrypted.
* Fixed backfilled media retries not getting sent if configured for local time.
* Fixed "delete for me" from WhatsApp not being bridged correctly in DMs.
* Fixed very long WhatsApp messages not being bridged.
* Fixed document message caption edits not being handled correctly.
[@voska]: https://github.com/voska
[#961]: https://github.com/mautrix/whatsapp/pull/961
# v26.09
* Bumped minimum Go version to 1.26.
* Added option to limit eager member sync in group chat portals.
* Added option to fetch avatars lazily when using direct media.
* Added one-time migration to ensure LID ghost avatars are consistent with the
old phone number ghosts.
* Fixed the resolve identifier command and API not returning any user info for
users who hadn't previously been encountered.
* Fixed group portal power levels to always allow poll responses.
* Fixed handling history sync events where the phone sends nonsensical
timestamps.
* Fixed handling edits to HD media captions.
* Fixed handling bridging own read receipts from the native apps in channels.
* Fixed decrypting messages from the new Muse AI bot
* May require resyncing app state using `!wa sync appstate regular_high`.
* The message contents aren't supported yet.
# v26.08
* Switched direct chats to use LIDs instead of phone numbers.
# v26.07
* Updated Docker image to Alpine 3.24.

View file

@ -1 +0,0 @@
See <https://docs.mau.fi/bridges/general/contributing.html>

View file

@ -18,7 +18,7 @@ var m = mxmain.BridgeMain{
Name: "mautrix-whatsapp",
URL: "https://github.com/mautrix/whatsapp",
Description: "A Matrix-WhatsApp puppeting bridge.",
Version: "26.10",
Version: "26.07",
SemCalVer: true,
Connector: &connector.WhatsAppConnector{},
}

115
go.mod
View file

@ -1,54 +1,125 @@
module go.mau.fi/mautrix-whatsapp
go 1.26.0
go 1.26
toolchain go1.27.2
toolchain go1.26.5
tool go.mau.fi/util/cmd/maubuild
require (
github.com/google/uuid v1.6.0
github.com/lib/pq v1.12.3
github.com/livekit/media-sdk v0.0.0-20260605212526-4c11a51d3c97
github.com/livekit/protocol v1.49.0
github.com/livekit/server-sdk-go/v2 v2.17.0
github.com/mattn/go-sqlite3 v1.14.48
github.com/pion/rtp v1.10.2
github.com/pion/webrtc/v4 v4.2.14
github.com/purpshell/meowcaller v0.0.0-20260726180203-6d9b7b2c1807
github.com/rs/zerolog v1.35.1
github.com/tidwall/gjson v1.20.0
github.com/yuin/goldmark v1.8.6
go.mau.fi/util v0.11.0
github.com/tidwall/gjson v1.19.0
go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391
go.mau.fi/webp v0.3.0
go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a
golang.org/x/image v0.47.0
golang.org/x/net v0.61.0
golang.org/x/sync v0.24.0
google.golang.org/protobuf v1.36.12
go.mau.fi/whatsmeow v0.0.0-20260722203353-e9a033b24933
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/image v0.44.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445
maunium.net/go/mautrix v0.29.1-0.20260719130752-5743d9b6f27e
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
buf.build/go/protovalidate v1.2.0 // indirect
buf.build/go/protoyaml v0.7.0 // indirect
cel.dev/expr v0.25.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/at-wat/ebml-go v0.18.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/cel-go v0.28.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e // indirect
github.com/livekit/psrpc v0.7.2 // indirect
github.com/mackerelio/go-osstat v0.2.7 // indirect
github.com/magefile/mage v1.17.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.52 // indirect
github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/ice/v4 v4.2.7 // indirect
github.com/pion/interceptor v0.1.45 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/opus v0.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/sctp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.11 // indirect
github.com/pion/stun/v3 v3.1.4 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.8 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
github.com/redis/go-redis/v9 v9.20.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yuin/goldmark v1.8.4 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.mau.fi/libsignal v0.2.2 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)

295
go.sum
View file

@ -1,58 +1,226 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/at-wat/ebml-go v0.18.0 h1:SNkpBFR4jCQV1rI4Bm1tSuIYnusxe2qQ4GHJia9eQg4=
github.com/at-wat/ebml-go v0.18.0/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww=
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/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/media-sdk v0.0.0-20260605212526-4c11a51d3c97 h1:AyjUVuJuVd+5Kt+KEnIUoyZVAv8pejNDqs691I5e8jM=
github.com/livekit/media-sdk v0.0.0-20260605212526-4c11a51d3c97/go.mod h1:uWrLXY4JeLYynX39htMG49Dl4BhFYY+RCeoXaLdU+Lw=
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e h1:SkgQRcG2VYEhh80Qb/zYZo8rWKJzNfJcfUQnXe6su2M=
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.49.0 h1:Q5nthDO1v7c0JHiWjMhgUQTlsKmCsBL/KCKxdHVaz00=
github.com/livekit/protocol v1.49.0/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc=
github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
github.com/livekit/server-sdk-go/v2 v2.17.0 h1:FzVQMoxHv0WIg164yGqSxLeV+h3aJomjAv1lFeR9MMw=
github.com/livekit/server-sdk-go/v2 v2.17.0/go.mod h1:5nzTfVBH2Jz+TW1SrfpqC7wrbcD1lT94KZCJ9hOMyvk=
github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94=
github.com/mackerelio/go-osstat v0.2.7/go.mod h1:dwpYh5pIPmvk+IEwBKNIWRFMB92mrC08CmXOhDC7nQk=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E=
github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E=
github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/opus v0.1.0 h1:GgK/a3DNDrffKjUFsK39rZKqfv7bQ2S2eqRKt0BnqAE=
github.com/pion/opus v0.1.0/go.mod h1:t5Xog2n682JnawoykACE6nKVmupFvmJvkpM7x6bTv6g=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY=
github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8=
github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU=
github.com/pion/webrtc/v4 v4.2.14 h1:Q6zMs+fSDsYuhZcNlvFGBxCOMHVV9oYcDa6O9/HIGTc=
github.com/pion/webrtc/v4 v4.2.14/go.mod h1:87NVKP86+g4OMrRxWhjWfUjeXP4JrV6RTlUrIW+/Jak=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/purpshell/meowcaller v0.0.0-20260726180203-6d9b7b2c1807 h1:SnLX76CnagumooXRm63BK9Rn2/e/Th6aWWJJKTqOk2k=
github.com/purpshell/meowcaller v0.0.0-20260726180203-6d9b7b2c1807/go.mod h1:kSME01MaSkwul6tSmExmgBWUOmfkw3DNpfnozsn01eE=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
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/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto=
github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
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=
@ -61,47 +229,90 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
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/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU=
go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0=
go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY=
go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s=
go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391 h1:lsvBEY8MJfYdV61YbwikiQvb0Al/onbmLW5wfl/0tag=
go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q=
go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA=
go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo=
go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a h1:ZyCV/mWfRkutxs5R0K3v+oyTbF4huprSlPZg/LEFQ7k=
go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a/go.mod h1:iY4lm61nTLWsNiIE1EmHUZwhKGBeoV4dhnVE2uMtNoQ=
go.mau.fi/whatsmeow v0.0.0-20260722203353-e9a033b24933 h1:7skZGs9q+rWKqYHok4ZufzhKpf6GmTKZnTjSm0aDtus=
go.mau.fi/whatsmeow v0.0.0-20260722203353-e9a033b24933/go.mod h1:Iy/xVSuVU2payR26MB1hv0UZUWRraEn4qKZ7+VRHulg=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A=
golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445 h1:ZI9Y2YEmjqQDloOCiciHeynTRYjUwK0ZZbNcRH/5NOc=
maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU=
maunium.net/go/mautrix v0.29.1-0.20260719130752-5743d9b6f27e h1:tPGnL/s5dfqhNoVLvmCKY3V60migQsNHBXAhUALBSd8=
maunium.net/go/mautrix v0.29.1-0.20260719130752-5743d9b6f27e/go.mod h1:PeLuIih5jnbwb2xKNNi0Te7AtINEQE2Uv3p6iG3RV80=

View file

@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"runtime/debug"
"slices"
"strconv"
"strings"
@ -13,8 +12,6 @@ import (
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exmaps"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
@ -64,7 +61,7 @@ func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) {
}
}()
for {
var resetTimer, errored bool
var resetTimer bool
select {
case <-wa.historySyncWakeup:
dispatchTimer.Stop()
@ -74,21 +71,12 @@ func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) {
} else if notif == nil {
wa.UserLogin.Log.Debug().Msg("No more queued history sync notifications")
} else {
resetTimer, errored = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
resetTimer = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
// Continue waking up the loop until all queued notifications are processed
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
if errored {
wa.UserLogin.Log.Debug().Msg("Temporarily stopping history sync loop after error")
select {
case <-time.After(1 * time.Minute):
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
return
}
}
}
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
@ -120,21 +108,13 @@ func (wa *WhatsAppClient) saveWAHistorySyncNotification(ctx context.Context, evt
}
}
func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, evt *waE2E.HistorySyncNotification, rowid int) (resetTimer, errored bool) {
func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, evt *waE2E.HistorySyncNotification, rowid int) (resetTimer bool) {
log := wa.UserLogin.Log.With().
Str("action", "download history sync").
Stringer("sync_type", evt.GetSyncType()).
Uint32("chunk_order", evt.GetChunkOrder()).
Uint32("progress", evt.GetProgress()).
Logger()
defer func() {
if v := recover(); v != nil {
log.Err(exerrors.RecoverToError(v)).
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Msg("History sync saving panicked")
errored = true
}
}()
log.Debug().
Int64("oldest_msg_in_chunk_ts", evt.GetOldestMsgInChunkTimestampSec()).
Any("full_request_meta", evt.GetFullHistorySyncOnDemandRequestMetadata()).
@ -142,20 +122,11 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context,
Str("peer_data_request_session_id", evt.GetPeerDataRequestSessionID()).
Msg("Downloading history sync")
blob, err := wa.Client.DownloadHistorySync(log.WithContext(ctx), evt, true)
if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) {
log.Err(err).Msg("History sync blob is no longer available, dropping notification")
if err = wa.Main.DB.HSNotif.Delete(ctx, rowid); err != nil {
log.Err(err).Msg("Failed to delete queued history sync notification")
errored = true
}
return
} else if err != nil {
if err != nil {
log.Err(err).Msg("Failed to download history sync")
errored = true
return
}
if blob.GetSyncType() == waHistorySync.HistorySync_ON_DEMAND {
wa.finishOnDemandHistorySyncRequest(evt.GetPeerDataRequestSessionID())
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")
@ -179,7 +150,6 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context,
})
if err != nil {
log.Err(err).Msg("Failed to store history sync notification data")
errored = true
} else {
resetTimer = blob.GetSyncType() == waHistorySync.HistorySync_INITIAL_BOOTSTRAP ||
blob.GetSyncType() == waHistorySync.HistorySync_RECENT ||
@ -255,42 +225,33 @@ func (wa *WhatsAppClient) handleWAHistorySync(
} else if jid.Server == types.BroadcastServer {
log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync")
continue
} else if jid == types.PSAJID {
// New messages in the official updates chat aren't bridged because they use a different path,
// so drop history as well. This should be removed if the PSA chat gets properly bridged in the future.
log.Debug().Stringer("chat_jid", jid).Msg("Skipping PSA in history sync")
continue
} else {
totalMessageCount += len(conv.GetMessages())
}
if jid == types.MetaAIJID {
jid = types.NewMetaAIJID
} else if jid.Server == types.DefaultUserServer {
lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid)
if jid.Server == types.HiddenUserServer {
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, jid)
if err != nil {
log.Err(err).Stringer("pn", jid).Msg("Failed to get LID for phone number in history sync")
} else if lid.IsEmpty() {
log.Warn().Stringer("pn", jid).Msg("No LID found for phone number in history sync")
log.Err(err).Stringer("lid", jid).Msg("Failed to get PN for LID in history sync")
} else if pn.IsEmpty() {
log.Warn().Stringer("lid", jid).Msg("No PN found for LID in history sync")
} else {
log.Debug().
Stringer("lid", lid).
Stringer("pn", jid).
Msg("Rerouting phone number DM to LID in history sync")
jid = lid
Stringer("lid", jid).
Stringer("pn", pn).
Msg("Rerouting LID DM to phone number in history sync")
jid = pn
}
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Stringer("chat_jid", jid)
})
var firstItemTime, lastItemTime time.Time
var minTime, maxTime, firstItemTime, lastItemTime time.Time
var minTimeIndex, maxTimeIndex int
ignoredTypes := 0
rawMessages := conv.GetMessages()
messages := make([]*wadb.HistorySyncMessageTuple, 0, len(rawMessages))
allowClamp := conv.GetCommentsCount() == 0
var newerTS uint64
for i, rawMsg := range rawMessages {
messages := make([]*wadb.HistorySyncMessageTuple, 0, len(conv.GetMessages()))
for i, rawMsg := range conv.GetMessages() {
// Don't store messages that will just be skipped.
msgEvt, err := wa.Client.ParseWebMessage(jid, rawMsg.GetMessage())
if err != nil {
@ -298,9 +259,6 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Int("msg_index", i).
Str("msg_id", rawMsg.GetMessage().GetKey().GetID()).
Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()).
Stringer("stub_type", rawMsg.GetMessage().GetMessageStubType()).
Str("message_type", getMessageType(rawMsg.GetMessage().GetMessage())).
Bool("from_me", rawMsg.GetMessage().GetKey().GetFromMe()).
Msg("Dropping historical message due to parse error")
log.Trace().
Any("web_message_info", rawMsg.GetMessage()).
@ -311,30 +269,20 @@ func (wa *WhatsAppClient) handleWAHistorySync(
firstItemTime = msgEvt.Info.Timestamp
}
lastItemTime = msgEvt.Info.Timestamp
if minTime.IsZero() || msgEvt.Info.Timestamp.Before(minTime) {
minTime = msgEvt.Info.Timestamp
minTimeIndex = i
}
if maxTime.IsZero() || msgEvt.Info.Timestamp.After(maxTime) {
maxTime = msgEvt.Info.Timestamp
maxTimeIndex = i
}
msgType := getMessageType(msgEvt.Message)
if msgType == "ignore" || strings.HasPrefix(msgType, "unknown_protocol_") {
ignoredTypes++
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)
if err != nil {
log.Warn().Err(err).
@ -349,8 +297,13 @@ func (wa *WhatsAppClient) handleWAHistorySync(
log.Debug().
Int("wrapped_count", len(messages)).
Int("ignored_msg_type_count", ignoredTypes).
Time("lowest_time", minTime).
Int("lowest_time_index", minTimeIndex).
Time("highest_time", maxTime).
Int("highest_time_index", maxTimeIndex).
Time("first_item_time", firstItemTime).
Time("last_item_time", lastItemTime).
Bool("highest_time_mismatch", firstItemTime != maxTime).
Dict("metadata", zerolog.Dict().
Uint32("ephemeral_expiration", conv.GetEphemeralExpiration()).
Int64("ephemeral_setting_timestamp", conv.GetEphemeralSettingTimestamp()).
@ -366,7 +319,7 @@ func (wa *WhatsAppClient) handleWAHistorySync(
Msg("Collected messages to save from history sync conversation")
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 stopOnError {
return fmt.Errorf("failed to save conversation metadata for %s: %w", jid, err)
@ -406,13 +359,6 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
log := wa.UserLogin.Log.With().
Str("action", "create portals from history sync").
Logger()
defer func() {
if v := recover(); v != nil {
log.Err(exerrors.RecoverToError(v)).
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Msg("History sync portal creation panicked")
}
}()
ctx = log.WithContext(ctx)
limit := wa.Main.Config.HistorySync.MaxInitialConversations
loginTS := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).LoggedInAt
@ -454,7 +400,7 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
return
}
wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv, true)
if errors.Is(err, whatsmeow.ErrNotInGroup) || errors.Is(err, whatsmeow.ErrGroupNotFound) || errors.Is(err, ErrNewsletterNotFound) {
if errors.Is(err, whatsmeow.ErrNotInGroup) || errors.Is(err, whatsmeow.ErrGroupNotFound) {
log.Debug().Err(err).Stringer("chat_jid", conv.ChatJID).
Msg("Skipping creating room because the user is not a participant")
//err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, conv.ChatJID)
@ -528,9 +474,6 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
if err != nil {
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 startTime, endTime *time.Time
var conv *wadb.Conversation
@ -578,7 +521,6 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
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) {
wa.deleteHistorySyncMessages(ctx, portalJID, 0, 0)
hasMore = hasMore && params.AnchorMessage != nil
if hasMore && !params.AllowSlowFetch {
return &bridgev2.FetchMessagesResponse{
MoreRequiresSlowFetch: true,
@ -586,7 +528,7 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
Forward: params.Forward,
}, nil
} else if hasMore {
return wa.fetchMessagesFromPhone(ctx, portalJID, params)
return wa.fetchMessagesFromPhone(ctx, params)
}
return &bridgev2.FetchMessagesResponse{
HasMore: false,
@ -605,36 +547,16 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet
}
}
}
resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true, !params.Forward && !hasMore)
resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true)
if err != nil {
return nil, fmt.Errorf("failed to convert messages: %w", err)
}
// TODO might be better to always set true here and let GetBetween return an empty result to mark as false?
resp.HasMore = hasMore
resp.Forward = params.Forward
resp.MarkRead = markRead
if params.Forward && !markRead && conv != nil && !ptr.Val(conv.MarkedAsUnread) {
resp.ReadUpTo = historySyncReadUpTo(resp.Messages, int(ptr.Val(conv.UnreadCount)))
}
return resp, nil
}
func historySyncReadUpTo(messages []*bridgev2.BackfillMessage, unreadCount int) time.Time {
if unreadCount <= 0 {
return time.Time{}
}
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Sender.IsFromMe {
continue
}
unreadCount--
if unreadCount == 0 {
return messages[i].Timestamp.Add(-time.Nanosecond)
}
}
return time.Time{}
}
func (wa *WhatsAppClient) deleteHistorySyncMessages(ctx context.Context, portalJID types.JID, newestTS, oldestTS uint64) {
var err error
var rows int64
@ -667,19 +589,13 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
portalJID types.JID,
messages []*waWeb.WebMessageInfo,
explodeOnError bool,
deleteEverything bool,
) (*bridgev2.FetchMessagesResponse, error) {
cli := wa.Client
if cli == nil {
return nil, bridgev2.ErrNotLoggedIn
}
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
newestTS := messages[0].GetMessageTimestamp()
convertedMessages := make([]*bridgev2.BackfillMessage, 0, len(messages))
var mediaRequests []*wadb.MediaRequest
dups := make(exmaps.Set[networkid.MessageID])
for i, msg := range messages {
evt, err := cli.ParseWebMessage(portalJID, msg)
evt, err := wa.Client.ParseWebMessage(portalJID, msg)
if err != nil {
if explodeOnError {
// This should never happen because the info is already parsed once before being stored in the database
@ -701,21 +617,10 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
continue
}
}
if !wa.ensureAltJIDs(ctx, &evt.Info.MessageSource, false) {
return nil, fmt.Errorf("failed to ensure alt JIDs for message %s", evt.Info.ID)
}
if evt.Info.IsFromMe {
wa.addPreviousOwnLID(ctx, evt.Info.Sender)
}
isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension
converted, mediaReq := wa.convertHistorySyncMessage(
ctx, cli, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions,
ctx, 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 {
mediaRequests = append(mediaRequests, mediaReq)
@ -726,11 +631,9 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
Messages: convertedMessages,
Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)),
CompleteCallback: func() {
if deleteEverything {
wa.deleteHistorySyncMessages(ctx, portalJID, 1<<62, oldestTS)
} else {
// TODO this only deletes after backfilling. If there's no need for backfill after a relogin,
// the messages will be stuck in the database
wa.deleteHistorySyncMessages(ctx, portalJID, newestTS, oldestTS)
}
if len(mediaRequests) > 0 {
go func(ctx context.Context) {
for _, req := range mediaRequests {
@ -748,45 +651,28 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
}, nil
}
func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID types.JID, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
if params.AnchorMessage == nil {
return nil, fmt.Errorf("anchor message is required to fetch messages from phone")
}
anchor := params.AnchorMessage
parsed, err := waid.ParseMessageID(anchor.ID)
if err != nil {
anchor, err = wa.Main.Bridge.DB.Message.GetFirstPortalMessageExcludingIDPattern(ctx, params.Portal.PortalKey, "fake:%")
if err != nil {
return nil, fmt.Errorf("failed to get first non-fake portal message: %w", err)
} else if anchor == nil {
return &bridgev2.FetchMessagesResponse{}, nil
}
parsed, err = waid.ParseMessageID(anchor.ID)
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()
if !wa.startOnDemandHistorySyncRequest(ctx, portalJID, msgID) {
return &bridgev2.FetchMessagesResponse{
HasMore: true,
Pending: true,
}, nil
}
reqData := wa.Client.BuildHistorySyncRequest(&types.MessageInfo{
MessageSource: types.MessageSource{
Chat: portalJID,
Chat: parsed.Chat,
Sender: parsed.Sender,
IsFromMe: wa.IsOwnJID(parsed.Sender),
IsFromMe: parsed.Sender.ToNonAD() == wa.JID.ToNonAD() || parsed.Sender.ToNonAD() == wa.Device.GetLID().ToNonAD(),
IsGroup: parsed.Chat.Server == types.GroupServer,
},
ID: parsed.ID,
Timestamp: anchor.Timestamp,
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")
@ -795,9 +681,6 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID
Peer: true,
})
if err != nil {
wa.onDemandHistorySyncLock.Lock()
delete(wa.onDemandHistorySyncRequests, portalJID)
wa.onDemandHistorySyncLock.Unlock()
return nil, fmt.Errorf("failed to send history sync request: %w", err)
}
return &bridgev2.FetchMessagesResponse{
@ -806,38 +689,6 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID
}, nil
}
type onDemandHistorySyncRequest struct {
ID types.MessageID
SentAt time.Time
}
const onDemandHistorySyncRequestTimeout = 1 * time.Hour
func (wa *WhatsAppClient) startOnDemandHistorySyncRequest(ctx context.Context, portalJID types.JID, msgID types.MessageID) bool {
wa.onDemandHistorySyncLock.Lock()
defer wa.onDemandHistorySyncLock.Unlock()
if req, inFlight := wa.onDemandHistorySyncRequests[portalJID]; inFlight && time.Since(req.SentAt) < onDemandHistorySyncRequestTimeout {
zerolog.Ctx(ctx).Debug().
Str("in_flight_request_msg_id", req.ID).
Stringer("portal_jid", portalJID).
Time("sent_at", req.SentAt).
Msg("Not sending history sync request, previous request is still in flight")
return false
}
wa.onDemandHistorySyncRequests[portalJID] = onDemandHistorySyncRequest{ID: msgID, SentAt: time.Now()}
return true
}
func (wa *WhatsAppClient) finishOnDemandHistorySyncRequest(sessionID string) {
wa.onDemandHistorySyncLock.Lock()
defer wa.onDemandHistorySyncLock.Unlock()
for chatJID, req := range wa.onDemandHistorySyncRequests {
if req.ID == sessionID {
delete(wa.onDemandHistorySyncRequests, chatJID)
}
}
}
func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *waHistorySync.HistorySync) {
if len(blob.GetConversations()) > 1 {
zerolog.Ctx(ctx).Warn().
@ -850,22 +701,6 @@ func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *w
zerolog.Ctx(ctx).Err(err).Str("jid", conv.GetID()).Msg("Failed to parse portal JID")
continue
}
if portalJID == types.MetaAIJID {
portalJID = types.NewMetaAIJID
} else 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")
@ -893,7 +728,7 @@ func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *w
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, false)
resp, err := wa.convertHistorySyncMessages(ctx, portal, portalJID, messages, false)
if err != nil {
return nil, err
}
@ -905,7 +740,7 @@ func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *w
}
func (wa *WhatsAppClient) convertHistorySyncMessage(
ctx context.Context, cli *whatsmeow.Client, 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) {
// New messages turn these into edits, but in backfill we only have the last version,
// so no need to do the edit thing. Instead, just unwrap the message.
@ -914,12 +749,11 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
}
// TODO use proper intent
intent := wa.Main.Bridge.Bot
msgID := waid.MakeMessageIDWithAltSender(info.Chat, info.Sender, info.SenderAlt, info.ID)
wrapped := &bridgev2.BackfillMessage{
ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, cli, intent, msg, rawMsg, info, isViewOnce, true, nil),
Sender: wa.makeEventSender(ctx, pickLID(info.Sender, info.SenderAlt)),
ID: msgID,
TxnID: networkid.TransactionID(msgID),
ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, rawMsg, info, nil, isViewOnce, true, nil),
Sender: wa.makeEventSender(ctx, info.Sender),
ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID),
TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)),
Timestamp: info.Timestamp,
StreamOrder: info.Timestamp.Unix(),
Reactions: make([]*bridgev2.BackfillReaction, 0, len(reactions)),
@ -928,10 +762,10 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
for _, reaction := range reactions {
var sender types.JID
if reaction.GetKey().GetFromMe() {
sender = wa.GetLID()
sender = wa.JID
} else if reaction.GetKey().GetParticipant() != "" {
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
}
if sender.IsEmpty() {

View file

@ -52,7 +52,7 @@ func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit
}
func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) {
return 1, 10
return 1, 8
}
const WAMaxFileSize = 2000 * 1024 * 1024
@ -67,7 +67,7 @@ func supportedIfFFmpeg() event.CapabilitySupportLevel {
}
func capID() string {
base := "fi.mau.whatsapp.capabilities.2026_10_01"
base := "fi.mau.whatsapp.capabilities.2026_05_12"
if ffmpeg.Supported() {
return base + "+ffmpeg"
}
@ -177,11 +177,6 @@ var whatsappCaps = &event.RoomFeatures{
MaxTextLength: MaxTextLength,
LocationMessage: event.CapLevelFullySupported,
Poll: event.CapLevelFullySupported,
PollEnd: event.CapLevelUnsupported,
PollHiddenVotes: event.CapLevelUnsupported,
PollDuplicateOptions: event.CapLevelUnsupported,
PollMaxOptions: 12,
PollOptionMaxLength: 100,
Reply: event.CapLevelFullySupported,
Edit: event.CapLevelFullySupported,
EditMaxAge: ptr.Ptr(jsontime.S(EditMaxAge)),
@ -198,7 +193,6 @@ var whatsappCaps = &event.RoomFeatures{
var whatsappDMCaps *event.RoomFeatures
var whatsappCAGCaps *event.RoomFeatures
var whatsappNoteToSelfCaps *event.RoomFeatures
func init() {
whatsappDMCaps = ptr.Clone(whatsappCaps)
@ -206,13 +200,7 @@ func init() {
whatsappDMCaps.State = event.StateFeatureMap{
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
}
whatsappDMCaps.BlockUser = true
whatsappDMCaps.MemberActions = nil
whatsappNoteToSelfCaps = ptr.Clone(whatsappDMCaps)
whatsappNoteToSelfCaps.ID = capID() + "+self"
whatsappNoteToSelfCaps.BlockUser = false
whatsappCAGCaps = ptr.Clone(whatsappCaps)
whatsappCAGCaps.ID = capID() + "+cag"
whatsappCAGCaps.Reply = event.CapLevelUnsupported
@ -223,10 +211,6 @@ func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.
if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup {
return whatsappCAGCaps
} else if portal.RoomType == database.RoomTypeDM {
jid, err := waid.ParsePortalID(portal.ID)
if err == nil && wa.IsOwnJID(jid) {
return whatsappNoteToSelfCaps
}
return whatsappDMCaps
}
return whatsappCaps

View file

@ -10,7 +10,6 @@ import (
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridgev2"
@ -30,21 +29,15 @@ func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port
return wa.getChatInfo(ctx, portalJID, nil, portal.MXID == "")
}
var ErrBroadcastList = errors.New("broadcast list bridging is currently not supported")
var ErrNewsletterNotFound = errors.New("newsletter doesn't exist")
func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, conv *wadb.Conversation, isNew bool) (wrapped *bridgev2.ChatInfo, err error) {
switch portalJID.Server {
case types.DefaultUserServer, types.HiddenUserServer, types.BotServer:
wrapped = wa.wrapDMInfo(ctx, portalJID)
if portalJID == types.MuseJID {
go wa.requestMuseProfile(ctx)
}
case types.BroadcastServer:
if portalJID == types.StatusBroadcastJID {
wrapped = wa.wrapStatusBroadcastInfo(ctx)
} else {
return nil, ErrBroadcastList
return nil, fmt.Errorf("broadcast list bridging is currently not supported")
}
case types.GroupServer:
info, err := wa.Client.GetGroupInfo(ctx, portalJID)
@ -57,8 +50,6 @@ func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID,
info, err := wa.Client.GetNewsletterInfo(ctx, portalJID)
if err != nil {
return nil, err
} else if info == nil || info.ID.IsEmpty() {
return nil, ErrNewsletterNotFound
}
wrapped = wa.wrapNewsletterInfo(ctx, info)
default:
@ -108,28 +99,9 @@ func (wa *WhatsAppClient) applyChatSettings(ctx context.Context, chatID types.JI
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get chat settings")
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
info.UserLocal = &bridgev2.UserLocalPortalInfo{
MutedUntil: ptr.Ptr(chat.MutedUntil),
}
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
}
}
wa.appStateRecoveryLock.Lock()
_, muteStateBroken := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).AppStateRecoveryAttempted[appstate.WAPatchRegularHigh]
wa.appStateRecoveryLock.Unlock()
userLocal := &bridgev2.UserLocalPortalInfo{}
if !muteStateBroken {
userLocal.MutedUntil = ptr.Ptr(chat.MutedUntil)
} else if info.UserLocal != nil {
userLocal.MutedUntil = info.UserLocal.MutedUntil
}
info.UserLocal = userLocal
if chat.Pinned {
info.UserLocal.Tag = ptr.Ptr(wa.Main.Config.PinnedTag)
} else if chat.Archived {
@ -169,12 +141,7 @@ const PrivateChatTopic = "WhatsApp private chat"
const BotChatTopic = "WhatsApp chat with a bot"
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{
Type: ptr.Ptr(database.RoomTypeDM),
Topic: ptr.Ptr(PrivateChatTopic),
Members: &bridgev2.ChatMemberList{
IsFull: true,
@ -182,7 +149,7 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge
OtherUserID: waid.MakeUserID(jid),
MemberMap: map[networkid.UserID]bridgev2.ChatMember{
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{
Events: map[event.Type]int{
@ -192,19 +159,13 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge
event.StateBeeperDisappearingTimer: 0,
},
},
ExcludeChangesFromTimeline: true,
},
ExcludeChangesFromTimeline: true,
}
var err error
info.UserBlocked, err = wa.isUserBlocked(ctx, jid)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get blocked state")
Type: ptr.Ptr(database.RoomTypeDM),
}
if jid.Server == types.BotServer {
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.
info.Members.MemberMap = map[networkid.UserID]bridgev2.ChatMember{
waid.MakeUserID(jid): {EventSender: bridgev2.EventSender{Sender: waid.MakeUserID(jid)}},
@ -228,7 +189,7 @@ func (wa *WhatsAppClient) wrapStatusBroadcastInfo(ctx context.Context) *bridgev2
Members: &bridgev2.ChatMemberList{
IsFull: false,
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),
@ -279,8 +240,6 @@ func setTopicID(id, topic string) bridgev2.ExtraUpdater[*bridgev2.Portal] {
func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupInfo) *bridgev2.ChatInfo {
sendEventPL := defaultPL
// Community announcement groups allow non-admins to send threaded replies.
// Power levels can't represent that, but the power level is checked separately when handling messages.
if info.IsAnnounce && !info.IsDefaultSubGroup {
sendEventPL = adminPL
}
@ -294,12 +253,11 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
setAddressingMode(info.AddressingMode),
setTopicID(info.TopicID, info.Topic),
)
syncAllMembers := wa.Main.Config.MaxMemberSync < 0 || len(info.Participants) < wa.Main.Config.MaxMemberSync
wrapped := &bridgev2.ChatInfo{
Name: ptr.Ptr(info.Name),
Topic: ptr.Ptr(info.Topic),
Members: &bridgev2.ChatMemberList{
IsFull: !info.IsIncognito && !info.IsParent && syncAllMembers,
IsFull: !info.IsIncognito && !info.IsParent,
TotalMemberCount: len(info.Participants),
MemberMap: make(map[networkid.UserID]bridgev2.ChatMember, len(info.Participants)),
PowerLevels: &bridgev2.PowerLevelOverrides{
@ -311,11 +269,11 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
event.StateRoomName: metaChangePL,
event.StateRoomAvatar: metaChangePL,
event.StateTopic: metaChangePL,
event.EventEncrypted: defaultPL, // Need to always allow encrypted events for poll responses
event.EventReaction: defaultPL,
event.EventRedaction: defaultPL,
event.EventUnstablePollResponse: defaultPL,
event.StateBeeperDisappearingTimer: metaChangePL,
// TODO always allow poll responses
},
},
},
@ -340,9 +298,6 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
} else if pcp.IsAdmin {
member.PowerLevel = ptr.Ptr(adminPL)
} else {
if !syncAllMembers && !member.EventSender.IsFromMe {
continue
}
member.PowerLevel = ptr.Ptr(defaultPL)
}
member.MemberEventExtra = map[string]any{
@ -361,7 +316,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn
}
}
if info.IsParent && !hasSelf && info.AddressingMode == types.AddressingModeLID {
wrapped.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: wa.makeEventSender(ctx, wa.GetLID())})
wrapped.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: wa.makeEventSender(ctx, wa.Device.LID)})
}
if !info.LinkedParentJID.IsEmpty() {
@ -568,8 +523,8 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
Members: &bridgev2.ChatMemberList{
TotalMemberCount: info.ThreadMeta.SubscriberCount,
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),
PowerLevel: &ownPowerLevel,
},
},
@ -583,27 +538,10 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
event.StateTopic: adminPL,
event.EventReaction: defaultPL,
event.EventRedaction: defaultPL,
// TODO support poll votes in channels
//event.EventUnstablePollResponse: defaultPL,
// TODO always allow poll responses
},
},
},
Type: ptr.Ptr(database.RoomTypeDefault),
}
}
func (wa *WhatsAppClient) isUserBlocked(ctx context.Context, jid types.JID) (*bool, error) {
wa.blocklistLock.Lock()
defer wa.blocklistLock.Unlock()
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
blocklist := meta.Blocklist
if blocklist == nil {
runHandlers := wa.resyncBlocklistUnlocked(ctx)
go runHandlers()
blocklist = meta.Blocklist
}
if blocklist == nil {
return nil, nil
}
return new(blocklist.IsBlocked(jid)), nil
}

View file

@ -24,6 +24,7 @@ import (
"sync/atomic"
"time"
"github.com/purpshell/meowcaller"
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"go.mau.fi/whatsmeow"
@ -40,6 +41,7 @@ import (
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/connector/voip"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
@ -54,12 +56,9 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
directMediaRetries: make(map[networkid.MessageID]*directMediaRetry),
mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle),
pushNamesSynced: exsync.NewEvent(),
createDedup: exsync.NewSet[types.MessageID](),
appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time),
previousOwnLIDs: exsync.NewSetWithItems(login.Metadata.(*waid.UserLoginMetadata).PreviousOwnLIDs),
onDemandHistorySyncRequests: make(map[types.JID]onDemandHistorySyncRequest),
disableNewsletter: store.BaseClientPayload.GetUserAgent().GetPlatform() == waWa6.ClientPayload_UserAgent_MACOS,
incomingCallGroups: make(map[string]incomingCallGroup),
}
login.Client = w
@ -74,21 +73,32 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
if err != nil {
return err
}
w.LID = w.Device.GetLID()
if w.Device != nil {
log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger()
w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log))
w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent)
w.Client.AddEventHandler(w.trackIncomingCallEvent)
w.Client.SynchronousAck = true
w.Client.EnableDecryptedEventBuffer = wa.Bridge.Config.PortalEventBuffer == 0
w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0
w.Client.ManualHistorySyncDownload = true
w.Client.SendReportingTokens = true
w.Client.AutomaticMessageRerequestFromPhone = true
w.Client.UnifiedSessionInterval = 24 * time.Hour
w.Client.GetMessageForRetry = w.trackNotFoundRetry
w.Client.PreRetryCallback = w.trackFoundRetry
w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx)
w.VOIP = voip.NewManager(w.Client, makeVOIPConfig(wa.Config.VOIP), w.UserLogin.Log.With().Str("component", "voip").Logger())
w.VOIP.SetIncomingCallHandler(w.handleIncomingVOIPCall)
w.VOIP.SetCallEndHandler(w.handleVOIPCallEnded)
w.VOIP.SetCallReactionHandler(func(callID string, reaction meowcaller.CallReaction) {
go w.handleWhatsAppCallReaction(withoutCancelOrBackground(w.Main.Bridge.BackgroundCtx), callID, reaction)
})
w.VOIP.SetHandRaiseHandler(func(callID string, state meowcaller.HandRaiseState) {
go w.handleWhatsAppHandRaise(withoutCancelOrBackground(w.Main.Bridge.BackgroundCtx), callID, state)
})
w.VOIP.SetWaitingRoomHandler(func(callID string, state meowcaller.WaitingRoomState) {
go w.handleWhatsAppWaitingRoom(withoutCancelOrBackground(w.Main.Bridge.BackgroundCtx), callID, state)
})
w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent)
w.Client.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts)
w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect
w.Client.UseRetryMessageStore = wa.Config.UseWhatsAppRetryStore
@ -108,9 +118,9 @@ type WhatsAppClient struct {
Main *WhatsAppConnector
UserLogin *bridgev2.UserLogin
Client *whatsmeow.Client
VOIP *voip.Manager
Device *store.Device
JID types.JID
LID types.JID
MC mClient
historySyncWakeup chan struct{}
@ -120,28 +130,21 @@ type WhatsAppClient struct {
nextResync time.Time
directMediaRetries map[networkid.MessageID]*directMediaRetry
directMediaLock sync.Mutex
avatarLock exsync.KeyedMutex[types.JID]
voipHandBridgeLock sync.Mutex
voipHandRaiseLock sync.Mutex
voipHandRaises map[string]map[types.JID]bool
voipCallStartLock sync.Mutex
incomingCallGroupLock sync.Mutex
incomingCallGroups map[string]incomingCallGroup
mediaRetryLock *semaphore.Weighted
offlineSyncWaiter atomic.Pointer[chan error]
isNewLogin bool
pushNamesSynced *exsync.Event
lastPresence types.Presence
blocklistLock sync.Mutex
disableNewsletter bool
createDedup *exsync.Set[types.MessageID]
appStateRecoveryLock sync.Mutex
appStateFullSyncAttempted map[appstate.WAPatchName]time.Time
wasaResyncLock sync.Mutex
museProfileLock sync.Mutex
lastMuseProfileRequest time.Time
onDemandHistorySyncLock sync.Mutex
onDemandHistorySyncRequests map[types.JID]onDemandHistorySyncRequest
previousOwnLIDs *exsync.Set[types.JID]
previousOwnLIDsLock sync.Mutex
}
var (
@ -205,52 +208,17 @@ func (wa *WhatsAppClient) RegisterPushNotifications(ctx context.Context, pushTyp
}
func (wa *WhatsAppClient) IsThisUser(_ context.Context, userID networkid.UserID) bool {
return userID == waid.MakeUserID(wa.JID) || userID == waid.MakeUserID(wa.GetLID())
}
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) ||
wa.previousOwnLIDs.Has(jid.ToNonAD())
}
func (wa *WhatsAppClient) addPreviousOwnLID(ctx context.Context, jid types.JID) {
if jid.Server != types.HiddenUserServer || wa.IsOwnJID(jid) {
return
}
jid = jid.ToNonAD()
wa.previousOwnLIDsLock.Lock()
defer wa.previousOwnLIDsLock.Unlock()
if !wa.previousOwnLIDs.Add(jid) {
return
}
zerolog.Ctx(ctx).Info().Stringer("lid", jid).Msg("Found previous own LID in history sync")
wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PreviousOwnLIDs = wa.previousOwnLIDs.AsList()
err := wa.UserLogin.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save user login after adding previous own LID")
}
}
func (wa *WhatsAppClient) GetLID() types.JID {
if wa.LID.IsEmpty() && !wa.JID.IsEmpty() {
wa.LID = wa.GetStore().GetLID()
}
return wa.LID
return userID == waid.MakeUserID(wa.JID)
}
func (wa *WhatsAppClient) Connect(ctx context.Context) {
wa.connect(ctx)
}
func (wa *WhatsAppClient) connect(ctx context.Context) bool {
if wa.Client == nil {
state := status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: WANotLoggedIn,
}
wa.UserLogin.BridgeState.Send(state)
return false
return
}
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect)
@ -258,8 +226,9 @@ func (wa *WhatsAppClient) connect(ctx context.Context) bool {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
}
if ctx.Err() != nil {
return false
return
}
wa.cleanupStaleMatrixRTCCalls(ctx)
wa.initMC()
wa.startLoops()
wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx)
@ -275,9 +244,7 @@ func (wa *WhatsAppClient) connect(ctx context.Context) bool {
},
}
wa.UserLogin.BridgeState.Send(state)
return false
}
return true
}
func (wa *WhatsAppClient) notifyOfflineSyncWaiter(err error) {
@ -420,6 +387,9 @@ func (wa *WhatsAppClient) callStopLoops() {
func (wa *WhatsAppClient) Disconnect() {
wa.callStopLoops()
if wa.VOIP != nil {
wa.VOIP.AbortAll()
}
if cli := wa.Client; cli != nil {
cli.Disconnect()
}
@ -434,10 +404,6 @@ func (wa *WhatsAppClient) LogoutRemote(ctx context.Context) {
}
wa.Disconnect()
wa.Client = nil
err := wa.Main.DB.Conversation.DeleteAll(ctx, wa.UserLogin.ID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to delete history sync data on logout")
}
}
func (wa *WhatsAppClient) IsLoggedIn() bool {
@ -494,10 +460,6 @@ func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *brid
}
}
if msg.Portal != nil && msg.Portal.ID == waid.MakePortalID(types.MuseJID) {
go wa.requestMuseProfile(ctx)
}
if msg.Portal == nil || msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Add(5*time.Minute).After(time.Now()) {
// If we resynced this portal within the last 5 minutes, don't do it again
return nil

View file

@ -148,7 +148,13 @@ func fnSync(ce *commands.Event) {
case "appstate":
names := appstate.AllPatchNames[:]
if len(ce.Args) > 1 {
names = parseAppStateNames(ce, 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)
@ -168,50 +174,6 @@ func fnSync(ce *commands.Event) {
}
}
func parseAppStateNames(ce *commands.Event, args []string) []appstate.WAPatchName {
return exslices.CastFuncFilter(args, 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
})
}
var cmdResetAppState = &commands.FullHandler{
Func: fnResetAppState,
Name: "reset-appstate",
Help: commands.HelpMeta{
Section: commands.HelpSectionAdmin,
Description: "Ask your phone to reset broken app state. This logs out all linked devices.",
Args: "<names...>",
},
RequiresLogin: true,
}
func fnResetAppState(ce *commands.Event) {
login := ce.User.GetDefaultLogin()
if login == nil {
ce.Reply("Login not found")
return
}
if len(ce.Args) == 0 {
ce.Reply("Usage: `$cmdprefix reset-appstate <names...>`\n\nThis logs out all linked devices, including the bridge. You'll have to log in again afterwards.")
return
}
names := parseAppStateNames(ce, ce.Args)
if len(names) != len(ce.Args) {
return
}
wa := login.Client.(*WhatsAppClient)
_, err := wa.Client.SendPeerMessage(ce.Ctx, whatsmeow.BuildFatalAppStateExceptionNotification(names...))
if err != nil {
ce.Reply("Failed to send app state reset request: %v", err)
return
}
ce.Reply("Sent app state reset request. Your phone should log out all linked devices shortly, after which you can log in again.")
}
var cmdInviteLink = &commands.FullHandler{
Func: fnInviteLink,
Name: "invite-link",

View file

@ -0,0 +1,530 @@
package connector
import (
"errors"
"fmt"
"slices"
"strings"
"github.com/purpshell/meowcaller"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
)
var HelpSectionCalls = commands.HelpSection{Name: "Calls", Order: 27}
var cmdCallParticipants = &commands.FullHandler{
Func: fnCallParticipants,
Name: "call-participants",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "List the WhatsApp participants in the active call.",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallAdd = &commands.FullHandler{
Func: fnCallAdd,
Name: "call-add",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Invite a WhatsApp user to the active call.",
Args: "<phone number or JID>",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallRing = &commands.FullHandler{
Func: fnCallRing,
Name: "call-ring",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Ring a non-connected WhatsApp participant already in the active call.",
Args: "<phone number or JID>",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallVideoSelect = &commands.FullHandler{
Func: fnCallVideoSelect,
Name: "call-video-select",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Select which WhatsApp group participant is shown on the Matrix camera track.",
Args: "<phone number or JID>",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallLinkCreate = &commands.FullHandler{
Func: fnCallLinkCreate,
Name: "call-link-create",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Create a reusable WhatsApp call link.",
Args: "[audio|video]",
},
RequiresLogin: true,
}
var cmdCallLinkPreview = &commands.FullHandler{
Func: fnCallLinkPreview,
Name: "call-link-preview",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Preview a WhatsApp call link without joining it.",
Args: "<link or token> [audio|video]",
},
RequiresLogin: true,
}
var cmdCallLinkJoin = &commands.FullHandler{
Func: fnCallLinkJoin,
Name: "call-link-join",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Join a WhatsApp call link and ring it into the current Matrix room.",
Args: "<link or token> [audio|video]",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallWaiting = &commands.FullHandler{
Func: fnCallWaiting,
Name: "call-waiting",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Show the waiting room for the active WhatsApp call link.",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallApproval = &commands.FullHandler{
Func: fnCallApproval,
Name: "call-approval",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Enable or disable approval for the active WhatsApp call link.",
Args: "<on|off>",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallAdmit = &commands.FullHandler{
Func: fnCallAdmit,
Name: "call-admit",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Admit a user from the active WhatsApp call link waiting room.",
Args: "<phone number or JID>",
},
RequiresLogin: true,
RequiresPortal: true,
}
var cmdCallDeny = &commands.FullHandler{
Func: fnCallDeny,
Name: "call-deny",
Help: commands.HelpMeta{
Section: HelpSectionCalls,
Description: "Deny a user from the active WhatsApp call link waiting room.",
Args: "<phone number or JID>",
},
RequiresLogin: true,
RequiresPortal: true,
}
func fnCallParticipants(ce *commands.Event) {
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
state, ok, err := client.VOIP.GroupState(call.WACallID)
if err != nil {
ce.Reply("Failed to read the active call roster: %v", err)
return
}
if !ok {
ce.Reply("WhatsApp has not advertised a group roster for this call yet.")
return
}
ce.Reply(formatGroupCallRoster(state))
}
func fnCallAdd(ce *commands.Event) {
target, ok := callTargetArg(ce)
if !ok {
return
}
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
if err = client.VOIP.AddParticipant(ce.Ctx, call.WACallID, target); err != nil {
ce.Reply("Failed to invite the participant: %v", err)
return
}
ce.Reply("Invited `%s` to the active WhatsApp call.", target)
}
func fnCallRing(ce *commands.Event) {
target, ok := callTargetArg(ce)
if !ok {
return
}
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
if err = client.VOIP.RingParticipant(ce.Ctx, call.WACallID, target); err != nil {
ce.Reply("Failed to ring the participant: %v", err)
return
}
ce.Reply("Rang `%s` in the active WhatsApp call.", target)
}
func fnCallVideoSelect(ce *commands.Event) {
target, ok := callTargetArg(ce)
if !ok {
return
}
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
if err = client.VOIP.SelectVideoParticipant(call.WACallID, target); err != nil {
ce.Reply("Failed to select the WhatsApp video participant: %v", err)
return
}
ce.Reply("Selected `%s` for the WhatsApp camera track.", target)
}
func fnCallLinkCreate(ce *commands.Event) {
video, err := callMediaArg(ce.Args)
if err != nil {
ce.Reply("Usage: `$cmdprefix call-link-create [audio|video]`")
return
}
client, err := commandWhatsAppClient(ce)
if err != nil {
ce.Reply("Failed to resolve the WhatsApp login: %v", err)
return
}
link, err := client.VOIP.CreateCallLink(ce.Ctx, video)
if err != nil {
ce.Reply("Failed to create the WhatsApp call link: %v", err)
return
}
ce.Reply("Created a WhatsApp %s call link:\n\n%s", callMediaName(video), link.URL)
}
func fnCallLinkPreview(ce *commands.Event) {
token, video, err := callLinkArgs(ce.Args)
if err != nil {
ce.Reply("Usage: `$cmdprefix call-link-preview <link or token> [audio|video]`")
return
}
client, err := commandWhatsAppClient(ce)
if err != nil {
ce.Reply("Failed to resolve the WhatsApp login: %v", err)
return
}
preview, err := client.VOIP.PreviewCallLink(ce.Ctx, token, video)
if err != nil {
ce.Reply("Failed to preview the WhatsApp call link: %v", err)
return
}
creator := preview.Creator
if !preview.CreatorPhoneNumber.IsEmpty() {
creator = preview.CreatorPhoneNumber
}
ce.Reply(
"**WhatsApp %s call link**\n\nCreator: `%s`\n\nApproval required: **%t**\n\nYou are an admin: **%t**",
callMediaName(preview.Video), creator, preview.ApprovalRequired, preview.IsAdmin,
)
}
func fnCallLinkJoin(ce *commands.Event) {
token, video, err := callLinkArgs(ce.Args)
if err != nil {
ce.Reply("Usage: `$cmdprefix call-link-join <link or token> [audio|video]`")
return
}
client, err := commandWhatsAppClient(ce)
if err != nil {
ce.Reply("Failed to resolve the WhatsApp login: %v", err)
return
}
call, err := client.joinMatrixRTCCallLink(ce.Ctx, ce.Portal, token, video)
if err != nil {
ce.Reply("Failed to join the WhatsApp call link: %v", err)
return
}
if state, ok, _ := client.VOIP.WaitingRoomState(call.ID()); ok && state.InWaitingRoom {
ce.Reply("Joined the WhatsApp call link waiting room. Element will ring in this room while approval is pending.")
} else {
ce.Reply("Joined the WhatsApp call link. Element will ring in this room.")
}
}
func fnCallWaiting(ce *commands.Event) {
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
state, ok, err := client.VOIP.WaitingRoomState(call.WACallID)
if err != nil {
ce.Reply("Failed to read the waiting room: %v", err)
return
}
if !ok {
ce.Reply("The active call has no WhatsApp call-link waiting-room state.")
return
}
ce.Reply(formatWaitingRoomState(state))
}
func fnCallApproval(ce *commands.Event) {
if len(ce.Args) != 1 {
ce.Reply("Usage: `$cmdprefix call-approval <on|off>`")
return
}
enabled, err := parseCallApproval(ce.Args[0])
if err != nil {
ce.Reply("Usage: `$cmdprefix call-approval <on|off>`")
return
}
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
if err = client.VOIP.SetApprovalRequired(ce.Ctx, call.WACallID, enabled); err != nil {
ce.Reply("Failed to change call-link approval: %v", err)
return
}
ce.Reply("WhatsApp call-link approval is now **%s**.", map[bool]string{true: "enabled", false: "disabled"}[enabled])
}
func fnCallAdmit(ce *commands.Event) {
fnCallWaitingParticipant(ce, true)
}
func fnCallDeny(ce *commands.Event) {
fnCallWaitingParticipant(ce, false)
}
func fnCallWaitingParticipant(ce *commands.Event, admit bool) {
target, ok := callTargetArg(ce)
if !ok {
return
}
client, call, err := activePortalCall(ce)
if err != nil {
ce.Reply("Failed to find the active call: %v", err)
return
}
if admit {
err = client.VOIP.AdmitParticipant(ce.Ctx, call.WACallID, target)
} else {
err = client.VOIP.DenyParticipant(ce.Ctx, call.WACallID, target)
}
if err != nil {
ce.Reply("Failed to update the waiting-room participant: %v", err)
return
}
action := "Admitted"
if !admit {
action = "Denied"
}
ce.Reply("%s `%s` in the WhatsApp call-link waiting room.", action, target)
}
func callTargetArg(ce *commands.Event) (string, bool) {
if len(ce.Args) != 1 {
ce.Reply("Usage: `$cmdprefix %s <phone number or JID>`", ce.Command)
return "", false
}
return strings.TrimSpace(ce.Args[0]), true
}
func activePortalCall(ce *commands.Event) (*WhatsAppClient, *wadb.MatrixRTCCall, error) {
if ce.Portal == nil {
return nil, nil, errors.New("this command can only be used in a portal room")
}
client, err := commandWhatsAppClient(ce)
if err != nil {
return nil, nil, err
}
calls, err := client.Main.DB.MatrixRTCCall.GetActiveInRoom(ce.Ctx, ce.Portal.MXID)
if err != nil {
return nil, nil, fmt.Errorf("query active calls: %w", err)
}
call, err := selectActiveCallForLogin(calls, client.UserLogin.ID)
if err != nil {
return nil, nil, err
}
return client, call, nil
}
func commandWhatsAppClient(ce *commands.Event) (*WhatsAppClient, error) {
var loginID networkid.UserLoginID
if ce.Portal != nil {
loginID = ce.Portal.Receiver
} else if login := ce.User.GetDefaultLogin(); login != nil {
loginID = login.ID
}
login := ce.Bridge.GetCachedUserLoginByID(loginID)
if login == nil {
return nil, errors.New("the WhatsApp login is not available")
}
var sender id.UserID
if ce.User != nil {
sender = ce.User.MXID
}
if !matrixRTCSenderOwnsLogin(sender, login) {
return nil, errors.New("the WhatsApp login for this portal belongs to another Matrix user")
}
client, ok := login.Client.(*WhatsAppClient)
if !ok || client == nil || !client.IsLoggedIn() {
return nil, errors.New("the WhatsApp login is not connected")
}
if client.VOIP == nil || !client.VOIP.Enabled() {
return nil, errors.New("WhatsApp calling is not enabled")
}
return client, nil
}
func selectActiveCallForLogin(calls []*wadb.MatrixRTCCall, loginID networkid.UserLoginID) (*wadb.MatrixRTCCall, error) {
var selected *wadb.MatrixRTCCall
for _, call := range calls {
if call == nil || call.UserLoginID != loginID {
continue
}
if selected != nil {
return nil, errors.New("multiple active calls are tracked in this room")
}
selected = call
}
if selected == nil {
return nil, errors.New("there is no active call in this room")
}
return selected, nil
}
func formatGroupCallRoster(state meowcaller.GroupCallState) string {
participants := slices.Clone(state.Participants)
slices.SortFunc(participants, func(a, b meowcaller.GroupCallParticipant) int {
return strings.Compare(a.JID.String(), b.JID.String())
})
lines := make([]string, 0, len(participants)+1)
lines = append(lines, fmt.Sprintf("**WhatsApp call participants (transaction %d):**", state.TransactionID))
for _, participant := range participants {
identity := participant.JID
if !participant.PN.IsEmpty() {
identity = participant.PN
}
detail := fmt.Sprintf("%s; %d device(s)", participant.State, len(participant.Devices))
if participant.HandRaised {
detail += "; hand raised"
}
lines = append(lines, fmt.Sprintf("- `%s`: %s", identity, detail))
}
if state.RekeyRequested {
lines = append(lines, "- WhatsApp requested a group media rekey.")
}
return strings.Join(lines, "\n")
}
func formatWaitingRoomState(state meowcaller.WaitingRoomState) string {
lines := []string{
fmt.Sprintf(
"**WhatsApp call-link waiting room (transaction %d):** approval **%s**, admin **%t**, waiting **%t**",
state.TransactionID,
map[bool]string{true: "enabled", false: "disabled"}[state.Enabled],
state.IsAdmin,
state.InWaitingRoom,
),
}
users := slices.Clone(state.Users)
slices.SortFunc(users, func(a, b meowcaller.WaitingRoomUser) int {
return strings.Compare(a.JID.String(), b.JID.String())
})
for _, user := range users {
identity := user.JID
if !user.PN.IsEmpty() {
identity = user.PN
}
lines = append(lines, fmt.Sprintf("- `%s`: %s", identity, user.State))
}
if len(users) == 0 {
lines = append(lines, "- No users are waiting.")
}
return strings.Join(lines, "\n")
}
func callLinkArgs(args []string) (token string, video bool, err error) {
if len(args) < 1 || len(args) > 2 {
return "", false, errors.New("invalid call-link arguments")
}
token = strings.TrimSpace(args[0])
if token == "" {
return "", false, errors.New("call-link token is empty")
}
if len(args) == 2 {
video, err = callMediaArg(args[1:])
return
}
video = strings.HasPrefix(strings.ToLower(token), "https://call.whatsapp.com/video/")
return
}
func callMediaArg(args []string) (bool, error) {
if len(args) == 0 {
return false, nil
}
if len(args) != 1 {
return false, errors.New("invalid call media")
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "audio":
return false, nil
case "video":
return true, nil
default:
return false, errors.New("invalid call media")
}
}
func callMediaName(video bool) string {
if video {
return "video"
}
return "audio"
}
func parseCallApproval(raw string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "on", "true", "enable", "enabled":
return true, nil
case "off", "false", "disable", "disabled":
return false, nil
default:
return false, errors.New("invalid approval state")
}
}

View file

@ -0,0 +1,144 @@
package connector
import (
"strings"
"testing"
"github.com/purpshell/meowcaller"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/bridgev2/networkid"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
)
func TestSelectActiveCallForLogin(t *testing.T) {
alice := networkid.UserLoginID("alice")
bob := networkid.UserLoginID("bob")
calls := []*wadb.MatrixRTCCall{
{UserLoginID: bob, WACallID: "bob-call"},
{UserLoginID: alice, WACallID: "alice-call"},
}
call, err := selectActiveCallForLogin(calls, alice)
if err != nil {
t.Fatalf("selectActiveCallForLogin returned error: %v", err)
}
if call.WACallID != "alice-call" {
t.Fatalf("selected call = %q, want alice-call", call.WACallID)
}
}
func TestSelectActiveCallForLoginRejectsMissingAndAmbiguousCalls(t *testing.T) {
loginID := networkid.UserLoginID("alice")
if _, err := selectActiveCallForLogin(nil, loginID); err == nil {
t.Fatal("selectActiveCallForLogin accepted an empty call list")
}
calls := []*wadb.MatrixRTCCall{
{UserLoginID: loginID, WACallID: "first"},
{UserLoginID: loginID, WACallID: "second"},
}
if _, err := selectActiveCallForLogin(calls, loginID); err == nil {
t.Fatal("selectActiveCallForLogin accepted multiple calls for one login")
}
}
func TestFormatGroupCallRoster(t *testing.T) {
state := meowcaller.GroupCallState{
TransactionID: 42,
RekeyRequested: true,
Participants: []meowcaller.GroupCallParticipant{
{
JID: types.NewJID("222", types.HiddenUserServer),
PN: types.NewJID("15550000002", types.DefaultUserServer),
State: "connected",
Devices: []meowcaller.GroupCallDevice{
{JID: types.NewJID("222", types.HiddenUserServer)},
},
HandRaised: true,
},
{
JID: types.NewJID("111", types.HiddenUserServer),
State: "ringing",
},
},
}
got := formatGroupCallRoster(state)
for _, want := range []string{
"transaction 42",
"`111@lid`: ringing; 0 device(s)",
"`15550000002@s.whatsapp.net`: connected; 1 device(s); hand raised",
"requested a group media rekey",
} {
if !strings.Contains(got, want) {
t.Errorf("formatted roster missing %q:\n%s", want, got)
}
}
}
func TestCallLinkArgsInfersMediaFromURL(t *testing.T) {
token, video, err := callLinkArgs([]string{"https://call.whatsapp.com/video/TOKEN"})
if err != nil {
t.Fatalf("callLinkArgs returned error: %v", err)
}
if token != "https://call.whatsapp.com/video/TOKEN" || !video {
t.Fatalf("callLinkArgs = (%q, %t), want video URL and true", token, video)
}
token, video, err = callLinkArgs([]string{"TOKEN", "video"})
if err != nil {
t.Fatalf("callLinkArgs with explicit media returned error: %v", err)
}
if token != "TOKEN" || !video {
t.Fatalf("callLinkArgs = (%q, %t), want TOKEN and true", token, video)
}
}
func TestCallMediaArgRejectsUnknownMedia(t *testing.T) {
if _, err := callMediaArg([]string{"screen"}); err == nil {
t.Fatal("callMediaArg accepted an unknown media kind")
}
if video, err := callMediaArg(nil); err != nil || video {
t.Fatalf("callMediaArg default = (%t, %v), want audio and nil", video, err)
}
}
func TestParseCallApproval(t *testing.T) {
for _, raw := range []string{"on", "true", "enabled"} {
if enabled, err := parseCallApproval(raw); err != nil || !enabled {
t.Errorf("parseCallApproval(%q) = (%t, %v), want true and nil", raw, enabled, err)
}
}
for _, raw := range []string{"off", "false", "disabled"} {
if enabled, err := parseCallApproval(raw); err != nil || enabled {
t.Errorf("parseCallApproval(%q) = (%t, %v), want false and nil", raw, enabled, err)
}
}
if _, err := parseCallApproval("maybe"); err == nil {
t.Fatal("parseCallApproval accepted an invalid value")
}
}
func TestFormatWaitingRoomState(t *testing.T) {
state := meowcaller.WaitingRoomState{
Enabled: true,
IsAdmin: true,
InWaitingRoom: false,
TransactionID: 7,
Users: []meowcaller.WaitingRoomUser{
{
JID: types.NewJID("222", types.HiddenUserServer),
PN: types.NewJID("15550000002", types.DefaultUserServer),
State: "pending",
},
},
}
got := formatWaitingRoomState(state)
for _, want := range []string{
"transaction 7",
"approval **enabled**",
"`15550000002@s.whatsapp.net`: pending",
} {
if !strings.Contains(got, want) {
t.Errorf("formatted waiting room missing %q:\n%s", want, got)
}
}
}

View file

@ -52,10 +52,9 @@ type Config struct {
DirectMediaAutoRequest bool `yaml:"direct_media_auto_request"`
InitialAutoReconnect bool `yaml:"initial_auto_reconnect"`
UseWhatsAppRetryStore bool `yaml:"use_whatsapp_retry_store"`
MaxMemberSync int `yaml:"max_member_sync"`
LazyAvatars bool `yaml:"lazy_avatars"`
AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"`
VOIP VOIPConfig `yaml:"voip"`
HistorySync struct {
MaxInitialConversations int `yaml:"max_initial_conversations"`
@ -80,6 +79,58 @@ type Config struct {
displaynameTemplate *template.Template `yaml:"-"`
}
type VOIPConfig struct {
Enabled bool `yaml:"enabled"`
MatrixSurface string `yaml:"matrix_surface"`
IncomingPolicy string `yaml:"incoming_policy"`
MaxActiveCallsPerLogin int `yaml:"max_active_calls_per_login"`
MatrixRTC MatrixRTCConfig `yaml:"matrixrtc"`
LiveKit LiveKitConfig `yaml:"livekit"`
Audio VOIPAudioConfig `yaml:"audio"`
Video VOIPVideoConfig `yaml:"video"`
Diagnostics VOIPDiagnostics `yaml:"diagnostics"`
}
type MatrixRTCConfig struct {
LiveKitServiceURL string `yaml:"livekit_service_url"`
RequireLiveKitFocus bool `yaml:"require_livekit_focus"`
MembershipEventCompat string `yaml:"membership_event_compat"`
NotificationEventCompat string `yaml:"notification_event_compat"`
UseDelayedEvents bool `yaml:"use_delayed_events"`
ParticipantMode string `yaml:"participant_mode"`
FallbackParticipantMXID string `yaml:"fallback_participant_mxid"`
}
type LiveKitConfig struct {
ConnectTimeout time.Duration `yaml:"connect_timeout"`
PublishSilenceBeforeWhatsAppAnswer bool `yaml:"publish_silence_before_whatsapp_answer"`
AutoSubscribe bool `yaml:"auto_subscribe"`
AudioUplinkPolicy string `yaml:"audio_uplink_policy"`
SelectedParticipantTimeout time.Duration `yaml:"selected_participant_timeout"`
}
type VOIPAudioConfig struct {
Enabled bool `yaml:"enabled"`
JitterBuffer time.Duration `yaml:"jitter_buffer_ms"`
OpusBackend string `yaml:"opus_backend"`
SilenceOnUnderrun bool `yaml:"silence_on_underrun"`
MaxMixParticipants int `yaml:"max_mix_participants"`
}
type VOIPVideoConfig struct {
Enabled bool `yaml:"enabled"`
SelectedSourcePolicy string `yaml:"selected_source_policy"`
MaxWidth int `yaml:"max_width"`
MaxHeight int `yaml:"max_height"`
MaxFPS int `yaml:"max_fps"`
}
type VOIPDiagnostics struct {
HealthcheckFocusOnStartup bool `yaml:"healthcheck_focus_on_startup"`
EnableMeowcallerDiagnostics bool `yaml:"enable_meowcaller_diagnostics"`
MediaTraceDir string `yaml:"media_trace_dir"`
}
type umConfig Config
func (c *Config) UnmarshalYAML(node *yaml.Node) error {
@ -101,9 +152,74 @@ func (c *Config) PostProcess() error {
if err != nil {
return fmt.Errorf("failed to execute displayname template: %w", err)
}
if err = c.validateVOIP(); err != nil {
return err
}
return nil
}
func (c *Config) validateVOIP() error {
if !c.VOIP.Enabled {
return nil
}
if c.VOIP.MatrixSurface != "matrixrtc_livekit" {
return fmt.Errorf("voip.matrix_surface must be matrixrtc_livekit")
}
if !oneOf(c.VOIP.IncomingPolicy, "notice", "ring", "auto_answer") {
return fmt.Errorf("voip.incoming_policy must be one of notice, ring, auto_answer")
}
if c.VOIP.MaxActiveCallsPerLogin <= 0 {
return fmt.Errorf("voip.max_active_calls_per_login must be greater than 0")
}
if !oneOf(c.VOIP.MatrixRTC.MembershipEventCompat, "auto", "msc4143", "msc3401") {
return fmt.Errorf("voip.matrixrtc.membership_event_compat must be one of auto, msc4143, msc3401")
}
if !oneOf(c.VOIP.MatrixRTC.NotificationEventCompat, "auto", "disabled") {
return fmt.Errorf("voip.matrixrtc.notification_event_compat must be one of auto, disabled")
}
if !oneOf(c.VOIP.MatrixRTC.ParticipantMode, "whatsapp_ghost", "bridge_user") {
return fmt.Errorf("voip.matrixrtc.participant_mode must be one of whatsapp_ghost, bridge_user")
}
if c.VOIP.LiveKit.ConnectTimeout <= 0 {
return fmt.Errorf("voip.livekit.connect_timeout must be greater than 0")
}
if !oneOf(c.VOIP.LiveKit.AudioUplinkPolicy, "dominant_speaker", "mix_all", "selected_participant") {
return fmt.Errorf("voip.livekit.audio_uplink_policy must be one of dominant_speaker, mix_all, selected_participant")
}
if c.VOIP.Audio.Enabled {
if c.VOIP.Audio.JitterBuffer <= 0 {
return fmt.Errorf("voip.audio.jitter_buffer_ms must be greater than 0")
}
if c.VOIP.Audio.OpusBackend == "" {
return fmt.Errorf("voip.audio.opus_backend must be set")
}
if c.VOIP.Audio.MaxMixParticipants <= 0 {
return fmt.Errorf("voip.audio.max_mix_participants must be greater than 0")
}
}
if c.VOIP.Video.Enabled {
if !oneOf(c.VOIP.Video.SelectedSourcePolicy, "active_speaker", "selected_participant") {
return fmt.Errorf("voip.video.selected_source_policy must be one of active_speaker, selected_participant")
}
if c.VOIP.Video.MaxWidth <= 0 || c.VOIP.Video.MaxHeight <= 0 || c.VOIP.Video.MaxFPS <= 0 {
return fmt.Errorf("voip.video max_width, max_height and max_fps must be greater than 0")
}
}
if c.VOIP.Diagnostics.EnableMeowcallerDiagnostics && c.VOIP.Diagnostics.MediaTraceDir == "" {
return fmt.Errorf("voip.diagnostics.media_trace_dir must be set when meowcaller diagnostics are enabled")
}
return nil
}
func oneOf(value string, allowed ...string) bool {
for _, item := range allowed {
if value == item {
return true
}
}
return false
}
func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "os_name")
helper.Copy(up.Str, "browser_name")
@ -131,14 +247,42 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Bool, "direct_media_auto_request")
helper.Copy(up.Bool, "initial_auto_reconnect")
helper.Copy(up.Bool, "use_whatsapp_retry_store")
helper.Copy(up.Int, "max_member_sync")
helper.Copy(up.Bool, "lazy_avatars")
helper.Copy(up.Str, "animated_sticker", "target")
helper.Copy(up.Int, "animated_sticker", "args", "width")
helper.Copy(up.Int, "animated_sticker", "args", "height")
helper.Copy(up.Int, "animated_sticker", "args", "fps")
helper.Copy(up.Bool, "voip", "enabled")
helper.Copy(up.Str, "voip", "matrix_surface")
helper.Copy(up.Str, "voip", "incoming_policy")
helper.Copy(up.Int, "voip", "max_active_calls_per_login")
helper.Copy(up.Str|up.Null, "voip", "matrixrtc", "livekit_service_url")
helper.Copy(up.Bool, "voip", "matrixrtc", "require_livekit_focus")
helper.Copy(up.Str, "voip", "matrixrtc", "membership_event_compat")
helper.Copy(up.Str, "voip", "matrixrtc", "notification_event_compat")
helper.Copy(up.Bool, "voip", "matrixrtc", "use_delayed_events")
helper.Copy(up.Str, "voip", "matrixrtc", "participant_mode")
helper.Copy(up.Str|up.Null, "voip", "matrixrtc", "fallback_participant_mxid")
helper.Copy(up.Str|up.Int, "voip", "livekit", "connect_timeout")
helper.Copy(up.Bool, "voip", "livekit", "publish_silence_before_whatsapp_answer")
helper.Copy(up.Bool, "voip", "livekit", "auto_subscribe")
helper.Copy(up.Str, "voip", "livekit", "audio_uplink_policy")
helper.Copy(up.Str|up.Int, "voip", "livekit", "selected_participant_timeout")
helper.Copy(up.Bool, "voip", "audio", "enabled")
helper.Copy(up.Str|up.Int, "voip", "audio", "jitter_buffer_ms")
helper.Copy(up.Str, "voip", "audio", "opus_backend")
helper.Copy(up.Bool, "voip", "audio", "silence_on_underrun")
helper.Copy(up.Int, "voip", "audio", "max_mix_participants")
helper.Copy(up.Bool, "voip", "video", "enabled")
helper.Copy(up.Str, "voip", "video", "selected_source_policy")
helper.Copy(up.Int, "voip", "video", "max_width")
helper.Copy(up.Int, "voip", "video", "max_height")
helper.Copy(up.Int, "voip", "video", "max_fps")
helper.Copy(up.Bool, "voip", "diagnostics", "healthcheck_focus_on_startup")
helper.Copy(up.Bool, "voip", "diagnostics", "enable_meowcaller_diagnostics")
helper.Copy(up.Str|up.Null, "voip", "diagnostics", "media_trace_dir")
helper.Copy(up.Int, "history_sync", "max_initial_conversations")
helper.Copy(up.Bool, "history_sync", "request_full_sync")
helper.Copy(up.Str|up.Int, "history_sync", "dispatch_wait")
@ -209,7 +353,7 @@ func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) {
{"proxy"},
{"displayname_template"},
{"call_start_notices"},
{"animated_sticker"},
{"voip"},
{"history_sync"},
},
Base: ExampleConfig,

View file

@ -0,0 +1,33 @@
package connector
import (
"os"
"testing"
"go.yaml.in/yaml/v3"
)
func TestExampleConfigDoesNotAdvertiseUnsupportedVideoModes(t *testing.T) {
data, err := os.ReadFile("example-config.yaml")
if err != nil {
t.Fatal(err)
}
var config map[string]any
if err = yaml.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
voip, ok := config["voip"].(map[string]any)
if !ok {
t.Fatal("example config has no voip section")
}
video, ok := voip["video"].(map[string]any)
if !ok {
t.Fatal("example config has no voip.video section")
}
for _, unsupported := range []string{"require_h264", "allow_transcode"} {
if _, exists := video[unsupported]; exists {
t.Errorf("example config advertises unsupported voip.video.%s option", unsupported)
}
}
}

View file

@ -28,24 +28,27 @@ import (
"time"
"github.com/lib/pq"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exsync"
"go.mau.fi/util/random"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades"
"go.mau.fi/whatsmeow/types"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
"go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
type WhatsAppConnector struct {
@ -62,7 +65,8 @@ type WhatsAppConnector struct {
mediaEditCacheLock sync.RWMutex
stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
unmigratedDMs *exsync.Set[networkid.PortalKey]
matrixRTCOutboundStartLock sync.Mutex
matrixRTCOutboundStartExpires map[string]time.Time
}
func init() {
@ -109,11 +113,16 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
}
wa.DB = wadb.New(bridge.ID, bridge.DB.Database, bridge.Log.With().Str("db_section", "whatsapp").Logger())
wa.MsgConv.DB = wa.DB
wa.matrixRTCOutboundStartExpires = make(map[string]time.Time)
wa.Bridge.Commands.(*commands.Processor).AddHandlers(
cmdAccept, cmdSync, cmdResetAppState, cmdInviteLink, cmdResolveLink, cmdJoin,
cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin,
cmdCallParticipants, cmdCallAdd, cmdCallRing, cmdCallVideoSelect,
cmdCallLinkCreate, cmdCallLinkPreview, cmdCallLinkJoin,
cmdCallWaiting, cmdCallApproval, cmdCallAdmit, cmdCallDeny,
)
wa.mediaEditCache = make(MediaEditCache)
wa.unmigratedDMs = exsync.NewSet[networkid.PortalKey]()
wa.initMatrixRTCEventHooks()
wa.startMatrixRTCHealthcheck()
whatsmeowDBLog := bridge.Log.With().Str("db_section", "whatsmeow").Logger()
wa.DeviceStore = sqlstore.NewWithWrappedDB(
@ -142,9 +151,6 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
}
func (wa *WhatsAppConnector) Start(ctx context.Context) error {
if !wa.MsgConv.DirectMedia && wa.Config.LazyAvatars {
return fmt.Errorf("lazy_avatars set without enabling global direct_media")
}
err := wa.DeviceStore.Upgrade(ctx)
if err != nil {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsmeow"}
@ -160,27 +166,80 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"}
}
err = wa.migrateToLIDDMs(ctx)
if err != nil {
return fmt.Errorf("failed to migrate to LID DMs: %w", err)
if !wa.Bridge.Background && wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dms_deleted") == "false" {
wa.deleteLIDDMsMigration(ctx)
}
err = wa.migrateMetaAIPortals(ctx)
if err != nil {
return fmt.Errorf("failed to migrate Meta AI portals: %w", err)
}
go func() {
err = wa.syncMismatchingGhosts(wa.Bridge.BackgroundCtx)
if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to sync mismatching ghosts")
}
err = wa.removeRejoinedPNGhosts(wa.Bridge.BackgroundCtx)
if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to remove rejoined PN ghosts")
}
}()
return nil
}
func (wa *WhatsAppConnector) deleteLIDDMsMigration(ctx context.Context) {
log := zerolog.Ctx(ctx).With().Str("action", "delete lid dms").Logger()
portals, err := wa.Bridge.GetAllPortalsWithMXID(ctx)
if err != nil {
log.Err(err).Msg("Failed to get portals for LID DM deletion")
return
}
defer wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dms_deleted", "true")
if len(portals) == 0 {
log.Debug().Msg("No portals found")
return
}
portalsByKey := make(map[networkid.PortalKey]*bridgev2.Portal, len(portals))
for _, p := range portals {
if p.Receiver == "" || p.RoomType != database.RoomTypeDM {
continue
}
portalsByKey[p.PortalKey] = p
}
_, err = wa.DB.Exec(ctx, "DELETE FROM whatsapp_history_sync_conversation WHERE chat_jid LIKE '%@lid'")
if err != nil {
log.Err(err).Msg("Failed to remove LID conversations from history sync")
}
for key, portal := range portalsByKey {
parsedID, err := waid.ParsePortalID(key.ID)
if err != nil {
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to parse portal ID")
continue
} else if parsedID.Server != types.HiddenUserServer {
continue
}
var pnStr string
err = wa.DB.QueryRow(ctx, "SELECT pn FROM whatsmeow_lid_map WHERE lid=$1", parsedID.User).Scan(&pnStr)
if err != nil {
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to get PN for LID portal")
continue
}
key.ID = waid.MakePortalID(types.JID{User: pnStr, Server: types.DefaultUserServer})
_, pnPortalExists := portalsByKey[key]
if !pnPortalExists {
log.Warn().Str("portal_id", string(key.ID)).Msg("PN portal does not exist, not deleting LID DM")
continue
}
err = portal.Delete(ctx)
if err != nil {
log.Err(err).
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Failed to delete LID DM portal from database")
continue
}
err = wa.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false)
if err != nil {
log.Err(err).
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Failed to delete LID DM portal from Matrix")
continue
}
log.Debug().
Object("portal_key", portal.PortalKey).
Stringer("portal_mxid", portal.MXID).
Msg("Deleted LID DM portal")
}
log.Info().Msg("Finished deleting LID DM portals")
}
func (wa *WhatsAppConnector) Stop() {
if stop := wa.stopMediaEditCacheLoop.Swap(nil); stop != nil {
(*stop)()
@ -243,16 +302,7 @@ func (wa *WhatsAppConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ e
}
func (wa *WhatsAppConnector) ResetHTTPTransport() {
for _, login := range wa.Bridge.GetAllCachedUserLogins() {
client := login.Client.(*WhatsAppClient).Client
if client == nil {
continue
}
err := wa.updateProxy(login.Log.WithContext(wa.Bridge.BackgroundCtx), client, false)
if err != nil {
login.Log.Err(err).Msg("Failed to recreate HTTP clients")
}
}
// No-op for now, whatsmeow doesn't use the shared transport config yet
}
func (wa *WhatsAppConnector) ResetNetworkConnections() {

View file

@ -30,11 +30,9 @@ import (
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waMmsRetry"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
@ -54,7 +52,7 @@ func (wa *WhatsAppConnector) SetUseDirectMedia() {
}
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",
StatusCode: http.StatusNotFound,
}
@ -86,15 +84,13 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
if waClient.Client == nil {
return nil, fmt.Errorf("no WhatsApp client found on login %s", parsedID.UserLogin)
}
waClient.avatarLock.Lock(parsedID.Avatar.TargetJID)
defer waClient.avatarLock.Unlock(parsedID.Avatar.TargetJID)
cachedInfo, err := wa.DB.AvatarCache.Get(ctx, parsedID.Avatar.TargetJID, parsedID.Avatar.AvatarID)
if err != nil {
return nil, fmt.Errorf("failed to get avatar cache entry: %w", err)
}
if cachedInfo.IsGone() {
if cachedInfo != nil && cachedInfo.Gone {
return nil, mautrix.MNotFound.WithMessage("Avatar is no longer available (cached response)")
} else if cachedInfo.Expired() {
} else if cachedInfo == nil || cachedInfo.Expiry.Time.Before(time.Now().Add(5*time.Minute)) {
zerolog.Ctx(ctx).Debug().
Str("avatar_id", parsedID.Avatar.AvatarID).
Msg("Refreshing avatar URL from WhatsApp servers")
@ -103,7 +99,7 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
})
if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) ||
errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) ||
(err == nil && (avatar == nil || (avatar.ID != parsedID.Avatar.AvatarID && !parsedID.Avatar.IsRandom()))) {
(err == nil && (avatar == nil || avatar.ID != parsedID.Avatar.AvatarID)) {
zerolog.Ctx(ctx).Debug().
Err(err).
Stringer("target_jid", parsedID.Avatar.TargetJID).
@ -111,14 +107,9 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
Str("wanted_avatar_id", parsedID.Avatar.AvatarID).
Str("got_avatar_id", ptr.Val(avatar).ID).
Msg("Avatar is no longer available")
var goneExpiry jsontime.Unix
if parsedID.Avatar.IsRandom() {
goneExpiry = jsontime.U(time.Now().Add(7 * 24 * time.Hour))
}
err = wa.DB.AvatarCache.Put(ctx, &wadb.AvatarCacheEntry{
EntityJID: parsedID.Avatar.TargetJID,
AvatarID: parsedID.Avatar.AvatarID,
Expiry: goneExpiry,
Gone: true,
})
if err != nil {
@ -137,15 +128,6 @@ func (wa *WhatsAppConnector) downloadAvatarDirectMedia(ctx context.Context, pars
Str("avatar_id", avatar.ID).
Msg("Failed to update avatar cache entry")
}
if cachedInfo.AvatarID != parsedID.Avatar.AvatarID {
cachedInfo.AvatarID = parsedID.Avatar.AvatarID
err = wa.DB.AvatarCache.Put(ctx, cachedInfo)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Str("avatar_id", parsedID.Avatar.AvatarID).
Msg("Failed to update avatar cache entry")
}
}
}
return &mediaproxy.GetMediaResponseFile{
Callback: func(w *os.File) (*mediaproxy.FileMeta, error) {
@ -164,19 +146,7 @@ func (wa *WhatsAppConnector) downloadStickerDirectMedia(ctx context.Context, par
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)
var httpErr whatsmeow.DownloadHTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case http.StatusNotFound, http.StatusGone:
return nil, mautrix.MNotFound.WithMessage("Sticker pack not found")
case http.StatusUnauthorized, http.StatusForbidden:
return nil, mautrix.MForbidden.WithMessage("WhatsApp denied access to the sticker pack")
case http.StatusTooManyRequests:
return nil, mautrix.MLimitExceeded.WithMessage("Rate limited by WhatsApp while fetching the sticker pack").WithCanRetry(true)
default:
return nil, mautrix.MUnknown.WithMessage("WhatsApp returned HTTP %d while fetching the sticker pack", httpErr.StatusCode).WithStatus(http.StatusBadGateway).WithCanRetry(true)
}
} else if err != nil {
if err != nil {
return nil, err
} else if sticker == nil {
return nil, mautrix.MNotFound.WithMessage("Sticker not found in pack")
@ -186,9 +156,6 @@ func (wa *WhatsAppConnector) downloadStickerDirectMedia(ctx context.Context, par
func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) {
msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, parsedID.Message.String())
if err == nil && msg == nil && parsedID.Message.Chat.Server == types.HiddenUserServer && parsedID.Message.Sender.Server == types.DefaultUserServer {
msg, err = wa.getDirectMediaMessageWithLIDSender(ctx, parsedID)
}
if err != nil {
return nil, fmt.Errorf("failed to get message: %w", err)
} else if msg == nil {
@ -231,16 +198,6 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par
return wa.makeDirectMediaResponse(ctx, waClient, keys, keys.MimeType, msg.ID, keys, params)
}
func (wa *WhatsAppConnector) getDirectMediaMessageWithLIDSender(ctx context.Context, parsedID *waid.ParsedMediaID) (*database.Message, error) {
lid, err := wa.DeviceStore.LIDMap.GetLIDForPN(ctx, parsedID.Message.Sender)
if err != nil {
return nil, fmt.Errorf("failed to get LID for sender: %w", err)
} else if lid.IsEmpty() {
return nil, nil
}
return wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, waid.MakeMessageID(parsedID.Message.Chat, lid, parsedID.Message.ID))
}
func (wa *WhatsAppConnector) makeDirectMediaResponse(
ctx context.Context,
waClient *WhatsAppClient,
@ -255,7 +212,7 @@ func (wa *WhatsAppConnector) makeDirectMediaResponse(
log := zerolog.Ctx(ctx)
err := waClient.Client.DownloadToFile(ctx, dm, f)
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["com.beeper.interactive_download_request"]
val := params["fi.mau.whatsapp.reload_media"]
if val == "false" || (!wa.Config.DirectMediaAutoRequest && val != "true") {
return nil, ErrReloadNeeded
}
@ -302,7 +259,6 @@ type directMediaRetry struct {
wait *exsync.Event
requested bool
resultType waMmsRetry.MediaRetryNotification_ResultType
decryptFail bool
}
func (wa *WhatsAppClient) getDirectMediaRetryState(msgID networkid.MessageID, create bool) *directMediaRetry {
@ -329,9 +285,6 @@ func (wa *WhatsAppClient) requestAndWaitDirectMedia(ctx context.Context, rawMsgI
keys.DirectPath = state.resultURL
return nil
}
if state.decryptFail {
return mautrix.MNotFound.WithMessage("Unable to retrieve media: failed to decrypt the media retry notification from your phone.")
}
switch state.resultType {
case waMmsRetry.MediaRetryNotification_NOT_FOUND:
return mautrix.MNotFound.WithMessage("This media was not found on your phone.")
@ -385,9 +338,6 @@ func (wa *WhatsAppClient) receiveDirectMediaRetry(ctx context.Context, msg *data
retryData, err := whatsmeow.DecryptMediaRetryNotification(retry, keys.Key)
if err != nil {
log.Warn().Err(err).Msg("Failed to decrypt media retry notification")
if state != nil {
state.decryptFail = true
}
return
}
if state != nil {

View file

@ -1,156 +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"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"go.mau.fi/whatsmeow"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
// wrapConversionError turns a failure to convert a Matrix event into a message status. Nothing has been
// sent to WhatsApp at that point, so the failure is certain.
func wrapConversionError(err error) error {
if err == nil || errors.As(err, &bridgev2.MessageStatus{}) {
return err
}
return bridgev2.WrapErrorInStatus(err).
WithMessage("failed to convert message for WhatsApp").
WithIsCertain(true).
WithSendNotice(true)
}
// wrapWhatsAppError turns an error from a whatsmeow request made while handling a Matrix event into a
// message status.
func wrapWhatsAppError(err error) error {
if err == nil || errors.As(err, &bridgev2.MessageStatus{}) {
return err
}
var iqErr *whatsmeow.IQError
var disconnectErr *whatsmeow.DisconnectedError
switch {
case errors.Is(err, whatsmeow.ErrNotConnected), errors.Is(err, whatsmeow.ErrClientIsNil):
return bridgev2.WrapErrorInStatus(err).
WithMessage("not connected to WhatsApp").
WithIsCertain(true).
WithSendNotice(true)
case errors.Is(err, whatsmeow.ErrMessageTimedOut), errors.Is(err, whatsmeow.ErrIQTimedOut),
errors.Is(err, context.DeadlineExceeded), errors.As(err, &disconnectErr):
return bridgev2.WrapErrorInStatus(err).
WithMessage("no response from WhatsApp").
WithIsCertain(false).
WithSendNotice(true)
case errors.Is(err, whatsmeow.ErrIQRateOverLimit):
return bridgev2.WrapErrorInStatus(err).
WithMessage("rate limited by WhatsApp").
WithErrorReason(event.MessageStatusNetworkError).
WithIsCertain(true).
WithSendNotice(true)
case errors.Is(err, whatsmeow.ErrIQForbidden), errors.Is(err, whatsmeow.ErrIQNotAuthorized):
return bridgev2.WrapErrorInStatus(err).
WithMessage("you don't have permission to do that on WhatsApp").
WithErrorReason(event.MessageStatusNoPermission).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
case errors.Is(err, whatsmeow.ErrServerReturnedError), errors.As(err, &iqErr):
return bridgev2.WrapErrorInStatus(err).
WithMessage("WhatsApp returned an error").
WithIsCertain(true).
WithSendNotice(true)
default:
return bridgev2.WrapErrorInStatus(err).
WithMessage("unexpected error from WhatsApp").
WithSendNotice(true)
}
}
// wrapSendError turns an error from sending a message to WhatsApp into a message status.
func (wa *WhatsAppClient) wrapSendError(err error) error {
switch sendErrorCode(err) {
case 403:
return bridgev2.WrapErrorInStatus(err).
WithMessage("you don't have permission to do that on WhatsApp").
WithErrorReason(event.MessageStatusNoPermission).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
case 463:
msg := "WhatsApp has temporarily restricted your account from messaging new contacts"
if until := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).ReachoutTimelockUntil; until.After(time.Now()) {
msg = fmt.Sprintf("WhatsApp has restricted your account from messaging new contacts until %s", until.UTC().Format("Jan 2, 2006 at 3:04 PM MST"))
}
return bridgev2.WrapErrorInStatus(err).
WithMessage(msg).
WithErrorReason(event.MessageStatusNoPermission).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
case 421, 479:
return bridgev2.WrapErrorInStatus(err).
WithMessage("WhatsApp rejected the message").
WithErrorReason(event.MessageStatusNetworkError).
WithStatus(event.MessageStatusFail).
WithIsCertain(true).
WithSendNotice(true)
default:
return wrapWhatsAppError(err)
}
}
// sendErrorCode returns the code WhatsApp rejected a message with, which whatsmeow only includes in the
// error text.
func sendErrorCode(err error) int {
if !errors.Is(err, whatsmeow.ErrServerReturnedError) {
return 0
}
_, codeStr, _ := strings.Cut(err.Error(), whatsmeow.ErrServerReturnedError.Error()+" ")
code, _ := strconv.Atoi(codeStr)
return code
}
// wrapWhatsAppRespError turns an error from a whatsmeow request made during login or provisioning into
// an HTTP error, keeping the original error in the chain. Clients retry 429 and 502-504 automatically,
// which isn't wanted for provisioning requests, so those statuses aren't used.
func wrapWhatsAppRespError(err error) error {
var mapped bridgev2.RespError
var disconnectErr *whatsmeow.DisconnectedError
switch {
case errors.Is(err, whatsmeow.ErrIQRateOverLimit):
mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("Rate limited by WhatsApp. Try again later.").WithStatus(http.StatusBadRequest))
case errors.Is(err, whatsmeow.ErrNotConnected), errors.Is(err, whatsmeow.ErrClientIsNil), errors.As(err, &disconnectErr):
mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Not connected to WhatsApp"))
case errors.Is(err, whatsmeow.ErrIQTimedOut), errors.Is(err, context.DeadlineExceeded):
mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("WhatsApp didn't respond in time"))
case errors.Is(err, whatsmeow.ErrIQForbidden), errors.Is(err, whatsmeow.ErrIQNotAuthorized):
mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("WhatsApp denied the request"))
default:
mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Request to WhatsApp failed"))
}
return fmt.Errorf("%w: %w", mapped.WithInternalError(err), err)
}

Some files were not shown because too many files have changed in this diff Show more