Watch
1
0
Fork
You've already forked mautrix-whatsapp
0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-11-10 08:09:19 -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
40 changed files with 8089 additions and 42 deletions

83
go.mod
View file

@ -1,6 +1,6 @@
module go.mau.fi/mautrix-whatsapp module go.mau.fi/mautrix-whatsapp
go 1.25.0 go 1.26
toolchain go1.26.5 toolchain go1.26.5
@ -8,11 +8,19 @@ tool go.mau.fi/util/cmd/maubuild
require ( require (
github.com/lib/pq v1.12.3 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/rs/zerolog v1.35.1
github.com/tidwall/gjson v1.19.0 github.com/tidwall/gjson v1.19.0
go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391 go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391
go.mau.fi/webp v0.3.0 go.mau.fi/webp v0.3.0
go.mau.fi/whatsmeow v0.0.0-20260720135917-a2381054887e 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/image v0.44.0
golang.org/x/net v0.57.0 golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0 golang.org/x/sync v0.22.0
@ -22,33 +30,96 @@ require (
) )
require ( 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 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/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/coder/websocket v1.8.15 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // 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/elliotchance/orderedmap/v3 v3.1.0 // 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/google/uuid v1.6.0 // indirect
github.com/kr/pretty v0.3.1 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.48 // 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/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect
github.com/rogpeppe/go-internal v1.10.0 // 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/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // 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/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/libsignal v0.2.2 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect go.mau.fi/zeroconfig v0.2.0 // 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/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
golang.org/x/mod v0.38.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.40.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // 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 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect maunium.net/go/mauflag v1.0.0 // indirect
) )

235
go.sum
View file

@ -1,55 +1,219 @@
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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= 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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/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 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= 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 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/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 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/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 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= 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 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/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 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= 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/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 h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E=
github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= 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 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@ -65,20 +229,52 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/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 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/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 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU=
go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0=
go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391 h1:lsvBEY8MJfYdV61YbwikiQvb0Al/onbmLW5wfl/0tag= 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/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 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA=
go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo=
go.mau.fi/whatsmeow v0.0.0-20260720135917-a2381054887e h1:Kn2XQTpYRploXqtmJKjeX4ZYGevhIeQkO2PWGPGdwrY= go.mau.fi/whatsmeow v0.0.0-20260722203353-e9a033b24933 h1:7skZGs9q+rWKqYHok4ZufzhKpf6GmTKZnTjSm0aDtus=
go.mau.fi/whatsmeow v0.0.0-20260720135917-a2381054887e/go.mod h1:Iy/xVSuVU2payR26MB1hv0UZUWRraEn4qKZ7+VRHulg= 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 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
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 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= 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 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
@ -91,16 +287,27 @@ 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/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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -24,6 +24,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/purpshell/meowcaller"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.mau.fi/util/exsync" "go.mau.fi/util/exsync"
"go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow"
@ -40,6 +41,7 @@ import (
"maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/connector/voip"
"go.mau.fi/mautrix-whatsapp/pkg/waid" "go.mau.fi/mautrix-whatsapp/pkg/waid"
) )
@ -56,6 +58,7 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
pushNamesSynced: exsync.NewEvent(), pushNamesSynced: exsync.NewEvent(),
createDedup: exsync.NewSet[types.MessageID](), createDedup: exsync.NewSet[types.MessageID](),
appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time), appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time),
incomingCallGroups: make(map[string]incomingCallGroup),
} }
login.Client = w login.Client = w
@ -74,7 +77,7 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
if w.Device != nil { if w.Device != nil {
log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger() log := w.UserLogin.Log.With().Str("component", "whatsmeow").Logger()
w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log)) w.Client = whatsmeow.NewClient(w.Device, waLog.Zerolog(log))
w.Client.AddEventHandlerWithSuccessStatus(w.handleWAEvent) w.Client.AddEventHandler(w.trackIncomingCallEvent)
w.Client.SynchronousAck = true w.Client.SynchronousAck = true
w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0 w.Client.EnableDecryptedEventBuffer = bridgev2.PortalEventBuffer == 0
w.Client.ManualHistorySyncDownload = true w.Client.ManualHistorySyncDownload = true
@ -83,6 +86,19 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2.
w.Client.GetMessageForRetry = w.trackNotFoundRetry w.Client.GetMessageForRetry = w.trackNotFoundRetry
w.Client.PreRetryCallback = w.trackFoundRetry w.Client.PreRetryCallback = w.trackFoundRetry
w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx) 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.SetForceActiveDeliveryReceipts(wa.Config.ForceActiveDeliveryReceipts)
w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect w.Client.InitialAutoReconnect = wa.Config.InitialAutoReconnect
w.Client.UseRetryMessageStore = wa.Config.UseWhatsAppRetryStore w.Client.UseRetryMessageStore = wa.Config.UseWhatsAppRetryStore
@ -102,6 +118,7 @@ type WhatsAppClient struct {
Main *WhatsAppConnector Main *WhatsAppConnector
UserLogin *bridgev2.UserLogin UserLogin *bridgev2.UserLogin
Client *whatsmeow.Client Client *whatsmeow.Client
VOIP *voip.Manager
Device *store.Device Device *store.Device
JID types.JID JID types.JID
MC mClient MC mClient
@ -113,6 +130,12 @@ type WhatsAppClient struct {
nextResync time.Time nextResync time.Time
directMediaRetries map[networkid.MessageID]*directMediaRetry directMediaRetries map[networkid.MessageID]*directMediaRetry
directMediaLock sync.Mutex directMediaLock sync.Mutex
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 mediaRetryLock *semaphore.Weighted
offlineSyncWaiter atomic.Pointer[chan error] offlineSyncWaiter atomic.Pointer[chan error]
isNewLogin bool isNewLogin bool
@ -205,6 +228,7 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
wa.cleanupStaleMatrixRTCCalls(ctx)
wa.initMC() wa.initMC()
wa.startLoops() wa.startLoops()
wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx) wa.Client.BackgroundEventCtx = wa.UserLogin.Log.WithContext(wa.Main.Bridge.BackgroundCtx)
@ -363,6 +387,9 @@ func (wa *WhatsAppClient) callStopLoops() {
func (wa *WhatsAppClient) Disconnect() { func (wa *WhatsAppClient) Disconnect() {
wa.callStopLoops() wa.callStopLoops()
if wa.VOIP != nil {
wa.VOIP.AbortAll()
}
if cli := wa.Client; cli != nil { if cli := wa.Client; cli != nil {
cli.Disconnect() cli.Disconnect()
} }

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

@ -54,6 +54,7 @@ type Config struct {
UseWhatsAppRetryStore bool `yaml:"use_whatsapp_retry_store"` UseWhatsAppRetryStore bool `yaml:"use_whatsapp_retry_store"`
AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"` AnimatedSticker msgconv.AnimatedStickerConfig `yaml:"animated_sticker"`
VOIP VOIPConfig `yaml:"voip"`
HistorySync struct { HistorySync struct {
MaxInitialConversations int `yaml:"max_initial_conversations"` MaxInitialConversations int `yaml:"max_initial_conversations"`
@ -78,6 +79,58 @@ type Config struct {
displaynameTemplate *template.Template `yaml:"-"` 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 type umConfig Config
func (c *Config) UnmarshalYAML(node *yaml.Node) error { func (c *Config) UnmarshalYAML(node *yaml.Node) error {
@ -99,9 +152,74 @@ func (c *Config) PostProcess() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to execute displayname template: %w", err) return fmt.Errorf("failed to execute displayname template: %w", err)
} }
if err = c.validateVOIP(); err != nil {
return err
}
return nil 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) { func upgradeConfig(helper up.Helper) {
helper.Copy(up.Str, "os_name") helper.Copy(up.Str, "os_name")
helper.Copy(up.Str, "browser_name") helper.Copy(up.Str, "browser_name")
@ -135,6 +253,36 @@ func upgradeConfig(helper up.Helper) {
helper.Copy(up.Int, "animated_sticker", "args", "height") helper.Copy(up.Int, "animated_sticker", "args", "height")
helper.Copy(up.Int, "animated_sticker", "args", "fps") 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.Int, "history_sync", "max_initial_conversations")
helper.Copy(up.Bool, "history_sync", "request_full_sync") helper.Copy(up.Bool, "history_sync", "request_full_sync")
helper.Copy(up.Str|up.Int, "history_sync", "dispatch_wait") helper.Copy(up.Str|up.Int, "history_sync", "dispatch_wait")
@ -205,6 +353,7 @@ func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) {
{"proxy"}, {"proxy"},
{"displayname_template"}, {"displayname_template"},
{"call_start_notices"}, {"call_start_notices"},
{"voip"},
{"history_sync"}, {"history_sync"},
}, },
Base: ExampleConfig, 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

@ -64,6 +64,9 @@ type WhatsAppConnector struct {
mediaEditCache MediaEditCache mediaEditCache MediaEditCache
mediaEditCacheLock sync.RWMutex mediaEditCacheLock sync.RWMutex
stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc] stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
matrixRTCOutboundStartLock sync.Mutex
matrixRTCOutboundStartExpires map[string]time.Time
} }
func init() { func init() {
@ -110,10 +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.DB = wadb.New(bridge.ID, bridge.DB.Database, bridge.Log.With().Str("db_section", "whatsapp").Logger())
wa.MsgConv.DB = wa.DB wa.MsgConv.DB = wa.DB
wa.matrixRTCOutboundStartExpires = make(map[string]time.Time)
wa.Bridge.Commands.(*commands.Processor).AddHandlers( wa.Bridge.Commands.(*commands.Processor).AddHandlers(
cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin, cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin,
cmdCallParticipants, cmdCallAdd, cmdCallRing, cmdCallVideoSelect,
cmdCallLinkCreate, cmdCallLinkPreview, cmdCallLinkJoin,
cmdCallWaiting, cmdCallApproval, cmdCallAdmit, cmdCallDeny,
) )
wa.mediaEditCache = make(MediaEditCache) wa.mediaEditCache = make(MediaEditCache)
wa.initMatrixRTCEventHooks()
wa.startMatrixRTCHealthcheck()
whatsmeowDBLog := bridge.Log.With().Str("db_section", "whatsmeow").Logger() whatsmeowDBLog := bridge.Log.With().Str("db_section", "whatsmeow").Logger()
wa.DeviceStore = sqlstore.NewWithWrappedDB( wa.DeviceStore = sqlstore.NewWithWrappedDB(

View file

@ -70,6 +70,67 @@ initial_auto_reconnect: true
# retry receipts if the bridge is restarted after the message is sent. # retry receipts if the bridge is restarted after the message is sent.
use_whatsapp_retry_store: false use_whatsapp_retry_store: false
# MatrixRTC/LiveKit call bridging for WhatsApp calls.
voip:
# Enables real call bridging through Element Call. When false, call_start_notices behavior remains.
enabled: false
# Only MatrixRTC with a LiveKit focus is supported by this bridge path.
matrix_surface: matrixrtc_livekit
# notice - keep call_start_notices fallback only
# ring - ring MatrixRTC/Element Call and answer WhatsApp only after a Matrix user joins
# auto_answer - test-only behavior that answers WhatsApp immediately
incoming_policy: ring
# Limit concurrent WhatsApp call legs per login. A direct, group, or call-link call counts as one leg.
max_active_calls_per_login: 1
matrixrtc:
# If null, discover from .well-known org.matrix.msc4143.rtc_foci.
livekit_service_url: null
# Refuse real call bridging when no LiveKit focus can be discovered.
require_livekit_focus: true
# auto, msc4143, or msc3401.
membership_event_compat: auto
# auto or disabled. Used for MatrixRTC call ringing/decline events when supported.
notification_event_compat: auto
# Use homeserver delayed events to expire MatrixRTC membership when supported.
use_delayed_events: true
# whatsapp_ghost or bridge_user.
participant_mode: whatsapp_ghost
# Optional explicit Matrix user to use when participant_mode is bridge_user.
fallback_participant_mxid: null
livekit:
connect_timeout: 10s
# Publish silence before WhatsApp answers so Element Call shows a stable participant.
publish_silence_before_whatsapp_answer: true
auto_subscribe: true
# dominant_speaker, mix_all, or selected_participant.
audio_uplink_policy: dominant_speaker
selected_participant_timeout: 30s
audio:
enabled: true
jitter_buffer_ms: 180ms
opus_backend: libopus
silence_on_underrun: true
max_mix_participants: 4
video:
# Video is passed through as H.264. Other codecs are ignored.
enabled: false
# active_speaker or selected_participant. Both keep one group camera stable;
# change the selected participant with !wa call-video-select.
selected_source_policy: active_speaker
max_width: 1280
max_height: 720
max_fps: 30
diagnostics:
healthcheck_focus_on_startup: true
# Unsafe: meowcaller diagnostics can contain call secrets and media.
enable_meowcaller_diagnostics: false
media_trace_dir: null
# Settings for converting animated stickers. # Settings for converting animated stickers.
animated_sticker: animated_sticker:
# Format to which animated stickers should be converted. # Format to which animated stickers should be converted.

View file

@ -93,7 +93,9 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, "", evt.Timestamp) success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, "", evt.Timestamp)
case *events.CallOfferNotice: case *events.CallOfferNotice:
success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, evt.Type, evt.Timestamp) success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, evt.Type, evt.Timestamp)
case *events.CallTerminate, *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent: case *events.CallTerminate:
wa.clearIncomingCallGroup(evt.CallID)
case *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent:
// ignore // ignore
case *events.IdentityChange: case *events.IdentityChange:
wa.handleWAIdentityChange(ctx, evt) wa.handleWAIdentityChange(ctx, evt)

340
pkg/connector/matrixrtc.go Normal file
View file

@ -0,0 +1,340 @@
package connector
import (
"context"
"strings"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/voip"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
)
const (
matrixRTCHealthcheckTimeout = 15 * time.Second
matrixRTCOutboundStartDedupWindow = 30 * time.Second
)
func withoutCancelOrBackground(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return context.WithoutCancel(ctx)
}
func (wa *WhatsAppConnector) initMatrixRTCEventHooks() {
matrixConnector, ok := wa.Bridge.Matrix.(*matrix.Connector)
if !ok || matrixConnector.EventProcessor == nil {
wa.Bridge.Log.Debug().Msg("Matrix connector does not expose an event processor for MatrixRTC hooks")
return
}
for _, evtType := range voip.SupportedMatrixRTCEventTypes() {
matrixConnector.EventProcessor.On(evtType, wa.handleMatrixRTCEvent)
}
wa.Bridge.Log.Debug().Int("event_type_count", len(voip.SupportedMatrixRTCEventTypes())).Msg("Registered MatrixRTC event hooks")
}
func (wa *WhatsAppConnector) startMatrixRTCHealthcheck() {
if !wa.Config.VOIP.Enabled || !wa.Config.VOIP.Diagnostics.HealthcheckFocusOnStartup {
return
}
go func() {
ctx, cancel := context.WithTimeout(withoutCancelOrBackground(wa.Bridge.BackgroundCtx), matrixRTCHealthcheckTimeout)
defer cancel()
focus, err := voip.DiscoverLiveKitFocus(ctx, nil, wa.Bridge.Matrix.ServerName(), wa.Config.VOIP.MatrixRTC.LiveKitServiceURL)
log := wa.Bridge.Log.With().Str("component", "voip_healthcheck").Logger()
if err != nil {
event := log.Warn()
if wa.Config.VOIP.MatrixRTC.RequireLiveKitFocus {
event = log.Error()
}
event.Err(err).Msg("MatrixRTC LiveKit focus healthcheck failed")
return
}
log.Info().
Str("focus_type", focus.Type).
Str("livekit_service_url", focus.LiveKitServiceURL).
Msg("MatrixRTC LiveKit focus healthcheck passed")
}()
}
func (wa *WhatsAppConnector) handleMatrixRTCEvent(ctx context.Context, evt *event.Event) {
if !wa.Config.VOIP.Enabled {
return
}
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
return
}
log := zerolog.Ctx(ctx).With().
Str("matrixrtc_kind", string(parsed.Kind)).
Str("matrix_event_type", parsed.Type.Type).
Stringer("matrix_room_id", parsed.RoomID).
Stringer("matrix_sender", parsed.Sender).
Str("matrix_call_id", parsed.CallID).
Logger()
if parsed.Sender == wa.Bridge.Bot.GetMXID() || wa.Bridge.IsGhostMXID(parsed.Sender) {
log.Debug().Msg("Ignoring MatrixRTC event sent by the bridge")
return
}
if !wa.Bridge.Config.Permissions.Get(parsed.Sender).SendEvents {
log.Debug().Msg("Dropping MatrixRTC event from user with no permission to send events")
wa.Bridge.Matrix.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt))
return
}
portal, err := wa.Bridge.GetPortalByMXID(ctx, parsed.RoomID)
if err != nil {
log.Err(err).Msg("Failed to look up portal for MatrixRTC event")
return
} else if portal == nil {
log.Debug().Msg("Ignoring MatrixRTC event outside a bridged portal")
return
}
portalLogin := wa.Bridge.GetCachedUserLoginByID(portal.Receiver)
if !matrixRTCSenderOwnsLogin(parsed.Sender, portalLogin) {
log.Warn().
Str("portal_receiver", string(portal.Receiver)).
Msg("Dropping MatrixRTC event from a user who does not own the portal login")
wa.Bridge.Matrix.SendMessageStatus(ctx, &bridgev2.ErrNoPermissionToInteract, bridgev2.StatusEventInfoFromEvent(evt))
return
}
activeCalls, err := wa.DB.MatrixRTCCall.GetActiveInRoom(ctx, parsed.RoomID)
if err != nil {
log.Err(err).Msg("Failed to look up active MatrixRTC calls for room")
return
}
if isMatrixRTCCallControlEvent(parsed) {
wa.handleMatrixRTCCallControlEvent(ctx, parsed, activeCalls, log)
return
}
if len(activeCalls) == 0 {
if !shouldStartOutboundMatrixRTCCall(evt, parsed, wa.Config.VOIP.MatrixRTC.MembershipEventCompat) {
log.Debug().Msg("Ignoring MatrixRTC event without active bridged calls in the room")
return
}
if !wa.reserveMatrixRTCOutboundStart(parsed.RoomID.String()) {
log.Debug().Msg("Ignoring duplicate outbound MatrixRTC start in dedupe window")
return
}
if err = wa.startOutboundMatrixRTCCall(ctx, portal, parsed); err != nil {
log.Err(err).Msg("Failed to start outbound WhatsApp call from MatrixRTC event")
if cleanupErr := wa.cleanupFailedOutboundMatrixRTCStart(ctx, parsed); cleanupErr != nil {
log.Err(cleanupErr).Msg("Failed to clean up failed outbound MatrixRTC event")
}
}
return
}
var matched, handled, activated, ended int
for _, activeCall := range activeCalls {
matched++
login, err := wa.Bridge.GetExistingUserLoginByID(ctx, activeCall.UserLoginID)
callLog := log.With().
Str("wa_call_id", activeCall.WACallID).
Str("user_login_id", string(activeCall.UserLoginID)).
Stringer("matrix_participant_mxid", activeCall.MatrixParticipantMXID).
Str("matrix_session_id", activeCall.MatrixSessionID).
Logger()
if err != nil {
callLog.Err(err).Msg("Failed to look up WhatsApp login for MatrixRTC call")
continue
} else if login == nil {
callLog.Debug().Msg("MatrixRTC call references a missing WhatsApp login")
continue
}
client, ok := login.Client.(*WhatsAppClient)
if !ok || client == nil || client.VOIP == nil {
callLog.Debug().Msg("WhatsApp login has no VOIP manager for MatrixRTC event")
continue
}
if matrixRTCMembershipEventMatchesCall(parsed, activeCall) &&
activeCall.SelectedMembershipEventID != parsed.EventID {
if activeCall.SelectedHandRaiseEventID != "" {
if err = client.VOIP.SetHandRaised(activeCall.WACallID, false); err != nil {
callLog.Warn().Err(err).Msg("Failed to lower WhatsApp hand after MatrixRTC membership replacement")
}
activeCall.SelectedHandRaiseEventID = ""
}
activeCall.SelectedMembershipEventID = parsed.EventID
if err = wa.DB.MatrixRTCCall.Put(ctx, activeCall); err != nil {
callLog.Err(err).Msg("Failed to persist replacement MatrixRTC membership event")
}
}
handled++
endedCalls := client.VOIP.HandleMatrixRTCCallEvent(ctx, parsed, activeCall.WACallID)
if shouldEndMatrixRTCCallFromMembership(parsed, activeCall.SelectedPublisherID) {
endedCalls += client.VOIP.HandleMatrixRTCCallEvent(ctx, voip.MatrixRTCEvent{
Kind: voip.MatrixRTCEventKindRTCDecline,
RoomID: parsed.RoomID,
Sender: parsed.Sender,
CallID: parsed.CallID,
}, activeCall.WACallID)
}
ended += endedCalls
if shouldActivateMatrixRTCCall(parsed, activeCall.State) {
if err = client.activateMatrixRTCCall(ctx, activeCall, parsed); err != nil {
callLog.Err(err).Msg("Failed to activate MatrixRTC LiveKit bridge for WhatsApp call")
} else {
activated++
}
}
if endedCalls > 0 {
err = wa.DB.MatrixRTCCall.MarkEnded(ctx, activeCall.UserLoginID, activeCall.WACallID, "ended", string(parsed.Kind), "", time.Now())
if err != nil {
callLog.Err(err).Msg("Failed to mark MatrixRTC call ended after MatrixRTC event")
}
}
}
log.Debug().
Int("active_call_count", len(activeCalls)).
Int("matched_call_count", matched).
Int("handled_call_count", handled).
Int("activated_call_count", activated).
Int("ended_call_count", ended).
Msg("Handled MatrixRTC event for active bridged calls")
}
func matrixRTCSenderOwnsLogin(sender id.UserID, login *bridgev2.UserLogin) bool {
return sender != "" && login != nil && login.User != nil && login.User.MXID == sender
}
func matrixRTCMembershipEventMatchesCall(evt voip.MatrixRTCEvent, call *wadb.MatrixRTCCall) bool {
if call == nil || evt.EventID == "" || !voip.MatrixRTCEventHasJoinContent(evt) {
return false
}
switch evt.Kind {
case voip.MatrixRTCEventKindRTCMembership, voip.MatrixRTCEventKindGroupCallMember:
return matrixRTCEventMatchesParticipant(evt, call.SelectedPublisherID)
default:
return false
}
}
func shouldActivateMatrixRTCCall(evt voip.MatrixRTCEvent, callState string) bool {
if callState != "ringing" {
return false
}
switch evt.Kind {
case voip.MatrixRTCEventKindRTCMembership, voip.MatrixRTCEventKindGroupCallMember:
return voip.MatrixRTCEventHasJoinContent(evt)
default:
return false
}
}
func shouldStartOutboundMatrixRTCCall(evt *event.Event, parsed voip.MatrixRTCEvent, membershipCompat string) bool {
if parsed.Type.Class != event.StateEventType || !voip.MatrixRTCEventHasJoinContent(parsed) {
return false
}
switch parsed.Kind {
case voip.MatrixRTCEventKindRTCMembership:
if !matrixRTCCompatAllowsModern(membershipCompat) {
return false
}
case voip.MatrixRTCEventKindGroupCallMember:
if !matrixRTCCompatAllowsLegacy(membershipCompat) {
return false
}
default:
return false
}
return !matrixRTCPrevContentHasJoinContent(evt)
}
func shouldEndMatrixRTCCallFromMembership(evt voip.MatrixRTCEvent, selectedParticipantID string) bool {
switch evt.Kind {
case voip.MatrixRTCEventKindRTCMembership, voip.MatrixRTCEventKindGroupCallMember:
default:
return false
}
if selectedParticipantID == "" || voip.MatrixRTCEventHasJoinContent(evt) {
return false
}
return matrixRTCEventMatchesParticipant(evt, selectedParticipantID)
}
func matrixRTCEventMatchesParticipant(evt voip.MatrixRTCEvent, participantID string) bool {
if participantID == "" {
return false
}
if matrixRTCTriggerParticipantID(evt) == participantID {
return true
}
if evt.StateKey == "" || evt.Sender == "" {
return false
}
if legacyID := legacyMatrixRTCParticipantIDFromStateKey(evt.Sender, evt.StateKey); legacyID == participantID {
return true
}
if modernID := modernMatrixRTCParticipantIDFromStateKey(evt.Sender, evt.StateKey); modernID == participantID {
return true
}
return false
}
func legacyMatrixRTCParticipantIDFromStateKey(sender id.UserID, stateKey string) string {
prefix := "_" + string(sender) + "_"
const suffix = "_m.call"
if !strings.HasPrefix(stateKey, prefix) || !strings.HasSuffix(stateKey, suffix) {
return ""
}
deviceID := strings.TrimSuffix(strings.TrimPrefix(stateKey, prefix), suffix)
if deviceID == "" {
return ""
}
return voip.MatrixRTCMemberID(sender, deviceID)
}
func modernMatrixRTCParticipantIDFromStateKey(sender id.UserID, stateKey string) string {
prefix := string(sender) + "_"
if !strings.HasPrefix(stateKey, prefix) {
return ""
}
deviceID := strings.TrimPrefix(stateKey, prefix)
if deviceID == "" {
return ""
}
return voip.MatrixRTCMemberID(sender, deviceID)
}
func (wa *WhatsAppConnector) reserveMatrixRTCOutboundStart(roomID string) bool {
if roomID == "" {
return false
}
now := time.Now()
wa.matrixRTCOutboundStartLock.Lock()
defer wa.matrixRTCOutboundStartLock.Unlock()
if wa.matrixRTCOutboundStartExpires == nil {
wa.matrixRTCOutboundStartExpires = make(map[string]time.Time)
}
for trackedRoomID, expires := range wa.matrixRTCOutboundStartExpires {
if !expires.After(now) {
delete(wa.matrixRTCOutboundStartExpires, trackedRoomID)
}
}
if expires := wa.matrixRTCOutboundStartExpires[roomID]; expires.After(now) {
return false
}
wa.matrixRTCOutboundStartExpires[roomID] = now.Add(matrixRTCOutboundStartDedupWindow)
return true
}
func matrixRTCPrevContentHasJoinContent(evt *event.Event) bool {
if evt == nil || evt.Unsigned.PrevContent == nil {
return false
}
prevEvt := *evt
prevEvt.Content = *evt.Unsigned.PrevContent
prevEvt.Unsigned.PrevContent = nil
parsedPrev, ok := voip.ParseMatrixRTCEvent(&prevEvt)
return ok && voip.MatrixRTCEventHasJoinContent(parsedPrev)
}

View file

@ -0,0 +1,950 @@
package connector
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/purpshell/meowcaller"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
mxbridge "maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/voip"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
const (
matrixRTCRingLifetime = 90 * time.Second
matrixRTCMembershipLifetime = 4 * time.Hour
matrixRTCStickyDuration = time.Hour
)
type incomingCallGroup struct {
JID types.JID
ExpiresAt time.Time
}
func (wa *WhatsAppClient) trackIncomingCallEvent(rawEvt any) {
switch evt := rawEvt.(type) {
case *events.CallOffer:
wa.trackIncomingCallGroup(evt.CallID, evt.GroupJID)
case *events.CallOfferNotice:
wa.trackIncomingCallGroup(evt.CallID, evt.GroupJID)
case *events.CallTerminate:
wa.clearIncomingCallGroup(evt.CallID)
}
}
func (wa *WhatsAppClient) handleIncomingVOIPCall(call *meowcaller.Call) {
if call == nil {
return
}
ctx := wa.UserLogin.Log.WithContext(withoutCancelOrBackground(wa.Main.Bridge.BackgroundCtx))
err := wa.announceIncomingMatrixRTCCall(ctx, call)
if err != nil {
wa.UserLogin.Log.Warn().
Err(err).
Str("call_id", call.ID()).
Stringer("peer_jid", call.Peer()).
Msg("Failed to announce incoming WhatsApp call over MatrixRTC")
}
}
func (wa *WhatsAppClient) handleVOIPCallEnded(callID, reason string) {
wa.clearIncomingCallGroup(callID)
wa.clearWhatsAppRemoteHandRaises(callID)
ctx := wa.UserLogin.Log.WithContext(withoutCancelOrBackground(wa.Main.Bridge.BackgroundCtx))
log := wa.UserLogin.Log.With().Str("call_id", callID).Str("reason", reason).Logger()
call, err := wa.Main.DB.MatrixRTCCall.Get(ctx, wa.UserLogin.ID, callID)
if err != nil {
log.Err(err).Msg("Failed to look up MatrixRTC call record after WhatsApp call ended")
return
} else if call == nil {
return
}
if err = wa.clearMatrixRTCMembership(ctx, call); err != nil {
log.Err(err).Msg("Failed to clear MatrixRTC membership after WhatsApp call ended")
}
endReason, lastError := matrixRTCFinalEndReason(call, reason)
if err = wa.Main.DB.MatrixRTCCall.MarkEnded(ctx, wa.UserLogin.ID, callID, "ended", endReason, lastError, time.Now()); err != nil {
log.Err(err).Msg("Failed to mark MatrixRTC call ended")
}
}
func (wa *WhatsAppClient) cleanupStaleMatrixRTCCalls(ctx context.Context) {
if wa == nil || wa.Main == nil || wa.Main.DB == nil || wa.UserLogin == nil {
return
}
calls, err := wa.Main.DB.MatrixRTCCall.GetActiveForLogin(ctx, wa.UserLogin.ID)
if err != nil {
wa.UserLogin.Log.Err(err).Msg("Failed to query stale MatrixRTC calls during login load")
return
}
for _, call := range calls {
if call == nil {
continue
}
lastError := ""
if clearErr := wa.clearMatrixRTCMembership(ctx, call); clearErr != nil {
lastError = clearErr.Error()
wa.UserLogin.Log.Warn().
Err(clearErr).
Str("call_id", call.WACallID).
Stringer("room_id", call.RoomID).
Msg("Failed to clear stale MatrixRTC membership during login load")
}
if markErr := wa.Main.DB.MatrixRTCCall.MarkEnded(
ctx,
wa.UserLogin.ID,
call.WACallID,
"ended",
"bridge_restart",
lastError,
time.Now(),
); markErr != nil {
wa.UserLogin.Log.Err(markErr).
Str("call_id", call.WACallID).
Msg("Failed to mark stale MatrixRTC call ended during login load")
}
}
if len(calls) > 0 {
wa.UserLogin.Log.Info().
Int("call_count", len(calls)).
Msg("Cleaned up stale MatrixRTC calls during login load")
}
}
func (wa *WhatsAppClient) handleWhatsAppWaitingRoom(
ctx context.Context,
callID string,
state meowcaller.WaitingRoomState,
) {
call, err := wa.Main.DB.MatrixRTCCall.Get(ctx, wa.UserLogin.ID, callID)
if err != nil {
wa.UserLogin.Log.Err(err).
Str("call_id", callID).
Msg("Failed to load MatrixRTC call for WhatsApp waiting-room update")
return
}
if call == nil || !call.EndedTS.IsZero() || call.RoomID == "" {
return
}
intent := wa.matrixRTCIntentForMXID(ctx, call.MatrixParticipantMXID)
_, err = intent.SendMessage(ctx, call.RoomID, event.EventMessage, &event.Content{
Parsed: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: formatWaitingRoomNotice(state),
},
}, nil)
if err != nil {
wa.UserLogin.Log.Warn().
Err(err).
Str("call_id", callID).
Msg("Failed to send WhatsApp waiting-room update to Matrix")
}
}
func formatWaitingRoomNotice(state meowcaller.WaitingRoomState) string {
approval := "disabled"
if state.Enabled {
approval = "enabled"
}
if state.InWaitingRoom {
return fmt.Sprintf("Waiting for approval to join the WhatsApp call link (approval %s).", approval)
}
if len(state.Users) == 0 {
return fmt.Sprintf("WhatsApp call-link waiting room is empty (approval %s).", approval)
}
participants := make([]string, 0, len(state.Users))
for _, user := range state.Users {
identity := user.JID
if !user.PN.IsEmpty() {
identity = user.PN
}
participants = append(participants, identity.String())
}
slices.Sort(participants)
return fmt.Sprintf(
"WhatsApp call-link waiting room has %d participant(s) pending (approval %s): %s",
len(participants),
approval,
strings.Join(participants, ", "),
)
}
func matrixRTCFinalEndReason(call *wadb.MatrixRTCCall, reason string) (string, string) {
if call != nil && !call.EndedTS.IsZero() && (call.EndReason != "" || call.LastError != "") {
if call.EndReason != "" {
reason = call.EndReason
}
return reason, call.LastError
}
return reason, ""
}
func (wa *WhatsAppClient) announceIncomingMatrixRTCCall(ctx context.Context, call *meowcaller.Call) error {
wa.voipCallStartLock.Lock()
defer wa.voipCallStartLock.Unlock()
log := zerolog.Ctx(ctx).With().
Str("call_id", call.ID()).
Stringer("peer_jid", call.Peer()).
Logger()
peer := wa.matrixRTCAnnouncementPeer(ctx, call.Peer())
portalPeer := wa.incomingCallPortalPeer(call.ID(), peer)
portal, err := wa.Main.Bridge.GetPortalByKey(ctx, wa.makeWAPortalKey(portalPeer))
if err != nil {
return err
}
if portal == nil || portal.MXID == "" {
log.Debug().Msg("No existing Matrix portal room for incoming MatrixRTC call announcement")
return nil
}
activeRoomCalls, err := wa.Main.DB.MatrixRTCCall.GetActiveInRoom(ctx, portal.MXID)
if err != nil {
return err
}
if len(activeRoomCalls) > 0 {
log.Warn().
Stringer("room_id", portal.MXID).
Int("active_call_count", len(activeRoomCalls)).
Msg("Rejecting incoming WhatsApp call because the Matrix room already has an active call")
return call.Reject()
}
if wa.Main.Config.VOIP.MaxActiveCallsPerLogin > 0 {
activeCalls, err := wa.Main.DB.MatrixRTCCall.GetActiveForLogin(ctx, wa.UserLogin.ID)
if err != nil {
return err
}
if len(activeCalls) >= wa.Main.Config.VOIP.MaxActiveCallsPerLogin {
log.Warn().
Int("active_call_count", len(activeCalls)).
Int("max_active_calls", wa.Main.Config.VOIP.MaxActiveCallsPerLogin).
Msg("Rejecting incoming WhatsApp call because the MatrixRTC active call limit was reached")
return call.Reject()
}
}
return wa.announceMatrixRTCCallInPortal(ctx, call, portal, peer, "incoming")
}
func (wa *WhatsAppClient) trackIncomingCallGroup(callID string, group types.JID) {
if wa == nil || callID == "" || group.Server != types.GroupServer || group.User == "" {
return
}
wa.incomingCallGroupLock.Lock()
if wa.incomingCallGroups == nil {
wa.incomingCallGroups = make(map[string]incomingCallGroup)
}
now := time.Now()
for trackedCallID, tracked := range wa.incomingCallGroups {
if !tracked.ExpiresAt.After(now) {
delete(wa.incomingCallGroups, trackedCallID)
}
}
wa.incomingCallGroups[callID] = incomingCallGroup{
JID: group.ToNonAD(),
ExpiresAt: now.Add(callEventMaxAge),
}
wa.incomingCallGroupLock.Unlock()
}
func (wa *WhatsAppClient) incomingCallPortalPeer(callID string, fallback types.JID) types.JID {
if wa == nil || callID == "" {
return fallback
}
wa.incomingCallGroupLock.Lock()
tracked := wa.incomingCallGroups[callID]
delete(wa.incomingCallGroups, callID)
wa.incomingCallGroupLock.Unlock()
if tracked.JID.IsEmpty() || !tracked.ExpiresAt.After(time.Now()) {
return fallback
}
return tracked.JID
}
func (wa *WhatsAppClient) clearIncomingCallGroup(callID string) {
if wa == nil || callID == "" {
return
}
wa.incomingCallGroupLock.Lock()
delete(wa.incomingCallGroups, callID)
wa.incomingCallGroupLock.Unlock()
}
func (wa *WhatsAppClient) joinMatrixRTCCallLink(
ctx context.Context,
portal *bridgev2.Portal,
tokenOrURL string,
video bool,
) (*meowcaller.Call, error) {
wa.voipCallStartLock.Lock()
defer wa.voipCallStartLock.Unlock()
if portal == nil || portal.MXID == "" {
return nil, fmt.Errorf("call links must be joined from an existing portal room")
}
if video && !wa.Main.Config.VOIP.Video.Enabled {
return nil, fmt.Errorf("WhatsApp call-link video requires voip.video.enabled")
}
activeRoomCalls, err := wa.Main.DB.MatrixRTCCall.GetActiveInRoom(ctx, portal.MXID)
if err != nil {
return nil, err
}
if len(activeRoomCalls) > 0 {
return nil, fmt.Errorf("the Matrix room already has an active call")
}
if wa.Main.Config.VOIP.MaxActiveCallsPerLogin > 0 {
activeCalls, err := wa.Main.DB.MatrixRTCCall.GetActiveForLogin(ctx, wa.UserLogin.ID)
if err != nil {
return nil, err
}
if len(activeCalls) >= wa.Main.Config.VOIP.MaxActiveCallsPerLogin {
return nil, fmt.Errorf("active MatrixRTC call limit reached for login %s", wa.UserLogin.ID)
}
}
call, err := wa.VOIP.JoinCallLink(ctx, tokenOrURL, video)
if err != nil {
return nil, err
}
peer := wa.matrixRTCAnnouncementPeer(ctx, call.Peer())
if err = wa.announceMatrixRTCCallInPortal(ctx, call, portal, peer, "call_link"); err != nil {
_ = call.Hangup()
return nil, err
}
return call, nil
}
func (wa *WhatsAppClient) announceMatrixRTCCallInPortal(
ctx context.Context,
call *meowcaller.Call,
portal *bridgev2.Portal,
peer types.JID,
direction string,
) error {
if call == nil {
return fmt.Errorf("WhatsApp call is nil")
}
if portal == nil || portal.MXID == "" {
return fmt.Errorf("Matrix portal room is not available")
}
focus, err := voip.DiscoverLiveKitFocus(ctx, nil, wa.Main.Bridge.Matrix.ServerName(), wa.Main.Config.VOIP.MatrixRTC.LiveKitServiceURL)
if err != nil {
return err
}
intent, err := wa.matrixRTCParticipantIntent(ctx, peer)
if err != nil {
return err
}
now := time.Now()
deviceID := voip.MatrixRTCDeviceID(string(wa.UserLogin.ID), call.ID())
session := voip.MatrixRTCSession{
UserID: intent.GetMXID(),
DeviceID: deviceID,
MemberID: voip.MatrixRTCMemberID(intent.GetMXID(), deviceID),
Intent: matrixRTCCallIntent(call),
Focus: *focus,
Created: now,
Expires: matrixRTCMembershipLifetime,
StickyKey: voip.MatrixRTCMemberID(intent.GetMXID(), deviceID),
}
record := &wadb.MatrixRTCCall{
UserLoginID: wa.UserLogin.ID,
WACallID: call.ID(),
RoomID: portal.MXID,
PortalKey: portal.PortalKey,
PeerJID: peer,
Direction: direction,
MediaKind: session.Intent,
FocusType: focus.Type,
LiveKitServiceURL: focus.LiveKitServiceURL,
LiveKitRoom: portal.MXID.String(),
MatrixParticipantMXID: intent.GetMXID(),
MatrixSessionID: deviceID,
AudioPolicy: wa.Main.Config.VOIP.LiveKit.AudioUplinkPolicy,
State: "ringing",
CreatedTS: now,
}
if err = wa.Main.DB.MatrixRTCCall.Put(ctx, record); err != nil {
return err
}
if err = wa.sendMatrixRTCRing(ctx, intent, portal.MXID, call.ID(), &session); err != nil {
_ = wa.Main.DB.MatrixRTCCall.MarkEnded(ctx, wa.UserLogin.ID, call.ID(), "ended", "matrixrtc_announce_failed", err.Error(), time.Now())
return err
}
record.BridgeMembershipEventID = session.MembershipEventID
if err = wa.Main.DB.MatrixRTCCall.Put(ctx, record); err != nil {
return err
}
zerolog.Ctx(ctx).Info().
Str("call_id", call.ID()).
Str("direction", direction).
Stringer("room_id", portal.MXID).
Stringer("participant_mxid", intent.GetMXID()).
Str("device_id", deviceID).
Msg("Announced WhatsApp call over MatrixRTC")
return nil
}
func (wa *WhatsAppClient) matrixRTCAnnouncementPeer(ctx context.Context, peer types.JID) types.JID {
peer = peer.ToNonAD()
if peer.Server != types.HiddenUserServer {
return peer
}
pn, err := wa.GetStore().LIDs.GetPNForLID(ctx, peer)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("lid", peer).
Msg("Failed to get phone number for LID incoming MatrixRTC call")
return peer
} else if pn.IsEmpty() {
return peer
}
pn = pn.ToNonAD()
zerolog.Ctx(ctx).Debug().
Stringer("lid", peer).
Stringer("pn", pn).
Msg("Using phone number portal for incoming MatrixRTC call from LID")
return pn
}
func (wa *WhatsAppConnector) startOutboundMatrixRTCCall(ctx context.Context, portal *bridgev2.Portal, trigger voip.MatrixRTCEvent) error {
if portal == nil {
return nil
}
if portal.Receiver == "" {
return fmt.Errorf("portal has no receiver login for outbound MatrixRTC call")
}
login, err := wa.Bridge.GetExistingUserLoginByID(ctx, portal.Receiver)
if err != nil {
return err
} else if login == nil || login.Client == nil {
return fmt.Errorf("receiver login %s not found for outbound MatrixRTC call", portal.Receiver)
} else if !login.Client.IsLoggedIn() {
return bridgev2.ErrNotLoggedIn
}
client, ok := login.Client.(*WhatsAppClient)
if !ok || client == nil || client.VOIP == nil {
return fmt.Errorf("receiver login %s has no WhatsApp VOIP manager", portal.Receiver)
}
return client.startOutboundMatrixRTCCall(ctx, portal, trigger)
}
func (wa *WhatsAppClient) startOutboundMatrixRTCCall(ctx context.Context, portal *bridgev2.Portal, trigger voip.MatrixRTCEvent) error {
wa.voipCallStartLock.Lock()
defer wa.voipCallStartLock.Unlock()
if wa.VOIP == nil || !wa.VOIP.Enabled() {
return voip.ErrNotEnabled
}
peer, err := waid.ParsePortalID(portal.ID)
if err != nil {
return err
}
if !matrixRTCPortalSupportsWhatsAppCalls(peer) {
return fmt.Errorf("MatrixRTC WhatsApp calls are not supported in %s portals", peer.Server)
}
mediaKind, downgradedMedia := matrixRTCOutboundMediaKind(trigger)
if mediaKind == "" {
return fmt.Errorf("outbound WhatsApp MatrixRTC calls only support audio/video, not %q", trigger.Intent)
}
if downgradedMedia {
wa.UserLogin.Log.Warn().
Stringer("room_id", trigger.RoomID).
Str("requested_media_kind", trigger.Intent).
Str("media_kind", mediaKind).
Msg("Downgrading outbound MatrixRTC call media kind")
}
if mediaKind == "video" && !wa.Main.Config.VOIP.Video.Enabled {
return fmt.Errorf("outbound WhatsApp MatrixRTC video calls require voip.video.enabled")
}
if wa.Main.Config.VOIP.MaxActiveCallsPerLogin > 0 {
activeCalls, err := wa.Main.DB.MatrixRTCCall.GetActiveForLogin(ctx, wa.UserLogin.ID)
if err != nil {
return err
}
if len(activeCalls) >= wa.Main.Config.VOIP.MaxActiveCallsPerLogin {
return fmt.Errorf("active MatrixRTC call limit reached for login %s", wa.UserLogin.ID)
}
}
focus, err := wa.matrixRTCLiveKitFocusForTrigger(ctx, trigger)
if err != nil {
return err
}
intent, err := wa.matrixRTCParticipantIntent(ctx, peer)
if err != nil {
return err
}
var call *meowcaller.Call
if peer.Server == types.GroupServer {
call, err = wa.VOIP.DialGroupByID(ctx, peer.ToNonAD().String(), mediaKind == "video")
} else {
call, err = wa.VOIP.Dial(ctx, peer.ToNonAD().String(), mediaKind == "video")
}
if err != nil {
return err
}
now := time.Now()
deviceID := voip.MatrixRTCDeviceID(string(wa.UserLogin.ID), call.ID())
record := &wadb.MatrixRTCCall{
UserLoginID: wa.UserLogin.ID,
WACallID: call.ID(),
RoomID: trigger.RoomID,
PortalKey: portal.PortalKey,
PeerJID: peer,
Direction: "outgoing",
MediaKind: mediaKind,
FocusType: focus.Type,
LiveKitServiceURL: focus.LiveKitServiceURL,
LiveKitRoom: trigger.RoomID.String(),
MatrixParticipantMXID: intent.GetMXID(),
MatrixSessionID: deviceID,
SelectedPublisherID: matrixRTCTriggerParticipantID(trigger),
SelectedMembershipEventID: trigger.EventID,
AudioPolicy: wa.Main.Config.VOIP.LiveKit.AudioUplinkPolicy,
State: "joining_livekit",
CreatedTS: now,
}
if err = wa.Main.DB.MatrixRTCCall.Put(ctx, record); err != nil {
_ = call.Hangup()
return err
}
session := &voip.MatrixRTCSession{
UserID: intent.GetMXID(),
DeviceID: deviceID,
MemberID: voip.MatrixRTCMemberID(intent.GetMXID(), deviceID),
Intent: mediaKind,
Focus: *focus,
Created: now,
Expires: matrixRTCMembershipLifetime,
StickyKey: voip.MatrixRTCMemberID(intent.GetMXID(), deviceID),
}
if err = wa.sendMatrixRTCMembership(ctx, intent, trigger.RoomID, session); err != nil {
return wa.failMatrixRTCActivation(ctx, record, "matrixrtc_membership_failed", err)
}
record.BridgeMembershipEventID = session.MembershipEventID
if err = wa.connectOutboundMatrixRTCCall(ctx, record, trigger); err != nil {
wa.UserLogin.Log.Warn().
Err(err).
Str("call_id", call.ID()).
Stringer("room_id", trigger.RoomID).
Stringer("peer_jid", peer).
Msg("Failed to connect outbound MatrixRTC call to LiveKit")
return err
}
wa.UserLogin.Log.Info().
Str("call_id", call.ID()).
Stringer("room_id", trigger.RoomID).
Stringer("peer_jid", peer).
Stringer("matrix_participant_mxid", trigger.Sender).
Msg("Started outbound WhatsApp call from MatrixRTC")
return nil
}
func (wa *WhatsAppClient) sendMatrixRTCRing(ctx context.Context, intent bridgev2.MatrixAPI, roomID id.RoomID, waCallID string, session *voip.MatrixRTCSession) error {
now := time.Now()
notificationMode := wa.Main.Config.VOIP.MatrixRTC.NotificationEventCompat
if matrixRTCCompatAllowsModern(notificationMode) {
resp, err := sendMatrixRTCMessage(ctx, intent, roomID, voip.RTCNotificationEventType(), voip.BuildRTCNotificationContent(now, matrixRTCRingLifetime, session.Intent), 0)
if err != nil {
return err
}
if resp != nil {
session.NotificationEventID = resp.EventID
}
}
if matrixRTCCompatAllowsLegacy(notificationMode) {
_, err := sendMatrixRTCMessage(ctx, intent, roomID, voip.LegacyCallNotifyEventType(), voip.BuildLegacyCallNotifyContent(waCallID, session.Intent), 0)
if err != nil {
return err
}
}
return wa.sendMatrixRTCMembership(ctx, intent, roomID, session)
}
func (wa *WhatsAppClient) sendMatrixRTCMembership(ctx context.Context, intent bridgev2.MatrixAPI, roomID id.RoomID, session *voip.MatrixRTCSession) error {
now := time.Now()
membershipMode := wa.Main.Config.VOIP.MatrixRTC.MembershipEventCompat
modernMessageSent := false
if matrixRTCCompatAllowsModern(membershipMode) {
content := voip.BuildRTCMembershipContent(*session)
resp, err := sendMatrixRTCMessage(ctx, intent, roomID, voip.RTCMembershipEventType(event.MessageEventType), content, matrixRTCStickyDuration)
if err != nil {
return err
}
if resp != nil {
session.MembershipEventID = resp.EventID
}
modernMessageSent = true
stateKey := voip.MatrixRTCStateKey(session.UserID, session.DeviceID)
stateResp, err := intent.SendState(ctx, roomID, voip.RTCMembershipEventType(event.StateEventType), stateKey, &event.Content{Raw: content}, now)
if err != nil {
wa.UserLogin.Log.Warn().
Err(err).
Stringer("room_id", roomID).
Str("state_key", stateKey).
Msg("Failed to send MatrixRTC membership state event after sticky message membership")
} else if session.MembershipEventID == "" && stateResp != nil {
session.MembershipEventID = stateResp.EventID
}
}
if matrixRTCCompatAllowsLegacy(membershipMode) {
resp, err := intent.SendState(ctx, roomID, voip.GroupCallMemberEventType(), "", &event.Content{Raw: voip.BuildLegacyCallMemberContent(*session)}, now)
if err != nil {
if modernMessageSent {
wa.UserLogin.Log.Warn().
Err(err).
Stringer("room_id", roomID).
Msg("Failed to send legacy MatrixRTC membership state event after modern membership")
return nil
}
return err
}
if session.MembershipEventID == "" && resp != nil {
session.MembershipEventID = resp.EventID
}
}
return nil
}
func (wa *WhatsAppClient) clearMatrixRTCMembership(ctx context.Context, call *wadb.MatrixRTCCall) error {
if call.RoomID == "" || call.MatrixParticipantMXID == "" {
return nil
}
intent := wa.matrixRTCIntentForMXID(ctx, call.MatrixParticipantMXID)
if intent == nil {
intent = wa.Main.Bridge.Bot
}
stickyKey := voip.MatrixRTCMemberID(call.MatrixParticipantMXID, call.MatrixSessionID)
emptyContent := voip.EmptyMatrixRTCContent(stickyKey)
now := time.Now()
membershipMode := wa.Main.Config.VOIP.MatrixRTC.MembershipEventCompat
modernMessageSent := false
if matrixRTCCompatAllowsModern(membershipMode) {
if _, err := sendMatrixRTCMessage(ctx, intent, call.RoomID, voip.RTCMembershipEventType(event.MessageEventType), emptyContent, matrixRTCStickyDuration); err != nil {
return err
}
modernMessageSent = true
stateKey := voip.MatrixRTCStateKey(call.MatrixParticipantMXID, call.MatrixSessionID)
if _, err := intent.SendState(ctx, call.RoomID, voip.RTCMembershipEventType(event.StateEventType), stateKey, &event.Content{Raw: map[string]any{}}, now); err != nil {
wa.UserLogin.Log.Warn().
Err(err).
Stringer("room_id", call.RoomID).
Str("state_key", stateKey).
Msg("Failed to clear MatrixRTC membership state event after sticky message cleanup")
}
}
if matrixRTCCompatAllowsLegacy(membershipMode) {
if _, err := intent.SendState(ctx, call.RoomID, voip.GroupCallMemberEventType(), "", &event.Content{Raw: map[string]any{}}, now); err != nil {
if modernMessageSent {
wa.UserLogin.Log.Warn().
Err(err).
Stringer("room_id", call.RoomID).
Msg("Failed to clear legacy MatrixRTC membership state event after modern cleanup")
return nil
}
return err
}
}
return nil
}
func (wa *WhatsAppConnector) cleanupFailedOutboundMatrixRTCStart(ctx context.Context, trigger voip.MatrixRTCEvent) error {
if trigger.RoomID == "" || wa.Bridge == nil || wa.Bridge.Bot == nil {
return nil
}
intent := wa.Bridge.Bot
now := time.Now()
membershipMode := wa.Config.VOIP.MatrixRTC.MembershipEventCompat
if trigger.Kind == voip.MatrixRTCEventKindRTCMembership && matrixRTCCompatAllowsModern(membershipMode) {
emptyContent := voip.EmptyMatrixRTCContent(matrixRTCTriggerStickyKey(trigger))
if _, err := sendMatrixRTCMessage(ctx, intent, trigger.RoomID, voip.RTCMembershipEventType(event.MessageEventType), emptyContent, matrixRTCStickyDuration); err != nil {
return err
}
stateKey := matrixRTCTriggerStateKey(trigger)
if stateKey != "" {
if _, err := intent.SendState(ctx, trigger.RoomID, voip.RTCMembershipEventType(event.StateEventType), stateKey, &event.Content{Raw: map[string]any{}}, now); err != nil {
return err
}
}
}
if trigger.Kind == voip.MatrixRTCEventKindGroupCallMember && matrixRTCCompatAllowsLegacy(membershipMode) {
if _, err := intent.SendState(ctx, trigger.RoomID, voip.GroupCallMemberEventType(), trigger.StateKey, &event.Content{Raw: map[string]any{}}, now); err != nil {
return err
}
}
return nil
}
func (wa *WhatsAppClient) activateMatrixRTCCall(ctx context.Context, call *wadb.MatrixRTCCall, trigger voip.MatrixRTCEvent) error {
if call == nil {
return nil
}
call.State = "joining_livekit"
call.LastError = ""
call.SelectedPublisherID = matrixRTCTriggerParticipantID(trigger)
call.SelectedMembershipEventID = trigger.EventID
if err := wa.Main.DB.MatrixRTCCall.Put(ctx, call); err != nil {
return err
}
authResp, err := wa.requestMatrixRTCLiveKitAuth(ctx, call, trigger)
if err != nil {
return err
}
if err = wa.VOIP.BridgeCallToLiveKit(ctx, call.WACallID, authResp, call.SelectedPublisherID); err != nil {
return wa.failMatrixRTCActivation(ctx, call, "livekit_bridge_failed", err)
}
now := time.Now()
call.State = "active"
call.JoinedTS = now
call.AnsweredTS = now
if authResp.RoomName != "" {
call.LiveKitRoom = authResp.RoomName
}
if err = wa.Main.DB.MatrixRTCCall.Put(ctx, call); err != nil {
return err
}
wa.UserLogin.Log.Info().
Str("call_id", call.WACallID).
Stringer("room_id", call.RoomID).
Stringer("trigger_sender", trigger.Sender).
Msg("Activated MatrixRTC LiveKit bridge for WhatsApp call")
return nil
}
func (wa *WhatsAppClient) connectOutboundMatrixRTCCall(ctx context.Context, call *wadb.MatrixRTCCall, trigger voip.MatrixRTCEvent) error {
authResp, err := wa.requestMatrixRTCLiveKitAuth(ctx, call, trigger)
if err != nil {
return err
}
if err = wa.VOIP.BridgeCallToLiveKit(ctx, call.WACallID, authResp, call.SelectedPublisherID); err != nil {
return wa.failMatrixRTCActivation(ctx, call, "livekit_bridge_failed", err)
}
now := time.Now()
call.State = "active"
call.JoinedTS = now
if authResp.RoomName != "" {
call.LiveKitRoom = authResp.RoomName
}
return wa.Main.DB.MatrixRTCCall.Put(ctx, call)
}
func (wa *WhatsAppClient) requestMatrixRTCLiveKitAuth(ctx context.Context, call *wadb.MatrixRTCCall, trigger voip.MatrixRTCEvent) (*voip.LiveKitAuthResponse, error) {
intent := wa.matrixRTCIntentForMXID(ctx, call.MatrixParticipantMXID)
openIDToken, err := requestMatrixOpenIDToken(ctx, intent)
if err != nil {
return nil, wa.failMatrixRTCActivation(ctx, call, "matrix_openid_failed", err)
}
if matrixRTCCompatAllowsLegacy(wa.Main.Config.VOIP.MatrixRTC.MembershipEventCompat) {
authResp, err := voip.RequestLegacyLiveKitAuth(ctx, nil, call.LiveKitServiceURL, matrixRTCLegacyLiveKitAuthRequest(call, openIDToken))
if err != nil {
return nil, wa.failMatrixRTCActivation(ctx, call, "livekit_auth_failed", err)
}
return authResp, nil
}
authResp, err := voip.RequestLiveKitAuth(ctx, nil, call.LiveKitServiceURL, matrixRTCLiveKitAuthRequest(call, openIDToken))
if err != nil {
return nil, wa.failMatrixRTCActivation(ctx, call, "livekit_auth_failed", err)
}
return authResp, nil
}
func (wa *WhatsAppClient) failMatrixRTCActivation(ctx context.Context, call *wadb.MatrixRTCCall, reason string, err error) error {
_ = wa.Main.DB.MatrixRTCCall.MarkEnded(ctx, call.UserLoginID, call.WACallID, "ended", reason, err.Error(), time.Now())
if wa.VOIP != nil {
wa.VOIP.HandleMatrixRTCCallEvent(ctx, voip.MatrixRTCEvent{
Kind: voip.MatrixRTCEventKindRTCDecline,
RoomID: call.RoomID,
}, call.WACallID)
}
return err
}
func matrixRTCLiveKitAuthRequest(call *wadb.MatrixRTCCall, openIDToken voip.MatrixOpenIDToken) voip.LiveKitAuthRequest {
if call == nil {
return voip.LiveKitAuthRequest{OpenIDToken: openIDToken}
}
return voip.LiveKitAuthRequest{
RoomID: call.RoomID.String(),
SlotID: voip.MatrixRTCDefaultSlotID,
OpenIDToken: openIDToken,
Member: matrixRTCLiveKitAuthMember(call),
}
}
func matrixRTCLegacyLiveKitAuthRequest(call *wadb.MatrixRTCCall, openIDToken voip.MatrixOpenIDToken) voip.LegacyLiveKitAuthRequest {
if call == nil {
return voip.LegacyLiveKitAuthRequest{OpenIDToken: openIDToken}
}
return voip.LegacyLiveKitAuthRequest{
Room: call.RoomID.String(),
OpenIDToken: openIDToken,
DeviceID: call.MatrixSessionID,
}
}
func matrixRTCLiveKitAuthMember(call *wadb.MatrixRTCCall) *voip.LiveKitAuthMember {
if call == nil {
return nil
}
return &voip.LiveKitAuthMember{
ID: voip.MatrixRTCMemberID(call.MatrixParticipantMXID, call.MatrixSessionID),
ClaimedDeviceID: call.MatrixSessionID,
ClaimedUserID: call.MatrixParticipantMXID.String(),
}
}
func (wa *WhatsAppClient) matrixRTCLiveKitFocusForTrigger(ctx context.Context, trigger voip.MatrixRTCEvent) (*voip.Focus, error) {
for _, focus := range trigger.FociPreferred {
if focus.Type == "livekit" && focus.LiveKitServiceURL != "" {
focusCopy := focus
return &focusCopy, nil
}
}
return voip.DiscoverLiveKitFocus(ctx, nil, wa.Main.Bridge.Matrix.ServerName(), wa.Main.Config.VOIP.MatrixRTC.LiveKitServiceURL)
}
func (wa *WhatsAppClient) matrixRTCParticipantIntent(ctx context.Context, peer types.JID) (bridgev2.MatrixAPI, error) {
mode := strings.ToLower(wa.Main.Config.VOIP.MatrixRTC.ParticipantMode)
if mode == "" || mode == "whatsapp_ghost" {
if ghostID := waid.MakeUserID(peer); ghostID != "" {
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, ghostID)
if err != nil {
return nil, err
}
if ghost != nil && ghost.Intent != nil {
return ghost.Intent, nil
}
}
}
return wa.Main.Bridge.Bot, nil
}
func (wa *WhatsAppClient) matrixRTCIntentForMXID(ctx context.Context, mxid id.UserID) bridgev2.MatrixAPI {
if mxid == "" || mxid == wa.Main.Bridge.Bot.GetMXID() {
return wa.Main.Bridge.Bot
}
if ghost, err := wa.Main.Bridge.GetGhostByMXID(ctx, mxid); err == nil && ghost != nil && ghost.Intent != nil {
return ghost.Intent
}
return wa.Main.Bridge.Bot
}
func matrixRTCPortalSupportsWhatsAppCalls(peer types.JID) bool {
switch peer.Server {
case types.DefaultUserServer, types.HiddenUserServer, types.GroupServer:
return true
default:
return false
}
}
func matrixRTCCallIntent(call *meowcaller.Call) string {
if call != nil && call.IsVideo() {
return "video"
}
return "audio"
}
func matrixRTCOutboundMediaKind(trigger voip.MatrixRTCEvent) (mediaKind string, downgraded bool) {
switch trigger.Intent {
case "", "audio":
return "audio", false
case "video":
return "video", false
default:
return "", false
}
}
func matrixRTCTriggerParticipantID(trigger voip.MatrixRTCEvent) string {
if trigger.Sender == "" {
return ""
}
deviceID := trigger.SessionID
if deviceID == "" {
deviceID = trigger.DeviceID
}
return voip.MatrixRTCMemberID(trigger.Sender, deviceID)
}
func matrixRTCTriggerStateKey(trigger voip.MatrixRTCEvent) string {
if trigger.StateKey != "" {
return trigger.StateKey
}
deviceID := trigger.SessionID
if deviceID == "" {
deviceID = trigger.DeviceID
}
if trigger.Sender == "" {
return deviceID
}
return voip.MatrixRTCStateKey(trigger.Sender, deviceID)
}
func matrixRTCTriggerStickyKey(trigger voip.MatrixRTCEvent) string {
if stickyKey, ok := trigger.Raw["sticky_key"].(string); ok && stickyKey != "" {
return stickyKey
}
if stickyKey, ok := trigger.Raw["msc4354_sticky_key"].(string); ok && stickyKey != "" {
return stickyKey
}
return matrixRTCTriggerParticipantID(trigger)
}
func sendMatrixRTCMessage(ctx context.Context, intent bridgev2.MatrixAPI, roomID id.RoomID, eventType event.Type, raw map[string]any, sticky time.Duration) (*mautrix.RespSendEvent, error) {
if asIntent, ok := intent.(*mxbridge.ASIntent); ok {
return asIntent.Matrix.SendMessageEvent(ctx, roomID, eventType, &event.Content{Raw: raw}, mautrix.ReqSendEvent{
UnstableStickyDuration: sticky,
DontEncrypt: true,
})
}
return intent.SendMessage(ctx, roomID, eventType, &event.Content{Raw: raw}, nil)
}
func requestMatrixOpenIDToken(ctx context.Context, intent bridgev2.MatrixAPI) (voip.MatrixOpenIDToken, error) {
asIntent, ok := intent.(*mxbridge.ASIntent)
if !ok {
return voip.MatrixOpenIDToken{}, fmt.Errorf("matrix intent %T does not support OpenID token requests", intent)
}
if asIntent.Matrix == nil || asIntent.Matrix.Client == nil {
return voip.MatrixOpenIDToken{}, fmt.Errorf("matrix intent %T has no Matrix client", intent)
}
resp, err := asIntent.Matrix.Client.RequestOpenIDToken(ctx)
if err != nil {
return voip.MatrixOpenIDToken{}, err
}
return voip.MatrixOpenIDToken{
AccessToken: resp.AccessToken,
TokenType: resp.TokenType,
MatrixServerName: resp.MatrixServerName,
ExpiresIn: resp.ExpiresIn,
}, nil
}
func matrixRTCCompatAllowsModern(mode string) bool {
switch strings.ToLower(mode) {
case "legacy", "legacy_only", "msc3401", "org.matrix.msc3401.call.member":
return false
default:
return true
}
}
func matrixRTCCompatAllowsLegacy(mode string) bool {
switch strings.ToLower(mode) {
case "modern", "modern_only", "msc4143", "org.matrix.msc4143.rtc.member", "none", "off", "false", "disabled":
return false
default:
return true
}
}

View file

@ -0,0 +1,254 @@
package connector
import (
"context"
"strings"
"github.com/purpshell/meowcaller"
"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/types"
"maunium.net/go/mautrix/event"
"go.mau.fi/mautrix-whatsapp/pkg/connector/voip"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
)
func isMatrixRTCCallControlEvent(evt voip.MatrixRTCEvent) bool {
switch evt.Kind {
case voip.MatrixRTCEventKindCallReaction, voip.MatrixRTCEventKindHandRaise, voip.MatrixRTCEventKindRedaction:
return true
default:
return false
}
}
func matrixRTCControlEventMatchesCall(evt voip.MatrixRTCEvent, call *wadb.MatrixRTCCall) bool {
if call == nil || !matrixRTCEventSenderMatchesPublisher(evt.Sender.String(), call.SelectedPublisherID) {
return false
}
switch evt.Kind {
case voip.MatrixRTCEventKindCallReaction, voip.MatrixRTCEventKindHandRaise:
return evt.RelatesToEventID != "" && evt.RelatesToEventID == call.SelectedMembershipEventID
case voip.MatrixRTCEventKindRedaction:
return evt.Redacts != "" && evt.Redacts == call.SelectedHandRaiseEventID
default:
return false
}
}
func matrixRTCEventSenderMatchesPublisher(sender, publisherID string) bool {
if sender == "" || publisherID == "" {
return false
}
return publisherID == sender || strings.HasPrefix(publisherID, sender+":")
}
func (wa *WhatsAppConnector) handleMatrixRTCCallControlEvent(
ctx context.Context,
evt voip.MatrixRTCEvent,
activeCalls []*wadb.MatrixRTCCall,
log zerolog.Logger,
) {
for _, activeCall := range activeCalls {
if !matrixRTCControlEventMatchesCall(evt, activeCall) {
continue
}
login, err := wa.Bridge.GetExistingUserLoginByID(ctx, activeCall.UserLoginID)
if err != nil {
log.Err(err).Str("wa_call_id", activeCall.WACallID).Msg("Failed to resolve login for MatrixRTC call control")
continue
}
if login == nil {
continue
}
client, ok := login.Client.(*WhatsAppClient)
if !ok || client == nil || client.VOIP == nil {
continue
}
switch evt.Kind {
case voip.MatrixRTCEventKindCallReaction:
if evt.RelationType != event.RelReference {
continue
}
emoji, supported := voip.NormalizeWhatsAppCallReaction(evt.ReactionEmoji)
if !supported {
log.Debug().Str("emoji", evt.ReactionEmoji).Msg("Ignoring unsupported MatrixRTC call reaction")
continue
}
if err = client.VOIP.SendReaction(activeCall.WACallID, emoji); err != nil {
log.Warn().Err(err).Str("wa_call_id", activeCall.WACallID).Str("emoji", emoji).Msg("Failed to send MatrixRTC reaction to WhatsApp")
}
case voip.MatrixRTCEventKindHandRaise:
if evt.RelationType != event.RelAnnotation || evt.RelationKey != "🖐️" || evt.EventID == "" {
continue
}
if activeCall.SelectedHandRaiseEventID != "" {
continue
}
if err = client.VOIP.SetHandRaised(activeCall.WACallID, true); err != nil {
log.Warn().Err(err).Str("wa_call_id", activeCall.WACallID).Msg("Failed to raise hand in WhatsApp call")
continue
}
activeCall.SelectedHandRaiseEventID = evt.EventID
if err = wa.DB.MatrixRTCCall.Put(ctx, activeCall); err != nil {
log.Err(err).Str("wa_call_id", activeCall.WACallID).Msg("Failed to persist MatrixRTC hand raise")
_ = client.VOIP.SetHandRaised(activeCall.WACallID, false)
}
case voip.MatrixRTCEventKindRedaction:
if err = client.VOIP.SetHandRaised(activeCall.WACallID, false); err != nil {
log.Warn().Err(err).Str("wa_call_id", activeCall.WACallID).Msg("Failed to lower hand in WhatsApp call")
continue
}
activeCall.SelectedHandRaiseEventID = ""
if err = wa.DB.MatrixRTCCall.Put(ctx, activeCall); err != nil {
log.Err(err).Str("wa_call_id", activeCall.WACallID).Msg("Failed to persist MatrixRTC hand lowering")
_ = client.VOIP.SetHandRaised(activeCall.WACallID, true)
}
}
}
}
func (wa *WhatsAppClient) handleWhatsAppCallReaction(ctx context.Context, callID string, reaction meowcaller.CallReaction) {
if reaction.Removed || wa.isOwnWhatsAppCallParticipant(reaction.Sender) {
return
}
emoji, supported := voip.NormalizeWhatsAppCallReaction(reaction.Emoji)
if !supported {
return
}
call, err := wa.Main.DB.MatrixRTCCall.Get(ctx, wa.UserLogin.ID, callID)
if err != nil || call == nil || !call.EndedTS.IsZero() || call.BridgeMembershipEventID == "" {
if err != nil {
wa.UserLogin.Log.Err(err).Str("call_id", callID).Msg("Failed to load MatrixRTC call for WhatsApp reaction")
}
return
}
intent := wa.matrixRTCIntentForMXID(ctx, call.MatrixParticipantMXID)
_, err = intent.SendMessage(ctx, call.RoomID, voip.ElementCallReactionEventType(), &event.Content{
Raw: voip.BuildElementCallReactionContent(call.BridgeMembershipEventID, emoji),
}, nil)
if err != nil {
wa.UserLogin.Log.Warn().Err(err).Str("call_id", callID).Str("emoji", emoji).Msg("Failed to bridge WhatsApp call reaction to MatrixRTC")
}
}
func (wa *WhatsAppClient) handleWhatsAppHandRaise(ctx context.Context, callID string, state meowcaller.HandRaiseState) {
if wa.isOwnWhatsAppCallParticipant(state.Participant) {
return
}
wa.voipHandBridgeLock.Lock()
defer wa.voipHandBridgeLock.Unlock()
raised, changed := wa.updateWhatsAppRemoteHandRaise(callID, state)
if !changed {
return
}
rollback := func() {
state.Raised = !state.Raised
wa.updateWhatsAppRemoteHandRaise(callID, state)
}
call, err := wa.Main.DB.MatrixRTCCall.Get(ctx, wa.UserLogin.ID, callID)
if err != nil || call == nil || !call.EndedTS.IsZero() || call.BridgeMembershipEventID == "" {
rollback()
if err != nil {
wa.UserLogin.Log.Err(err).Str("call_id", callID).Msg("Failed to load MatrixRTC call for WhatsApp hand state")
}
return
}
intent := wa.matrixRTCIntentForMXID(ctx, call.MatrixParticipantMXID)
if raised {
if call.BridgeHandRaiseEventID != "" {
return
}
resp, sendErr := intent.SendMessage(ctx, call.RoomID, event.EventReaction, &event.Content{
Raw: voip.BuildElementCallHandRaiseContent(call.BridgeMembershipEventID),
}, nil)
if sendErr != nil {
rollback()
wa.UserLogin.Log.Warn().Err(sendErr).Str("call_id", callID).Msg("Failed to bridge WhatsApp hand raise to MatrixRTC")
return
}
if resp != nil {
call.BridgeHandRaiseEventID = resp.EventID
}
if call.BridgeHandRaiseEventID == "" {
rollback()
return
}
if err = wa.Main.DB.MatrixRTCCall.Put(ctx, call); err != nil {
rollback()
_, _ = intent.SendMessage(ctx, call.RoomID, event.EventRedaction, &event.Content{
Parsed: &event.RedactionEventContent{Redacts: call.BridgeHandRaiseEventID},
}, nil)
wa.UserLogin.Log.Err(err).Str("call_id", callID).Msg("Failed to persist bridged WhatsApp hand raise")
}
return
} else {
if call.BridgeHandRaiseEventID == "" {
return
}
handRaiseEventID := call.BridgeHandRaiseEventID
call.BridgeHandRaiseEventID = ""
if err = wa.Main.DB.MatrixRTCCall.Put(ctx, call); err != nil {
rollback()
wa.UserLogin.Log.Err(err).Str("call_id", callID).Msg("Failed to persist bridged WhatsApp hand lowering")
return
}
_, sendErr := intent.SendMessage(ctx, call.RoomID, event.EventRedaction, &event.Content{
Parsed: &event.RedactionEventContent{Redacts: handRaiseEventID},
}, nil)
if sendErr != nil {
rollback()
call.BridgeHandRaiseEventID = handRaiseEventID
_ = wa.Main.DB.MatrixRTCCall.Put(ctx, call)
wa.UserLogin.Log.Warn().Err(sendErr).Str("call_id", callID).Msg("Failed to bridge WhatsApp hand lowering to MatrixRTC")
return
}
}
}
func (wa *WhatsAppClient) updateWhatsAppRemoteHandRaise(callID string, state meowcaller.HandRaiseState) (raised, changed bool) {
if callID == "" || state.Participant.IsEmpty() {
return false, false
}
participant := state.Participant.ToNonAD()
wa.voipHandRaiseLock.Lock()
defer wa.voipHandRaiseLock.Unlock()
if wa.voipHandRaises == nil {
wa.voipHandRaises = make(map[string]map[types.JID]bool)
}
hands := wa.voipHandRaises[callID]
if hands == nil {
hands = make(map[types.JID]bool)
wa.voipHandRaises[callID] = hands
}
wasRaised := len(hands) > 0
if state.Raised {
hands[participant] = true
} else {
delete(hands, participant)
}
raised = len(hands) > 0
if !raised {
delete(wa.voipHandRaises, callID)
}
return raised, wasRaised != raised
}
func (wa *WhatsAppClient) clearWhatsAppRemoteHandRaises(callID string) {
wa.voipHandBridgeLock.Lock()
defer wa.voipHandBridgeLock.Unlock()
wa.voipHandRaiseLock.Lock()
delete(wa.voipHandRaises, callID)
wa.voipHandRaiseLock.Unlock()
}
func (wa *WhatsAppClient) isOwnWhatsAppCallParticipant(participant types.JID) bool {
if participant.IsEmpty() || wa == nil || wa.Client == nil || wa.Client.Store == nil {
return false
}
device := wa.Client.Store
participant = participant.ToNonAD()
return participant == device.GetLID().ToNonAD() ||
participant == device.GetJID().ToNonAD()
}

View file

@ -0,0 +1,485 @@
package connector
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/purpshell/meowcaller"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-whatsapp/pkg/connector/voip"
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
)
func TestShouldStartOutboundMatrixRTCCall(t *testing.T) {
evt := matrixRTCMemberEvent(event.StateEventType)
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if !shouldStartOutboundMatrixRTCCall(evt, parsed, "auto") {
t.Fatalf("shouldStartOutboundMatrixRTCCall returned false for active state membership")
}
}
func TestMatrixRTCSenderMustOwnPortalLogin(t *testing.T) {
login := &bridgev2.UserLogin{
User: &bridgev2.User{
User: &database.User{MXID: "@alice:example.com"},
},
}
if !matrixRTCSenderOwnsLogin("@alice:example.com", login) {
t.Fatal("matrixRTCSenderOwnsLogin rejected the login owner")
}
if matrixRTCSenderOwnsLogin("@mallory:example.com", login) {
t.Fatal("matrixRTCSenderOwnsLogin accepted another Matrix user")
}
if matrixRTCSenderOwnsLogin("@alice:example.com", nil) {
t.Fatal("matrixRTCSenderOwnsLogin accepted a missing login")
}
}
func TestOwnWhatsAppCallParticipantMatchesPhoneAndLID(t *testing.T) {
phone := types.NewJID("15550000001", types.DefaultUserServer)
lid := types.NewJID("111", types.HiddenUserServer)
client := &WhatsAppClient{
Client: &whatsmeow.Client{
Store: &store.Device{ID: &phone, LID: lid},
},
}
if !client.isOwnWhatsAppCallParticipant(phone) {
t.Fatal("phone JID was not recognized as the local WhatsApp call participant")
}
if !client.isOwnWhatsAppCallParticipant(lid) {
t.Fatal("LID was not recognized as the local WhatsApp call participant")
}
if client.isOwnWhatsAppCallParticipant(types.NewJID("222", types.HiddenUserServer)) {
t.Fatal("remote LID was recognized as the local WhatsApp call participant")
}
}
func TestIncomingGroupCallUsesGroupPortalAndConsumesCorrelation(t *testing.T) {
client := &WhatsAppClient{incomingCallGroups: make(map[string]incomingCallGroup)}
group := types.NewJID("120363000000000000", types.GroupServer)
fallback := types.NewJID("15550000001", types.DefaultUserServer)
client.trackIncomingCallGroup("CALL", group)
if got := client.incomingCallPortalPeer("CALL", fallback); got != group {
t.Fatalf("incoming portal peer = %s, want group %s", got, group)
}
if got := client.incomingCallPortalPeer("CALL", fallback); got != fallback {
t.Fatalf("consumed incoming portal peer = %s, want fallback %s", got, fallback)
}
}
func TestIncomingCallEventTracksOfferGroupBeforeManagedCall(t *testing.T) {
client := &WhatsAppClient{incomingCallGroups: make(map[string]incomingCallGroup)}
group := types.NewJID("120363000000000000", types.GroupServer)
client.trackIncomingCallEvent(&events.CallOffer{
BasicCallMeta: types.BasicCallMeta{
CallID: "CALL",
GroupJID: group,
},
})
if got := client.incomingCallPortalPeer("CALL", types.EmptyJID); got != group {
t.Fatalf("incoming portal peer = %s, want group %s", got, group)
}
}
func TestIncomingCallGroupIgnoresNonGroupJID(t *testing.T) {
client := &WhatsAppClient{incomingCallGroups: make(map[string]incomingCallGroup)}
direct := types.NewJID("15550000001", types.DefaultUserServer)
client.trackIncomingCallGroup("CALL", direct)
if got := client.incomingCallPortalPeer("CALL", direct); got != direct {
t.Fatalf("incoming portal peer = %s, want direct fallback %s", got, direct)
}
if len(client.incomingCallGroups) != 0 {
t.Fatalf("tracked non-group calls = %d, want 0", len(client.incomingCallGroups))
}
}
func TestIncomingCallGroupIgnoresExpiredCorrelation(t *testing.T) {
group := types.NewJID("120363000000000000", types.GroupServer)
fallback := types.NewJID("15550000001", types.DefaultUserServer)
client := &WhatsAppClient{incomingCallGroups: map[string]incomingCallGroup{
"CALL": {
JID: group,
ExpiresAt: time.Now().Add(-time.Second),
},
}}
if got := client.incomingCallPortalPeer("CALL", fallback); got != fallback {
t.Fatalf("expired incoming portal peer = %s, want fallback %s", got, fallback)
}
}
func TestFormatWaitingRoomNotice(t *testing.T) {
waiting := formatWaitingRoomNotice(meowcaller.WaitingRoomState{
Enabled: true,
InWaitingRoom: true,
})
if !strings.Contains(waiting, "Waiting for approval") {
t.Fatalf("waiting-room self notice = %q", waiting)
}
participants := formatWaitingRoomNotice(meowcaller.WaitingRoomState{
Enabled: true,
Users: []meowcaller.WaitingRoomUser{
{
JID: types.NewJID("222", types.HiddenUserServer),
PN: types.NewJID("15550000002", types.DefaultUserServer),
},
{JID: types.NewJID("111", types.HiddenUserServer)},
},
})
if !strings.Contains(participants, "2 participant(s)") ||
!strings.Contains(participants, "111@lid, 15550000002@s.whatsapp.net") {
t.Fatalf("waiting-room participant notice = %q", participants)
}
}
func TestShouldStartOutboundMatrixRTCCallRejectsMessageMembership(t *testing.T) {
evt := matrixRTCMemberEvent(event.MessageEventType)
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if shouldStartOutboundMatrixRTCCall(evt, parsed, "auto") {
t.Fatalf("shouldStartOutboundMatrixRTCCall returned true for message membership")
}
}
func TestShouldStartOutboundMatrixRTCCallRejectsActivePreviousState(t *testing.T) {
evt := matrixRTCMemberEvent(event.StateEventType)
evt.Unsigned.PrevContent = &event.Content{Raw: matrixRTCMemberContent()}
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if shouldStartOutboundMatrixRTCCall(evt, parsed, "auto") {
t.Fatalf("shouldStartOutboundMatrixRTCCall returned true for an active-to-active state update")
}
}
func TestMatrixRTCTriggerStateKeyUsesEventStateKey(t *testing.T) {
evt := matrixRTCMemberEvent(event.StateEventType)
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if stateKey := matrixRTCTriggerStateKey(parsed); stateKey != "@alice:example.com_DEVICE" {
t.Fatalf("state key = %q, want event state key", stateKey)
}
}
func TestMatrixRTCTriggerStateKeyFallsBackToSenderAndSession(t *testing.T) {
parsed := voip.MatrixRTCEvent{
Sender: "@alice:example.com",
SessionID: "SESSION",
}
if stateKey := matrixRTCTriggerStateKey(parsed); stateKey != "@alice:example.com_SESSION" {
t.Fatalf("state key = %q, want sender/session-derived key", stateKey)
}
}
func TestMatrixRTCTriggerStickyKeyPrefersContentStickyKey(t *testing.T) {
parsed := voip.MatrixRTCEvent{
Sender: "@alice:example.com",
SessionID: "SESSION",
Raw: map[string]any{
"sticky_key": "sticky",
},
}
if stickyKey := matrixRTCTriggerStickyKey(parsed); stickyKey != "sticky" {
t.Fatalf("sticky key = %q, want content sticky key", stickyKey)
}
}
func TestMatrixRTCTriggerStickyKeyFallsBackToParticipantID(t *testing.T) {
parsed := voip.MatrixRTCEvent{
Sender: "@alice:example.com",
DeviceID: "DEVICE",
Raw: map[string]any{},
}
if stickyKey := matrixRTCTriggerStickyKey(parsed); stickyKey != "@alice:example.com:DEVICE" {
t.Fatalf("sticky key = %q, want participant id", stickyKey)
}
}
func TestMatrixRTCOutboundMediaKindDefaultsToAudio(t *testing.T) {
mediaKind, downgraded := matrixRTCOutboundMediaKind(voip.MatrixRTCEvent{})
if mediaKind != "audio" {
t.Fatalf("mediaKind = %q, want audio", mediaKind)
}
if downgraded {
t.Fatalf("downgraded = true, want false")
}
}
func TestMatrixRTCOutboundMediaKindKeepsAudio(t *testing.T) {
mediaKind, downgraded := matrixRTCOutboundMediaKind(voip.MatrixRTCEvent{Intent: "audio"})
if mediaKind != "audio" {
t.Fatalf("mediaKind = %q, want audio", mediaKind)
}
if downgraded {
t.Fatalf("downgraded = true, want false")
}
}
func TestMatrixRTCOutboundMediaKindKeepsVideo(t *testing.T) {
mediaKind, downgraded := matrixRTCOutboundMediaKind(voip.MatrixRTCEvent{Intent: "video"})
if mediaKind != "video" {
t.Fatalf("mediaKind = %q, want video", mediaKind)
}
if downgraded {
t.Fatalf("downgraded = true, want false")
}
}
func TestMatrixRTCOutboundMediaKindRejectsUnknown(t *testing.T) {
mediaKind, downgraded := matrixRTCOutboundMediaKind(voip.MatrixRTCEvent{Intent: "screen"})
if mediaKind != "" {
t.Fatalf("mediaKind = %q, want empty", mediaKind)
}
if downgraded {
t.Fatalf("downgraded = true, want false")
}
}
func TestShouldEndMatrixRTCCallFromLegacyMembershipLeave(t *testing.T) {
stateKey := "_@alice:example.com_DEVICE_m.call"
evt := &event.Event{
Type: voip.GroupCallMemberEventType(),
RoomID: id.RoomID("!room:example.com"),
Sender: id.UserID("@alice:example.com"),
StateKey: &stateKey,
Content: event.Content{Raw: map[string]any{}},
}
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if !shouldEndMatrixRTCCallFromMembership(parsed, "@alice:example.com:DEVICE") {
t.Fatalf("shouldEndMatrixRTCCallFromMembership returned false for selected participant leave")
}
}
func TestShouldEndMatrixRTCCallFromMembershipKeepsActiveJoin(t *testing.T) {
evt := matrixRTCMemberEvent(event.StateEventType)
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if shouldEndMatrixRTCCallFromMembership(parsed, "@alice:example.com:DEVICE") {
t.Fatalf("shouldEndMatrixRTCCallFromMembership returned true for active join")
}
}
func TestShouldEndMatrixRTCCallFromMembershipRejectsOtherParticipant(t *testing.T) {
stateKey := "_@alice:example.com_OTHER_m.call"
evt := &event.Event{
Type: voip.GroupCallMemberEventType(),
RoomID: id.RoomID("!room:example.com"),
Sender: id.UserID("@alice:example.com"),
StateKey: &stateKey,
Content: event.Content{Raw: map[string]any{}},
}
parsed, ok := voip.ParseMatrixRTCEvent(evt)
if !ok {
t.Fatalf("ParseMatrixRTCEvent did not recognize membership")
}
if shouldEndMatrixRTCCallFromMembership(parsed, "@alice:example.com:DEVICE") {
t.Fatalf("shouldEndMatrixRTCCallFromMembership returned true for another participant")
}
}
func TestMatrixRTCPortalSupportsWhatsAppGroupCalls(t *testing.T) {
peer := types.NewJID("120363000000000000", types.GroupServer)
if !matrixRTCPortalSupportsWhatsAppCalls(peer) {
t.Fatal("matrixRTCPortalSupportsWhatsAppCalls rejected a WhatsApp group portal")
}
}
func TestMatrixRTCControlEventMatchesSelectedMembership(t *testing.T) {
call := &wadb.MatrixRTCCall{
SelectedPublisherID: "@alice:example.com:DEVICE",
SelectedMembershipEventID: "$membership",
}
reaction := voip.MatrixRTCEvent{
Kind: voip.MatrixRTCEventKindCallReaction,
Sender: "@alice:example.com",
RelatesToEventID: "$membership",
}
if !matrixRTCControlEventMatchesCall(reaction, call) {
t.Fatal("reaction did not match its selected MatrixRTC membership")
}
reaction.Sender = "@mallory:example.com"
if matrixRTCControlEventMatchesCall(reaction, call) {
t.Fatal("reaction from another Matrix user matched the selected membership")
}
}
func TestWhatsAppRemoteHandRaisesAreAggregated(t *testing.T) {
wa := &WhatsAppClient{}
alice := types.NewJID("111", types.DefaultUserServer)
bob := types.NewJID("222", types.DefaultUserServer)
raised, changed := wa.updateWhatsAppRemoteHandRaise("call", meowcaller.HandRaiseState{Participant: alice, Raised: true})
if !raised || !changed {
t.Fatalf("first raise = %t, %t, want true, true", raised, changed)
}
raised, changed = wa.updateWhatsAppRemoteHandRaise("call", meowcaller.HandRaiseState{Participant: bob, Raised: true})
if !raised || changed {
t.Fatalf("second raise = %t, %t, want true, false", raised, changed)
}
raised, changed = wa.updateWhatsAppRemoteHandRaise("call", meowcaller.HandRaiseState{Participant: alice, Raised: false})
if !raised || changed {
t.Fatalf("first lower = %t, %t, want true, false", raised, changed)
}
raised, changed = wa.updateWhatsAppRemoteHandRaise("call", meowcaller.HandRaiseState{Participant: bob, Raised: false})
if raised || !changed {
t.Fatalf("last lower = %t, %t, want false, true", raised, changed)
}
}
func TestReserveMatrixRTCOutboundStartSuppressesDuplicates(t *testing.T) {
wa := &WhatsAppConnector{}
if !wa.reserveMatrixRTCOutboundStart("!room:example.com") {
t.Fatalf("first reserve returned false")
}
if wa.reserveMatrixRTCOutboundStart("!room:example.com") {
t.Fatalf("second reserve returned true, want false")
}
if !wa.reserveMatrixRTCOutboundStart("!other:example.com") {
t.Fatalf("reserve for another room returned false")
}
}
func TestMatrixRTCFinalEndReasonPreservesActivationFailure(t *testing.T) {
reason, lastErr := matrixRTCFinalEndReason(&wadb.MatrixRTCCall{
EndedTS: time.Unix(123, 0),
EndReason: "livekit_bridge_failed",
LastError: "could not connect after timeout",
}, "rejected")
if reason != "livekit_bridge_failed" {
t.Fatalf("reason = %q, want livekit_bridge_failed", reason)
}
if lastErr != "could not connect after timeout" {
t.Fatalf("lastErr = %q, want timeout error", lastErr)
}
}
func TestMatrixRTCFinalEndReasonUsesWhatsAppReasonForFreshEnd(t *testing.T) {
reason, lastErr := matrixRTCFinalEndReason(&wadb.MatrixRTCCall{}, "rejected")
if reason != "rejected" {
t.Fatalf("reason = %q, want rejected", reason)
}
if lastErr != "" {
t.Fatalf("lastErr = %q, want empty", lastErr)
}
}
func TestMatrixRTCLiveKitAuthRequestUsesStrictJWTFields(t *testing.T) {
req := matrixRTCLiveKitAuthRequest(&wadb.MatrixRTCCall{
RoomID: "!room:example.com",
MatrixParticipantMXID: "@whatsapp_123:example.com",
MatrixSessionID: "WA123",
}, voip.MatrixOpenIDToken{AccessToken: "openid"})
body, err := json.Marshal(req)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}
for _, field := range []string{
`"device_id"`,
`"session_id"`,
`"participant_id"`,
`"focus_type"`,
`"extra"`,
} {
if bytes.Contains(body, []byte(field)) {
t.Fatalf("request body contains strict-JWT-incompatible field %s: %s", field, string(body))
}
}
if req.RoomID != "!room:example.com" || req.SlotID != voip.MatrixRTCDefaultSlotID {
t.Fatalf("unexpected room or slot in request: %+v", req)
}
if req.Member == nil || req.Member.ID != "@whatsapp_123:example.com:WA123" {
t.Fatalf("unexpected member in request: %+v", req.Member)
}
}
func TestMatrixRTCLegacyLiveKitAuthRequestUsesRoomAndDevice(t *testing.T) {
req := matrixRTCLegacyLiveKitAuthRequest(&wadb.MatrixRTCCall{
RoomID: "!room:example.com",
MatrixSessionID: "WA123",
}, voip.MatrixOpenIDToken{AccessToken: "openid"})
body, err := json.Marshal(req)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}
if !bytes.Contains(body, []byte(`"room":"!room:example.com"`)) {
t.Fatalf("request body missing legacy room: %s", string(body))
}
if !bytes.Contains(body, []byte(`"device_id":"WA123"`)) {
t.Fatalf("request body missing legacy device_id: %s", string(body))
}
if bytes.Contains(body, []byte(`"member"`)) || bytes.Contains(body, []byte(`"slot_id"`)) {
t.Fatalf("legacy request body contains modern fields: %s", string(body))
}
}
func TestMatrixRTCCompatModeConfigValues(t *testing.T) {
tests := []struct {
mode string
wantModern bool
wantLegacy bool
}{
{mode: "auto", wantModern: true, wantLegacy: true},
{mode: "msc4143", wantModern: true, wantLegacy: false},
{mode: "msc3401", wantModern: false, wantLegacy: true},
}
for _, tt := range tests {
t.Run(tt.mode, func(t *testing.T) {
if got := matrixRTCCompatAllowsModern(tt.mode); got != tt.wantModern {
t.Fatalf("matrixRTCCompatAllowsModern(%q) = %v, want %v", tt.mode, got, tt.wantModern)
}
if got := matrixRTCCompatAllowsLegacy(tt.mode); got != tt.wantLegacy {
t.Fatalf("matrixRTCCompatAllowsLegacy(%q) = %v, want %v", tt.mode, got, tt.wantLegacy)
}
})
}
}
func matrixRTCMemberEvent(class event.TypeClass) *event.Event {
stateKey := "@alice:example.com_DEVICE"
return &event.Event{
Type: voip.RTCMembershipEventType(class),
RoomID: id.RoomID("!room:example.com"),
Sender: id.UserID("@alice:example.com"),
StateKey: &stateKey,
Content: event.Content{Raw: matrixRTCMemberContent()},
}
}
func matrixRTCMemberContent() map[string]any {
return voip.BuildRTCMembershipContent(voip.MatrixRTCSession{
UserID: "@alice:example.com",
DeviceID: "DEVICE",
Intent: "audio",
Focus: voip.Focus{
Type: "livekit",
LiveKitServiceURL: "https://rtc.example.com/jwt",
},
})
}

151
pkg/connector/voip/audio.go Normal file
View file

@ -0,0 +1,151 @@
package voip
import (
"errors"
"fmt"
"io"
"math"
"sync"
lkmedia "github.com/livekit/media-sdk"
"github.com/purpshell/meowcaller"
)
var ErrAudioSourceClosed = errors.New("voip: audio source closed")
func Float32FrameToPCM16(frame []float32) lkmedia.PCM16Sample {
sample := make(lkmedia.PCM16Sample, len(frame))
for i, value := range frame {
switch {
case value > 1:
value = 1
case value < -1:
value = -1
}
if value == 1 {
sample[i] = math.MaxInt16
} else {
sample[i] = int16(value * 32768)
}
}
return sample
}
func PCM16ToFloat32Frame(sample lkmedia.PCM16Sample) []float32 {
frame := make([]float32, len(sample))
for i, value := range sample {
frame[i] = float32(value) / 32768
}
return frame
}
type LiveKitPCMWriter struct {
mu sync.RWMutex
track interface {
WriteSample(lkmedia.PCM16Sample) error
}
closed bool
}
func NewLiveKitPCMWriter(track interface {
WriteSample(lkmedia.PCM16Sample) error
}) *LiveKitPCMWriter {
return &LiveKitPCMWriter{track: track}
}
func (w *LiveKitPCMWriter) WriteFrame(frame []float32) error {
w.mu.RLock()
defer w.mu.RUnlock()
if w.closed {
return ErrAudioSourceClosed
}
if w.track == nil {
return nil
}
return w.track.WriteSample(Float32FrameToPCM16(frame))
}
func (w *LiveKitPCMWriter) Close() error {
w.mu.Lock()
w.closed = true
w.track = nil
w.mu.Unlock()
return nil
}
type MeowcallerAudioSource struct {
mu sync.Mutex
cond *sync.Cond
queue []float32
closed bool
maxSize int
}
func NewMeowcallerAudioSource(maxFrames int) *MeowcallerAudioSource {
if maxFrames <= 0 {
maxFrames = 8
}
src := &MeowcallerAudioSource{
maxSize: maxFrames * meowcaller.FrameSamples,
}
src.cond = sync.NewCond(&src.mu)
return src
}
func (src *MeowcallerAudioSource) WriteSample(sample lkmedia.PCM16Sample) error {
src.mu.Lock()
defer src.mu.Unlock()
if src.closed {
return ErrAudioSourceClosed
}
frame := PCM16ToFloat32Frame(sample)
src.queue = append(src.queue, frame...)
if len(src.queue) > src.maxSize {
copy(src.queue, src.queue[len(src.queue)-src.maxSize:])
src.queue = src.queue[:src.maxSize]
}
src.cond.Signal()
return nil
}
func (src *MeowcallerAudioSource) SampleRate() int {
return meowcaller.SampleRate
}
func (src *MeowcallerAudioSource) String() string {
return fmt.Sprintf("MeowcallerAudioSource(%d)", meowcaller.SampleRate)
}
func (src *MeowcallerAudioSource) ReadFrame() ([]float32, error) {
src.mu.Lock()
defer src.mu.Unlock()
for len(src.queue) < meowcaller.FrameSamples && !src.closed {
src.cond.Wait()
}
if len(src.queue) == 0 && src.closed {
return nil, io.EOF
}
frame := make([]float32, meowcaller.FrameSamples)
n := copy(frame, src.queue)
if n == len(src.queue) {
src.queue = src.queue[:0]
} else {
copy(src.queue, src.queue[n:])
src.queue = src.queue[:len(src.queue)-n]
}
return frame, nil
}
func (src *MeowcallerAudioSource) Close() error {
src.mu.Lock()
src.closed = true
src.queue = nil
src.cond.Broadcast()
src.mu.Unlock()
return nil
}
var (
_ meowcaller.AudioSink = (*LiveKitPCMWriter)(nil)
_ meowcaller.AudioSource = (*MeowcallerAudioSource)(nil)
)

View file

@ -0,0 +1,49 @@
package voip
import (
"io"
"math"
"testing"
lkmedia "github.com/livekit/media-sdk"
"github.com/purpshell/meowcaller"
)
func TestFloat32FrameToPCM16ClipsAndScales(t *testing.T) {
frame := []float32{-2, -1, -0.5, 0, 0.5, 1, 2}
sample := Float32FrameToPCM16(frame)
expected := lkmedia.PCM16Sample{math.MinInt16, math.MinInt16, -16384, 0, 16384, math.MaxInt16, math.MaxInt16}
for i, value := range expected {
if sample[i] != value {
t.Fatalf("sample[%d] = %d, want %d", i, sample[i], value)
}
}
}
func TestMeowcallerAudioSourceFramesAndEOF(t *testing.T) {
src := NewMeowcallerAudioSource(1)
sample := make(lkmedia.PCM16Sample, meowcaller.FrameSamples)
for i := range sample {
sample[i] = int16(i)
}
if err := src.WriteSample(sample); err != nil {
t.Fatalf("WriteSample returned error: %v", err)
}
frame, err := src.ReadFrame()
if err != nil {
t.Fatalf("ReadFrame returned error: %v", err)
}
if len(frame) != meowcaller.FrameSamples {
t.Fatalf("frame length = %d, want %d", len(frame), meowcaller.FrameSamples)
}
if frame[1] != float32(1)/32768 {
t.Fatalf("frame[1] = %f, want %f", frame[1], float32(1)/32768)
}
if err = src.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
_, err = src.ReadFrame()
if err != io.EOF {
t.Fatalf("ReadFrame after close returned %v, want io.EOF", err)
}
}

View file

@ -0,0 +1,54 @@
package voip
import "time"
type Config struct {
Enabled bool
IncomingPolicy string
MaxActiveCallsPerLogin int
MatrixRTC MatrixRTCConfig
LiveKit LiveKitConfig
Audio AudioConfig
Video VideoConfig
Diagnostics DiagnosticsConfig
}
type MatrixRTCConfig struct {
LiveKitServiceURL string
RequireLiveKitFocus bool
MembershipEventCompat string
NotificationEventCompat string
UseDelayedEvents bool
ParticipantMode string
FallbackParticipantMXID string
}
type LiveKitConfig struct {
ConnectTimeout time.Duration
PublishSilenceBeforeWhatsAppAnswer bool
AutoSubscribe bool
AudioUplinkPolicy string
SelectedParticipantTimeout time.Duration
}
type AudioConfig struct {
Enabled bool
JitterBuffer time.Duration
OpusBackend string
SilenceOnUnderrun bool
MaxMixParticipants int
}
type VideoConfig struct {
Enabled bool
SelectedSourcePolicy string
MaxWidth int
MaxHeight int
MaxFPS int
}
type DiagnosticsConfig struct {
HealthcheckFocusOnStartup bool
EnableMeowcallerDiagnostics bool
MediaTraceDir string
}

View file

@ -0,0 +1,6 @@
package voip
import "errors"
var ErrNotEnabled = errors.New("voip: MatrixRTC LiveKit bridge is not enabled")
var ErrCallNotFound = errors.New("voip: call not found")

241
pkg/connector/voip/focus.go Normal file
View file

@ -0,0 +1,241 @@
package voip
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const FocusWellKnownKey = "org.matrix.msc4143.rtc_foci"
var ErrNoLiveKitFocus = errors.New("voip: no LiveKit MatrixRTC focus found")
type Focus struct {
Type string `json:"type"`
LiveKitServiceURL string `json:"livekit_service_url"`
}
type WellKnownClient struct {
RTCFoci []Focus `json:"org.matrix.msc4143.rtc_foci"`
}
func DiscoverLiveKitFocus(ctx context.Context, httpClient *http.Client, serverName, overrideURL string) (*Focus, error) {
if overrideURL != "" {
if err := validateHTTPSURL(overrideURL); err != nil {
return nil, fmt.Errorf("invalid configured livekit service URL: %w", err)
}
return &Focus{Type: "livekit", LiveKitServiceURL: overrideURL}, nil
}
if serverName == "" {
return nil, fmt.Errorf("matrix server name is required")
}
if strings.Contains(serverName, "://") {
return nil, fmt.Errorf("matrix server name must not include a scheme")
}
if httpClient == nil {
httpClient = http.DefaultClient
}
wellKnownURL := "https://" + serverName + "/.well-known/matrix/client"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch Matrix client well-known: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Matrix client well-known returned HTTP %d", resp.StatusCode)
}
var wellKnown WellKnownClient
if err = json.NewDecoder(resp.Body).Decode(&wellKnown); err != nil {
return nil, fmt.Errorf("failed to decode Matrix client well-known: %w", err)
}
for _, focus := range wellKnown.RTCFoci {
if focus.Type != "livekit" || focus.LiveKitServiceURL == "" {
continue
}
if err = validateHTTPSURL(focus.LiveKitServiceURL); err != nil {
return nil, fmt.Errorf("invalid livekit focus URL in well-known: %w", err)
}
return &focus, nil
}
return nil, ErrNoLiveKitFocus
}
type MatrixOpenIDToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
MatrixServerName string `json:"matrix_server_name"`
ExpiresIn int `json:"expires_in"`
}
type LiveKitAuthRequest struct {
RoomID string `json:"room_id,omitempty"`
SlotID string `json:"slot_id,omitempty"`
OpenIDToken MatrixOpenIDToken `json:"openid_token"`
Member *LiveKitAuthMember `json:"member,omitempty"`
DeviceID string `json:"device_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
ParticipantID string `json:"participant_id,omitempty"`
FocusType string `json:"focus_type,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
type LegacyLiveKitAuthRequest struct {
Room string `json:"room"`
OpenIDToken MatrixOpenIDToken `json:"openid_token"`
DeviceID string `json:"device_id"`
}
type LiveKitAuthMember struct {
ID string `json:"id,omitempty"`
ClaimedDeviceID string `json:"claimed_device_id,omitempty"`
ClaimedUserID string `json:"claimed_user_id,omitempty"`
}
type LiveKitAuthResponse struct {
URL string `json:"url,omitempty"`
Token string `json:"token,omitempty"`
JWTToken string `json:"jwt,omitempty"`
RoomName string `json:"room,omitempty"`
ServerURL string `json:"server_url,omitempty"`
LiveKitURL string `json:"livekit_url,omitempty"`
AccessToken string `json:"access_token,omitempty"`
}
func (resp LiveKitAuthResponse) ConnectionURL() string {
for _, candidate := range []string{resp.URL, resp.ServerURL, resp.LiveKitURL} {
if candidate != "" {
return candidate
}
}
return ""
}
func (resp LiveKitAuthResponse) JWT() string {
for _, candidate := range []string{resp.Token, resp.JWTToken, resp.AccessToken} {
if candidate != "" {
return candidate
}
}
return ""
}
func RequestLiveKitAuth(ctx context.Context, httpClient *http.Client, liveKitServiceURL string, authReq LiveKitAuthRequest) (*LiveKitAuthResponse, error) {
if err := validateHTTPSURL(liveKitServiceURL); err != nil {
return nil, fmt.Errorf("invalid livekit service URL: %w", err)
}
if httpClient == nil {
httpClient = http.DefaultClient
}
body, err := json.Marshal(authReq)
if err != nil {
return nil, err
}
var lastErr error
for _, endpoint := range liveKitAuthEndpoints(liveKitServiceURL) {
resp, err := postLiveKitAuth(ctx, httpClient, endpoint, body)
if err != nil {
lastErr = err
continue
}
if resp.ConnectionURL() == "" || resp.JWT() == "" {
return nil, fmt.Errorf("livekit auth response did not include both URL and token")
}
return resp, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("livekit auth did not try any endpoints")
}
func RequestLegacyLiveKitAuth(ctx context.Context, httpClient *http.Client, liveKitServiceURL string, authReq LegacyLiveKitAuthRequest) (*LiveKitAuthResponse, error) {
if err := validateHTTPSURL(liveKitServiceURL); err != nil {
return nil, fmt.Errorf("invalid livekit service URL: %w", err)
}
if httpClient == nil {
httpClient = http.DefaultClient
}
body, err := json.Marshal(authReq)
if err != nil {
return nil, err
}
resp, err := postLiveKitAuth(ctx, httpClient, legacyLiveKitAuthEndpoint(liveKitServiceURL), body)
if err != nil {
return nil, err
}
if resp.ConnectionURL() == "" || resp.JWT() == "" {
return nil, fmt.Errorf("livekit auth response did not include both URL and token")
}
return resp, nil
}
func liveKitAuthEndpoints(rawURL string) []string {
trimmed := strings.TrimRight(rawURL, "/")
if strings.HasSuffix(trimmed, "/get_token") || strings.HasSuffix(trimmed, "/sfu/get") {
return []string{trimmed}
}
return []string{
trimmed + "/get_token",
trimmed + "/sfu/get",
trimmed,
}
}
func legacyLiveKitAuthEndpoint(rawURL string) string {
trimmed := strings.TrimRight(rawURL, "/")
if strings.HasSuffix(trimmed, "/sfu/get") {
return trimmed
}
if strings.HasSuffix(trimmed, "/get_token") {
return strings.TrimSuffix(trimmed, "/get_token") + "/sfu/get"
}
return trimmed + "/sfu/get"
}
func postLiveKitAuth(ctx context.Context, httpClient *http.Client, liveKitServiceURL string, body []byte) (*LiveKitAuthResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, liveKitServiceURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request livekit token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("livekit auth returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var authResp LiveKitAuthResponse
if err = json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return nil, fmt.Errorf("failed to decode livekit auth response: %w", err)
}
return &authResp, nil
}
func validateHTTPSURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return err
}
if parsed.Scheme != "https" && parsed.Scheme != "wss" {
return fmt.Errorf("URL must use https or wss")
}
if parsed.Host == "" {
return fmt.Errorf("URL must include a host")
}
return nil
}

View file

@ -0,0 +1,131 @@
package voip
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestDiscoverLiveKitFocusOverride(t *testing.T) {
focus, err := DiscoverLiveKitFocus(context.Background(), nil, "", "https://rtc.example.com/livekit/jwt")
if err != nil {
t.Fatalf("DiscoverLiveKitFocus returned error: %v", err)
}
if focus.Type != "livekit" || focus.LiveKitServiceURL != "https://rtc.example.com/livekit/jwt" {
t.Fatalf("unexpected focus: %+v", focus)
}
}
func TestRequestLiveKitAuthAcceptsResponseAliases(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/get_token" {
t.Fatalf("path = %q, want /get_token", r.URL.Path)
}
var req LiveKitAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request: %v", err)
}
if req.OpenIDToken.AccessToken != "openid" {
t.Fatalf("OpenID token = %q, want openid", req.OpenIDToken.AccessToken)
}
_ = json.NewEncoder(w).Encode(LiveKitAuthResponse{
ServerURL: "wss://livekit.example.com",
JWTToken: "jwt",
})
}))
defer server.Close()
resp, err := RequestLiveKitAuth(context.Background(), server.Client(), server.URL, LiveKitAuthRequest{
OpenIDToken: MatrixOpenIDToken{AccessToken: "openid"},
})
if err != nil {
t.Fatalf("RequestLiveKitAuth returned error: %v", err)
}
if resp.ConnectionURL() != "wss://livekit.example.com" || resp.JWT() != "jwt" {
t.Fatalf("unexpected response aliases: %+v", resp)
}
}
func TestRequestLegacyLiveKitAuthUsesSFUEndpoint(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sfu/get" {
t.Fatalf("path = %q, want /sfu/get", r.URL.Path)
}
var req LegacyLiveKitAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request: %v", err)
}
if req.Room != "!room:example.com" || req.DeviceID != "DEVICE" {
t.Fatalf("unexpected legacy request: %+v", req)
}
_ = json.NewEncoder(w).Encode(LiveKitAuthResponse{
URL: "wss://livekit.example.com",
Token: "jwt",
})
}))
defer server.Close()
resp, err := RequestLegacyLiveKitAuth(context.Background(), server.Client(), server.URL, LegacyLiveKitAuthRequest{
Room: "!room:example.com",
DeviceID: "DEVICE",
OpenIDToken: MatrixOpenIDToken{AccessToken: "openid", MatrixServerName: "example.com"},
})
if err != nil {
t.Fatalf("RequestLegacyLiveKitAuth returned error: %v", err)
}
if resp.ConnectionURL() != "wss://livekit.example.com" || resp.JWT() != "jwt" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestLiveKitAuthEndpoints(t *testing.T) {
tests := []struct {
name string
url string
want []string
}{
{
name: "base",
url: "https://rtc.example.com/livekit/jwt",
want: []string{
"https://rtc.example.com/livekit/jwt/get_token",
"https://rtc.example.com/livekit/jwt/sfu/get",
"https://rtc.example.com/livekit/jwt",
},
},
{
name: "explicit",
url: "https://rtc.example.com/livekit/jwt/sfu/get",
want: []string{"https://rtc.example.com/livekit/jwt/sfu/get"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := liveKitAuthEndpoints(test.url)
if len(got) != len(test.want) {
t.Fatalf("got %d endpoints, want %d: %+v", len(got), len(test.want), got)
}
for i := range got {
if got[i] != test.want[i] {
t.Fatalf("endpoint %d = %q, want %q", i, got[i], test.want[i])
}
}
})
}
}
func TestLegacyLiveKitAuthEndpoint(t *testing.T) {
tests := map[string]string{
"https://rtc.example.com/livekit/jwt": "https://rtc.example.com/livekit/jwt/sfu/get",
"https://rtc.example.com/livekit/jwt/": "https://rtc.example.com/livekit/jwt/sfu/get",
"https://rtc.example.com/livekit/jwt/get_token": "https://rtc.example.com/livekit/jwt/sfu/get",
"https://rtc.example.com/livekit/jwt/sfu/get": "https://rtc.example.com/livekit/jwt/sfu/get",
}
for input, want := range tests {
if got := legacyLiveKitAuthEndpoint(input); got != want {
t.Fatalf("legacy endpoint for %q = %q, want %q", input, got, want)
}
}
}

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