From 7effa3aa531a27e44583a63351fa4e1a48aba473 Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Fri, 20 Mar 2026 16:23:29 +0000 Subject: [PATCH 1/7] Add ntgcalls submodule (v2.1.0), bump Go to 1.24 --- .gitmodules | 3 +++ Dockerfile | 3 ++- go.mod | 4 +++- go.sum | 12 ++---------- ntgcalls | 1 + staging.Dockerfile | 3 ++- tdlib.Dockerfile | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 .gitmodules create mode 160000 ntgcalls diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6569705 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ntgcalls"] + path = ntgcalls + url = https://github.com/pytgcalls/ntgcalls diff --git a/Dockerfile b/Dockerfile index c3858e9..c67eaab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19-bookworm AS base +FROM golang:1.24-bookworm AS base RUN apt-get update run apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git @@ -20,6 +20,7 @@ ARG VERSION COPY --from=tdlib /compiled/ /usr/local/ COPY ./ /src RUN git -C /src checkout "${VERSION}" +RUN git -C /src submodule update --init WORKDIR /src RUN go get diff --git a/go.mod b/go.mod index 4eb2643..cca0f28 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module dev.narayana.im/narayana/telegabber -go 1.19 +go 1.24 require ( github.com/dgraph-io/badger/v4 v4.1.0 @@ -36,3 +36,5 @@ require ( replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 + +replace gotgcalls => ./ntgcalls/examples/go diff --git a/go.sum b/go.sum index 82e391a..ef244b7 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615 h1:RRUZJSro+k8FkazNx7QEYLVoO4wZtchvsd0Y2RBWjeU= -dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= -dev.narayana.im/narayana/go-tdlib v0.0.0-20231111182840-bc2f985e6268 h1:NCbc2bYuUGQsb/3z5SCIia3N34Ktwq3FwaUAfgF/WEU= -dev.narayana.im/narayana/go-tdlib v0.0.0-20231111182840-bc2f985e6268/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 h1:CWAQT74LwQne/3Po5KXDvudu3N0FBWm3XZZZhtl5j2w= dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= -dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f h1:6249ajbMjgYz53Oq0IjTvjHXbxTfu29Mj1J/6swRHs4= -dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= -dev.narayana.im/narayana/go-xmpp v0.0.0-20240131013505-18c46e6c59fd h1:+UW+E7JjI88aH4beDn1cw6D8rs1I061hN91HU4Y4pT8= -dev.narayana.im/narayana/go-xmpp v0.0.0-20240131013505-18c46e6c59fd/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 h1:29/NjOGOUDceO73Hk4Nj4uVa1je8MULJlsDSvKxSN/k= dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -62,6 +54,7 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= @@ -118,8 +111,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zelenin/go-tdlib v0.5.2 h1:inEATEM0Pz6/HBI3wTlhd+brDHpmoXGgwdSb8/V6GiA= -github.com/zelenin/go-tdlib v0.5.2/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw= go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= @@ -200,6 +191,7 @@ gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/ntgcalls b/ntgcalls new file mode 160000 index 0000000..17f7552 --- /dev/null +++ b/ntgcalls @@ -0,0 +1 @@ +Subproject commit 17f755231a3dab27c121153eab4b155639d24fcb diff --git a/staging.Dockerfile b/staging.Dockerfile index 95cdea5..67731c0 100644 --- a/staging.Dockerfile +++ b/staging.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19-bullseye AS base +FROM golang:1.24-bookworm AS base RUN apt-get update RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php @@ -25,6 +25,7 @@ COPY --from=tdlib /compiled/ /usr/local/ WORKDIR /src RUN go env -w GOCACHE=/go-cache RUN go env -w GOMODCACHE=/gomod-cache +# Note: submodules must be initialized on host before docker build (bind mount) RUN --mount=type=cache,target=/gomod-cache \ --mount=type=bind,source=./,target=/src,rw \ /bin/bash -c 'go mod tidy; go get -t' diff --git a/tdlib.Dockerfile b/tdlib.Dockerfile index 5774405..a058991 100644 --- a/tdlib.Dockerfile +++ b/tdlib.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19-bullseye AS base +FROM golang:1.24-bookworm AS base RUN apt-get update RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php From 85c822cad67bd6dbdcf0eefc83f43017dfd5391b Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Fri, 20 Mar 2026 17:58:28 +0000 Subject: [PATCH 2/7] Add ntgcalls C++ build stage and CGO linking --- Dockerfile | 24 ++++++++++++++++++++++++ Makefile | 6 ++++++ docker/resolv_shim.c | 22 ++++++++++++++++++++++ go.mod | 22 +++++++++++++++++++--- go.sum | 41 +++++++++++++++++++++++++++++++++++++---- staging.Dockerfile | 26 ++++++++++++++++++++++++++ telegabber.go | 2 ++ 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 docker/resolv_shim.c diff --git a/Dockerfile b/Dockerfile index c67eaab..9a47de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,30 @@ RUN cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/compiled/ /src/ RUN cmake --build . ${MAKEOPTS} RUN make install +FROM base AS ntgcalls-build +ARG MAKEOPTS +# CMake >= 3.27 required; bookworm ships 3.25 +ARG CMAKE_VERSION=3.31.6 +RUN curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-$(uname -m).tar.gz \ + | tar xz -C /usr/local --strip-components=1 +RUN apt-get install -y libasound2-dev libpulse-dev +COPY ntgcalls/ /ntgcalls-src/ +WORKDIR /ntgcalls-build/ +RUN cmake -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=/ntgcalls-src/cmake/Toolchain.cmake \ + -DPython_EXECUTABLE=$(which python3) \ + -DSTATIC_BUILD=ON \ + /ntgcalls-src/ +RUN cmake --build . ${MAKEOPTS} + FROM base AS cache ARG VERSION COPY --from=tdlib /compiled/ /usr/local/ +COPY --from=ntgcalls-build /ntgcalls-src/static-output/lib/libntgcalls.a /usr/local/lib/ +COPY --from=ntgcalls-build /ntgcalls-src/static-output/include/ /usr/local/include/ +COPY docker/resolv_shim.c /tmp/resolv_shim.c +RUN gcc -c -o /tmp/resolv_shim.o /tmp/resolv_shim.c && \ + ar rcs /usr/local/lib/libresolv_shim.a /tmp/resolv_shim.o COPY ./ /src RUN git -C /src checkout "${VERSION}" RUN git -C /src submodule update --init @@ -26,6 +47,9 @@ RUN go get FROM cache AS build ARG MAKEOPTS +ENV CGO_ENABLED=1 +ENV CGO_CFLAGS="-I/usr/local/include" +ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" WORKDIR /src RUN make ${MAKEOPTS} diff --git a/Makefile b/Makefile index 4db1e45..ea63906 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,14 @@ TD_COMMIT := "5bbfc1cf5dab94f82e02f3430ded7241d4653551" VERSION := "v1.12.8" MAKEOPTS := "-j4" +NTG_LIB_DIR ?= /usr/local/lib +NTG_INC_DIR ?= /usr/local/include + all: mkdir -p release + CGO_ENABLED=1 \ + CGO_CFLAGS="-I$(NTG_INC_DIR)" \ + CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" \ go build -ldflags "-X main.commit=${COMMIT}" -o release/telegabber test: diff --git a/docker/resolv_shim.c b/docker/resolv_shim.c new file mode 100644 index 0000000..8ca302f --- /dev/null +++ b/docker/resolv_shim.c @@ -0,0 +1,22 @@ +// resolv_shim.c — Wrappers for glibc-internal resolver symbols. +// +// ntgcalls' CMake (cmake/FindGLib.cmake) downloads a pre-built static glib +// from pytgcalls/glib GitHub releases. That binary was compiled on an older +// glibc (manylinux2014 / CentOS 7) where __dn_expand and __res_nquery were +// linkable symbols. On bookworm's glibc 2.36 they are no longer exposed to +// the linker — only the public POSIX names (dn_expand, res_nquery) are. +// +// Since we don't control the glib build (it's downloaded, not compiled by us), +// the simplest fix is to forward the internal names to the public ones. +#include +#include + +int __dn_expand(const unsigned char *msg, const unsigned char *eom, + const unsigned char *src, char *dst, int dstsiz) { + return dn_expand(msg, eom, src, dst, dstsiz); +} + +int __res_nquery(res_state statp, const char *name, int class, int type, + unsigned char *answer, int anslen) { + return res_nquery(statp, name, class, type, answer, anslen); +} diff --git a/go.mod b/go.mod index cca0f28..837a02a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module dev.narayana.im/narayana/telegabber -go 1.24 +go 1.24.2 require ( github.com/dgraph-io/badger/v4 v4.1.0 @@ -12,10 +12,19 @@ require ( github.com/zelenin/go-tdlib v0.5.2 gopkg.in/yaml.v2 v2.2.4 gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1 + gotgcalls v0.0.0-00010101000000-000000000000 ) require ( + github.com/Laky-64/gologging v1.1.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dustin/go-humanize v1.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -26,9 +35,16 @@ require ( github.com/google/flatbuffers v1.12.1 // indirect github.com/klauspost/compress v1.12.3 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opencensus.io v0.22.5 // indirect - golang.org/x/net v0.7.0 // indirect - golang.org/x/sys v0.5.0 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect nhooyr.io/websocket v1.6.5 // indirect ) diff --git a/go.sum b/go.sum index ef244b7..a257e03 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,24 @@ dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:X dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 h1:29/NjOGOUDceO73Hk4Nj4uVa1je8MULJlsDSvKxSN/k= dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Laky-64/gologging v1.1.0 h1:iV/VgoIbImLrI3EPssOzzoyv1OQrp6t5RtDQTKzUes8= +github.com/Laky-64/gologging v1.1.0/go.mod h1:Ody93tsM0OZUAsWApkfb3rg35fAvyZDAx07kNH13DhI= github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= @@ -15,6 +29,8 @@ github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVz github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM= github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -74,6 +90,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -84,6 +102,12 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +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-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -93,6 +117,8 @@ 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.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis= github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= @@ -109,6 +135,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw= @@ -123,6 +151,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -139,8 +169,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -162,8 +192,11 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= diff --git a/staging.Dockerfile b/staging.Dockerfile index 67731c0..c17ac22 100644 --- a/staging.Dockerfile +++ b/staging.Dockerfile @@ -19,9 +19,32 @@ WORKDIR /build/ RUN cmake --build . ${MAKEOPTS} RUN make install +FROM base AS ntgcalls-build +ARG MAKEOPTS +# CMake >= 3.27 required; bookworm ships 3.25 +ARG CMAKE_VERSION=3.31.6 +RUN curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-$(uname -m).tar.gz \ + | tar xz -C /usr/local --strip-components=1 +RUN apt-get install -y libasound2-dev libpulse-dev +# Note: ntgcalls submodule must be initialized on host before docker build +RUN --mount=type=bind,source=./ntgcalls,target=/ntgcalls-src,rw \ + mkdir -p /ntgcalls-build && cd /ntgcalls-build && \ + cmake -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE=/ntgcalls-src/cmake/Toolchain.cmake \ + -DPython_EXECUTABLE=$(which python3) \ + -DSTATIC_BUILD=ON \ + /ntgcalls-src/ && \ + cmake --build . ${MAKEOPTS} && \ + cp -r /ntgcalls-src/static-output /ntgcalls-output + FROM base AS cache ARG VERSION COPY --from=tdlib /compiled/ /usr/local/ +COPY --from=ntgcalls-build /ntgcalls-output/lib/libntgcalls.a /usr/local/lib/ +COPY --from=ntgcalls-build /ntgcalls-output/include/ /usr/local/include/ +RUN --mount=type=bind,source=./docker/resolv_shim.c,target=/tmp/resolv_shim.c \ + gcc -c -o /tmp/resolv_shim.o /tmp/resolv_shim.c && \ + ar rcs /usr/local/lib/libresolv_shim.a /tmp/resolv_shim.o WORKDIR /src RUN go env -w GOCACHE=/go-cache RUN go env -w GOMODCACHE=/gomod-cache @@ -32,6 +55,9 @@ RUN --mount=type=cache,target=/gomod-cache \ FROM cache AS build ARG MAKEOPTS +ENV CGO_ENABLED=1 +ENV CGO_CFLAGS="-I/usr/local/include" +ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" WORKDIR /src RUN --mount=type=bind,source=./,target=/src,rw \ --mount=type=cache,target=/go-cache \ diff --git a/telegabber.go b/telegabber.go index 8bd46f5..1714e88 100644 --- a/telegabber.go +++ b/telegabber.go @@ -10,6 +10,7 @@ import ( "dev.narayana.im/narayana/telegabber/config" "dev.narayana.im/narayana/telegabber/xmpp" + "gotgcalls/ntgcalls" log "github.com/sirupsen/logrus" "github.com/zelenin/go-tdlib/client" @@ -67,6 +68,7 @@ func main() { SetLogrusLevel(config.XMPP.Loglevel) log.Infof("Starting telegabber version %v", version) + log.Infof("ntgcalls version %v", ntgcalls.Version()) sm, component, err = xmpp.NewComponent(config.XMPP, config.Telegram, *idsPath, version) if err != nil { From 785032583922b8ccf555bd45fdeda05e67dd9f01 Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Sun, 24 May 2026 04:19:29 -0700 Subject: [PATCH 3/7] fix failing tests --- persistence/sessions_test.go | 1 + telegram/loginwizard.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/persistence/sessions_test.go b/persistence/sessions_test.go index 001cfa0..f7daa3f 100644 --- a/persistence/sessions_test.go +++ b/persistence/sessions_test.go @@ -61,6 +61,7 @@ func TestSessionToMap(t *testing.T) { "hideids": "false", "receipts": "true", "nativeedits": "false", + "ignoregroupdeletions": "false", } if !reflect.DeepEqual(m, sample) { t.Errorf("Map does not match the sample: %v", m) diff --git a/telegram/loginwizard.go b/telegram/loginwizard.go index 0e5cc9e..296e748 100644 --- a/telegram/loginwizard.go +++ b/telegram/loginwizard.go @@ -74,7 +74,7 @@ func (c *Client) wizardStageOrPrompt(stage, message string) { log.Debugf("writing wizard stage %v", stage) c.loginWizard.nextStage <- stage } else { - log.Warn("Skipping stage %v, wizard cannot keep up", stage) + log.Warnf("Skipping stage %v, wizard cannot keep up", stage) } c.loginWizard.chanBusy = true c.locks.loginWizardWriteLock.Unlock() From 1e57a14a1971db656582e9a9663c5f219b77a39a Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Sun, 24 May 2026 07:00:52 -0700 Subject: [PATCH 4/7] Resilient session storage: recover on failed startup --- xmpp/component.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/xmpp/component.go b/xmpp/component.go index 784b25f..79fe103 100644 --- a/xmpp/component.go +++ b/xmpp/component.go @@ -5,6 +5,7 @@ import ( "regexp" "strconv" "sync" + "sync/atomic" "time" "dev.narayana.im/narayana/telegabber/badger" @@ -21,6 +22,10 @@ import ( var tgConf config.TelegramConfig var sessions map[string]*telegram.Client var db *persistence.SessionsYamlDB + +// componentEverConnected gates SaveSessions in Close(): a shutdown that +// never connected must not overwrite persisted YAML with an empty map. +var componentEverConnected atomic.Bool var sessionLock sync.Mutex const ( @@ -95,6 +100,7 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri } sm := xmpp.NewStreamManager(component, func(s xmpp.Sender) { + componentEverConnected.Store(true) go heartbeat(component) }) @@ -245,7 +251,9 @@ func SaveSessions() { }, persistence.SessionMarshaller) } -// Close gracefully terminates the component and saves active sessions +// Close gracefully terminates the component and saves active sessions. +// If the component never reached steady state, SaveSessions is skipped +// to avoid overwriting persisted state with the empty-at-startup map. func Close(component *xmpp.Component) { log.Error("Disconnecting...") @@ -256,8 +264,11 @@ func Close(component *xmpp.Component) { } sessionLock.Unlock() - // save sessions - SaveSessions() + if componentEverConnected.Load() { + SaveSessions() + } else { + log.Warn("Close: component never connected, skipping SaveSessions to preserve on-disk state") + } // flush the ids database gateway.IdsDB.Close() From 0e01dab3c1c9598e5c62a54bf9bb17da728c1733 Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Sun, 24 May 2026 08:14:55 -0700 Subject: [PATCH 5/7] add calls support --- Dockerfile | 31 +- Makefile | 13 +- calls/DESIGN.md | 15 + calls/audio/capture.go | 160 ++++ calls/audio/fmtp.go | 142 ++++ calls/audio/fmtp_test.go | 131 ++++ calls/audio/ntg.go | 29 + calls/audio/ntgadapter/adapter.go | 97 +++ calls/audio/opus.go | 127 ++++ calls/audio/opus_test.go | 48 ++ calls/audio/playout.go | 76 ++ calls/audio/playout_test.go | 94 +++ calls/audio/tg_to_xmpp.go | 187 +++++ calls/audio/tg_to_xmpp_test.go | 125 ++++ calls/audio/xmpp_to_tg.go | 233 ++++++ calls/audio/xmpp_to_tg_test.go | 152 ++++ calls/orchestrator.go | 274 +++++++ calls/orchestrator_test.go | 376 ++++++++++ calls/signaling/bridge.go | 249 +++++++ calls/signaling/bridge_test.go | 277 +++++++ calls/signaling/tgsig/adapter.go | 249 +++++++ calls/signaling/tgsig/adapter_race_test.go | 217 ++++++ calls/signaling/tgsig/base.go | 130 ++++ calls/signaling/tgsig/callee.go | 34 + calls/signaling/tgsig/caller.go | 36 + calls/signaling/tgsig/convert.go | 48 ++ calls/signaling/tgsig/manager.go | 207 ++++++ calls/signaling/tgsig/manager_test.go | 304 ++++++++ calls/signaling/timers.go | 48 ++ calls/signaling/xmppsig/adapter.go | 344 +++++++++ calls/signaling/xmppsig/base.go | 128 ++++ calls/signaling/xmppsig/callee.go | 75 ++ calls/signaling/xmppsig/callee_test.go | 224 ++++++ calls/signaling/xmppsig/caller.go | 86 +++ calls/signaling/xmppsig/caller_test.go | 217 ++++++ calls/signaling/xmppsig/extdisco.go | 167 +++++ calls/signaling/xmppsig/extdisco_test.go | 181 +++++ calls/signaling/xmppsig/pion_loop_test.go | 181 +++++ calls/signaling/xmppsig/track_buffer_test.go | 175 +++++ docker/isolate_ntgcalls_crypto.sh | 34 + docker/resolv_shim.c | 8 +- go.mod | 28 +- go.sum | 68 +- telegram/client.go | 51 ++ telegram/connect.go | 26 + telegram/handlers.go | 16 + telegram/utils.go | 6 + xmpp/component.go | 95 ++- xmpp/extensions/extensions.go | 3 + xmpp/handlers.go | 43 ++ xmpp/jingle/candidate_pion.go | 66 ++ xmpp/jingle/convert.go | 225 ++++++ xmpp/jingle/convert_payload.go | 268 +++++++ xmpp/jingle/convert_test.go | 231 ++++++ xmpp/jingle/convert_transport.go | 167 +++++ xmpp/jingle/jmi.go | 81 ++ xmpp/jingle/manager.go | 197 +++++ xmpp/jingle/namespaces.go | 13 + xmpp/jingle/sdp.go | 141 ++++ xmpp/jingle/session.go | 736 +++++++++++++++++++ xmpp/jingle/session_test.go | 260 +++++++ xmpp/jingle/stanzas.go | 210 ++++++ 62 files changed, 8831 insertions(+), 29 deletions(-) create mode 100644 calls/DESIGN.md create mode 100644 calls/audio/capture.go create mode 100644 calls/audio/fmtp.go create mode 100644 calls/audio/fmtp_test.go create mode 100644 calls/audio/ntg.go create mode 100644 calls/audio/ntgadapter/adapter.go create mode 100644 calls/audio/opus.go create mode 100644 calls/audio/opus_test.go create mode 100644 calls/audio/playout.go create mode 100644 calls/audio/playout_test.go create mode 100644 calls/audio/tg_to_xmpp.go create mode 100644 calls/audio/tg_to_xmpp_test.go create mode 100644 calls/audio/xmpp_to_tg.go create mode 100644 calls/audio/xmpp_to_tg_test.go create mode 100644 calls/orchestrator.go create mode 100644 calls/orchestrator_test.go create mode 100644 calls/signaling/bridge.go create mode 100644 calls/signaling/bridge_test.go create mode 100644 calls/signaling/tgsig/adapter.go create mode 100644 calls/signaling/tgsig/adapter_race_test.go create mode 100644 calls/signaling/tgsig/base.go create mode 100644 calls/signaling/tgsig/callee.go create mode 100644 calls/signaling/tgsig/caller.go create mode 100644 calls/signaling/tgsig/convert.go create mode 100644 calls/signaling/tgsig/manager.go create mode 100644 calls/signaling/tgsig/manager_test.go create mode 100644 calls/signaling/timers.go create mode 100644 calls/signaling/xmppsig/adapter.go create mode 100644 calls/signaling/xmppsig/base.go create mode 100644 calls/signaling/xmppsig/callee.go create mode 100644 calls/signaling/xmppsig/callee_test.go create mode 100644 calls/signaling/xmppsig/caller.go create mode 100644 calls/signaling/xmppsig/caller_test.go create mode 100644 calls/signaling/xmppsig/extdisco.go create mode 100644 calls/signaling/xmppsig/extdisco_test.go create mode 100644 calls/signaling/xmppsig/pion_loop_test.go create mode 100644 calls/signaling/xmppsig/track_buffer_test.go create mode 100755 docker/isolate_ntgcalls_crypto.sh create mode 100644 xmpp/jingle/candidate_pion.go create mode 100644 xmpp/jingle/convert.go create mode 100644 xmpp/jingle/convert_payload.go create mode 100644 xmpp/jingle/convert_test.go create mode 100644 xmpp/jingle/convert_transport.go create mode 100644 xmpp/jingle/jmi.go create mode 100644 xmpp/jingle/manager.go create mode 100644 xmpp/jingle/namespaces.go create mode 100644 xmpp/jingle/sdp.go create mode 100644 xmpp/jingle/session.go create mode 100644 xmpp/jingle/session_test.go create mode 100644 xmpp/jingle/stanzas.go diff --git a/Dockerfile b/Dockerfile index 9a47de0..972468b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,19 +31,26 @@ RUN cmake -DCMAKE_BUILD_TYPE=Release \ /ntgcalls-src/ RUN cmake --build . ${MAKEOPTS} -FROM base AS cache -ARG VERSION +FROM base AS deps COPY --from=tdlib /compiled/ /usr/local/ COPY --from=ntgcalls-build /ntgcalls-src/static-output/lib/libntgcalls.a /usr/local/lib/ COPY --from=ntgcalls-build /ntgcalls-src/static-output/include/ /usr/local/include/ COPY docker/resolv_shim.c /tmp/resolv_shim.c RUN gcc -c -o /tmp/resolv_shim.o /tmp/resolv_shim.c && \ ar rcs /usr/local/lib/libresolv_shim.a /tmp/resolv_shim.o +# Needed by gopkg.in/hraban/opus.v2 (cgo pkg-config) and faster final link. +# Installed here (not in `base`) to keep tdlib/ntgcalls layers cached. +RUN apt-get install -y libopus-dev libopusfile-dev mold +# Isolate libntgcalls.a's bundled BoringSSL so its symbols don't displace +# system OpenSSL at link time (TDLib was built against the latter). +COPY docker/isolate_ntgcalls_crypto.sh /tmp/isolate_ntgcalls_crypto.sh +RUN bash /tmp/isolate_ntgcalls_crypto.sh + +FROM deps AS cache COPY ./ /src -RUN git -C /src checkout "${VERSION}" RUN git -C /src submodule update --init WORKDIR /src -RUN go get +RUN --mount=type=cache,target=/go/pkg/mod go get FROM cache AS build ARG MAKEOPTS @@ -51,7 +58,21 @@ ENV CGO_ENABLED=1 ENV CGO_CFLAGS="-I/usr/local/include" ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" WORKDIR /src -RUN make ${MAKEOPTS} +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + make ${MAKEOPTS} + +FROM deps AS test +COPY ./ /src +WORKDIR /src +RUN git submodule update --init +RUN --mount=type=cache,target=/go/pkg/mod go get +ENV CGO_ENABLED=1 +ENV CGO_CFLAGS="-I/usr/local/include" +ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + make test FROM scratch AS telegabber COPY --from=build /src/release/telegabber /usr/local/bin/ diff --git a/Makefile b/Makefile index ea63906..24f0585 100644 --- a/Makefile +++ b/Makefile @@ -12,17 +12,24 @@ all: mkdir -p release CGO_ENABLED=1 \ CGO_CFLAGS="-I$(NTG_INC_DIR)" \ - CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" \ + CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv -fuse-ld=mold" \ go build -ldflags "-X main.commit=${COMMIT}" -o release/telegabber test: - go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger + go test -race -short -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./xmpp/jingle ./persistence ./telegram/formatter ./badger ./calls ./calls/signaling ./calls/signaling/tgsig ./calls/signaling/xmppsig ./calls/audio + +# Pion-loopback test +test_loop: + go test -race -v -count=1 -run '^(TestXmppsigPionLoop_|TestHookTrack_|TestSetTrackHandler_)' ./calls/signaling/xmppsig + +test_indocker: + DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --target test . lint: $(GOPATH)/bin/golint ./... build_indocker: - docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries . + DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries . build_indocker_staging: DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --network host --output=release --target binaries -f staging.Dockerfile . diff --git a/calls/DESIGN.md b/calls/DESIGN.md new file mode 100644 index 0000000..a668906 --- /dev/null +++ b/calls/DESIGN.md @@ -0,0 +1,15 @@ +Overall flow looks like: +``` +telegram->ntgcalls(opus dec under the hood)->opus enc->pion->xmpp +xmpp->pion->opus dec->ntgcalls(opus enc under the hood)->telegram +``` + +In code: +``` +Call +├── Bridge (call state) +│ ├── Callee: tgsig|xmppsig.Callee (signaling) +│ └── Caller: tgsig|xmppsig.Caller (signaling) +├── TgToXmpp (audio) +└── XmppToTg (audio) +``` diff --git a/calls/audio/capture.go b/calls/audio/capture.go new file mode 100644 index 0000000..293863c --- /dev/null +++ b/calls/audio/capture.go @@ -0,0 +1,160 @@ +// optional offline-replay dump of the two media streams; gated by +// CALLS_CAPTURE_DIR (no-op when unset). big-endian throughout, ts is ms +// since writer construction. +// header: [magic 8B][ver u8=1][reserved 7B] +// PCM rec (tg->xmpp): [ts_ms i64][ssrc u32][len u32][pcm...] +// RTP rec (xmpp->tg): [ts_ms i64][ssrc u32][seq u16][rtp_ts u32][pt u8][len u32][opus...] +package audio + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + captureEnvVar = "CALLS_CAPTURE_DIR" + captureVer = byte(1) +) + +var ( + magicPCM = [8]byte{'T', 'G', 'C', 'A', 'P', 'P', 'C', 'M'} + magicRTP = [8]byte{'T', 'G', 'C', 'A', 'P', 'R', 'T', 'P'} +) + +// per-call file sink; nil-safe so call sites can stay branchless +type captureWriter struct { + mu sync.Mutex + f *os.File + start time.Time + closed bool +} + +// returns (nil, nil) when CALLS_CAPTURE_DIR is unset +func newCaptureWriter(name string, magic [8]byte) (*captureWriter, error) { + dir := os.Getenv(captureEnvVar) + if dir == "" { + return nil, nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("capture: mkdir %s: %w", dir, err) + } + path := filepath.Join(dir, name) + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("capture: create %s: %w", path, err) + } + var hdr [16]byte + copy(hdr[0:8], magic[:]) + hdr[8] = captureVer + if _, err := f.Write(hdr[:]); err != nil { + _ = f.Close() + return nil, fmt.Errorf("capture: write header: %w", err) + } + return &captureWriter{f: f, start: time.Now()}, nil +} + +func (w *captureWriter) writePCM(ssrc uint32, data []byte) { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return + } + var hdr [16]byte + binary.BigEndian.PutUint64(hdr[0:8], uint64(time.Since(w.start).Milliseconds())) + binary.BigEndian.PutUint32(hdr[8:12], ssrc) + binary.BigEndian.PutUint32(hdr[12:16], uint32(len(data))) + _, _ = w.f.Write(hdr[:]) + _, _ = w.f.Write(data) +} + +func (w *captureWriter) writeRTP(ssrc uint32, seq uint16, rtpTS uint32, pt uint8, data []byte) { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return + } + var hdr [23]byte + binary.BigEndian.PutUint64(hdr[0:8], uint64(time.Since(w.start).Milliseconds())) + binary.BigEndian.PutUint32(hdr[8:12], ssrc) + binary.BigEndian.PutUint16(hdr[12:14], seq) + binary.BigEndian.PutUint32(hdr[14:18], rtpTS) + hdr[18] = pt + binary.BigEndian.PutUint32(hdr[19:23], uint32(len(data))) + _, _ = w.f.Write(hdr[:]) + _, _ = w.f.Write(data) +} + +func (w *captureWriter) close() { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return + } + w.closed = true + _ = w.f.Close() +} + +// one tg->xmpp record from a PCM capture file +type CapturedPCM struct { + TimestampMs int64 + SSRC uint32 + Data []byte +} + +func ReadPCMCapture(r io.Reader) ([]CapturedPCM, error) { + if err := verifyMagic(r, magicPCM); err != nil { + return nil, err + } + var out []CapturedPCM + for { + var hdr [16]byte + _, err := io.ReadFull(r, hdr[:]) + if errors.Is(err, io.EOF) { + return out, nil + } + if err != nil { + return nil, err + } + rec := CapturedPCM{ + TimestampMs: int64(binary.BigEndian.Uint64(hdr[0:8])), + SSRC: binary.BigEndian.Uint32(hdr[8:12]), + } + length := binary.BigEndian.Uint32(hdr[12:16]) + rec.Data = make([]byte, length) + if _, err := io.ReadFull(r, rec.Data); err != nil { + return nil, err + } + out = append(out, rec) + } +} + +func verifyMagic(r io.Reader, want [8]byte) error { + var hdr [16]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return fmt.Errorf("capture: read header: %w", err) + } + var got [8]byte + copy(got[:], hdr[0:8]) + if got != want { + return fmt.Errorf("capture: bad magic %q, want %q", string(got[:]), string(want[:])) + } + if hdr[8] != captureVer { + return fmt.Errorf("capture: unsupported version %d", hdr[8]) + } + return nil +} diff --git a/calls/audio/fmtp.go b/calls/audio/fmtp.go new file mode 100644 index 0000000..86c72a5 --- /dev/null +++ b/calls/audio/fmtp.go @@ -0,0 +1,142 @@ +package audio + +import ( + "strconv" + "strings" +) + +// opus fmtp params from peer SDP. +// ApplyTo honors UseInBandFEC, UseDTX, MaxAverageBitrate, MaxPlaybackRate. +// CBR/Stereo/Ptime are parsed but not applied (pipeline is fixed shape and +// hraban/opus.v2 doesn't wrap OPUS_SET_VBR). +type Fmtp struct { + UseInBandFEC bool // useinbandfec=1 + UseDTX bool // usedtx=1 + CBR bool // cbr=1 (not applied; see type doc) + MaxAverageBitrate int // maxaveragebitrate=, 0 = unspecified + MaxPlaybackRate int // maxplaybackrate=, 0 = unspecified + SpropStereo bool // sprop-stereo=1 (not applied) + Stereo bool // stereo=1 (not applied) + MinPtime int // ms (not applied) + MaxPtime int // ms (not applied) +} + +// params half of the opus a=fmtp line; empty if no opus fmtp. +// Two-pass because pion may emit rtpmap and fmtp in any order. +func ExtractOpusFmtp(sdp string) string { + opusPTs := make(map[string]bool) + type fmtpEntry struct { + pt string + params string + } + var fmtps []fmtpEntry + for _, raw := range strings.Split(sdp, "\n") { + line := strings.TrimRight(raw, "\r") + if !strings.HasPrefix(line, "a=") { + continue + } + body := line[2:] + if rest, ok := strings.CutPrefix(body, "rtpmap:"); ok { + // " opus/48000/2" + sp := strings.IndexByte(rest, ' ') + if sp < 0 { + continue + } + pt, codec := rest[:sp], rest[sp+1:] + if strings.HasPrefix(strings.ToLower(codec), "opus/") { + opusPTs[pt] = true + } + continue + } + if rest, ok := strings.CutPrefix(body, "fmtp:"); ok { + sp := strings.IndexByte(rest, ' ') + if sp < 0 { + continue + } + fmtps = append(fmtps, fmtpEntry{pt: rest[:sp], params: rest[sp+1:]}) + } + } + for _, f := range fmtps { + if opusPTs[f.pt] { + return f.params + } + } + return "" +} + +// unknown keys and malformed numerics are silently ignored; weird SDP +// shouldn't kill the call +func ParseFmtp(s string) Fmtp { + var f Fmtp + for _, kv := range strings.Split(s, ";") { + kv = strings.TrimSpace(kv) + if kv == "" { + continue + } + eq := strings.IndexByte(kv, '=') + if eq < 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(kv[:eq])) + val := strings.TrimSpace(kv[eq+1:]) + switch key { + case "useinbandfec": + f.UseInBandFEC = val == "1" + case "usedtx": + f.UseDTX = val == "1" + case "cbr": + f.CBR = val == "1" + case "maxaveragebitrate": + if n, err := strconv.Atoi(val); err == nil { + f.MaxAverageBitrate = n + } + case "maxplaybackrate": + if n, err := strconv.Atoi(val); err == nil { + f.MaxPlaybackRate = n + } + case "sprop-stereo": + f.SpropStereo = val == "1" + case "stereo": + f.Stereo = val == "1" + case "minptime": + if n, err := strconv.Atoi(val); err == nil { + f.MinPtime = n + } + case "maxptime": + if n, err := strconv.Atoi(val); err == nil { + f.MaxPtime = n + } + } + } + return f +} + +func (f Fmtp) ApplyTo(e *Encoder) error { + bitrate := f.MaxAverageBitrate + if bitrate <= 0 { + bitrate = DefaultBitrate + } + if err := e.SetBitrate(bitrate); err != nil { + return err + } + if f.MaxPlaybackRate > 0 { + if err := e.SetMaxBandwidth(BandwidthForPlaybackRate(f.MaxPlaybackRate)); err != nil { + return err + } + } + if f.UseInBandFEC { + if err := e.SetInBandFEC(true); err != nil { + return err + } + // hint to opus, not a guarantee + if err := e.SetPacketLossPerc(5); err != nil { + return err + } + } + if f.UseDTX { + if err := e.SetDTX(true); err != nil { + return err + } + } + return nil +} diff --git a/calls/audio/fmtp_test.go b/calls/audio/fmtp_test.go new file mode 100644 index 0000000..a151a1b --- /dev/null +++ b/calls/audio/fmtp_test.go @@ -0,0 +1,131 @@ +package audio + +import ( + "testing" +) + +func TestApplyFmtpConfiguresEncoder(t *testing.T) { + // Verify the encoder's libopus state actually reflects the requested + // settings, not just that ApplyTo returned nil. + cases := []struct { + name string + in string + // checks accept the encoder and report any mismatches. + check func(t *testing.T, e *Encoder) + }{ + { + "default bitrate when missing", + "", + func(t *testing.T, e *Encoder) { + br, err := e.Bitrate() + if err != nil { + t.Fatalf("Bitrate: %v", err) + } + if br != DefaultBitrate { + t.Errorf("Bitrate=%d, want %d", br, DefaultBitrate) + } + }, + }, + { + "useinbandfec sets FEC and loss perc", + "useinbandfec=1", + func(t *testing.T, e *Encoder) { + fec, err := e.InBandFEC() + if err != nil { + t.Fatalf("InBandFEC: %v", err) + } + if !fec { + t.Error("InBandFEC=false, want true") + } + lp, err := e.PacketLossPerc() + if err != nil { + t.Fatalf("PacketLossPerc: %v", err) + } + if lp != 5 { + t.Errorf("PacketLossPerc=%d, want 5", lp) + } + }, + }, + { + "usedtx sets DTX", + "usedtx=1", + func(t *testing.T, e *Encoder) { + dtx, err := e.DTX() + if err != nil { + t.Fatalf("DTX: %v", err) + } + if !dtx { + t.Error("DTX=false, want true") + } + }, + }, + { + "maxaveragebitrate sets bitrate", + "maxaveragebitrate=20000", + func(t *testing.T, e *Encoder) { + br, err := e.Bitrate() + if err != nil { + t.Fatalf("Bitrate: %v", err) + } + if br != 20000 { + t.Errorf("Bitrate=%d, want 20000", br) + } + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + enc, err := NewEncoder() + if err != nil { + t.Fatalf("NewEncoder: %v", err) + } + if err := ParseFmtp(tc.in).ApplyTo(enc); err != nil { + t.Fatalf("Apply(%q): %v", tc.in, err) + } + tc.check(t, enc) + }) + } +} + +func TestExtractOpusFmtp(t *testing.T) { + cases := []struct { + name string + sdp string + want string + }{ + { + "matched opus pt", + "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 minptime=10;useinbandfec=1\r\n", + "minptime=10;useinbandfec=1", + }, + { + "rtpmap after fmtp", + "a=fmtp:111 minptime=10;useinbandfec=1\r\na=rtpmap:111 opus/48000/2\r\n", + "minptime=10;useinbandfec=1", + }, + { + "multiple codecs, opus matched", + "a=rtpmap:0 PCMU/8000\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 stereo=1;sprop-stereo=1\r\na=fmtp:0 unrelated=1\r\n", + "stereo=1;sprop-stereo=1", + }, + { + "no opus -> empty", + "a=rtpmap:0 PCMU/8000\r\na=fmtp:0 unrelated=1\r\n", + "", + }, + { + "opus without fmtp -> empty", + "a=rtpmap:111 opus/48000/2\r\n", + "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ExtractOpusFmtp(tc.sdp) + if got != tc.want { + t.Errorf("ExtractOpusFmtp:\n got %q\nwant %q", got, tc.want) + } + }) + } +} + diff --git a/calls/audio/ntg.go b/calls/audio/ntg.go new file mode 100644 index 0000000..70b8177 --- /dev/null +++ b/calls/audio/ntg.go @@ -0,0 +1,29 @@ +package audio + +// audio doesn't import ntgcalls directly so it builds and tests without +// CGO + libntgcalls; ntgadapter bridges *ntgcalls.Client to NtgClient + +// one 10ms s16-LE interleaved PCM frame from ntgcalls +type PCMFrame struct { + SSRC uint32 + Data []byte // 1920 bytes for 48k stereo 10ms +} + +// batch handler for PCM frames already filtered down to +// (PlaybackStream, MicrophoneStream) by the adapter +type NtgFrameHandler func(chatID int64, frames []PCMFrame) + +// bridge's view of ntgcalls +type NtgClient interface { + // returned cancel drops the handler; the Go ntgcalls binding has no + // unregister, so we own the dispatch list ourselves + OnFrame(NtgFrameHandler) func() + + // capture=true: gateway sends to peer; capture=false: gateway receives + SetExternalMicrophone(chatID int64, capture bool, sampleRate uint32, channels uint8) error + + ClearStreams(chatID int64) error + + // ships one 10ms s16-LE stereo PCM frame + SendMicrophonePCM(chatID int64, pcm []byte) error +} diff --git a/calls/audio/ntgadapter/adapter.go b/calls/audio/ntgadapter/adapter.go new file mode 100644 index 0000000..12901d8 --- /dev/null +++ b/calls/audio/ntgadapter/adapter.go @@ -0,0 +1,97 @@ +// adapts *ntgcalls.Client to audio.NtgClient; sub-package so audio/ stays +// buildable without libntgcalls/CGO locally +package ntgadapter + +import ( + "sync" + + "dev.narayana.im/narayana/telegabber/calls/audio" + "gotgcalls/ntgcalls" +) + +// wraps *ntgcalls.Client and filters OnFrame to (Playback, Microphone) - +// the bridge only wants the peer's audio coming back via the external mic +type Adapter struct { + c *ntgcalls.Client + + mu sync.RWMutex + handlers map[uint64]audio.NtgFrameHandler + nextID uint64 + wired bool +} + +func New(c *ntgcalls.Client) *Adapter { + return &Adapter{c: c, handlers: make(map[uint64]audio.NtgFrameHandler)} +} + +// lazily wires the ntgcalls callback on first call; cancel drops from +// the adapter-owned dispatch table (C++ binding's OnFrame is append-only) +func (a *Adapter) OnFrame(h audio.NtgFrameHandler) func() { + a.mu.Lock() + id := a.nextID + a.nextID++ + a.handlers[id] = h + wire := !a.wired + a.wired = true + a.mu.Unlock() + + if wire { + a.c.OnFrame(func(chatID int64, mode ntgcalls.StreamMode, device ntgcalls.StreamDevice, frames []ntgcalls.Frame) { + if mode != ntgcalls.PlaybackStream || device != ntgcalls.MicrophoneStream { + return + } + pcm := make([]audio.PCMFrame, 0, len(frames)) + for _, f := range frames { + pcm = append(pcm, audio.PCMFrame{SSRC: f.Ssrc, Data: f.Data}) + } + a.mu.RLock() + hs := make([]audio.NtgFrameHandler, 0, len(a.handlers)) + for _, h := range a.handlers { + hs = append(hs, h) + } + a.mu.RUnlock() + for _, h := range hs { + h(chatID, pcm) + } + }) + } + + return func() { + a.mu.Lock() + delete(a.handlers, id) + a.mu.Unlock() + } +} + +// uses the Microphone device slot in both directions (ntgcalls P2P pattern) +func (a *Adapter) SetExternalMicrophone(chatID int64, capture bool, sampleRate uint32, channels uint8) error { + desc := ntgcalls.MediaDescription{ + Microphone: &ntgcalls.AudioDescription{ + MediaSource: ntgcalls.MediaSourceExternal, + SampleRate: sampleRate, + ChannelCount: channels, + }, + } + mode := ntgcalls.PlaybackStream + if capture { + mode = ntgcalls.CaptureStream + } + return a.c.SetStreamSources(chatID, mode, desc) +} + +// errors only if both clears fail; partial cleanup is best-effort +func (a *Adapter) ClearStreams(chatID int64) error { + empty := ntgcalls.MediaDescription{} + capErr := a.c.SetStreamSources(chatID, ntgcalls.CaptureStream, empty) + pbErr := a.c.SetStreamSources(chatID, ntgcalls.PlaybackStream, empty) + if capErr != nil && pbErr != nil { + return capErr + } + return nil +} + +func (a *Adapter) SendMicrophonePCM(chatID int64, pcm []byte) error { + return a.c.SendExternalFrame(chatID, ntgcalls.MicrophoneStream, pcm, ntgcalls.FrameData{}) +} + +var _ audio.NtgClient = (*Adapter)(nil) diff --git a/calls/audio/opus.go b/calls/audio/opus.go new file mode 100644 index 0000000..abf0423 --- /dev/null +++ b/calls/audio/opus.go @@ -0,0 +1,127 @@ +// bridges PCM frames between an ntgcalls P2P call and pion's opus-over-RTP +package audio + +import ( + "encoding/binary" + "errors" + "fmt" + + "gopkg.in/hraban/opus.v2" +) + +// hardcoded for 48kHz / stereo / 20ms / VoIP +const ( + SampleRate = 48000 + Channels = 2 + + OpusFrameMs = 20 + NtgFrameMs = 10 + + // samples per channel + SamplesPerOpusFrame = SampleRate * OpusFrameMs / 1000 // 960 + SamplesPerNtgFrame = SampleRate * NtgFrameMs / 1000 // 480 + + // libwebrtc may emit up to 120ms opus frames under load + MaxDecodeSamplesPerChannel = SampleRate * 120 / 1000 // 5760 + + MaxOpusPacketBytes = 1500 + NtgFrameBytes = SamplesPerNtgFrame * Channels * 2 // 1920 (s16 stereo) + OpusInputBytes = SamplesPerOpusFrame * Channels * 2 // 3840 (s16 stereo) + + // fallback when remote SDP omits maxaveragebitrate + DefaultBitrate = 128000 +) + +// thin opus.v2 wrapper fixed to the 48k/stereo/20ms/VoIP shape; fmtp.go +// layers SDP tuning on top. Keeps the CGO opus dep confined to this file. +type Encoder struct { + enc *opus.Encoder +} + +func NewEncoder() (*Encoder, error) { + e, err := opus.NewEncoder(SampleRate, Channels, opus.AppVoIP) + if err != nil { + return nil, fmt.Errorf("opus.NewEncoder: %w", err) + } + if err := e.SetBitrate(DefaultBitrate); err != nil { + return nil, fmt.Errorf("opus SetBitrate: %w", err) + } + return &Encoder{enc: e}, nil +} + +// one 20ms stereo frame into out; len(pcm) must be SamplesPerOpusFrame*Channels +func (e *Encoder) Encode(pcm []int16, out []byte) (int, error) { + if len(pcm) != SamplesPerOpusFrame*Channels { + return 0, fmt.Errorf("opus encode: pcm len %d, want %d", len(pcm), SamplesPerOpusFrame*Channels) + } + return e.enc.Encode(pcm, out) +} + +func (e *Encoder) SetInBandFEC(on bool) error { return e.enc.SetInBandFEC(on) } +func (e *Encoder) SetDTX(on bool) error { return e.enc.SetDTX(on) } +func (e *Encoder) SetBitrate(bps int) error { return e.enc.SetBitrate(bps) } +func (e *Encoder) SetPacketLossPerc(p int) error { return e.enc.SetPacketLossPerc(p) } +func (e *Encoder) SetMaxBandwidth(bw opus.Bandwidth) error { + return e.enc.SetMaxBandwidth(bw) +} + +// getters for tests asserting fmtp took effect +func (e *Encoder) InBandFEC() (bool, error) { return e.enc.InBandFEC() } +func (e *Encoder) DTX() (bool, error) { return e.enc.DTX() } +func (e *Encoder) Bitrate() (int, error) { return e.enc.Bitrate() } +func (e *Encoder) PacketLossPerc() (int, error) { return e.enc.PacketLossPerc() } +func (e *Encoder) MaxBandwidth() (opus.Bandwidth, error) { return e.enc.MaxBandwidth() } + +type Decoder struct { + dec *opus.Decoder +} + +func NewDecoder() (*Decoder, error) { + d, err := opus.NewDecoder(SampleRate, Channels) + if err != nil { + return nil, fmt.Errorf("opus.NewDecoder: %w", err) + } + return &Decoder{dec: d}, nil +} + +// one opus packet -> interleaved s16 stereo PCM; out sized for max frame +func (d *Decoder) Decode(data []byte, out []int16) (int, error) { + if len(data) == 0 { + return 0, errors.New("opus decode: empty packet") + } + return d.dec.Decode(data, out) +} + +// one PLC frame; out sized to the last successful Decode's frame +func (d *Decoder) DecodePLC(out []int16) error { + return d.dec.DecodePLC(out) +} + +// SDP maxplaybackrate -> opus.Bandwidth (RFC 7587 §6.1) +func BandwidthForPlaybackRate(hz int) opus.Bandwidth { + switch { + case hz <= 8000: + return opus.Narrowband + case hz <= 12000: + return opus.Mediumband + case hz <= 16000: + return opus.Wideband + case hz <= 24000: + return opus.SuperWideband + default: + return opus.Fullband + } +} + +// interleaved s16-LE bytes <-> int16 samples; dst must be sized correctly +func PCMBytesToInt16(src []byte, dst []int16) { + for i := range dst { + dst[i] = int16(binary.LittleEndian.Uint16(src[i*2:])) + } +} + +func PCMInt16ToBytes(src []int16, dst []byte) { + for i, s := range src { + binary.LittleEndian.PutUint16(dst[i*2:], uint16(s)) + } +} diff --git a/calls/audio/opus_test.go b/calls/audio/opus_test.go new file mode 100644 index 0000000..553d238 --- /dev/null +++ b/calls/audio/opus_test.go @@ -0,0 +1,48 @@ +package audio + +import ( + "math" + "testing" +) + +// 20ms interleaved stereo sine; both channels identical +func makeSineStereo(freqHz float64) []int16 { + pcm := make([]int16, SamplesPerOpusFrame*Channels) + for i := 0; i < SamplesPerOpusFrame; i++ { + v := int16(math.Sin(2*math.Pi*freqHz*float64(i)/SampleRate) * 16000) + pcm[i*2] = v + pcm[i*2+1] = v + } + return pcm +} + +func TestEncodeDecodeRoundtrip(t *testing.T) { + enc, err := NewEncoder() + if err != nil { + t.Fatalf("NewEncoder: %v", err) + } + dec, err := NewDecoder() + if err != nil { + t.Fatalf("NewDecoder: %v", err) + } + + in := makeSineStereo(440) + pkt := make([]byte, MaxOpusPacketBytes) + n, err := enc.Encode(in, pkt) + if err != nil { + t.Fatalf("Encode: %v", err) + } + if n <= 0 || n > MaxOpusPacketBytes { + t.Fatalf("Encode returned %d bytes", n) + } + + out := make([]int16, MaxDecodeSamplesPerChannel*Channels) + samplesPerCh, err := dec.Decode(pkt[:n], out) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if samplesPerCh != SamplesPerOpusFrame { + t.Errorf("Decode produced %d samples/ch, want %d", samplesPerCh, SamplesPerOpusFrame) + } +} + diff --git a/calls/audio/playout.go b/calls/audio/playout.go new file mode 100644 index 0000000..2188b2e --- /dev/null +++ b/calls/audio/playout.go @@ -0,0 +1,76 @@ +package audio + +import ( + "errors" + "sync" + + "github.com/pion/interceptor/pkg/jitterbuffer" + "github.com/pion/rtp" +) + +// pion's jitterbuffer + late-drop on Push + skip-ahead on persistent gap; +// PopOrSkip reports gap counts so the caller can run PLC over them +type Playout struct { + mu sync.Mutex + jb *jitterbuffer.JitterBuffer + started bool // first Pop succeeded -> headSeq is meaningful + headSeq uint16 // expected next seq + // caps the scan in PopOrSkip's slow path + MaxSkip uint16 +} + +var ErrEmpty = errors.New("playout: empty") + +// primePackets=2 is ~40ms at 20ms framing +func NewPlayout(primePackets uint16) *Playout { + if primePackets == 0 { + primePackets = 2 + } + return &Playout{ + jb: jitterbuffer.New(jitterbuffer.WithMinimumPacketCount(primePackets)), + MaxSkip: 64, + } +} + +// late packets (seq behind headSeq) are dropped +func (p *Playout) Push(pkt *rtp.Packet) { + if pkt == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + // int16 cast -> signed seq distance + if p.started && int16(pkt.SequenceNumber-p.headSeq) < 0 { + return + } + p.jb.Push(pkt) +} + +// returns (next playable packet, gap count to PLC over, err); +// ErrEmpty means priming or genuinely empty +func (p *Playout) PopOrSkip() (*rtp.Packet, int, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if pkt, err := p.jb.Pop(); err == nil { + p.started = true + p.headSeq = pkt.SequenceNumber + 1 + return pkt, 0, nil + } else if errors.Is(err, jitterbuffer.ErrPopWhileBuffering) { + return nil, 0, ErrEmpty + } + + // scan forward for the next available packet + head := p.jb.PlayoutHead() + for i := uint16(1); i <= p.MaxSkip; i++ { + pkt, err := p.jb.PopAtSequence(head + i) + if err == nil { + // PopAtSequence advances by 1; head must skip past the gap + p.jb.SetPlayoutHead(pkt.SequenceNumber + 1) + p.started = true + p.headSeq = pkt.SequenceNumber + 1 + return pkt, int(i), nil + } + } + return nil, 0, ErrEmpty +} diff --git a/calls/audio/playout_test.go b/calls/audio/playout_test.go new file mode 100644 index 0000000..24a509b --- /dev/null +++ b/calls/audio/playout_test.go @@ -0,0 +1,94 @@ +package audio + +import ( + "errors" + "testing" + + "github.com/pion/rtp" +) + +func pkt(seq uint16) *rtp.Packet { + return &rtp.Packet{ + Header: rtp.Header{SequenceNumber: seq, PayloadType: opusPayloadType}, + Payload: []byte{0x00}, + } +} + +func TestPlayoutLateDrop(t *testing.T) { + p := NewPlayout(2) + p.Push(pkt(500)) + p.Push(pkt(501)) + // Drain to set the playout head past 500. + if _, _, err := p.PopOrSkip(); err != nil { + t.Fatalf("first pop: %v", err) + } + if _, _, err := p.PopOrSkip(); err != nil { + t.Fatalf("second pop: %v", err) + } + // A late packet with seq 499 must be dropped (not block, not surface + // later). + p.Push(pkt(499)) + if _, _, err := p.PopOrSkip(); !errors.Is(err, ErrEmpty) { + t.Fatalf("late packet should not have been buffered; got err=%v", err) + } +} + +func TestPlayoutSkipAhead(t *testing.T) { + p := NewPlayout(2) + // Push 600, 601 to prime, then drain so playoutHead = 602. + p.Push(pkt(600)) + p.Push(pkt(601)) + if _, _, err := p.PopOrSkip(); err != nil { + t.Fatalf("first pop: %v", err) + } + if _, _, err := p.PopOrSkip(); err != nil { + t.Fatalf("second pop: %v", err) + } + // Now skip seq 602, 603; deliver 604, 605. + p.Push(pkt(604)) + p.Push(pkt(605)) + got, gap, err := p.PopOrSkip() + if err != nil { + t.Fatalf("skip-ahead pop: %v", err) + } + if got.SequenceNumber != 604 { + t.Errorf("got seq=%d, want 604", got.SequenceNumber) + } + if gap != 2 { + t.Errorf("got gap=%d, want 2 (skipped 602, 603)", gap) + } + // Next pop should be 605 with no gap. + got, gap, err = p.PopOrSkip() + if err != nil { + t.Fatalf("follow-up pop: %v", err) + } + if got.SequenceNumber != 605 { + t.Errorf("got seq=%d, want 605", got.SequenceNumber) + } + if gap != 0 { + t.Errorf("follow-up gap=%d, want 0", gap) + } +} + +func TestPlayoutSeqWraparound(t *testing.T) { + p := NewPlayout(2) + // Push packets straddling the uint16 boundary: 65534, 65535, 0, 1. + for _, s := range []uint16{65534, 65535, 0, 1} { + p.Push(pkt(s)) + } + // Should pop in correct order despite the wrap. + wantOrder := []uint16{65534, 65535, 0, 1} + for _, want := range wantOrder { + got, gap, err := p.PopOrSkip() + if err != nil { + t.Fatalf("Pop want=%d: %v", want, err) + } + if got.SequenceNumber != want { + t.Errorf("got seq=%d, want %d", got.SequenceNumber, want) + } + if gap != 0 { + t.Errorf("seq=%d gap=%d, want 0", want, gap) + } + } +} + diff --git a/calls/audio/tg_to_xmpp.go b/calls/audio/tg_to_xmpp.go new file mode 100644 index 0000000..2e9c714 --- /dev/null +++ b/calls/audio/tg_to_xmpp.go @@ -0,0 +1,187 @@ +package audio + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" + log "github.com/sirupsen/logrus" +) + +// pion's default MediaEngine PT for opus; production reads the actual +// negotiated PT from TrackRemote, this is for tests with synthetic RTP +const opusPayloadType = 111 + +type TgToXmppOptions struct { + Ntg NtgClient + ChatID int64 // P2P: equals the Telegram peer's user_id + // params half of the peer's opus a=fmtp; empty -> defaults + RemoteFmtp string + Logger *log.Entry // nil -> default logrus +} + +// tg->xmpp half: ntgcalls Playback -> opus encode -> pion LocalTrack; +// callback-driven, no goroutines +type TgToXmpp struct { + opts TgToXmppOptions + log *log.Entry + + enc *Encoder + track *webrtc.TrackLocalStaticSample + + mu sync.Mutex + acc []byte // accumulates 10ms ntg frames until 20ms + primarySSRC uint32 // first SSRC wins; mixing is wrong for 1:1 + opusOut []byte // reusable encode destination + + closed atomic.Bool + started atomic.Bool + + unregister func() // returned by Ntg.OnFrame; called from Close + + capture *captureWriter +} + +// builds the encoder, local opus track, registers ntgcalls frame handler; +// Start() flips on the ntgcalls source +func NewTgToXmpp(opts TgToXmppOptions) (*TgToXmpp, error) { + if opts.Ntg == nil { + return nil, errors.New("audio: Ntg required") + } + if opts.ChatID == 0 { + return nil, errors.New("audio: ChatID required") + } + logger := opts.Logger + if logger == nil { + logger = log.WithField("module", "audio") + } + logger = logger.WithFields(log.Fields{"dir": "tg->xmpp", "chat_id": opts.ChatID}) + + enc, err := NewEncoder() + if err != nil { + return nil, err + } + if err := ParseFmtp(opts.RemoteFmtp).ApplyTo(enc); err != nil { + return nil, fmt.Errorf("apply fmtp: %w", err) + } + + track, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: SampleRate, + Channels: Channels, + SDPFmtpLine: "minptime=10;useinbandfec=1", + }, + "audio", + fmt.Sprintf("telegabber-%d", opts.ChatID), + ) + if err != nil { + return nil, fmt.Errorf("new track: %w", err) + } + + h := &TgToXmpp{ + opts: opts, + log: logger, + enc: enc, + track: track, + acc: make([]byte, 0, OpusInputBytes), + opusOut: make([]byte, MaxOpusPacketBytes), + } + if cap, err := newCaptureWriter( + fmt.Sprintf("tg-to-xmpp-%d-%d.bin", opts.ChatID, time.Now().Unix()), + magicPCM, + ); err != nil { + logger.WithError(err).Warn("capture: tg->xmpp writer disabled") + } else if cap != nil { + logger.Info("capture: tg->xmpp PCM capture enabled") + h.capture = cap + } + + // adapter pre-filters to (Playback, Microphone); handler also filters + // by chatID. Close calls the returned cancel to drop our dispatch slot. + h.unregister = opts.Ntg.OnFrame(h.onNtgFrames) + return h, nil +} + +func (h *TgToXmpp) LocalTrack() *webrtc.TrackLocalStaticSample { return h.track } + +func (h *TgToXmpp) Start() error { + if !h.started.CompareAndSwap(false, true) { + return nil + } + if err := h.opts.Ntg.SetExternalMicrophone(h.opts.ChatID, false, SampleRate, Channels); err != nil { + return fmt.Errorf("SetExternalMicrophone(playback): %w", err) + } + h.log.Info("tg->xmpp half started") + return nil +} + +// flips the closed flag and releases the capture writer; ntg sources +// are released by the xmpp->tg half's Close +func (h *TgToXmpp) Close() error { + if !h.closed.CompareAndSwap(false, true) { + return nil + } + if h.unregister != nil { + h.unregister() + } + h.capture.close() + h.log.Info("tg->xmpp half closed") + return nil +} + +func (h *TgToXmpp) onNtgFrames(chatID int64, frames []PCMFrame) { + if h.closed.Load() || chatID != h.opts.ChatID { + return + } + for _, f := range frames { + h.capture.writePCM(f.SSRC, f.Data) + h.feedFrame(f) + } +} + +// returns number of opus packets emitted (0 or 1) +func (h *TgToXmpp) feedFrame(f PCMFrame) int { + if len(f.Data) != NtgFrameBytes { + h.log.WithField("len", len(f.Data)).Warn("unexpected ntg frame size") + return 0 + } + h.mu.Lock() + defer h.mu.Unlock() + + if h.primarySSRC == 0 { + h.primarySSRC = f.SSRC + } else if f.SSRC != h.primarySSRC { + return 0 + } + + h.acc = append(h.acc, f.Data...) + if len(h.acc) < OpusInputBytes { + return 0 + } + + pcm := make([]int16, SamplesPerOpusFrame*Channels) + PCMBytesToInt16(h.acc[:OpusInputBytes], pcm) + h.acc = h.acc[:0] + + n, err := h.enc.Encode(pcm, h.opusOut) + if err != nil { + h.log.WithError(err).Warn("opus encode failed") + return 0 + } + // copy because the next encode reuses h.opusOut + payload := make([]byte, n) + copy(payload, h.opusOut[:n]) + if err := h.track.WriteSample(media.Sample{ + Data: payload, + Duration: OpusFrameMs * time.Millisecond, + }); err != nil { + h.log.WithError(err).Warn("WriteSample failed") + return 0 + } + return 1 +} diff --git a/calls/audio/tg_to_xmpp_test.go b/calls/audio/tg_to_xmpp_test.go new file mode 100644 index 0000000..ef06bfc --- /dev/null +++ b/calls/audio/tg_to_xmpp_test.go @@ -0,0 +1,125 @@ +package audio + +import ( + "sync" + "testing" +) + +// shared between tg->xmpp and xmpp->tg tests +type fakeNtg struct { + mu sync.Mutex + handler NtgFrameHandler + unregistered int // count of cancel() invocations returned by OnFrame + sentPCM [][]byte + micCalls int + clears int + micCallCapture []bool // ordered record of capture-flag arg per SetExternalMicrophone call + sendErr error // if set, SendMicrophonePCM returns this +} + +// stashes handler so tests can dispatch directly; cancel just bumps a +// counter (handler stays reachable so tests can exercise closed-flag guards) +func (f *fakeNtg) OnFrame(h NtgFrameHandler) func() { + f.mu.Lock() + defer f.mu.Unlock() + f.handler = h + return func() { + f.mu.Lock() + defer f.mu.Unlock() + f.unregistered++ + } +} +func (f *fakeNtg) SetExternalMicrophone(_ int64, capture bool, _ uint32, _ uint8) error { + f.mu.Lock() + defer f.mu.Unlock() + f.micCalls++ + f.micCallCapture = append(f.micCallCapture, capture) + return nil +} +func (f *fakeNtg) ClearStreams(int64) error { + f.mu.Lock() + defer f.mu.Unlock() + f.clears++ + return nil +} +func (f *fakeNtg) SendMicrophonePCM(_ int64, pcm []byte) error { + cp := make([]byte, len(pcm)) + copy(cp, pcm) + f.mu.Lock() + defer f.mu.Unlock() + err := f.sendErr + f.sentPCM = append(f.sentPCM, cp) + return err +} + +func (f *fakeNtg) sentCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sentPCM) +} + +func newTestTgToXmpp(t *testing.T) (*TgToXmpp, *fakeNtg) { + t.Helper() + n := &fakeNtg{} + h, err := NewTgToXmpp(TgToXmppOptions{ + Ntg: n, + ChatID: 12345, + }) + if err != nil { + t.Fatalf("NewTgToXmpp: %v", err) + } + return h, n +} + +func TestTgToXmppStartConfiguresPlayback(t *testing.T) { + h, n := newTestTgToXmpp(t) + if err := h.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer h.Close() + n.mu.Lock() + defer n.mu.Unlock() + if n.micCalls != 1 { + t.Errorf("SetExternalMicrophone called %d times, want 1 (playback only)", n.micCalls) + } + if len(n.micCallCapture) != 1 || n.micCallCapture[0] { + t.Errorf("expected capture=false (playback) for tg->xmpp, got %v", n.micCallCapture) + } +} + +func TestTgToXmppCloseStopsCallbackHandling(t *testing.T) { + h, n := newTestTgToXmpp(t) + if err := h.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := h.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + // Post-close dispatches must be ignored. + frame := PCMFrame{SSRC: 1, Data: make([]byte, NtgFrameBytes)} + n.handler(h.opts.ChatID, []PCMFrame{frame, frame, frame, frame}) + if got := len(h.acc); got != 0 { + t.Errorf("closed half accumulated %d bytes, want 0", got) + } +} + +// mirrors ntgcalls' callback-per-goroutine dispatch; run with -race +func TestTgToXmppFeedFrameConcurrent(t *testing.T) { + h, _ := newTestTgToXmpp(t) + const N = 100 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func() { + defer wg.Done() + f := PCMFrame{SSRC: 42, Data: make([]byte, NtgFrameBytes)} + h.feedFrame(f) + }() + } + wg.Wait() + h.mu.Lock() + defer h.mu.Unlock() + if got := len(h.acc); got != 0 && got != NtgFrameBytes { + t.Errorf("acc len=%d after %d concurrent feeds; want 0 or %d", got, N, NtgFrameBytes) + } +} diff --git a/calls/audio/xmpp_to_tg.go b/calls/audio/xmpp_to_tg.go new file mode 100644 index 0000000..842dbf3 --- /dev/null +++ b/calls/audio/xmpp_to_tg.go @@ -0,0 +1,233 @@ +package audio + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/pion/webrtc/v4" + log "github.com/sirupsen/logrus" +) + +type XmppToTgOptions struct { + Ntg NtgClient + ChatID int64 // P2P: equals the Telegram peer's user_id + PrimePackets uint16 // buffered before playout starts; default 2 (~40ms) + Logger *log.Entry +} + +// xmpp->tg half: pion RemoteTrack RTP -> opus decode -> ntgcalls +// SendMicrophonePCM. One reader goroutine per HandleTrack, plus one +// playout ticker spawned by Start. +type XmppToTg struct { + opts XmppToTgOptions + log *log.Entry + + dec *Decoder + playout *Playout + + stop chan struct{} + wg sync.WaitGroup + pcmBuf []int16 // decoder output (worst-case 120ms stereo) + silence []byte // 10ms silence for underrun + + closed atomic.Bool + started atomic.Bool + // false until Start runs. Audio between pion OnTrack (during + // SetRemoteDescription) and bridge OnEstablished is drained from + // pion's per-track queue but NOT enqueued - otherwise every packet + // from the setup window would back up and the ticker would replay + // it at real time, manifesting as call-long lag. + livePush atomic.Bool + preLiveDiscards atomic.Int64 + + capture *captureWriter +} + +func NewXmppToTg(opts XmppToTgOptions) (*XmppToTg, error) { + if opts.Ntg == nil { + return nil, errors.New("audio: Ntg required") + } + if opts.ChatID == 0 { + return nil, errors.New("audio: ChatID required") + } + logger := opts.Logger + if logger == nil { + logger = log.WithField("module", "audio") + } + logger = logger.WithFields(log.Fields{"dir": "xmpp->tg", "chat_id": opts.ChatID}) + + dec, err := NewDecoder() + if err != nil { + return nil, err + } + + h := &XmppToTg{ + opts: opts, + log: logger, + dec: dec, + playout: NewPlayout(opts.PrimePackets), + pcmBuf: make([]int16, MaxDecodeSamplesPerChannel*Channels), + silence: make([]byte, NtgFrameBytes), + stop: make(chan struct{}), + } + if cap, err := newCaptureWriter( + fmt.Sprintf("xmpp-to-tg-%d-%d.bin", opts.ChatID, time.Now().Unix()), + magicRTP, + ); err != nil { + logger.WithError(err).Warn("capture: xmpp->tg writer disabled") + } else if cap != nil { + logger.Info("capture: xmpp->tg RTP capture enabled") + h.capture = cap + } + return h, nil +} + +// flips on the ntgcalls Capture source, the playout ticker, and livePush +func (h *XmppToTg) Start() error { + if !h.started.CompareAndSwap(false, true) { + return nil + } + if err := h.opts.Ntg.SetExternalMicrophone(h.opts.ChatID, true, SampleRate, Channels); err != nil { + return fmt.Errorf("SetExternalMicrophone(capture): %w", err) + } + h.livePush.Store(true) + h.wg.Add(1) + go h.tickerLoop() + h.log.WithField("pre_live_discards", h.preLiveDiscards.Load()). + Info("xmpp->tg half started") + return nil +} + +// spawns a reader for each pc.OnTrack +func (h *XmppToTg) HandleTrack(t *webrtc.TrackRemote) { + if t == nil || t.Kind() != webrtc.RTPCodecTypeAudio { + return + } + h.log.WithFields(log.Fields{ + "codec": t.Codec().MimeType, + "ssrc": t.SSRC(), + }).Info("xmpp->tg: remote audio track received from pion") + h.wg.Add(1) + go h.reader(t) +} + +// stops both pumps; ntgcalls Capture+Playback are released by Call.Close +func (h *XmppToTg) Close() error { + if !h.closed.CompareAndSwap(false, true) { + return nil + } + close(h.stop) + h.wg.Wait() + h.capture.close() + h.log.Info("xmpp->tg half closed") + return nil +} + +func (h *XmppToTg) reader(t *webrtc.TrackRemote) { + defer h.wg.Done() + // negotiated PT for this track; peer is free to pick anything in 96-127, + // hardcoding 111 would silently drop opus from peers that picked another + expectedPT := uint8(t.PayloadType()) + for { + select { + case <-h.stop: + return + default: + } + pkt, _, err := t.ReadRTP() + if err != nil { + return + } + if pkt.PayloadType != expectedPT { + // CN/DTMF/RED can share an SSRC; libopus rejects them + continue + } + if !h.livePush.Load() { + // pre-Start: drain pion's per-track queue but don't enqueue; + // see livePush docstring for the lag rationale + h.preLiveDiscards.Add(1) + continue + } + h.capture.writeRTP(pkt.SSRC, pkt.SequenceNumber, pkt.Timestamp, pkt.PayloadType, pkt.Payload) + h.playout.Push(pkt) + } +} + +// cross-tick playout state; separated so tests can drive processTick +// step by step without a time.Ticker +type tickState struct { + lastDecodedSamples int // samples/ch from last Decode; DecodePLC output matches + pending10ms [][]byte // 10ms PCM chunks awaiting shipment, one per tick +} + +func newTickState() *tickState { + return &tickState{lastDecodedSamples: SamplesPerOpusFrame} +} + +func (h *XmppToTg) tickerLoop() { + defer h.wg.Done() + ticker := time.NewTicker(NtgFrameMs * time.Millisecond) + defer ticker.Stop() + + st := newTickState() + for { + select { + case <-h.stop: + return + case <-ticker.C: + } + h.processTick(st) + } +} + +// one iteration of the playout loop +func (h *XmppToTg) processTick(st *tickState) { + if len(st.pending10ms) == 0 { + pkt, plcCount, err := h.playout.PopOrSkip() + if err != nil { + // empty or still priming - feed silence + h.sendToNtg(h.silence) + return + } + for i := 0; i < plcCount; i++ { + plcOut := h.pcmBuf[:st.lastDecodedSamples*Channels] + if err := h.dec.DecodePLC(plcOut); err != nil { + h.log.WithError(err).Warn("DecodePLC failed") + break + } + st.pending10ms = appendChunks10ms(st.pending10ms, plcOut) + } + n, err := h.dec.Decode(pkt.Payload, h.pcmBuf) + if err != nil { + h.log.WithError(err).Warn("opus decode failed") + h.sendToNtg(h.silence) + return + } + st.lastDecodedSamples = n + st.pending10ms = appendChunks10ms(st.pending10ms, h.pcmBuf[:n*Channels]) + } + chunk := st.pending10ms[0] + st.pending10ms = st.pending10ms[1:] + h.sendToNtg(chunk) +} + +// slice interleaved stereo PCM into 10ms chunks; partial trailing chunks +// are dropped (libopus's 2.5/5ms frames aren't multiples of 10ms) +func appendChunks10ms(dst [][]byte, pcm []int16) [][]byte { + samplesPerChunk := SamplesPerNtgFrame * Channels + for off := 0; off+samplesPerChunk <= len(pcm); off += samplesPerChunk { + buf := make([]byte, NtgFrameBytes) + PCMInt16ToBytes(pcm[off:off+samplesPerChunk], buf) + dst = append(dst, buf) + } + return dst +} + +func (h *XmppToTg) sendToNtg(pcm []byte) { + if err := h.opts.Ntg.SendMicrophonePCM(h.opts.ChatID, pcm); err != nil { + h.log.WithError(err).Debug("SendMicrophonePCM failed") + } +} diff --git a/calls/audio/xmpp_to_tg_test.go b/calls/audio/xmpp_to_tg_test.go new file mode 100644 index 0000000..93bfeca --- /dev/null +++ b/calls/audio/xmpp_to_tg_test.go @@ -0,0 +1,152 @@ +package audio + +import ( + "testing" + + "github.com/pion/rtp" +) + +func newTestXmppToTg(t *testing.T) (*XmppToTg, *fakeNtg) { + t.Helper() + n := &fakeNtg{} + h, err := NewXmppToTg(XmppToTgOptions{ + Ntg: n, + ChatID: 12345, + }) + if err != nil { + t.Fatalf("NewXmppToTg: %v", err) + } + return h, n +} + +func TestAppendChunks10ms(t *testing.T) { + // One 20ms decode -> two 10ms chunks. + pcm := make([]int16, SamplesPerOpusFrame*Channels) + got := appendChunks10ms(nil, pcm) + if len(got) != 2 { + t.Fatalf("got %d chunks for 20ms, want 2", len(got)) + } + for i, c := range got { + if len(c) != NtgFrameBytes { + t.Errorf("chunk %d len=%d, want %d", i, len(c), NtgFrameBytes) + } + } + // Partial trailing samples are dropped (a 15ms slice yields one 10ms chunk). + partial := make([]int16, (SamplesPerNtgFrame+SamplesPerNtgFrame/2)*Channels) + got = appendChunks10ms(nil, partial) + if len(got) != 1 { + t.Errorf("partial slice: got %d chunks, want 1", len(got)) + } +} + +// encodeOpusFrame returns a real opus packet encoding a 440Hz sine wave. +func encodeOpusFrame(t *testing.T, enc *Encoder) []byte { + t.Helper() + pcm := makeSineStereo(440) + buf := make([]byte, MaxOpusPacketBytes) + n, err := enc.Encode(pcm, buf) + if err != nil { + t.Fatalf("encode: %v", err) + } + return buf[:n] +} + +// rtpWithSeq wraps payload in an RTP packet with the given seq. +func rtpWithSeq(seq uint16, payload []byte) *rtp.Packet { + return &rtp.Packet{ + Header: rtp.Header{ + SequenceNumber: seq, + PayloadType: opusPayloadType, + }, + Payload: payload, + } +} + +func TestProcessTickSilenceWhenEmpty(t *testing.T) { + h, n := newTestXmppToTg(t) + st := newTickState() + // Buffer is empty + not primed: each tick should ship one silence frame. + for i := 0; i < 3; i++ { + h.processTick(st) + } + if got := n.sentCount(); got != 3 { + t.Errorf("expected 3 silence frames, got %d", got) + } + // All sent buffers should equal h.silence. + n.mu.Lock() + defer n.mu.Unlock() + for i, p := range n.sentPCM { + for j, x := range p { + if x != 0 { + t.Fatalf("frame %d byte %d = %x, want 0 (silence)", i, j, x) + return + } + } + } +} + +func TestProcessTickDecodesAndSplits(t *testing.T) { + h, n := newTestXmppToTg(t) + // Push enough packets to prime the buffer (default 2). + enc, err := NewEncoder() + if err != nil { + t.Fatalf("NewEncoder: %v", err) + } + for seq := uint16(100); seq < 102; seq++ { + h.playout.Push(rtpWithSeq(seq, encodeOpusFrame(t, enc))) + } + + st := newTickState() + // First tick: pops packet 100 (20ms PCM = 2 chunks queued), ships chunk 1. + h.processTick(st) + // Second tick: ships chunk 2 from same decode (still pending). + h.processTick(st) + // Third tick: pops packet 101, ships its first chunk. + h.processTick(st) + // Fourth tick: ships packet 101's second chunk. + h.processTick(st) + + if got := n.sentCount(); got != 4 { + t.Errorf("expected 4 PCM frames shipped, got %d", got) + } + // Verify each is the right size. + n.mu.Lock() + defer n.mu.Unlock() + for i, p := range n.sentPCM { + if len(p) != NtgFrameBytes { + t.Errorf("frame %d len=%d, want %d", i, len(p), NtgFrameBytes) + } + } +} + +func TestProcessTickRunsPLCBeforeGapRecovery(t *testing.T) { + h, _ := newTestXmppToTg(t) + enc, err := NewEncoder() + if err != nil { + t.Fatalf("NewEncoder: %v", err) + } + // Prime with two packets, drain them to leave the buffer empty but + // established (playoutHead = 502). + h.playout.Push(rtpWithSeq(500, encodeOpusFrame(t, enc))) + h.playout.Push(rtpWithSeq(501, encodeOpusFrame(t, enc))) + st := newTickState() + for i := 0; i < 4; i++ { // drain 2 packets * 2 chunks each + h.processTick(st) + } + // Now skip 502, 503 entirely; deliver 504. + h.playout.Push(rtpWithSeq(504, encodeOpusFrame(t, enc))) + // Next tick should detect the gap, run 2 PLC decodes (502 + 503), + // then decode 504. That's 6 chunks queued (2 PLC * 2 chunks + 1 real * 2). + // processTick ships one per tick, so 6 ticks are required to drain. + h.processTick(st) // ships first chunk + // pending should still hold 5 more chunks + if len(st.pending10ms) != 5 { + t.Errorf("after first post-gap tick: pending=%d, want 5", len(st.pending10ms)) + } + for i := 0; i < 5; i++ { + h.processTick(st) + } + if len(st.pending10ms) != 0 { + t.Errorf("pending should be drained, got %d", len(st.pending10ms)) + } +} diff --git a/calls/orchestrator.go b/calls/orchestrator.go new file mode 100644 index 0000000..1582fc6 --- /dev/null +++ b/calls/orchestrator.go @@ -0,0 +1,274 @@ +// turns protocol events into bridged calls +package calls + +import ( + "fmt" + "sync" + "time" + + "dev.narayana.im/narayana/telegabber/calls/audio" + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/calls/signaling/tgsig" + "dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "github.com/pion/webrtc/v4" + log "github.com/sirupsen/logrus" + "gosrc.io/xmpp/stanza" +) + +// look up per-session adapters by bare jid; ok=false drops the call +type SessionLookup func(bareJID string) (tg *tgsig.Adapter, xmpp *xmppsig.Adapter, ok bool) + +type Config struct { + JingleManager *jingle.Manager + TgManager *tgsig.Manager + Lookup SessionLookup + + // zero falls through to signaling defaults (60s/20s/15s) + RingTimeout time.Duration + ExchangeTimeout time.Duration + ConnectTimeout time.Duration +} + +type Orchestrator struct { + cfg Config +} + +func New(cfg Config) *Orchestrator { + o := &Orchestrator{cfg: cfg} + if cfg.JingleManager != nil { + cfg.JingleManager.OnProposal = o.onProposal + } + return o +} + +// per-side surfaces the audio layer needs +type tgAudioSide interface { + NtgClient() audio.NtgClient + ChatID() int64 +} + +type xmppAudioSide interface { + PeerConnection() *webrtc.PeerConnection + // swap in the orchestrator's track handler and drain buffered tracks + SetTrackHandler(fn func(t *webrtc.TrackRemote)) +} + +type Call struct { + bridge *signaling.Bridge + + mu sync.Mutex + tgSide tgAudioSide + xmppSide xmppAudioSide + audioTgToXmpp *audio.TgToXmpp + audioXmppToTg *audio.XmppToTg + + closeOnce sync.Once +} + +func (c *Call) Bridge() *signaling.Bridge { return c.bridge } + +// build the audio halves once the negotiated opus fmtp is in the SDP +func (c *Call) setupAudio(remoteSDP string) { + remoteFmtp := audio.ExtractOpusFmtp(remoteSDP) + c.mu.Lock() + if c.audioTgToXmpp != nil || c.audioXmppToTg != nil { + c.mu.Unlock() + return + } + tgSide, xmppSide := c.tgSide, c.xmppSide + c.mu.Unlock() + if tgSide == nil || xmppSide == nil { + return + } + + ntg := tgSide.NtgClient() + pc := xmppSide.PeerConnection() + if ntg == nil || pc == nil { + // signaling-only test setup + return + } + chatID := tgSide.ChatID() + + tgToXmpp, err := audio.NewTgToXmpp(audio.TgToXmppOptions{ + Ntg: ntg, + ChatID: chatID, + RemoteFmtp: remoteFmtp, + }) + if err != nil { + log.WithError(err).Error("setupAudio: NewTgToXmpp") + return + } + xmppToTg, err := audio.NewXmppToTg(audio.XmppToTgOptions{ + Ntg: ntg, + ChatID: chatID, + }) + if err != nil { + _ = tgToXmpp.Close() + log.WithError(err).Error("setupAudio: NewXmppToTg") + return + } + + if err := attachOutgoingTrack(pc, tgToXmpp.LocalTrack()); err != nil { + log.WithField("chat_id", chatID).WithError(err).Warn("setupAudio: attach outgoing track failed") + } + xmppSide.SetTrackHandler(xmppToTg.HandleTrack) + log.WithFields(log.Fields{ + "chat_id": chatID, + "remote_fmtp": remoteFmtp, + }).Info("setupAudio: halves wired; awaiting startAudio on bridge OnEstablished") + + c.mu.Lock() + c.audioTgToXmpp = tgToXmpp + c.audioXmppToTg = xmppToTg + c.mu.Unlock() +} + +// swap the real outgoing track into the placeholder sender from buildPC +func attachOutgoingTrack(pc *webrtc.PeerConnection, track *webrtc.TrackLocalStaticSample) error { + for _, s := range pc.GetSenders() { + if s == nil { + continue + } + if err := s.ReplaceTrack(track); err == nil { + return nil + } + } + return fmt.Errorf("no sender accepted track") +} + +// fired by Bridge.OnEstablished +func (c *Call) startAudio() { + c.mu.Lock() + tgHalf := c.audioTgToXmpp + xmppHalf := c.audioXmppToTg + c.mu.Unlock() + if tgHalf == nil || xmppHalf == nil { + return + } + if err := tgHalf.Start(); err != nil { + log.WithError(err).Warn("tg->xmpp audio Start failed") + } + if err := xmppHalf.Start(); err != nil { + log.WithError(err).Warn("xmpp->tg audio Start failed") + } +} + +func (c *Call) Close() { + c.closeOnce.Do(func() { + c.mu.Lock() + tgHalf := c.audioTgToXmpp + xmppHalf := c.audioXmppToTg + tgSide := c.tgSide + c.audioTgToXmpp = nil + c.audioXmppToTg = nil + c.mu.Unlock() + if tgHalf != nil { + _ = tgHalf.Close() + } + if xmppHalf != nil { + _ = xmppHalf.Close() + } + // release ntg streams set in setupAudio; both halves are built + // together, skipped in signaling-only test setups + if (tgHalf != nil || xmppHalf != nil) && tgSide != nil { + if ntg := tgSide.NtgClient(); ntg != nil { + _ = ntg.ClearStreams(tgSide.ChatID()) + } + } + }) +} + +// inbound JMI ; postReady fires after the session is in StateRinging +// so a fast tdlib Pending{IsReceived:true} doesn't drop the JMI +func (o *Orchestrator) onProposal(p jingle.IncomingProposal) (*jingle.Session, func(), error) { + j, err := stanza.NewJid(p.From) + if err != nil { + return nil, nil, nil + } + tg, xmpp, ok := o.cfg.Lookup(j.Bare()) + if !ok || tg == nil || xmpp == nil { + return nil, nil, nil + } + userID, ok := xmppsig.ParseTargetJID(p.To) + if !ok { + return nil, nil, nil + } + + callee, sess, err := xmpp.NewCallee(p) + if err != nil { + return nil, nil, err + } + caller := tg.NewCaller(userID, false) // audio only + + call := &Call{tgSide: caller, xmppSide: callee} + br := o.newBridge(caller, callee, call) + call.bridge = br + callee.Bind(br) + caller.Bind(br) + + // defer audio half construction until SDP lands so a pre-SDP cancel + // doesn't spin up ntgcalls + callee.SetOnRemoteSDP(call.setupAudio) + + // off the dispatcher goroutine - Caller.Start hits tdlib CreateCall + return sess, func() { go br.Start() }, nil +} + +// IncomingCallHandler for tgsig.Manager +func (o *Orchestrator) NewFromTelegram(jid string, callID int32, userID int64, isVideo bool) (ok bool) { + entry := log.WithFields(log.Fields{ + "jid": jid, + "call_id": callID, + "user_id": userID, + "is_video": isVideo, + }) + defer func() { + if r := recover(); r != nil { + entry.WithField("panic", r).Error("NewFromTelegram: panic recovered") + ok = false + } + }() + + tg, xmpp, lookupOk := o.cfg.Lookup(jid) + if !lookupOk || tg == nil || xmpp == nil { + entry.Warn("NewFromTelegram: no live session for jid") + return false + } + + // xmpp side first - it's the only fallible step, otherwise a failure here + // would leak the tg side until tdlib timed the call out + caller, err := xmpp.NewCaller(jid, userID) + if err != nil { + entry.WithError(err).Error("NewFromTelegram: NewCaller failed") + return false + } + callee := tg.NewCallee(callID, userID, isVideo) + + call := &Call{tgSide: callee, xmppSide: caller} + br := o.newBridge(caller, callee, call) + call.bridge = br + callee.Bind(br) + caller.Bind(br) + + caller.SetOnRemoteSDP(call.setupAudio) + br.Start() + return true +} + +func (o *Orchestrator) newBridge(caller signaling.Caller, callee signaling.Callee, call *Call) *signaling.Bridge { + timers := signaling.NewStdTimers() + br := signaling.New(signaling.Config{ + Caller: caller, + Callee: callee, + Timers: timers, + RingTimeout: o.cfg.RingTimeout, + ExchangeTimeout: o.cfg.ExchangeTimeout, + ConnectTimeout: o.cfg.ConnectTimeout, + OnEstablished: call.startAudio, + OnTerminated: call.Close, + }) + timers.SetBridge(br) + return br +} diff --git a/calls/orchestrator_test.go b/calls/orchestrator_test.go new file mode 100644 index 0000000..ef1c110 --- /dev/null +++ b/calls/orchestrator_test.go @@ -0,0 +1,376 @@ +package calls + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "dev.narayana.im/narayana/telegabber/calls/signaling/tgsig" + "dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "github.com/pion/webrtc/v4" + "github.com/zelenin/go-tdlib/client" + "gosrc.io/xmpp/stanza" + "gotgcalls/ntgcalls" +) + +type captureSender struct { + mu sync.Mutex + sent []stanza.Packet +} + +func (c *captureSender) Send(p stanza.Packet) error { + c.mu.Lock() + c.sent = append(c.sent, p) + c.mu.Unlock() + return nil +} + +func (c *captureSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + ch := make(chan stanza.IQ, 1) + return ch, c.Send(iq) +} + +func (c *captureSender) sentCopy() []stanza.Packet { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]stanza.Packet, len(c.sent)) + copy(out, c.sent) + return out +} + +func (c *captureSender) hasExt(match func(stanza.MsgExtension) bool) bool { + for _, p := range c.sentCopy() { + msg, ok := p.(*stanza.Message) + if !ok { + continue + } + for _, ext := range msg.Extensions { + if match(ext) { + return true + } + } + } + return false +} + +type fakeTdlib struct { + nextCallID atomic.Int32 + + created chan *client.CreateCallRequest + accepted chan *client.AcceptCallRequest + discard chan *client.DiscardCallRequest +} + +func newFakeTdlib() *fakeTdlib { + return &fakeTdlib{ + created: make(chan *client.CreateCallRequest, 4), + accepted: make(chan *client.AcceptCallRequest, 4), + discard: make(chan *client.DiscardCallRequest, 4), + } +} + +func (f *fakeTdlib) CreateCall(req *client.CreateCallRequest) (*client.CallId, error) { + id := f.nextCallID.Add(1) + select { + case f.created <- req: + default: + } + return &client.CallId{Id: id}, nil +} +func (f *fakeTdlib) AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) { + select { + case f.accepted <- req: + default: + } + return &client.Ok{}, nil +} +func (f *fakeTdlib) DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) { + select { + case f.discard <- req: + default: + } + return &client.Ok{}, nil +} +func (f *fakeTdlib) SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) { + return &client.Ok{}, nil +} + +// fakeNtg is a no-op: orchestrator tests don't exercise media setup. +type fakeNtg struct{} + +func (f *fakeNtg) OnSignal(ntgcalls.SignalCallback) {} +func (f *fakeNtg) OnConnectionChange(ntgcalls.ConnectionChangeCallback) {} +func (f *fakeNtg) CreateP2PCall(int64) error { return nil } +func (f *fakeNtg) SkipExchange(int64, []byte, bool) error { return nil } +func (f *fakeNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error { + return nil +} +func (f *fakeNtg) SendSignalingData(int64, []byte) error { return nil } +func (f *fakeNtg) Stop(int64) error { return nil } + +type fixture struct { + t *testing.T + sender *captureSender + tdlib *fakeTdlib + ntg *fakeNtg + jingleMgr *jingle.Manager + tgMgr *tgsig.Manager + tgAdapter *tgsig.Adapter + xmppAd *xmppsig.Adapter + orch *Orchestrator + userBare string +} + +func newFixture(t *testing.T, opts ...func(*Config)) *fixture { + t.Helper() + sender := &captureSender{} + tdlib := newFakeTdlib() + ntg := &fakeNtg{} + + jm := &jingle.Manager{LocalJID: "gw.example", Sender: sender} + tgMgr := tgsig.NewManager(nil) + + const userBare = "user@example" + tgAdapter := tgsig.New(tdlib, ntg, nil, userBare, tgMgr) + xmppAd := xmppsig.NewAdapter(xmppsig.AdapterConfig{ + Sender: sender, + LocalJID: "gw.example", + Manager: jm, + PCFactory: testPCFactory(t), + }) + + lookup := func(jid string) (*tgsig.Adapter, *xmppsig.Adapter, bool) { + if jid != userBare { + return nil, nil, false + } + return tgAdapter, xmppAd, true + } + + cfg := Config{ + JingleManager: jm, + TgManager: tgMgr, + Lookup: lookup, + } + for _, opt := range opts { + opt(&cfg) + } + orch := New(cfg) + tgMgr.SetIncomingHandler(orch.NewFromTelegram) + + return &fixture{ + t: t, + sender: sender, + tdlib: tdlib, + ntg: ntg, + jingleMgr: jm, + tgMgr: tgMgr, + tgAdapter: tgAdapter, + xmppAd: xmppAd, + orch: orch, + userBare: userBare, + } +} + +func testPCFactory(t *testing.T) func() (*webrtc.PeerConnection, error) { + return func() (*webrtc.PeerConnection, error) { + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return nil, err + } + t.Cleanup(func() { _ = pc.Close() }) + return pc, nil + } +} + +func TestIncomingProposeFiresTelegramCreateCall(t *testing.T) { + fx := newFixture(t) + + fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-in-1")) + + select { + case req := <-fx.tdlib.created: + if req.UserId != 12345 { + t.Fatalf("CreateCall userID = %d, want 12345", req.UserId) + } + case <-time.After(1 * time.Second): + t.Fatalf("tdlib.CreateCall never invoked") + } + + // No should have been auto-sent: Observer is wired. + if fx.sender.hasExt(func(e stanza.MsgExtension) bool { _, ok := e.(*jingle.JMIRinging); return ok }) { + t.Fatalf("expected no auto-; observer should gate it") + } +} + +func TestRetractTearsDownTelegramPeer(t *testing.T) { + fx := newFixture(t) + + fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-rj-1")) + + select { + case <-fx.tdlib.created: + case <-time.After(1 * time.Second): + t.Fatalf("tdlib.CreateCall never invoked") + } + + // Caller retracts (they originated; is the cancel verb). + fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIRetract{ID: "sid-rj-1"})) + + select { + case <-fx.tdlib.discard: + // good + case <-time.After(1 * time.Second): + t.Fatalf("tdlib.DiscardCall never invoked after ") + } +} + +func TestRingTimeoutTearsDownBothSides(t *testing.T) { + fx := newFixture(t, func(c *Config) { c.RingTimeout = 100 * time.Millisecond }) + + fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-to-1")) + + select { + case <-fx.tdlib.created: + case <-time.After(1 * time.Second): + t.Fatalf("tdlib.CreateCall never invoked") + } + + select { + case <-fx.tdlib.discard: + // good + case <-time.After(2 * time.Second): + t.Fatalf("tdlib.DiscardCall never invoked on ring timeout") + } + + waitForCond(t, "reject emitted", 1*time.Second, func() bool { + return fx.sender.hasExt(func(e stanza.MsgExtension) bool { + _, ok := e.(*jingle.JMIReject) + return ok + }) + }) +} + +func TestNewFromTelegramSendsPropose(t *testing.T) { + fx := newFixture(t) + + fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{ + Call: &client.Call{ + Id: 42, + UserId: 67890, + IsOutgoing: false, + IsVideo: false, + State: &client.CallStatePending{}, + }, + }) + + waitForCond(t, "propose emitted", 1*time.Second, func() bool { + return fx.sender.hasExt(func(e stanza.MsgExtension) bool { + _, ok := e.(*jingle.JMIPropose) + return ok + }) + }) +} + +func TestTelegramOriginatedProceedFiresTdlibAccept(t *testing.T) { + fx := newFixture(t) + + fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{ + Call: &client.Call{ + Id: 42, + UserId: 67890, + IsOutgoing: false, + State: &client.CallStatePending{}, + }, + }) + + sid := waitForProposedSID(t, fx.sender, 1*time.Second) + + fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIProceed{ID: sid})) + + select { + case req := <-fx.tdlib.accepted: + if req.CallId != 42 { + t.Fatalf("AcceptCall callID = %d, want 42", req.CallId) + } + case <-time.After(1 * time.Second): + t.Fatalf("tdlib.AcceptCall never invoked") + } +} + +func TestTelegramOriginatedRejectDiscardsTelegramSide(t *testing.T) { + fx := newFixture(t) + + fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{ + Call: &client.Call{ + Id: 99, + UserId: 67890, + IsOutgoing: false, + State: &client.CallStatePending{}, + }, + }) + + sid := waitForProposedSID(t, fx.sender, 1*time.Second) + + fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIReject{ID: sid})) + + select { + case req := <-fx.tdlib.discard: + if req.CallId != 99 { + t.Fatalf("DiscardCall callID = %d, want 99", req.CallId) + } + case <-time.After(1 * time.Second): + t.Fatalf("tdlib.DiscardCall never invoked after ") + } +} + +func waitForProposedSID(t *testing.T, s *captureSender, d time.Duration) string { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + for _, p := range s.sentCopy() { + msg, ok := p.(*stanza.Message) + if !ok { + continue + } + for _, ext := range msg.Extensions { + if pr, ok := ext.(*jingle.JMIPropose); ok { + return pr.ID + } + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("waitForProposedSID: timeout after %v", d) + return "" +} + +func waitForCond(t *testing.T, what string, d time.Duration, fn func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("waitForCond(%s): timeout after %v", what, d) +} + +func buildProposeMessage(from, to, sid string) *stanza.Message { + m := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, From: from, To: to, Id: "m-" + sid}) + m.Extensions = []stanza.MsgExtension{&jingle.JMIPropose{ + ID: sid, + Descriptions: []jingle.JMIDescription{{Media: "audio"}}, + }} + return &m +} + +func buildJMIMessage(from, to string, ext stanza.MsgExtension) *stanza.Message { + m := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, From: from, To: to, Id: "m-jmi"}) + m.Extensions = []stanza.MsgExtension{ext} + return &m +} diff --git a/calls/signaling/bridge.go b/calls/signaling/bridge.go new file mode 100644 index 0000000..1f8d852 --- /dev/null +++ b/calls/signaling/bridge.go @@ -0,0 +1,249 @@ +// coordinates one bridged call between a caller and a callee +package signaling + +import ( + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +type State uint8 + +const ( + StateIdle State = iota + StateOriginating + StateRinging + StateAccepted + StateEstablished + StateTerminated +) + +func (s State) String() string { + names := [...]string{"idle", "originating", "ringing", "accepted", "established", "terminated"} + if int(s) < len(names) { + return names[s] + } + return fmt.Sprintf("state(%d)", uint8(s)) +} + +type TerminationReason uint8 + +const ( + ReasonUnknown TerminationReason = iota + ReasonHangup + ReasonDecline + ReasonPeerGone + ReasonRingTimeout + ReasonExchangeTimeout + ReasonConnectTimeout + ReasonMediaFailed + ReasonBusy +) + +type TimerKind uint8 + +const ( + TimerRing TimerKind = iota + TimerExchange + TimerConnect +) + +// which transport reported a media event; bridge waits for both +// before going to Established (otherwise the slow side lags the call) +type Side uint8 + +const ( + CallerSide Side = iota + CalleeSide +) + +type Caller interface { + Start() + Terminate(reason TerminationReason) +} + +// Ringing tells the peer the callee is alerting; tg side is a no-op +type Callee interface { + Ringing() + Accept() + Terminate(reason TerminationReason) +} + +// must call Bridge.Timeout on fire; Cancel is a no-op if not set +type Timers interface { + Set(kind TimerKind, dur time.Duration) + Cancel(kind TimerKind) +} + +type Config struct { + Caller Caller + Callee Callee + Timers Timers + + RingTimeout time.Duration // default 60s + ExchangeTimeout time.Duration // default 20s + ConnectTimeout time.Duration // default 15s + + // fires once after both sides reported MediaConnected; invoked unlocked + OnEstablished func() + // fires once on transition into Terminated + OnTerminated func() +} + +// caller/callee impls must not call back into Bridge synchronously +// from a method Bridge invoked - that deadlocks +type Bridge struct { + cfg Config + + mu sync.Mutex + state State + callerMediaUp bool + calleeMediaUp bool + establishedAt time.Time +} + +func New(cfg Config) *Bridge { + if cfg.RingTimeout == 0 { + cfg.RingTimeout = 60 * time.Second + } + if cfg.ExchangeTimeout == 0 { + cfg.ExchangeTimeout = 20 * time.Second + } + if cfg.ConnectTimeout == 0 { + cfg.ConnectTimeout = 15 * time.Second + } + return &Bridge{cfg: cfg, state: StateIdle} +} + +func (b *Bridge) State() State { + b.mu.Lock() + defer b.mu.Unlock() + return b.state +} + +// time both endpoints have been MediaConnected, or 0 if never established; +// tgsig passes this to tdlib DiscardCall so the call isn't classified as missed +func (b *Bridge) Duration() time.Duration { + b.mu.Lock() + defer b.mu.Unlock() + if b.establishedAt.IsZero() { + return 0 + } + return time.Since(b.establishedAt) +} + +func (b *Bridge) Start() { + b.mu.Lock() + if b.state != StateIdle { + st := b.state + b.mu.Unlock() + log.WithField("state", st).Warn("Bridge.Start: not idle, skipping") + return + } + b.state = StateOriginating + b.cfg.Timers.Set(TimerRing, b.cfg.RingTimeout) + b.mu.Unlock() + b.cfg.Caller.Start() +} + +func (b *Bridge) Ringing() { + b.mu.Lock() + defer b.mu.Unlock() + if b.state != StateOriginating { + return + } + b.state = StateRinging + b.cfg.Callee.Ringing() +} + +func (b *Bridge) CalleeAccepted() { + b.mu.Lock() + defer b.mu.Unlock() + if b.state != StateOriginating && b.state != StateRinging { + return + } + b.state = StateAccepted + b.cfg.Timers.Cancel(TimerRing) + b.cfg.Timers.Set(TimerExchange, b.cfg.ExchangeTimeout) + b.cfg.Callee.Accept() +} + +// one transport endpoint is up; transition to Established only after both +func (b *Bridge) MediaConnected(side Side) { + var fireEstablished func() + b.mu.Lock() + if b.state == StateAccepted { + switch side { + case CallerSide: + b.callerMediaUp = true + case CalleeSide: + b.calleeMediaUp = true + } + if b.callerMediaUp && b.calleeMediaUp { + b.state = StateEstablished + b.establishedAt = time.Now() + b.cfg.Timers.Cancel(TimerExchange) + b.cfg.Timers.Cancel(TimerConnect) + fireEstablished = b.cfg.OnEstablished + } else { + // first side up; arm connect timer for the laggard + b.cfg.Timers.Cancel(TimerExchange) + b.cfg.Timers.Set(TimerConnect, b.cfg.ConnectTimeout) + } + } + // already Established: further reports no-op here, ConnectionState Failed + // still goes through MediaFailed + b.mu.Unlock() + if fireEstablished != nil { + fireEstablished() + } +} + +func (b *Bridge) MediaFailed(reason TerminationReason) { + b.terminate(reason) +} + +func (b *Bridge) Terminate(reason TerminationReason) { + b.terminate(reason) +} + +func (b *Bridge) Timeout(kind TimerKind) { + var reason TerminationReason + switch kind { + case TimerRing: + reason = ReasonRingTimeout + case TimerExchange: + reason = ReasonExchangeTimeout + case TimerConnect: + reason = ReasonConnectTimeout + default: + return + } + b.terminate(reason) +} + +func (b *Bridge) terminate(reason TerminationReason) { + b.mu.Lock() + if b.state == StateIdle || b.state == StateTerminated { + b.mu.Unlock() + return + } + log.WithFields(log.Fields{ + "prev_state": b.state, + "reason": reason, + }).Info("Bridge terminating") + b.state = StateTerminated + b.cfg.Timers.Cancel(TimerRing) + b.cfg.Timers.Cancel(TimerExchange) + b.cfg.Timers.Cancel(TimerConnect) + b.mu.Unlock() + + // callbacks run unlocked; re-entrant bridge methods bail on StateTerminated + b.cfg.Caller.Terminate(reason) + b.cfg.Callee.Terminate(reason) + if b.cfg.OnTerminated != nil { + b.cfg.OnTerminated() + } +} diff --git a/calls/signaling/bridge_test.go b/calls/signaling/bridge_test.go new file mode 100644 index 0000000..b28117e --- /dev/null +++ b/calls/signaling/bridge_test.go @@ -0,0 +1,277 @@ +package signaling + +import ( + "testing" + "time" +) + +type fakeCaller struct { + started bool + terminated bool + terminateReason TerminationReason +} + +func (f *fakeCaller) Start() { f.started = true } +func (f *fakeCaller) Terminate(r TerminationReason) { + f.terminated = true + f.terminateReason = r +} + +type fakeCallee struct { + ringing bool + accepted bool + terminated bool + terminateReason TerminationReason +} + +func (f *fakeCallee) Ringing() { f.ringing = true } +func (f *fakeCallee) Accept() { f.accepted = true } +func (f *fakeCallee) Terminate(r TerminationReason) { + f.terminated = true + f.terminateReason = r +} + +type fakeTimers struct { + set map[TimerKind]time.Duration + cancel map[TimerKind]int +} + +func newFakeTimers() *fakeTimers { + return &fakeTimers{set: map[TimerKind]time.Duration{}, cancel: map[TimerKind]int{}} +} +func (f *fakeTimers) Set(k TimerKind, d time.Duration) { f.set[k] = d } +func (f *fakeTimers) Cancel(k TimerKind) { f.cancel[k]++ } + +type fakes struct { + caller *fakeCaller + callee *fakeCallee + timers *fakeTimers +} + +func newBridge() (*Bridge, *fakes) { + f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()} + b := New(Config{Caller: f.caller, Callee: f.callee, Timers: f.timers}) + return b, f +} + +func driveTo(t *testing.T, target State) (*Bridge, *fakes) { + t.Helper() + b, f := newBridge() + if target == StateIdle { + return b, f + } + b.Start() + if target == StateOriginating { + return b, f + } + b.Ringing() + if target == StateRinging { + return b, f + } + b.CalleeAccepted() + if target == StateAccepted { + return b, f + } + b.MediaConnected(CallerSide) + b.MediaConnected(CalleeSide) + if target == StateEstablished { + return b, f + } + t.Fatalf("driveTo: unknown target %s", target) + return nil, nil +} + +func TestTerminalStateIgnoresEvents(t *testing.T) { + b, _ := driveTo(t, StateRinging) + b.Timeout(TimerRing) + if b.State() != StateTerminated { + t.Fatalf("setup: state = %s", b.State()) + } + b.Start() + b.Ringing() + b.CalleeAccepted() + b.MediaConnected(CallerSide) + b.MediaConnected(CalleeSide) + b.Terminate(ReasonHangup) + b.Timeout(TimerExchange) + if b.State() != StateTerminated { + t.Errorf("state drifted to %s", b.State()) + } +} + +// teardown matrix: every call-ending event tears down both sides via +// Terminate, leaves the bridge in StateTerminated, and cancels every timer + +func checkTerminated(t *testing.T, b *Bridge, f *fakes, wantReason TerminationReason) { + t.Helper() + if b.State() != StateTerminated { + t.Fatalf("bridge state = %s, want terminated", b.State()) + } + if !f.caller.terminated { + t.Errorf("caller.terminated = false, want true") + } + if !f.callee.terminated { + t.Errorf("callee.terminated = false, want true") + } + if f.caller.terminateReason != wantReason { + t.Errorf("caller.terminateReason = %v, want %v", f.caller.terminateReason, wantReason) + } + if f.callee.terminateReason != wantReason { + t.Errorf("callee.terminateReason = %v, want %v", f.callee.terminateReason, wantReason) + } + for _, k := range []TimerKind{TimerRing, TimerExchange, TimerConnect} { + if f.timers.cancel[k] == 0 { + t.Errorf("timer %v not cancelled on terminate", k) + } + } +} + +func TestBridgeTeardown_TearsDownBoth(t *testing.T) { + cases := []struct { + name string + states []State + trigger func(*Bridge) + want TerminationReason + }{ + {"Terminate", + []State{StateOriginating, StateRinging, StateAccepted, StateEstablished}, + func(b *Bridge) { b.Terminate(ReasonHangup) }, ReasonHangup}, + {"MediaFailed", + []State{StateAccepted, StateEstablished}, + func(b *Bridge) { b.MediaFailed(ReasonMediaFailed) }, ReasonMediaFailed}, + {"TimerRing", + []State{StateOriginating, StateRinging}, + func(b *Bridge) { b.Timeout(TimerRing) }, ReasonRingTimeout}, + {"TimerExchange", + []State{StateAccepted}, + func(b *Bridge) { b.Timeout(TimerExchange) }, ReasonExchangeTimeout}, + {"TimerConnect", + []State{StateEstablished}, + func(b *Bridge) { b.Timeout(TimerConnect) }, ReasonConnectTimeout}, + } + for _, tc := range cases { + for _, st := range tc.states { + t.Run(tc.name+"/"+st.String(), func(t *testing.T) { + b, f := driveTo(t, st) + tc.trigger(b) + checkTerminated(t, b, f, tc.want) + }) + } + } +} + +// further teardowns after the first must not re-fire Terminate +func TestBridgeTeardown_Idempotent(t *testing.T) { + b, f := driveTo(t, StateEstablished) + b.Terminate(ReasonHangup) + if !f.caller.terminated || !f.callee.terminated { + t.Fatalf("setup: both sides should be torn down") + } + // subsequent Terminates must no-op; recorded reasons must not change + f.caller.terminateReason = ReasonUnknown // sentinel + f.callee.terminateReason = ReasonUnknown // sentinel + b.Terminate(ReasonMediaFailed) + b.Terminate(ReasonDecline) + b.MediaFailed(ReasonMediaFailed) + b.Timeout(TimerRing) + if f.caller.terminateReason != ReasonUnknown { + t.Errorf("caller.Terminate re-fired (reason now %v)", f.caller.terminateReason) + } + if f.callee.terminateReason != ReasonUnknown { + t.Errorf("callee.Terminate re-fired (reason now %v)", f.callee.terminateReason) + } +} + +// 0 before Established, positive after; needed by tgsig.DiscardCall +func TestBridge_Duration(t *testing.T) { + b, _ := newBridge() + if d := b.Duration(); d != 0 { + t.Errorf("Duration before Start = %v, want 0", d) + } + b.Start() + b.Ringing() + b.CalleeAccepted() + b.MediaConnected(CallerSide) + if d := b.Duration(); d != 0 { + t.Errorf("Duration after one-sided MediaConnected = %v, want 0 (not yet Established)", d) + } + b.MediaConnected(CalleeSide) + time.Sleep(20 * time.Millisecond) + if d := b.Duration(); d < 10*time.Millisecond { + t.Errorf("Duration after Established = %v, want >= 10ms", d) + } +} + +// only one side up: cancel TimerExchange, arm TimerConnect, don't fire OnEstablished +func TestBridge_MediaConnectedOneSide_DoesNotEstablish(t *testing.T) { + f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()} + var established int + b := New(Config{ + Caller: f.caller, Callee: f.callee, Timers: f.timers, + OnEstablished: func() { established++ }, + }) + b.Start() + b.Ringing() + b.CalleeAccepted() + b.MediaConnected(CallerSide) + if b.State() != StateAccepted { + t.Errorf("state after one-sided MediaConnected = %s, want accepted", b.State()) + } + if established != 0 { + t.Errorf("OnEstablished fired prematurely (%d times) with only one side up", established) + } + if _, ok := f.timers.set[TimerConnect]; !ok { + t.Errorf("TimerConnect not armed after first MediaConnected") + } + if f.timers.cancel[TimerExchange] == 0 { + t.Errorf("TimerExchange not cancelled after first MediaConnected") + } +} + +// OnEstablished / OnTerminated fire exactly once +func TestBridge_LifecycleHooks(t *testing.T) { + f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()} + var established, terminated int + b := New(Config{ + Caller: f.caller, Callee: f.callee, Timers: f.timers, + OnEstablished: func() { established++ }, + OnTerminated: func() { terminated++ }, + }) + b.Start() + b.Ringing() + b.CalleeAccepted() + b.MediaConnected(CallerSide) + b.MediaConnected(CalleeSide) + b.MediaConnected(CallerSide) // duplicate post-Established; must be no-op + b.Terminate(ReasonHangup) + b.Terminate(ReasonHangup) // duplicate; must not re-fire OnTerminated + if established != 1 { + t.Errorf("OnEstablished fired %d times, want 1", established) + } + if terminated != 1 { + t.Errorf("OnTerminated fired %d times, want 1", terminated) + } +} + +// CalleeAccepted outside Originating/Ringing must be a no-op +func TestBridge_CalleeAcceptedStateGuard(t *testing.T) { + for _, st := range []State{StateIdle, StateAccepted, StateEstablished, StateTerminated} { + t.Run(st.String(), func(t *testing.T) { + var b *Bridge + var f *fakes + if st == StateTerminated { + b, f = driveTo(t, StateRinging) + b.Terminate(ReasonHangup) + } else { + b, f = driveTo(t, st) + } + // driveTo invokes CalleeAccepted along the way; reset so we + // observe only the upcoming call + f.callee.accepted = false + b.CalleeAccepted() + if f.callee.accepted { + t.Errorf("Callee.Accept fired from state %s", st) + } + }) + } +} diff --git a/calls/signaling/tgsig/adapter.go b/calls/signaling/tgsig/adapter.go new file mode 100644 index 0000000..fc3c452 --- /dev/null +++ b/calls/signaling/tgsig/adapter.go @@ -0,0 +1,249 @@ +// telegram side of signaling.Bridge. +// Adapter is per-session; Manager is keyed by (jid, callID). +// Caller = gateway issued CreateCall, Callee = gateway received Pending. +package tgsig + +import ( + "sync" + "time" + + "dev.narayana.im/narayana/telegabber/calls/audio" + "dev.narayana.im/narayana/telegabber/calls/signaling" + "gotgcalls/ntgcalls" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// past this point ntgcalls is probably stuck and the user hears silence - +// audio sent before Connected is dropped +const ntgConnectStallThreshold = 7 * time.Second + +// narrow upstream APIs to what we actually use, so tests can fake them + +type TdlibClient interface { + CreateCall(req *client.CreateCallRequest) (*client.CallId, error) + AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) + DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) + SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) +} + +type NtgCallsClient interface { + OnSignal(callback ntgcalls.SignalCallback) + OnConnectionChange(callback ntgcalls.ConnectionChangeCallback) + CreateP2PCall(chatId int64) error + SkipExchange(chatId int64, encryptionKey []byte, isOutgoing bool) error + ConnectP2P(chatId int64, rtcServers []ntgcalls.RTCServer, versions []string, p2pAllowed bool) error + SendSignalingData(chatId int64, data []byte) error + Stop(chatId int64) error +} + +type Adapter struct { + tdlib TdlibClient + ntg NtgCallsClient + audioNtg audio.NtgClient // nil disables audio (signaling-only tests) + jid string + mgr *Manager + + mu sync.Mutex + byUserID map[int64]*callBase + + // gates ntg/tdlib touches against Close()/Free() on the C++ object. + // dispatchers take RLock via withCallContext; Close takes Lock. + lifecycleLock sync.RWMutex + closed bool + + // per-chatID stall warning while ntgcalls is in Connecting + ntgConnectTimersMu sync.Mutex + ntgConnectTimers map[int64]*time.Timer +} + +// runs fn under the lifecycle RLock; returns false (and skips fn) if closed +func (a *Adapter) withCallContext(fn func()) bool { + a.lifecycleLock.RLock() + defer a.lifecycleLock.RUnlock() + if a.closed { + return false + } + fn() + return true +} + +func New(tdlib TdlibClient, ntg NtgCallsClient, audioNtg audio.NtgClient, jid string, mgr *Manager) *Adapter { + a := &Adapter{ + tdlib: tdlib, + ntg: ntg, + audioNtg: audioNtg, + jid: jid, + mgr: mgr, + byUserID: make(map[int64]*callBase), + } + a.attachNtgCallbacks() + return a +} + +func (a *Adapter) JID() string { return a.jid } + +// outbound call; callID is unknown until CreateCall returns, so +// the entry registers with Manager from Caller.Start +func (a *Adapter) NewCaller(userID int64, isVideo bool) *Caller { + return &Caller{callBase: callBase{ + adapter: a, userID: userID, isVideo: isVideo, side: signaling.CallerSide, + }} +} + +// inbound call; callID is known so the entry registers immediately +func (a *Adapter) NewCallee(callID int32, userID int64, isVideo bool) *Callee { + c := &Callee{callBase: callBase{ + adapter: a, userID: userID, isVideo: isVideo, callID: callID, side: signaling.CalleeSide, + }} + a.registerSide(userID, &c.callBase) + a.mgr.Register(a.jid, callID, &c.callBase) + return c +} + +func (a *Adapter) registerSide(userID int64, s *callBase) { + a.mu.Lock() + a.byUserID[userID] = s + a.mu.Unlock() +} + +func (a *Adapter) dropSide(userID int64) { + a.mu.Lock() + delete(a.byUserID, userID) + a.mu.Unlock() +} + +func (a *Adapter) lookupSideByUser(userID int64) (*callBase, bool) { + a.mu.Lock() + defer a.mu.Unlock() + s, ok := a.byUserID[userID] + return s, ok +} + +// terminates every active call and marks the adapter closed; after this +// returns no goroutine is mid-ntg/tdlib through here, safe to Free() the +// C++ ntgcalls object. Bridge.Terminate runs outside the lock and is +// synchronous so audio halves close (and ClearStreams runs) while C++ +// is still alive +func (a *Adapter) Close() { + a.lifecycleLock.Lock() + if a.closed { + a.lifecycleLock.Unlock() + return + } + a.mu.Lock() + sides := make([]*callBase, 0, len(a.byUserID)) + for _, s := range a.byUserID { + sides = append(sides, s) + } + a.byUserID = make(map[int64]*callBase) + a.mu.Unlock() + a.closed = true + a.lifecycleLock.Unlock() + + for _, s := range sides { + if b := s.getBridge(); b != nil { + b.Terminate(signaling.ReasonHangup) + } + if cid := s.getCallID(); cid != 0 { + a.mgr.Drop(a.jid, cid) + } + } + for _, s := range sides { + if err := a.ntg.Stop(s.userID); err != nil { + log.WithField("user_id", s.userID).WithError(err).Debug("ntgcalls Stop during teardown") + } + } +} + +func (a *Adapter) attachNtgCallbacks() { + a.ntg.OnSignal(func(chatID int64, data []byte) { + // invoked from the ntgcalls C++ thread; guard against Close()/Free() + a.withCallContext(func() { + s, ok := a.lookupSideByUser(chatID) + if !ok { + log.WithFields(log.Fields{ + "jid": a.jid, + "user_id": chatID, + }).Warn("ntgcalls OnSignal for unknown user") + return + } + if _, err := a.tdlib.SendCallSignalingData(&client.SendCallSignalingDataRequest{ + CallId: s.getCallID(), + Data: data, + }); err != nil { + log.WithFields(log.Fields{ + "call_id": s.getCallID(), + "user_id": chatID, + }).WithError(err).Error("Failed to send signaling data to TDLib") + } + }) + }) + + a.ntg.OnConnectionChange(func(chatID int64, info ntgcalls.NetworkInfo) { + entry := log.WithFields(log.Fields{ + "jid": a.jid, + "user_id": chatID, + "state": info.State, + "kind": info.Kind, + }) + entry.Info("ntgcalls connection state changed") + + // the TG peer can hear nothing while ntgcalls is still negotiating + // with TG's TURN relays even though the XMPP side has RTP flowing + switch info.State { + case ntgcalls.Connecting: + a.startNtgConnectStallTimer(chatID) + case ntgcalls.Connected, ntgcalls.Failed, ntgcalls.Timeout, ntgcalls.Closed: + a.stopNtgConnectStallTimer(chatID) + } + + s, ok := a.lookupSideByUser(chatID) + if !ok { + return + } + b := s.getBridge() + if b == nil { + return + } + switch info.State { + case ntgcalls.Connected: + b.MediaConnected(s.side) + case ntgcalls.Failed: + b.MediaFailed(signaling.ReasonMediaFailed) + case ntgcalls.Timeout: + b.MediaFailed(signaling.ReasonConnectTimeout) + } + }) +} + +// (re)arms the stall timer; Connecting can fire more than once +func (a *Adapter) startNtgConnectStallTimer(chatID int64) { + a.stopNtgConnectStallTimer(chatID) + t := time.AfterFunc(ntgConnectStallThreshold, func() { + log.WithFields(log.Fields{ + "jid": a.jid, + "user_id": chatID, + "after": ntgConnectStallThreshold, + }).Warn("ntgcalls still Connecting - TG peer is likely hearing silence") + }) + a.ntgConnectTimersMu.Lock() + if a.ntgConnectTimers == nil { + a.ntgConnectTimers = make(map[int64]*time.Timer) + } + a.ntgConnectTimers[chatID] = t + a.ntgConnectTimersMu.Unlock() +} + +func (a *Adapter) stopNtgConnectStallTimer(chatID int64) { + a.ntgConnectTimersMu.Lock() + t, ok := a.ntgConnectTimers[chatID] + if ok { + delete(a.ntgConnectTimers, chatID) + } + a.ntgConnectTimersMu.Unlock() + if t != nil { + t.Stop() + } +} diff --git a/calls/signaling/tgsig/adapter_race_test.go b/calls/signaling/tgsig/adapter_race_test.go new file mode 100644 index 0000000..1833c85 --- /dev/null +++ b/calls/signaling/tgsig/adapter_race_test.go @@ -0,0 +1,217 @@ +package tgsig + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "gotgcalls/ntgcalls" + + "github.com/zelenin/go-tdlib/client" +) + +// concurrent-safe variant of fakeNtg; vanilla fakeNtg appends to slices +// without locking, unusable under -race +type raceNtg struct { + mu sync.Mutex + onSignal ntgcalls.SignalCallback + onConnCh ntgcalls.ConnectionChangeCallback + stopCount atomic.Int64 + signalSent atomic.Int64 +} + +func (f *raceNtg) OnSignal(cb ntgcalls.SignalCallback) { + f.mu.Lock() + f.onSignal = cb + f.mu.Unlock() +} +func (f *raceNtg) OnConnectionChange(cb ntgcalls.ConnectionChangeCallback) { + f.mu.Lock() + f.onConnCh = cb + f.mu.Unlock() +} +func (f *raceNtg) CreateP2PCall(int64) error { return nil } +func (f *raceNtg) SkipExchange(int64, []byte, bool) error { return nil } +func (f *raceNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error { + return nil +} +func (f *raceNtg) SendSignalingData(int64, []byte) error { + f.signalSent.Add(1) + return nil +} +func (f *raceNtg) Stop(int64) error { + f.stopCount.Add(1) + return nil +} + +// simulates ntgcalls' C++ thread reaching into Go +func (f *raceNtg) fire(chatID int64, data []byte) { + f.mu.Lock() + cb := f.onSignal + f.mu.Unlock() + if cb != nil { + cb(chatID, data) + } +} + +// concurrent-safe variant of fakeTdlib +type raceTdlib struct { + created atomic.Int64 + accepted atomic.Int64 + discarded atomic.Int64 + signalSent atomic.Int64 + nextCallID atomic.Int32 +} + +func (f *raceTdlib) CreateCall(*client.CreateCallRequest) (*client.CallId, error) { + f.created.Add(1) + id := f.nextCallID.Add(1) + return &client.CallId{Id: id}, nil +} +func (f *raceTdlib) AcceptCall(*client.AcceptCallRequest) (*client.Ok, error) { + f.accepted.Add(1) + return &client.Ok{}, nil +} +func (f *raceTdlib) DiscardCall(*client.DiscardCallRequest) (*client.Ok, error) { + f.discarded.Add(1) + return &client.Ok{}, nil +} +func (f *raceTdlib) SendCallSignalingData(*client.SendCallSignalingDataRequest) (*client.Ok, error) { + f.signalSent.Add(1) + return &client.Ok{}, nil +} + +// many dispatchers in parallel with Adapter.Close; -race must not flag +// the ntg/tdlib touch sites, post-Close dispatches must be skipped, and +// every withCallContext must release its RLock or Close blocks forever +func TestAdapter_NoRaceUnderConcurrentDispatchAndClose(t *testing.T) { + tdlib := &raceTdlib{} + ntg := &raceNtg{} + mgr := NewManager(nil) + a := New(tdlib, ntg, nil, "jid@gw", mgr) + + // pre-register a side so dispatchers find something + userID := int64(42) + callee := a.NewCallee(7, userID, false) + cb := &callee.callBase + + bridgeSide := &recCaller{} + calleeSide := &recCallee{} + br := signaling.New(signaling.Config{ + Caller: bridgeSide, Callee: calleeSide, Timers: noopTimers{}, + }) + cb.Bind(br) + br.Start() + br.Ringing() + + var wg sync.WaitGroup + stop := make(chan struct{}) + + // Spammer 1: OnSignal callback (simulates ntgcalls C++ thread). + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + ntg.fire(userID, []byte("data")) + } + }() + + // spammer 2: OnNewSignalingData (tdlib updateHandler) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + mgr.OnNewSignalingData("jid@gw", &client.UpdateNewCallSignalingData{ + CallId: 7, + Data: []byte("data"), + }) + } + }() + + // spammer 3: callBase.Terminate (bridge tear-down attempts) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + cb.Terminate(signaling.ReasonHangup) + } + }() + + // let spammers warm up + time.Sleep(20 * time.Millisecond) + + // close concurrently with the spammers + closeDone := make(chan struct{}) + go func() { + a.Close() + close(closeDone) + }() + + select { + case <-closeDone: + case <-time.After(2 * time.Second): + t.Fatal("Close blocked > 2s - a withCallContext somewhere is not releasing its RLock") + } + + // snapshot counters at the moment Close returned + postCloseSignals := tdlib.signalSent.Load() + postCloseDiscards := tdlib.discarded.Load() + postCloseNtgSignals := ntg.signalSent.Load() + postCloseStops := ntg.stopCount.Load() + + // let spammers run a bit more - counters must stay flat + time.Sleep(50 * time.Millisecond) + close(stop) + wg.Wait() + + if tdlib.signalSent.Load() != postCloseSignals { + t.Errorf("tdlib.signalSent advanced after Close: %d -> %d", + postCloseSignals, tdlib.signalSent.Load()) + } + if tdlib.discarded.Load() != postCloseDiscards { + t.Errorf("tdlib.discarded advanced after Close: %d -> %d", + postCloseDiscards, tdlib.discarded.Load()) + } + if ntg.signalSent.Load() != postCloseNtgSignals { + t.Errorf("ntg.signalSent advanced after Close: %d -> %d", + postCloseNtgSignals, ntg.signalSent.Load()) + } + if ntg.stopCount.Load() != postCloseStops { + t.Errorf("ntg.stopCount advanced after Close: %d -> %d", + postCloseStops, ntg.stopCount.Load()) + } +} + +// Close twice must not panic or race +func TestAdapter_CloseIsIdempotent(t *testing.T) { + tdlib := &raceTdlib{} + ntg := &raceNtg{} + a := New(tdlib, ntg, nil, "jid@gw", NewManager(nil)) + + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + a.Close() + }() + } + wg.Wait() +} diff --git a/calls/signaling/tgsig/base.go b/calls/signaling/tgsig/base.go new file mode 100644 index 0000000..2fe8527 --- /dev/null +++ b/calls/signaling/tgsig/base.go @@ -0,0 +1,130 @@ +package tgsig + +import ( + "sync" + + "dev.narayana.im/narayana/telegabber/calls/audio" + "dev.narayana.im/narayana/telegabber/calls/signaling" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// per-call tg-side state shared by Caller and Callee +type callBase struct { + adapter *Adapter + userID int64 + isVideo bool + // CallerSide / CalleeSide - lets ntg callbacks fire MediaConnected per-side + side signaling.Side + + mu sync.Mutex + bridge *signaling.Bridge + callID int32 +} + +// must run before any callback that re-enters the bridge +func (b *callBase) Bind(br *signaling.Bridge) { + b.mu.Lock() + b.bridge = br + b.mu.Unlock() +} + +func (b *callBase) getBridge() *signaling.Bridge { + b.mu.Lock() + defer b.mu.Unlock() + return b.bridge +} + +func (b *callBase) getCallID() int32 { + b.mu.Lock() + defer b.mu.Unlock() + return b.callID +} + +func (b *callBase) setCallID(id int32) { + b.mu.Lock() + b.callID = id + b.mu.Unlock() +} + +// audio-layer view of the ntgcalls client; nil for signaling-only tests +func (b *callBase) NtgClient() audio.NtgClient { return b.adapter.audioNtg } + +// for P2P calls this is ntgcalls' "chatId" - the remote user_id +func (b *callBase) ChatID() int64 { return b.userID } + +// ask tdlib to discard; map removal happens in cleanup from +// Manager.endCall once tdlib echoes Discarded. +// +// Duration matters: with 0 the call shows up as missed on other tdlib +// clients of the same account. +func (b *callBase) Terminate(reason signaling.TerminationReason) { + callID := b.getCallID() + if callID == 0 { + // CreateCall never succeeded + return + } + var duration int32 + if br := b.getBridge(); br != nil { + duration = int32(br.Duration().Seconds()) + } + b.adapter.withCallContext(func() { + if _, err := b.adapter.tdlib.DiscardCall(&client.DiscardCallRequest{ + CallId: callID, + IsDisconnected: reason == signaling.ReasonMediaFailed, + Duration: duration, + IsVideo: b.isVideo, + ConnectionId: 0, + }); err != nil { + log.WithFields(log.Fields{ + "call_id": callID, + "user_id": b.userID, + "reason": reason, + "duration": duration, + }).WithError(err).Error("DiscardCall failed") + } + }) +} + +// drives the ntgcalls P2P setup on tdlib's CallStateReady +func (b *callBase) onReady(state *client.CallStateReady, isOutgoing bool, entry *log.Entry) { + b.adapter.withCallContext(func() { + a := b.adapter + if err := a.ntg.CreateP2PCall(b.userID); err != nil { + entry.WithError(err).Error("Failed to create P2P call") + return + } + if err := a.ntg.SkipExchange(b.userID, state.EncryptionKey, isOutgoing); err != nil { + entry.WithError(err).Error("Failed to skip exchange") + _ = a.ntg.Stop(b.userID) + return + } + rtcServers := convertCallServers(state.Servers) + versions := state.Protocol.LibraryVersions + if err := a.ntg.ConnectP2P(b.userID, rtcServers, versions, state.AllowP2p); err != nil { + entry.WithError(err).Error("Failed to connect P2P") + _ = a.ntg.Stop(b.userID) + return + } + entry.Info("P2P connection established") + }) +} + +// drops the side from both maps and stops ntgcalls +func (b *callBase) cleanup() { + b.adapter.withCallContext(func() { + a := b.adapter + callID := b.getCallID() + if callID != 0 { + a.mgr.Drop(a.jid, callID) + } + a.dropSide(b.userID) + if err := a.ntg.Stop(b.userID); err != nil { + log.WithFields(log.Fields{ + "call_id": callID, + "user_id": b.userID, + }).WithError(err).Debug("ntgcalls Stop returned error (call may not have started)") + } + }) +} diff --git a/calls/signaling/tgsig/callee.go b/calls/signaling/tgsig/callee.go new file mode 100644 index 0000000..4c6c2c9 --- /dev/null +++ b/calls/signaling/tgsig/callee.go @@ -0,0 +1,34 @@ +package tgsig + +import ( + "dev.narayana.im/narayana/telegabber/calls/signaling" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// tg-side Callee for TG-originated calls +type Callee struct { + callBase +} + +// no-op on the tg side; tdlib drives Pending{IsReceived:true} instead +func (c *Callee) Ringing() {} + +func (c *Callee) Accept() { + callID := c.getCallID() + entry := log.WithFields(log.Fields{"call_id": callID, "user_id": c.userID}) + c.adapter.withCallContext(func() { + if _, err := c.adapter.tdlib.AcceptCall(&client.AcceptCallRequest{ + CallId: callID, + Protocol: callProtocol(), + }); err != nil { + entry.WithError(err).Error("tgsig.Callee.Accept: AcceptCall failed") + if b := c.getBridge(); b != nil { + go b.MediaFailed(signaling.ReasonUnknown) + } + return + } + entry.Info("AcceptCall issued") + }) +} diff --git a/calls/signaling/tgsig/caller.go b/calls/signaling/tgsig/caller.go new file mode 100644 index 0000000..dda5f1e --- /dev/null +++ b/calls/signaling/tgsig/caller.go @@ -0,0 +1,36 @@ +package tgsig + +import ( + "dev.narayana.im/narayana/telegabber/calls/signaling" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// tg-side Caller for XMPP-originated calls +type Caller struct { + callBase +} + +// invoked by Bridge under its mutex; bridge re-entry must be deferred +func (c *Caller) Start() { + entry := log.WithFields(log.Fields{"user_id": c.userID, "is_video": c.isVideo, "jid": c.adapter.jid}) + c.adapter.withCallContext(func() { + resp, err := c.adapter.tdlib.CreateCall(&client.CreateCallRequest{ + UserId: c.userID, + Protocol: callProtocol(), + IsVideo: c.isVideo, + }) + if err != nil { + entry.WithError(err).Error("tgsig.Caller.Start: CreateCall failed") + if b := c.getBridge(); b != nil { + go b.Terminate(signaling.ReasonUnknown) + } + return + } + c.setCallID(resp.Id) + c.adapter.registerSide(c.userID, &c.callBase) + c.adapter.mgr.Register(c.adapter.jid, resp.Id, &c.callBase) + entry.WithField("call_id", resp.Id).Info("CreateCall issued") + }) +} diff --git a/calls/signaling/tgsig/convert.go b/calls/signaling/tgsig/convert.go new file mode 100644 index 0000000..a605833 --- /dev/null +++ b/calls/signaling/tgsig/convert.go @@ -0,0 +1,48 @@ +// tdlib <-> ntgcalls type conversions for call setup +package tgsig + +import ( + "gotgcalls/ntgcalls" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +func callProtocol() *client.CallProtocol { + p := ntgcalls.GetProtocol() + return &client.CallProtocol{ + UdpP2p: p.UdpP2P, + UdpReflector: p.UdpReflector, + MinLayer: p.MinLayer, + MaxLayer: p.MaxLayer, + LibraryVersions: p.Versions, + } +} + +func convertCallServers(servers []*client.CallServer) []ntgcalls.RTCServer { + out := make([]ntgcalls.RTCServer, 0, len(servers)) + for _, s := range servers { + rtc := ntgcalls.RTCServer{ + ID: int64(s.Id), + Ipv4: s.IpAddress, + Ipv6: s.Ipv6Address, + Port: s.Port, + } + switch t := s.Type.(type) { + case *client.CallServerTypeTelegramReflector: + rtc.PeerTag = t.PeerTag + rtc.Tcp = t.IsTcp + rtc.Turn = true + case *client.CallServerTypeWebrtc: + rtc.Username = t.Username + rtc.Password = t.Password + rtc.Turn = t.SupportsTurn + rtc.Stun = t.SupportsStun + default: + log.WithField("type", s.Type.CallServerTypeType()).Warn("Unknown call server type") + continue + } + out = append(out, rtc) + } + return out +} diff --git a/calls/signaling/tgsig/manager.go b/calls/signaling/tgsig/manager.go new file mode 100644 index 0000000..3b67615 --- /dev/null +++ b/calls/signaling/tgsig/manager.go @@ -0,0 +1,207 @@ +package tgsig + +import ( + "sync" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// runs for a brand-new inbound Pending; must build the bridge, register +// the Callee with Manager.Register, and start the bridge; false drops the call +type IncomingCallHandler func(jid string, callID int32, userID int64, isVideo bool) (ok bool) + +// router for tdlib call updates; callIDs are per-tdlib-client so key is (jid, callID) +type Manager struct { + mu sync.Mutex + sides map[managerKey]*callBase + incoming IncomingCallHandler +} + +type managerKey struct { + jid string + callID int32 +} + +// h may be nil and set later via SetIncomingHandler +func NewManager(h IncomingCallHandler) *Manager { + return &Manager{ + sides: make(map[managerKey]*callBase), + incoming: h, + } +} + +func (m *Manager) SetIncomingHandler(h IncomingCallHandler) { + m.mu.Lock() + m.incoming = h + m.mu.Unlock() +} + +func (m *Manager) incomingHandler() IncomingCallHandler { + m.mu.Lock() + defer m.mu.Unlock() + return m.incoming +} + +func (m *Manager) Register(jid string, callID int32, s *callBase) { + if s == nil { + return + } + m.mu.Lock() + m.sides[managerKey{jid, callID}] = s + m.mu.Unlock() +} + +func (m *Manager) Drop(jid string, callID int32) { + m.mu.Lock() + delete(m.sides, managerKey{jid, callID}) + m.mu.Unlock() +} + +func (m *Manager) lookup(jid string, callID int32) *callBase { + m.mu.Lock() + defer m.mu.Unlock() + return m.sides[managerKey{jid, callID}] +} + +func (m *Manager) OnUpdateCall(jid string, u *client.UpdateCall) { + call := u.Call + entry := log.WithFields(log.Fields{ + "jid": jid, + "call_id": call.Id, + "user_id": call.UserId, + "is_outgoing": call.IsOutgoing, + "is_video": call.IsVideo, + }) + + switch call.State.CallStateType() { + case client.TypeCallStatePending: + entry.Info("Call pending") + if call.IsOutgoing { + // Pending fires twice: server-created (IsReceived=false) and + // peer-notified (IsReceived=true); only the second is ringing. + // Near-instant pickups may skip the second one - the bridge + // state guard at ExchangingKeys covers that. + pending, _ := call.State.(*client.CallStatePending) + if pending == nil || !pending.IsReceived { + return + } + if b := m.bridgeFor(jid, call.Id); b != nil { + b.Ringing() + } + return + } + if existing := m.lookup(jid, call.Id); existing != nil { + // duplicate Pending + return + } + h := m.incomingHandler() + if h == nil { + entry.Warn("Inbound call but no incoming-call handler wired; ignoring") + return + } + if ok := h(jid, call.Id, call.UserId, call.IsVideo); !ok { + entry.Warn("Incoming-call handler dropped the call") + } + + case client.TypeCallStateExchangingKeys: + entry.Info("Call exchanging keys") + // outbound: peer accepted, bridge fires Callee.Accept on xmpp side + // inbound: AcceptCall already ran, state guard makes this a no-op + if b := m.bridgeFor(jid, call.Id); b != nil { + b.CalleeAccepted() + } + + case client.TypeCallStateReady: + state, ok := call.State.(*client.CallStateReady) + if !ok { + entry.Error("Failed to cast CallStateReady") + return + } + entry.Info("Call ready, setting up P2P connection") + s := m.lookup(jid, call.Id) + if s == nil { + entry.Warn("Ready for unknown call") + return + } + s.onReady(state, call.IsOutgoing, entry) + + case client.TypeCallStateHangingUp: + entry.Info("Call hanging up") + m.endCall(jid, call.Id, signaling.ReasonHangup) + + case client.TypeCallStateDiscarded: + reason := "unknown" + if disc, ok := call.State.(*client.CallStateDiscarded); ok && disc.Reason != nil { + reason = disc.Reason.CallDiscardReasonType() + } + entry.WithField("reason", reason).Info("Call discarded") + m.endCall(jid, call.Id, mapDiscardReason(reason)) + + case client.TypeCallStateError: + errMsg := "unknown" + if errState, ok := call.State.(*client.CallStateError); ok && errState.Error != nil { + errMsg = errState.Error.Message + } + entry.WithField("error", errMsg).Error("Call error") + m.endCall(jid, call.Id, signaling.ReasonMediaFailed) + } +} + +// forwards tdlib signaling data into ntgcalls +func (m *Manager) OnNewSignalingData(jid string, u *client.UpdateNewCallSignalingData) { + s := m.lookup(jid, u.CallId) + if s == nil { + log.WithFields(log.Fields{ + "jid": jid, + "call_id": u.CallId, + }).Warn("Signaling data for unknown call") + return + } + s.adapter.withCallContext(func() { + if err := s.adapter.ntg.SendSignalingData(s.userID, u.Data); err != nil { + log.WithFields(log.Fields{ + "jid": jid, + "call_id": u.CallId, + "user_id": s.userID, + }).WithError(err).Error("Failed to forward signaling data to ntgcalls") + } + }) +} + +func (m *Manager) bridgeFor(jid string, callID int32) *signaling.Bridge { + s := m.lookup(jid, callID) + if s == nil { + return nil + } + return s.getBridge() +} + +// unified teardown for HangingUp / Discarded / Error +func (m *Manager) endCall(jid string, callID int32, reason signaling.TerminationReason) { + s := m.lookup(jid, callID) + if s == nil { + return + } + if b := s.getBridge(); b != nil { + b.Terminate(reason) + } + s.cleanup() +} + +func mapDiscardReason(tdlibReason string) signaling.TerminationReason { + switch tdlibReason { + case "callDiscardReasonDeclined": + return signaling.ReasonDecline + case "callDiscardReasonMissed", "callDiscardReasonExpired": + return signaling.ReasonRingTimeout + case "callDiscardReasonDisconnected": + return signaling.ReasonMediaFailed + case "callDiscardReasonHungUp", "callDiscardReasonEmpty": + return signaling.ReasonHangup + default: + return signaling.ReasonUnknown + } +} diff --git a/calls/signaling/tgsig/manager_test.go b/calls/signaling/tgsig/manager_test.go new file mode 100644 index 0000000..ceba59a --- /dev/null +++ b/calls/signaling/tgsig/manager_test.go @@ -0,0 +1,304 @@ +package tgsig + +import ( + "testing" + "time" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "gotgcalls/ntgcalls" + + "github.com/zelenin/go-tdlib/client" +) + +type fakeTdlib struct { + createCalls []*client.CreateCallRequest + acceptCalls []*client.AcceptCallRequest + discardCalls []*client.DiscardCallRequest + sentSignal []*client.SendCallSignalingDataRequest + nextCallID int32 +} + +func (f *fakeTdlib) CreateCall(req *client.CreateCallRequest) (*client.CallId, error) { + f.createCalls = append(f.createCalls, req) + f.nextCallID++ + return &client.CallId{Id: f.nextCallID}, nil +} +func (f *fakeTdlib) AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) { + f.acceptCalls = append(f.acceptCalls, req) + return &client.Ok{}, nil +} +func (f *fakeTdlib) DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) { + f.discardCalls = append(f.discardCalls, req) + return &client.Ok{}, nil +} +func (f *fakeTdlib) SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) { + f.sentSignal = append(f.sentSignal, req) + return &client.Ok{}, nil +} + +type fakeNtg struct { + onSignal ntgcalls.SignalCallback + onConnCh ntgcalls.ConnectionChangeCallback + stopCalls []int64 +} + +func (f *fakeNtg) OnSignal(cb ntgcalls.SignalCallback) { f.onSignal = cb } +func (f *fakeNtg) OnConnectionChange(cb ntgcalls.ConnectionChangeCallback) { f.onConnCh = cb } +func (f *fakeNtg) CreateP2PCall(int64) error { return nil } +func (f *fakeNtg) SkipExchange(int64, []byte, bool) error { return nil } +func (f *fakeNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error { + return nil +} +func (f *fakeNtg) SendSignalingData(int64, []byte) error { return nil } +func (f *fakeNtg) Stop(chatID int64) error { + f.stopCalls = append(f.stopCalls, chatID) + return nil +} + +// recorders sitting in place of the real per-side handlers, so tests can +// observe which side the bridge fires Terminate on + +type recCaller struct { + started bool + terminated bool + terminateReason signaling.TerminationReason +} + +func (r *recCaller) Start() { r.started = true } +func (r *recCaller) Terminate(reason signaling.TerminationReason) { + r.terminated = true + r.terminateReason = reason +} + +type recCallee struct { + ringing bool + accepted bool + terminated bool + terminateReason signaling.TerminationReason +} + +func (r *recCallee) Ringing() { r.ringing = true } +func (r *recCallee) Accept() { r.accepted = true } +func (r *recCallee) Terminate(reason signaling.TerminationReason) { + r.terminated = true + r.terminateReason = reason +} + +type noopTimers struct{} + +func (noopTimers) Set(signaling.TimerKind, time.Duration) {} +func (noopTimers) Cancel(signaling.TimerKind) {} + +type tgFixture struct { + mgr *Manager + adapter *Adapter + tdlib *fakeTdlib + ntg *fakeNtg + caller *recCaller + callee *recCallee + bridge *signaling.Bridge +} + +func newFixture() *tgFixture { + tdlib := &fakeTdlib{} + ntg := &fakeNtg{} + mgr := NewManager(nil) + a := New(tdlib, ntg, nil, "jid@gw", mgr) + return &tgFixture{ + mgr: mgr, adapter: a, tdlib: tdlib, ntg: ntg, + caller: &recCaller{}, callee: &recCallee{}, + } +} + +// TG-originated bridge: tg is the bridge's Callee, recCaller plays the xmpp +// side. Returns *callBase so the test can drive its state. +func (f *tgFixture) buildTGOriginated(callID int32, userID int64) *callBase { + tgSide := f.adapter.NewCallee(callID, userID, false) + f.bridge = signaling.New(signaling.Config{ + Caller: f.caller, // xmpp-side (recorder) + Callee: f.callee, // tg-side (recorder; the real tg side is registered with the Manager for endCall lookup) + Timers: noopTimers{}, + }) + tgSide.callBase.Bind(f.bridge) + return &tgSide.callBase +} + +// buildXmppOriginated wires an XMPP-originated bridge: tg-side is the bridge's +// Caller. Manually registers the callBase since NewCaller doesn't auto-register +// (production code registers from Caller.Start after tdlib.CreateCall succeeds). +func (f *tgFixture) buildXmppOriginated(callID int32, userID int64) *callBase { + tgSide := f.adapter.NewCaller(userID, false) + tgSide.callBase.setCallID(callID) + f.adapter.registerSide(userID, &tgSide.callBase) + f.mgr.Register(f.adapter.jid, callID, &tgSide.callBase) + f.bridge = signaling.New(signaling.Config{ + Caller: f.caller, // tg-side here (recorder; real tg side is what the Manager has) + Callee: f.callee, // xmpp-side + Timers: noopTimers{}, + }) + tgSide.callBase.Bind(f.bridge) + return &tgSide.callBase +} + +func discardUpdate(callID int32, userID int64, isOutgoing bool, reason client.CallDiscardReason) *client.UpdateCall { + return &client.UpdateCall{ + Call: &client.Call{ + Id: callID, + UserId: userID, + IsOutgoing: isOutgoing, + IsVideo: false, + State: &client.CallStateDiscarded{Reason: reason}, + }, + } +} + +func hangingUpUpdate(callID int32, userID int64, isOutgoing bool) *client.UpdateCall { + return &client.UpdateCall{ + Call: &client.Call{ + Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false, + State: &client.CallStateHangingUp{}, + }, + } +} + +func pendingUpdate(callID int32, userID int64, isOutgoing, isReceived bool) *client.UpdateCall { + return &client.UpdateCall{ + Call: &client.Call{ + Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false, + State: &client.CallStatePending{IsCreated: true, IsReceived: isReceived}, + }, + } +} + +// every tdlib teardown event -> bridge.Terminate on both halves with the +// mapped reason; catches new discard reasons leaking through as Unknown +func TestTGTeardownMatrix_TGOriginated(t *testing.T) { + errorUpdate := func(callID int32, userID int64) *client.UpdateCall { + return &client.UpdateCall{Call: &client.Call{ + Id: callID, UserId: userID, IsOutgoing: false, + State: &client.CallStateError{Error: &client.Error{Code: 500, Message: "boom"}}, + }} + } + cases := []struct { + name string + build func(callID int32, userID int64) *client.UpdateCall + want signaling.TerminationReason + }{ + {"discard/declined", func(c int32, u int64) *client.UpdateCall { + return discardUpdate(c, u, false, &client.CallDiscardReasonDeclined{}) + }, signaling.ReasonDecline}, + {"discard/missed", func(c int32, u int64) *client.UpdateCall { + return discardUpdate(c, u, false, &client.CallDiscardReasonMissed{}) + }, signaling.ReasonRingTimeout}, + {"discard/disconnected", func(c int32, u int64) *client.UpdateCall { + return discardUpdate(c, u, false, &client.CallDiscardReasonDisconnected{}) + }, signaling.ReasonMediaFailed}, + {"discard/hungUp", func(c int32, u int64) *client.UpdateCall { + return discardUpdate(c, u, false, &client.CallDiscardReasonHungUp{}) + }, signaling.ReasonHangup}, + {"discard/empty", func(c int32, u int64) *client.UpdateCall { + return discardUpdate(c, u, false, &client.CallDiscardReasonEmpty{}) + }, signaling.ReasonHangup}, + {"hangingUp", func(c int32, u int64) *client.UpdateCall { + return hangingUpUpdate(c, u, false) + }, signaling.ReasonHangup}, + {"error", errorUpdate, signaling.ReasonMediaFailed}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture() + callID := int32(500 + i) + userID := int64(8000 + i) + f.buildTGOriginated(callID, userID) + f.bridge.Start() + f.bridge.Ringing() + + f.mgr.OnUpdateCall("jid@gw", tc.build(callID, userID)) + + if !f.caller.terminated || !f.callee.terminated { + t.Fatalf("both sides should tear down: caller=%v callee=%v", f.caller.terminated, f.callee.terminated) + } + if f.caller.terminateReason != tc.want || f.callee.terminateReason != tc.want { + t.Errorf("reason: caller=%v callee=%v, want %v", f.caller.terminateReason, f.callee.terminateReason, tc.want) + } + }) + } +} + +// XMPP-originated outbound: transition to Ringing only on Pending{IsReceived:true}. +// Pending{IsReceived:false} (server-created, peer not notified) must not transition, +// or goes out before the peer is alerting. +func TestTGPending_XMPPOriginated_DrivesBridgeToRinging(t *testing.T) { + f := newFixture() + f.buildXmppOriginated(303, 9303) + f.bridge.Start() + + if got := f.bridge.State(); got != signaling.StateOriginating { + t.Fatalf("pre-Pending state = %s, want originating", got) + } + + // first Pending: server-created, must NOT ring + f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, false)) + if got := f.bridge.State(); got != signaling.StateOriginating { + t.Fatalf("post-Pending(IsReceived=false) state = %s, want originating", got) + } + if f.callee.ringing { + t.Error("xmpp-Callee.Ringing fired on Pending(IsReceived=false); want only on IsReceived=true") + } + + // second Pending: peer device alerting, ring + f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, true)) + if got := f.bridge.State(); got != signaling.StateRinging { + t.Fatalf("post-Pending(IsReceived=true) state = %s, want ringing", got) + } + if !f.callee.ringing { + t.Error("xmpp-Callee.Ringing did not fire on Pending(IsReceived=true)") + } + + // duplicate Pending(IsReceived=true) no-ops via Bridge.Ringing's state guard + f.callee.ringing = false + f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, true)) + if got := f.bridge.State(); got != signaling.StateRinging { + t.Fatalf("after duplicate Pending state = %s, want ringing", got) + } + if f.callee.ringing { + t.Error("xmpp-Callee.Ringing re-fired on duplicate Pending; want once") + } + + // ExchangingKeys drives Ringing -> Accepted. Accept is called without + // re-emitting : the ringback window between the two stanzas + // is the time the user spent looking at their phone, not 30ms. + f.mgr.OnUpdateCall("jid@gw", &client.UpdateCall{ + Call: &client.Call{ + Id: 303, UserId: 9303, IsOutgoing: true, IsVideo: false, + State: &client.CallStateExchangingKeys{}, + }, + }) + if got := f.bridge.State(); got != signaling.StateAccepted { + t.Fatalf("post-ExchangingKeys state = %s, want accepted", got) + } + if !f.callee.accepted { + t.Error("xmpp-Callee.Accept did not fire on ExchangingKeys") + } +} + +// endCall must drop registrations: subsequent OnUpdateCall for the same +// (jid, callID) is a no-op (no side gets re-fired). +func TestTGDiscard_DropsRegistrationFromManager(t *testing.T) { + f := newFixture() + f.buildTGOriginated(111, 9011) + f.bridge.Start() + + f.mgr.OnUpdateCall("jid@gw", discardUpdate(111, 9011, false, &client.CallDiscardReasonHungUp{})) + if got := f.mgr.lookup("jid@gw", 111); got != nil { + t.Errorf("callBase still registered after discard: %v", got) + } + // dropSide on the adapter + if _, ok := f.adapter.lookupSideByUser(9011); ok { + t.Errorf("adapter still has user 9011 after discard") + } + // ntgcalls Stop was called + if len(f.ntg.stopCalls) != 1 || f.ntg.stopCalls[0] != 9011 { + t.Errorf("ntg.Stop calls = %v, want [9011]", f.ntg.stopCalls) + } +} diff --git a/calls/signaling/timers.go b/calls/signaling/timers.go new file mode 100644 index 0000000..f4104ca --- /dev/null +++ b/calls/signaling/timers.go @@ -0,0 +1,48 @@ +package signaling + +import ( + "sync" + "time" +) + +// AfterFunc-backed Timers, one per kind, firing Bridge.Timeout. +// Wiring is two-step because Bridge and timers want each other at +// construction: NewStdTimers, New(...), SetBridge. +type StdTimers struct { + mu sync.Mutex + bridge *Bridge + t map[TimerKind]*time.Timer +} + +func NewStdTimers() *StdTimers { + return &StdTimers{t: make(map[TimerKind]*time.Timer)} +} + +func (s *StdTimers) SetBridge(b *Bridge) { + s.mu.Lock() + defer s.mu.Unlock() + s.bridge = b +} + +func (s *StdTimers) Set(kind TimerKind, dur time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + if old, ok := s.t[kind]; ok { + old.Stop() + } + b := s.bridge + s.t[kind] = time.AfterFunc(dur, func() { + if b != nil { + b.Timeout(kind) + } + }) +} + +func (s *StdTimers) Cancel(kind TimerKind) { + s.mu.Lock() + defer s.mu.Unlock() + if old, ok := s.t[kind]; ok { + old.Stop() + delete(s.t, kind) + } +} diff --git a/calls/signaling/xmppsig/adapter.go b/calls/signaling/xmppsig/adapter.go new file mode 100644 index 0000000..d22869b --- /dev/null +++ b/calls/signaling/xmppsig/adapter.go @@ -0,0 +1,344 @@ +// xmpp side of signaling.Bridge; one Adapter per session, +// sharing a *jingle.Manager for stanza dispatch +package xmppsig + +import ( + "fmt" + "strconv" + "strings" + "sync" + "sync/atomic" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "github.com/pion/webrtc/v4" + log "github.com/sirupsen/logrus" +) + +type AdapterConfig struct { + Sender jingle.Sender // *xmpp.Component satisfies this + LocalJID string // gateway's bare JID + Manager *jingle.Manager + // fresh PC per call, with the transceivers/tracks SDP negotiation needs + PCFactory func() (*webrtc.PeerConnection, error) +} + +type Adapter struct { + cfg AdapterConfig +} + +func NewAdapter(cfg AdapterConfig) *Adapter { return &Adapter{cfg: cfg} } +func (a *Adapter) Manager() *jingle.Manager { return a.cfg.Manager } +func (a *Adapter) LocalJID() string { return a.cfg.LocalJID } + +// xmpp side for a TG-originated call; PC and session built upfront so a +// failure unwinds both. Bind must run before bridge.Start. +func (a *Adapter) NewCaller(remoteJID string, callerUserID int64) (*Caller, error) { + pc, err := a.cfg.PCFactory() + if err != nil { + return nil, fmt.Errorf("PCFactory: %w", err) + } + // XEP-0353 requires a full JID; /telegabber matches the gateway's other + // per-contact outbound stanzas + localJID := fmt.Sprintf("%d@%s/telegabber", callerUserID, a.cfg.LocalJID) + c := &Caller{xmppBase: xmppBase{ + a: a, remote: remoteJID, pc: pc, localJID: localJID, + side: signaling.CallerSide, + }} + // must install OnTrack forwarder before SetRemoteDescription, otherwise + // pion fires OnTrack during SDP processing and a late handler misses it + c.hookTrack(pc) + + sid := newSID() + sess, err := jingle.New(jingle.SessionOpts{ + PC: pc, Sender: a.cfg.Sender, + LocalJID: localJID, RemoteJID: remoteJID, + SID: sid, Role: jingle.RoleInitiator, Media: []string{"audio"}, + Observer: c, + }) + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("jingle.New: %w", err) + } + c.set(sid, sess) + return c, nil +} + +// xmpp side for an XMPP-originated call; Bind must run before bridge.Start +func (a *Adapter) NewCallee(p jingle.IncomingProposal) (*Callee, *jingle.Session, error) { + pc, err := a.cfg.PCFactory() + if err != nil { + return nil, nil, fmt.Errorf("PCFactory: %w", err) + } + + c := &Callee{xmppBase: xmppBase{a: a, remote: p.From, pc: pc, side: signaling.CalleeSide}} + c.hookTrack(pc) + // LocalJID is the propose's `to`, not the bare component domain; + // anotherim's libdino filters proceed with from.equals_bare(peer_state.jid) + // so a proceed from the bare component domain silently fails to match + // and anotherim never sends session-initiate + localJID := p.To + if localJID == "" { + localJID = a.cfg.LocalJID + } + // Conversations' RtpSessionActivity crashes on bare-JID `with` once ICE + // completes (IllegalStateException "No RTP connection found"); real + // callees always send from a full JID. Stick the SID on as + // resource so concurrent calls don't collide. + if !strings.ContainsRune(localJID, '/') && p.SID != "" { + localJID = localJID + "/" + p.SID + } + sess, err := jingle.New(jingle.SessionOpts{ + PC: pc, Sender: a.cfg.Sender, + LocalJID: localJID, RemoteJID: p.From, + SID: p.SID, Role: jingle.RoleResponder, Media: p.Media, + Observer: c, + }) + if err != nil { + _ = pc.Close() + return nil, nil, err + } + c.set(p.SID, sess) + return c, sess, nil +} + +// wires pion's connection-state callback to bridge media transitions. +// Disconnected can be transient (ICE restart) so only Failed reacts. +// side dedups MediaConnected per source. Called from xmppBase.Bind. +func hookPCState(pc *webrtc.PeerConnection, br *signaling.Bridge, side signaling.Side) { + if pc == nil || br == nil { + return + } + // DTLS logger lazily on first state change - the transceiver's + // DTLSTransport doesn't exist until SetLocalDescription runs + var dtlsHookOnce sync.Once + pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { + dtlsHookOnce.Do(func() { hookDTLSState(pc) }) + entry := log.WithField("state", s.String()) + // log the selected ICE pair on Connected - flaky calls have happened + // when pion picked a docker-bridge or Tailscale pair whose STUN pings + // succeed but real RTP doesn't transit. SCTP isn't negotiated for + // audio-only, so reach the ICE transport via any RTP sender's + // DTLSTransport (BUNDLE means they all share one). + if s == webrtc.PeerConnectionStateConnected { + var dtls *webrtc.DTLSTransport + for _, snd := range pc.GetSenders() { + if snd != nil && snd.Transport() != nil { + dtls = snd.Transport() + break + } + } + if dtls == nil { + for _, r := range pc.GetReceivers() { + if r != nil && r.Transport() != nil { + dtls = r.Transport() + break + } + } + } + switch { + case dtls == nil: + entry = entry.WithField("ice_pair", "no-dtls-transport") + case dtls.ICETransport() == nil: + entry = entry.WithField("ice_pair", "no-ice-transport") + default: + pair, err := dtls.ICETransport().GetSelectedCandidatePair() + switch { + case err != nil: + entry = entry.WithError(err).WithField("ice_pair", "get-selected-failed") + case pair == nil || pair.Local == nil || pair.Remote == nil: + entry = entry.WithField("ice_pair", "nil") + default: + entry = entry.WithFields(log.Fields{ + "local": fmt.Sprintf("%s:%d (%s)", pair.Local.Address, pair.Local.Port, pair.Local.Typ.String()), + "remote": fmt.Sprintf("%s:%d (%s)", pair.Remote.Address, pair.Remote.Port, pair.Remote.Typ.String()), + "local_related": pair.Local.RelatedAddress, + }) + } + } + } + entry.Debug("pion: PC connection state") + switch s { + case webrtc.PeerConnectionStateConnected: + br.MediaConnected(side) + case webrtc.PeerConnectionStateFailed: + br.MediaFailed(signaling.ReasonMediaFailed) + } + }) + pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) { + log.WithField("state", s.String()).Debug("pion: ICE connection state") + }) + pc.OnICEGatheringStateChange(func(s webrtc.ICEGatheringState) { + log.WithField("state", s.String()).Debug("pion: ICE gathering state") + }) + // do NOT register pc.OnICECandidate here - jingle.New attaches the + // session's trickle handler there, pion's API is replace-only, and + // overwriting it strands srflx/relay candidates -> breaks TURN-only paths +} + +// logs DTLS state - separates "handshake never completed" from "completed +// but no audio" (SRTP/SSRC/codec). Called from the first state change so +// the transport actually exists. +func hookDTLSState(pc *webrtc.PeerConnection) { + var dtls *webrtc.DTLSTransport + for _, s := range pc.GetSenders() { + if s != nil && s.Transport() != nil { + dtls = s.Transport() + break + } + } + if dtls == nil { + for _, r := range pc.GetReceivers() { + if r != nil && r.Transport() != nil { + dtls = r.Transport() + break + } + } + } + if dtls == nil { + log.Debug("pion: no DTLSTransport available at hook time; DTLS state changes will go unlogged") + return + } + dtls.OnStateChange(func(s webrtc.DTLSTransportState) { + log.WithField("state", s.String()).Info("pion: DTLS transport state") + }) +} + +func mapReason(r signaling.TerminationReason) string { + switch r { + case signaling.ReasonHangup: + return jingle.ReasonSuccess + case signaling.ReasonDecline: + return jingle.ReasonDecline + case signaling.ReasonBusy: + return jingle.ReasonBusy + case signaling.ReasonRingTimeout: + return jingle.ReasonTimeout + case signaling.ReasonExchangeTimeout, signaling.ReasonConnectTimeout: + return jingle.ReasonConnectivityError + case signaling.ReasonMediaFailed: + return jingle.ReasonFailedTransport + case signaling.ReasonPeerGone: + return jingle.ReasonGone + default: + return jingle.ReasonGeneralError + } +} + +func reasonFromCondition(cond string) signaling.TerminationReason { + switch cond { + case "rejected", jingle.ReasonDecline: + return signaling.ReasonDecline + case "retracted", jingle.ReasonGone, jingle.ReasonCancel: + return signaling.ReasonPeerGone + case jingle.ReasonBusy: + return signaling.ReasonBusy + case jingle.ReasonTimeout: + return signaling.ReasonRingTimeout + case jingle.ReasonConnectivityError: + return signaling.ReasonConnectTimeout + case jingle.ReasonFailedTransport, jingle.ReasonFailedApplication: + return signaling.ReasonMediaFailed + default: + return signaling.ReasonHangup + } +} + +var sidCounter atomic.Uint64 + +func newSID() string { + return fmt.Sprintf("tg-call-%d", sidCounter.Add(1)) +} + +// telegram user-id from a JID localpart: "12345@gw.example" -> (12345, true) +func ParseTargetJID(jid string) (int64, bool) { + parts := strings.SplitN(jid, "@", 2) + if len(parts) == 0 || parts[0] == "" { + return 0, false + } + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, false + } + return id, true +} + +// PC with no ICE servers - for tests; production uses MakePCFactory +func DefaultPCFactory() (*webrtc.PeerConnection, error) { + return buildPC(nil) +} + +// PCFactory pulling ICE servers from provider per call, so short-lived TURN +// creds refresh without restarting the component +func MakePCFactory(provider func() []webrtc.ICEServer) func() (*webrtc.PeerConnection, error) { + return func() (*webrtc.PeerConnection, error) { + var servers []webrtc.ICEServer + if provider != nil { + servers = provider() + } + return buildPC(servers) + } +} + +func buildPC(iceServers []webrtc.ICEServer) (*webrtc.PeerConnection, error) { + // SetHandleUndeclaredSSRCWithoutAnswer: anotherim's libnice starts + // sending RTP the moment ICE is selected, which often beats the local + // SetRemoteDescription(answer) - so pion drops the inbound track with + // "Incoming unhandled RTP ssrc(N), OnTrack will not be fired" and the + // peer's audio never reaches TG. With this flag pion buffers early RTP. + s := webrtc.SettingEngine{} + s.SetHandleUndeclaredSSRCWithoutAnswer(true) + m := &webrtc.MediaEngine{} + if err := m.RegisterDefaultCodecs(); err != nil { + return nil, fmt.Errorf("RegisterDefaultCodecs: %w", err) + } + api := webrtc.NewAPI(webrtc.WithSettingEngine(s), webrtc.WithMediaEngine(m)) + pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers}) + if err != nil { + return nil, err + } + // placeholder opus track BEFORE the transceiver (not just a kind). + // Otherwise pion's RTPSender.Send leaves hasSent=false, the later + // ReplaceTrack short-circuits without Bind, the real track has no + // packetizer, and WriteSample silently drops every sample. Caller + // path always hit this because setupAudio attaches the real track + // after SetLocalDescription. + placeholder, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + SDPFmtpLine: "minptime=10;useinbandfec=1", + }, + "audio-placeholder", + "telegabber-placeholder", + ) + if err != nil { + _ = pc.Close() + return nil, fmt.Errorf("placeholder track: %w", err) + } + tr, err := pc.AddTransceiverFromTrack(placeholder, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }) + if err != nil { + _ = pc.Close() + return nil, err + } + // drain RTCP off the outgoing sender, otherwise its queue can stall + // under load. exits when pc.Close makes Read error out. + go drainSenderRTCP(tr.Sender()) + return pc, nil +} + +func drainSenderRTCP(sender *webrtc.RTPSender) { + if sender == nil { + return + } + buf := make([]byte, 1500) + for { + if _, _, err := sender.Read(buf); err != nil { + return + } + } +} diff --git a/calls/signaling/xmppsig/base.go b/calls/signaling/xmppsig/base.go new file mode 100644 index 0000000..2130428 --- /dev/null +++ b/calls/signaling/xmppsig/base.go @@ -0,0 +1,128 @@ +package xmppsig + +import ( + "sync" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "github.com/pion/webrtc/v4" +) + +// per-call state shared by Caller and Callee +type xmppBase struct { + a *Adapter + remote string + // full JID we act as on the wire; falls back to a.cfg.LocalJID + localJID string + // CallerSide/CalleeSide for Bind's MediaConnected wiring + side signaling.Side + + mu sync.Mutex + sid string + session *jingle.Session + bridge *signaling.Bridge + pc *webrtc.PeerConnection + remoteSDPSeen bool + onRemoteSDP func(sdp string) + + // pion fires OnTrack during SetRemoteDescription, before any handler + // the orchestrator might want to install - so hookTrack runs a permanent + // forwarder at PC construction and buffers into pendingTracks until + // SetTrackHandler arrives + onTrackFn func(t *webrtc.TrackRemote) + pendingTracks []*webrtc.TrackRemote +} + +func (b *xmppBase) set(sid string, sess *jingle.Session) { + b.mu.Lock() + b.sid, b.session = sid, sess + b.mu.Unlock() +} + +// attaches the bridge and wires pion's connection-state callback +func (b *xmppBase) Bind(br *signaling.Bridge) { + b.mu.Lock() + b.bridge = br + pc := b.pc + side := b.side + b.mu.Unlock() + hookPCState(pc, br, side) +} + +func (b *xmppBase) bridgeRef() *signaling.Bridge { + b.mu.Lock() + defer b.mu.Unlock() + return b.bridge +} + +func (b *xmppBase) sessionRef() *jingle.Session { + b.mu.Lock() + defer b.mu.Unlock() + return b.session +} + +func (b *xmppBase) cleanup() { + b.mu.Lock() + sid := b.sid + b.mu.Unlock() + if sid == "" { + return + } + b.a.cfg.Manager.Unregister(sid) +} + +func (b *xmppBase) PeerConnection() *webrtc.PeerConnection { + b.mu.Lock() + defer b.mu.Unlock() + return b.pc +} + +func (b *xmppBase) SetOnRemoteSDP(fn func(sdp string)) { + b.mu.Lock() + b.onRemoteSDP = fn + b.mu.Unlock() +} + +// must run before the first SetRemoteDescription, or pion's first +// OnTrack event is lost +func (b *xmppBase) hookTrack(pc *webrtc.PeerConnection) { + pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) { + b.mu.Lock() + fn := b.onTrackFn + if fn == nil { + b.pendingTracks = append(b.pendingTracks, t) + b.mu.Unlock() + return + } + b.mu.Unlock() + fn(t) + }) +} + +// swaps in the real handler and drains buffered tracks +func (b *xmppBase) SetTrackHandler(fn func(t *webrtc.TrackRemote)) { + b.mu.Lock() + b.onTrackFn = fn + pending := b.pendingTracks + b.pendingTracks = nil + b.mu.Unlock() + for _, t := range pending { + fn(t) + } +} + +// fires the orchestrator hook exactly once per call +func (b *xmppBase) onRemoteDescription(sdp string) { + b.mu.Lock() + if b.remoteSDPSeen { + b.mu.Unlock() + return + } + b.remoteSDPSeen = true + hook := b.onRemoteSDP + b.mu.Unlock() + if hook != nil { + hook(sdp) + } +} diff --git a/calls/signaling/xmppsig/callee.go b/calls/signaling/xmppsig/callee.go new file mode 100644 index 0000000..2b71fd6 --- /dev/null +++ b/calls/signaling/xmppsig/callee.go @@ -0,0 +1,75 @@ +package xmppsig + +import ( + "context" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + log "github.com/sirupsen/logrus" +) + +// xmpp-side Callee for XMPP-originated calls; the gateway is the JMI responder +type Callee struct { + xmppBase +} + +func (c *Callee) Ringing() { + sess := c.sessionRef() + if sess == nil || sess.State() != jingle.StateRinging { + return + } + if err := sess.Ringing(context.Background()); err != nil { + log.WithField("remote", c.remote).WithError(err).Debug("xmppsig.Callee.Ringing: send failed") + } +} + +func (c *Callee) Accept() { + sess := c.sessionRef() + if sess == nil { + return + } + if err := sess.AcceptProposal(context.Background()); err != nil { + log.WithField("remote", c.remote).WithError(err).Error("xmppsig.Callee.Accept: AcceptProposal failed") + if b := c.bridgeRef(); b != nil { + go b.Terminate(signaling.ReasonUnknown) + } + } +} + +// pick the right verb for the current session state: Close, Decline, or +// session-terminate +func (c *Callee) Terminate(reason signaling.TerminationReason) { + sess := c.sessionRef() + cond := mapReason(reason) + var err error + switch sess.State() { + case jingle.StateNew: + err = sess.Close() + case jingle.StateRinging: + err = sess.Decline(context.Background(), cond) + case jingle.StateTerminated: + // already torn down + default: + err = sess.Terminate(context.Background(), cond) + } + if err != nil { + log.WithError(err).Debug("xmppsig.Callee.Terminate: tear-down send failed") + } + c.cleanup() +} + +// never fire on the responder side +func (c *Callee) OnRinging(_ string) {} +func (c *Callee) OnProceeded(_ string) {} + +func (c *Callee) OnRemoteDescriptionApplied(sdp string) { + c.onRemoteDescription(sdp) +} + +func (c *Callee) OnTerminated(reason string) { + if b := c.bridgeRef(); b != nil { + go b.Terminate(reasonFromCondition(reason)) + } + c.cleanup() +} diff --git a/calls/signaling/xmppsig/callee_test.go b/calls/signaling/xmppsig/callee_test.go new file mode 100644 index 0000000..2645921 --- /dev/null +++ b/calls/signaling/xmppsig/callee_test.go @@ -0,0 +1,224 @@ +package xmppsig + +import ( + "context" + "strings" + "testing" + "time" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "gosrc.io/xmpp/stanza" +) + +// cross-side teardown for XMPP-originated calls: +// inbound terminate -> Session observer -> bridge.Terminate -> both halves Terminate + +func TestCalleeOnTerminated_SessionTerminate_TearsDownTGCaller(t *testing.T) { + callee, _, tgSide := newBoundCallee(t) + callee.OnTerminated(jingle.ReasonSuccess) + waitUntil(t, "tg-Caller.Terminate", time.Second, tgSide.isTerminated) + if tgSide.reason() != signaling.ReasonHangup { + t.Errorf("tg-side reason = %v, want ReasonHangup", tgSide.reason()) + } +} + +// inbound JMI in StateRinging drives the same teardown +func TestCalleeInboundRetract_TearsDownTGCaller(t *testing.T) { + callee, sess, tgSide := newBoundCallee(t) + // simulate the post-propose StateRinging by hand + sess.HandleJMI("alice@xmpp.example/desktop", &jingle.JMIPropose{ + ID: sess.SID(), + Descriptions: []jingle.JMIDescription{{Media: "audio"}}, + }) + waitUntil(t, "session in ringing", time.Second, func() bool { + return sess.State() == jingle.StateRinging + }) + + mgr := callee.a.cfg.Manager + ret := stanza.NewMessage(stanza.Attrs{ + Type: stanza.MessageTypeChat, + From: "alice@xmpp.example/desktop", + To: "gw.example", + Id: "ret-1", + }) + ret.Extensions = []stanza.MsgExtension{&jingle.JMIRetract{ID: sess.SID()}} + mgr.HandleMessage(&ret) + + waitUntil(t, "tg-Caller.Terminate", time.Second, tgSide.isTerminated) + if tgSide.reason() != signaling.ReasonPeerGone { + t.Errorf("tg-side reason = %v, want ReasonPeerGone", tgSide.reason()) + } +} + +// outgoing JMI / `from` must match propose's `to`, +// otherwise libdino's from.equals_bare(peer_state.jid) silently drops +// the proceed and no session-initiate follows +func TestCallee_RingingProceedFromMatchesProposeTo(t *testing.T) { + send := &recSender{} + mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} + a := NewAdapter(AdapterConfig{ + Sender: send, LocalJID: "gw.example", Manager: mgr, + PCFactory: pcFactory(t), + }) + + // realistic XMPP-originated propose + prop := jingle.IncomingProposal{ + SID: "incoming-sid-1", + From: "alice@xmpp.example/desktop", + To: "12345@gw.example", + Media: []string{"audio"}, + } + _, sess, err := a.NewCallee(prop) + if err != nil { + t.Fatalf("NewCallee: %v", err) + } + mgr.Register(sess) + + // Bring the session into StateRinging via the same path production + // uses, so Ringing/AcceptProposal pass their state guards. + sess.HandleJMI(prop.From, &jingle.JMIPropose{ + ID: prop.SID, + Descriptions: []jingle.JMIDescription{{Media: "audio"}}, + }) + waitUntil(t, "session in ringing", time.Second, func() bool { + return sess.State() == jingle.StateRinging + }) + + if err := sess.Ringing(context.Background()); err != nil { + t.Fatalf("Ringing: %v", err) + } + if err := sess.AcceptProposal(context.Background()); err != nil { + t.Fatalf("AcceptProposal: %v", err) + } + + // every JMI : from's bare must equal propose.To and from must + // be a full JID (Conversations crashes on bare-JID `with`) + wantBare := prop.To + wantFrom := prop.To + "/" + prop.SID + dontWantFrom := "gw.example" + var sawRinging, sawProceed bool + for _, pkt := range send.snapshot() { + var msg *stanza.Message + switch m := pkt.(type) { + case *stanza.Message: + msg = m + case stanza.Message: + msg = &m + default: + continue + } + if msg.From == dontWantFrom { + t.Errorf("outbound JMI message from=%q (the bare component domain); want bare=%q with a resource", msg.From, wantBare) + } + if msg.From != wantFrom { + t.Errorf("outbound JMI message from=%q; want %q", msg.From, wantFrom) + } + if !strings.Contains(msg.From, "/") { + t.Errorf("outbound JMI message from=%q is bare; Conversations routes bare-JID updates through a crash-prone proposal-state lambda", msg.From) + } + for _, ext := range msg.Extensions { + switch ext.(type) { + case *jingle.JMIRinging: + sawRinging = true + case *jingle.JMIProceed: + sawProceed = true + } + } + } + if !sawRinging { + t.Error("never observed an outbound ") + } + if !sawProceed { + t.Error("never observed an outbound ") + } +} + +// must come from Callee.Ringing (driven by tdlib Pending), not +// from Callee.Accept; otherwise back-to-back ringing+proceed produces a +// millisecond ringback window in Dino/anotherim and "discovering devices" +// in Conversations +func TestCalleeAccept_DoesNotEmitRinging(t *testing.T) { + send := &recSender{} + mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} + a := NewAdapter(AdapterConfig{ + Sender: send, LocalJID: "gw.example", Manager: mgr, + PCFactory: pcFactory(t), + }) + prop := jingle.IncomingProposal{ + SID: "sid-accept-no-ring", + From: "alice@xmpp.example/desktop", + To: "12345@gw.example", + Media: []string{"audio"}, + } + callee, sess, err := a.NewCallee(prop) + if err != nil { + t.Fatalf("NewCallee: %v", err) + } + mgr.Register(sess) + + sess.HandleJMI(prop.From, &jingle.JMIPropose{ + ID: prop.SID, + Descriptions: []jingle.JMIDescription{{Media: "audio"}}, + }) + waitUntil(t, "session in ringing", time.Second, func() bool { + return sess.State() == jingle.StateRinging + }) + + // Accept without Ringing: must appear, must not + callee.Accept() + waitUntil(t, "proceed emitted", time.Second, func() bool { + for _, pkt := range send.snapshot() { + msg, ok := pkt.(*stanza.Message) + if !ok { + continue + } + for _, ext := range msg.Extensions { + if _, isProceed := ext.(*jingle.JMIProceed); isProceed { + return true + } + } + } + return false + }) + + for _, pkt := range send.snapshot() { + msg, ok := pkt.(*stanza.Message) + if !ok { + continue + } + for _, ext := range msg.Extensions { + if _, isRinging := ext.(*jingle.JMIRinging); isRinging { + t.Fatalf("Accept emitted ; must come exclusively from Callee.Ringing (split was deliberate, see callee.go)") + } + } + } +} + +func newBoundCallee(t *testing.T) (*Callee, *jingle.Session, *recBridgeSide) { + t.Helper() + send := &recSender{} + mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} + a := NewAdapter(AdapterConfig{ + Sender: send, LocalJID: "gw.example", Manager: mgr, + PCFactory: pcFactory(t), + }) + callee, sess, err := a.NewCallee(jingle.IncomingProposal{ + SID: "incoming-sid-1", From: "alice@xmpp.example/desktop", To: "gw.example", Media: []string{"audio"}, + }) + if err != nil { + t.Fatalf("NewCallee: %v", err) + } + // register so inbound stanzas in integration tests find the session + mgr.Register(sess) + + tgSide := &recBridgeSide{} + br := signaling.New(signaling.Config{ + Caller: tgSide, Callee: callee, Timers: noopTimers{}, + }) + callee.Bind(br) + // bridge.Start moves out of StateIdle so terminate fires for real + br.Start() + return callee, sess, tgSide +} diff --git a/calls/signaling/xmppsig/caller.go b/calls/signaling/xmppsig/caller.go new file mode 100644 index 0000000..a6cd36a --- /dev/null +++ b/calls/signaling/xmppsig/caller.go @@ -0,0 +1,86 @@ +package xmppsig + +import ( + "context" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + log "github.com/sirupsen/logrus" +) + +// xmpp-side Caller for TG-originated calls; the gateway is the JMI initiator +type Caller struct { + xmppBase +} + +// invoked by Bridge under its mutex; bridge re-entry must be deferred +func (c *Caller) Start() { + sess := c.sessionRef() + entry := log.WithFields(log.Fields{ + "sid": sess.SID(), + "remote": c.remote, + "local_jid": c.localJID, + }) + + c.a.cfg.Manager.Register(sess) + + if err := sess.Propose(context.Background()); err != nil { + entry.WithError(err).Error("xmppsig.Caller.Start: Session.Propose failed") + if b := c.bridgeRef(); b != nil { + go b.Terminate(signaling.ReasonUnknown) + } + return + } +} + +// pick the right verb for the current session state: Close, Retract, or +// session-terminate +func (c *Caller) Terminate(reason signaling.TerminationReason) { + sess := c.sessionRef() + cond := mapReason(reason) + var err error + switch sess.State() { + case jingle.StateNew: + err = sess.Close() + case jingle.StateProposed: + err = sess.Retract(context.Background()) + case jingle.StateTerminated: + // already torn down + default: + err = sess.Terminate(context.Background(), cond) + } + if err != nil { + log.WithFields(log.Fields{"reason": reason, "remote": c.remote}). + WithError(err).Debug("xmppsig.Caller.Terminate: tear-down send failed") + } + c.cleanup() +} + +// jingle.SessionObserver + +func (c *Caller) OnRinging(_ string) { + if b := c.bridgeRef(); b != nil { + go b.Ringing() + } +} + +func (c *Caller) OnProceeded(_ string) { + if b := c.bridgeRef(); b != nil { + go b.CalleeAccepted() + } +} + +func (c *Caller) OnRemoteDescriptionApplied(sdp string) { + c.onRemoteDescription(sdp) +} + +// the local Terminate then no-ops on the wire because the session is +// already StateTerminated; cleanup runs early to shorten the window +// the session stays registered +func (c *Caller) OnTerminated(reason string) { + if b := c.bridgeRef(); b != nil { + go b.Terminate(reasonFromCondition(reason)) + } + c.cleanup() +} diff --git a/calls/signaling/xmppsig/caller_test.go b/calls/signaling/xmppsig/caller_test.go new file mode 100644 index 0000000..6f97576 --- /dev/null +++ b/calls/signaling/xmppsig/caller_test.go @@ -0,0 +1,217 @@ +package xmppsig + +import ( + "context" + "encoding/xml" + "strings" + "sync" + "testing" + "time" + + "dev.narayana.im/narayana/telegabber/calls/signaling" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "github.com/pion/webrtc/v4" + "gosrc.io/xmpp/stanza" +) + +// goroutine-safe; Session.Send fires from background goroutines after +// JMI +type recSender struct { + mu sync.Mutex + sent []stanza.Packet + iqSent []*stanza.IQ +} + +func (r *recSender) Send(p stanza.Packet) error { + r.mu.Lock() + defer r.mu.Unlock() + r.sent = append(r.sent, p) + if iq, ok := p.(*stanza.IQ); ok { + r.iqSent = append(r.iqSent, iq) + } + return nil +} + +func (r *recSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + ch := make(chan stanza.IQ, 1) + return ch, r.Send(iq) +} + +func (r *recSender) SendRaw(string) error { return nil } + +func (r *recSender) snapshot() []stanza.Packet { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]stanza.Packet, len(r.sent)) + copy(out, r.sent) + return out +} + +func (r *recSender) firstMessage() *stanza.Message { + for _, p := range r.snapshot() { + switch m := p.(type) { + case *stanza.Message: + return m + case stanza.Message: + return &m + } + } + return nil +} + +// records bridge-side Terminate firings; serves as the other-side stand-in +// for tests of either xmppsig.Caller or xmppsig.Callee +type recBridgeSide struct { + mu sync.Mutex + startedOrAccept bool + terminated bool + terminateReason signaling.TerminationReason +} + +func (r *recBridgeSide) Start() { r.mu.Lock(); r.startedOrAccept = true; r.mu.Unlock() } +func (r *recBridgeSide) Ringing() {} +func (r *recBridgeSide) Accept() { r.mu.Lock(); r.startedOrAccept = true; r.mu.Unlock() } +func (r *recBridgeSide) Terminate(reason signaling.TerminationReason) { + r.mu.Lock() + r.terminated = true + r.terminateReason = reason + r.mu.Unlock() +} +func (r *recBridgeSide) isTerminated() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.terminated +} +func (r *recBridgeSide) reason() signaling.TerminationReason { + r.mu.Lock() + defer r.mu.Unlock() + return r.terminateReason +} + +type noopTimers struct{} + +func (noopTimers) Set(signaling.TimerKind, time.Duration) {} +func (noopTimers) Cancel(signaling.TimerKind) {} + +// real pion PC; faking webrtc is more invasive than running on loopback +func pcFactory(t *testing.T) func() (*webrtc.PeerConnection, error) { + t.Helper() + return func() (*webrtc.PeerConnection, error) { + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + return nil, err + } + t.Cleanup(func() { _ = pc.Close() }) + if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }); err != nil { + _ = pc.Close() + return nil, err + } + return pc, nil + } +} + +// OnTerminated fires bridge.Terminate on a goroutine (deferred re-entry), +// so callers poll +func waitUntil(t *testing.T, desc string, d time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("waitUntil(%s): timed out after %v", desc, d) +} + +// JMI must use a full JID (userID@gw/telegabber). XEP-0353 +// requires it so the recipient can route / back. + +func TestCallerStart_SendsProposeWithFullLocalJID(t *testing.T) { + send := &recSender{} + mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} + a := NewAdapter(AdapterConfig{ + Sender: send, LocalJID: "gw.example", Manager: mgr, + PCFactory: pcFactory(t), + }) + caller, err := a.NewCaller("alice@xmpp.example", 12345) + if err != nil { + t.Fatalf("NewCaller: %v", err) + } + br := signaling.New(signaling.Config{ + Caller: caller, Callee: &recBridgeSide{}, Timers: noopTimers{}, + }) + caller.Bind(br) + + br.Start() // triggers caller.Start synchronously + + msg := send.firstMessage() + if msg == nil { + t.Fatal("no message sent - Caller.Start did not produce a JMI ") + } + if msg.From != "12345@gw.example/telegabber" { + t.Errorf("propose from = %q, want %q (full JID with resource per XEP-0353)", msg.From, "12345@gw.example/telegabber") + } + if msg.To != "alice@xmpp.example" { + t.Errorf("propose to = %q, want alice@xmpp.example", msg.To) + } + if string(msg.Type) != "chat" { + t.Errorf("propose type = %q, want chat", msg.Type) + } + + // payload: one in JMI ns with a + xmlBytes, err := xml.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(xmlBytes) + if !strings.Contains(s, `//session-terminate -> observer -> bridge.Terminate + +func TestCallerOnTerminated_RejectedReason_TearsDownTGCalleeWithDecline(t *testing.T) { + caller, tgSide := newStartedCaller(t) + caller.OnTerminated("rejected") + + waitUntil(t, "tg-Callee.Terminate", time.Second, tgSide.isTerminated) + if tgSide.reason() != signaling.ReasonDecline { + t.Errorf("tg-side reason = %v, want ReasonDecline", tgSide.reason()) + } +} + +// Caller bound to a TG-originated bridge, started so the session is in +// StateProposed; returns the Caller and the tg-side stand-in +func newStartedCaller(t *testing.T) (*Caller, *recBridgeSide) { + t.Helper() + send := &recSender{} + mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send} + a := NewAdapter(AdapterConfig{ + Sender: send, LocalJID: "gw.example", Manager: mgr, + PCFactory: pcFactory(t), + }) + caller, err := a.NewCaller("alice@xmpp.example", 12345) + if err != nil { + t.Fatalf("NewCaller: %v", err) + } + tgSide := &recBridgeSide{} + br := signaling.New(signaling.Config{ + Caller: caller, Callee: tgSide, Timers: noopTimers{}, + }) + caller.Bind(br) + br.Start() + waitUntil(t, "session proposed", time.Second, func() bool { + s := caller.sessionRef() + return s != nil && s.State() == jingle.StateProposed + }) + return caller, tgSide +} diff --git a/calls/signaling/xmppsig/extdisco.go b/calls/signaling/xmppsig/extdisco.go new file mode 100644 index 0000000..2bdd520 --- /dev/null +++ b/calls/signaling/xmppsig/extdisco.go @@ -0,0 +1,167 @@ +package xmppsig + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/pion/webrtc/v4" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +// XEP-0215 v2; older v1 is dead on modern prosody/ejabberd +const NSExtDisco = "urn:xmpp:extdisco:2" + +// one resolved STUN/TURN/TURNS entry; for TURN the user/pass are +// short-lived HMAC creds the server issues per-query +type Service struct { + Type string // "stun" | "turn" | "turns" | "stuns" + Host string + Port int + Transport string // "udp" | "tcp" + Username string + Password string +} + +// XEP-0215 wire encoding. Port is decoded as string because some servers +// have emitted non-numeric values; we surface the parse error. +type servicesQuery struct { + XMLName xml.Name `xml:"urn:xmpp:extdisco:2 services"` + Services []serviceElem `xml:"service"` +} + +type serviceElem struct { + XMLName xml.Name `xml:"service"` + Type string `xml:"type,attr"` + Host string `xml:"host,attr"` + Port string `xml:"port,attr"` + Transport string `xml:"transport,attr"` + Username string `xml:"username,attr,omitempty"` + Password string `xml:"password,attr,omitempty"` +} + +// gosrc.io/xmpp IQ payload interface +func (servicesQuery) Namespace() string { return NSExtDisco } +func (servicesQuery) Name() string { return "services" } +func (servicesQuery) GetSet() *stanza.ResultSet { return nil } + +// XEP-0215 services request; toJID is the c2s/parent server domain. +// Empty slice is a legitimate result. +func QueryServices(sender xmpp.Sender, fromJID, toJID string, timeout time.Duration) ([]Service, error) { + iq, err := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeGet, + From: fromJID, + To: toJID, + Id: "extdisco-" + uuid.New().String(), + }) + if err != nil { + return nil, fmt.Errorf("build IQ: %w", err) + } + iq.Payload = &servicesQuery{} + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + ch, err := sender.SendIQ(ctx, iq) + if err != nil { + return nil, fmt.Errorf("send IQ: %w", err) + } + + var resp stanza.IQ + select { + case <-ctx.Done(): + return nil, fmt.Errorf("extdisco timeout after %s", timeout) + case resp = <-ch: + } + + if resp.Type == stanza.IQTypeError { + // dump the marshalled IQ so the condition is visible + raw, _ := xml.Marshal(resp) + return nil, fmt.Errorf("extdisco error from %s: %s", toJID, string(raw)) + } + + // gosrc.io decodes unrecognised payloads opaquely - re-marshal and + // parse ourselves rather than fight the stanza registry + raw, err := xml.Marshal(resp) + if err != nil { + return nil, fmt.Errorf("re-marshal response: %w", err) + } + return extractServices(raw) +} + +// unit-testable without gosrc.io; walks raw IQ XML +func extractServices(raw []byte) ([]Service, error) { + dec := xml.NewDecoder(bytes.NewReader(raw)) + for { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("no element in IQ") + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if se.Name.Space != NSExtDisco || se.Name.Local != "services" { + continue + } + var q servicesQuery + if err := dec.DecodeElement(&q, &se); err != nil { + return nil, fmt.Errorf("decode services: %w", err) + } + out := make([]Service, 0, len(q.Services)) + for _, s := range q.Services { + port, err := strconv.Atoi(s.Port) + if err != nil { + return nil, fmt.Errorf("service %s: bad port %q: %w", s.Type, s.Port, err) + } + out = append(out, Service{ + Type: s.Type, + Host: s.Host, + Port: port, + Transport: s.Transport, + Username: s.Username, + Password: s.Password, + }) + } + return out, nil + } +} + +// XEP-0215 services -> pion webrtc.ICEServer list +func ToICEServers(services []Service) []webrtc.ICEServer { + out := make([]webrtc.ICEServer, 0, len(services)) + for _, s := range services { + switch s.Type { + case "stun", "stuns": + out = append(out, webrtc.ICEServer{ + URLs: []string{fmt.Sprintf("%s:%s:%d", s.Type, s.Host, s.Port)}, + }) + case "turn", "turns": + // skip turn entries without creds - pion rejects the whole PC + // (InvalidAccessError) on any one missing-creds entry. Common + // cause: prosody's mod_external_services only auto-maps + // algorithms["turn"], so a `turns` entry needs an explicit + // algorithm = "turn" to get creds generated. + if s.Username == "" || s.Password == "" { + continue + } + url := fmt.Sprintf("%s:%s:%d", s.Type, s.Host, s.Port) + if s.Transport != "" { + url += "?transport=" + s.Transport + } + out = append(out, webrtc.ICEServer{ + URLs: []string{url}, + Username: s.Username, + Credential: s.Password, + CredentialType: webrtc.ICECredentialTypePassword, + }) + default: + // skip unknown service types (e.g. ftp from older XEPs) + } + } + return out +} diff --git a/calls/signaling/xmppsig/extdisco_test.go b/calls/signaling/xmppsig/extdisco_test.go new file mode 100644 index 0000000..d2d35d9 --- /dev/null +++ b/calls/signaling/xmppsig/extdisco_test.go @@ -0,0 +1,181 @@ +package xmppsig + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/pion/webrtc/v4" + "gosrc.io/xmpp/stanza" +) + +func TestExtractServices_ProsodyResponse(t *testing.T) { + // verbatim shape from prosody 0.12 mod_external_services + raw := []byte(`` + + `` + + `` + + `` + + `` + + ``) + got, err := extractServices(raw) + if err != nil { + t.Fatalf("extractServices: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 services, got %d: %+v", len(got), got) + } + if got[0] != (Service{Type: "stun", Host: "172.18.0.1", Port: 3478, Transport: "udp"}) { + t.Errorf("STUN entry mismatch: %+v", got[0]) + } + if got[1] != (Service{ + Type: "turn", Host: "172.18.0.1", Port: 3478, Transport: "udp", + Username: "1779481470", Password: "F5aXytaIwbEJWt6FJ66OqeEQtvw=", + }) { + t.Errorf("TURN entry mismatch: %+v", got[1]) + } +} + +func TestToICEServers_StunAndTurn(t *testing.T) { + in := []Service{ + {Type: "stun", Host: "stun.example.com", Port: 3478, Transport: "udp"}, + {Type: "turn", Host: "turn.example.com", Port: 3478, Transport: "udp", Username: "u", Password: "p"}, + {Type: "turns", Host: "turn.example.com", Port: 5349, Transport: "tcp", Username: "u", Password: "p"}, + } + out := ToICEServers(in) + if len(out) != 3 { + t.Fatalf("want 3 servers, got %d", len(out)) + } + if out[0].URLs[0] != "stun:stun.example.com:3478" { + t.Errorf("STUN URL mismatch: %q", out[0].URLs[0]) + } + if out[1].URLs[0] != "turn:turn.example.com:3478?transport=udp" { + t.Errorf("TURN URL mismatch: %q", out[1].URLs[0]) + } + if out[1].Username != "u" || out[1].Credential != "p" { + t.Errorf("TURN creds mismatch: user=%q cred=%v", out[1].Username, out[1].Credential) + } + if out[1].CredentialType != webrtc.ICECredentialTypePassword { + t.Errorf("TURN cred type: want Password, got %v", out[1].CredentialType) + } + if out[2].URLs[0] != "turns:turn.example.com:5349?transport=tcp" { + t.Errorf("TURNS URL mismatch: %q", out[2].URLs[0]) + } +} + +// pion rejects the whole PC on any creds-less turn URL, so a single +// misconfigured prosody entry must not poison every call +func TestToICEServers_SkipsBrokenAndUnknown(t *testing.T) { + in := []Service{ + {Type: "stun", Host: "h", Port: 3478, Transport: "udp"}, + {Type: "turn", Host: "h", Port: 3478, Transport: "udp", Username: "u", Password: "p"}, + // turns with creds: kept + {Type: "turns", Host: "h", Port: 5349, Transport: "tcp", Username: "u", Password: "p"}, + // turn with no creds: dropped + {Type: "turn", Host: "broken", Port: 3478, Transport: "udp"}, + // turns with no creds (prosody algorithm-omission bug): dropped + {Type: "turns", Host: "broken", Port: 5349, Transport: "tcp"}, + // turn with username but no password: dropped (defensive) + {Type: "turn", Host: "half", Port: 3478, Transport: "udp", Username: "u"}, + // unknown type: dropped + {Type: "ftp", Host: "h", Port: 21, Transport: "tcp"}, + } + out := ToICEServers(in) + if len(out) != 3 { + t.Fatalf("want 3 servers (stun + turn-with-creds + turns-with-creds), got %d: %+v", len(out), out) + } + for _, srv := range out { + for _, u := range srv.URLs { + if (containsScheme(u, "turn:") || containsScheme(u, "turns:")) && (srv.Username == "" || srv.Credential == nil || srv.Credential == "") { + t.Errorf("TURN entry kept without creds: %+v", srv) + } + } + } +} + +func containsScheme(url, scheme string) bool { + return len(url) >= len(scheme) && url[:len(scheme)] == scheme +} + +// minimal Sender for QueryServices E2E against a hand-crafted response +type fakeSender struct { + sent []stanza.Packet + reply func(*stanza.IQ) stanza.IQ + failSend bool +} + +func (f *fakeSender) Send(p stanza.Packet) error { + if f.failSend { + return fmt.Errorf("simulated send failure") + } + f.sent = append(f.sent, p) + return nil +} +func (f *fakeSender) SendRaw(string) error { return nil } +func (f *fakeSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + ch := make(chan stanza.IQ, 1) + if err := f.Send(iq); err != nil { + return nil, err + } + if f.reply != nil { + ch <- f.reply(iq) + } + return ch, nil +} + +func TestQueryServices_ParsesReply(t *testing.T) { + fs := &fakeSender{ + reply: func(req *stanza.IQ) stanza.IQ { + respPtr, _ := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeResult, + From: req.To, + To: req.From, + Id: req.Id, + }) + respPtr.Payload = &servicesQuery{Services: []serviceElem{ + {Type: "stun", Host: "1.2.3.4", Port: "3478", Transport: "udp"}, + {Type: "turn", Host: "1.2.3.4", Port: "3478", Transport: "udp", Username: "u", Password: "p"}, + }} + return *respPtr + }, + } + got, err := QueryServices(fs, "tlgrm.example.com", "example.com", time.Second) + if err != nil { + t.Fatalf("QueryServices: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2, got %d: %+v", len(got), got) + } + if got[0].Type != "stun" || got[1].Type != "turn" { + t.Errorf("unexpected service types: %+v", got) + } + if got[1].Username != "u" || got[1].Password != "p" { + t.Errorf("TURN creds lost: %+v", got[1]) + } +} + +func TestQueryServices_Timeout(t *testing.T) { + // no reply hook -> never delivers, must hit context timeout + fs := &fakeSender{} + _, err := QueryServices(fs, "tlgrm.example.com", "example.com", 50*time.Millisecond) + if err == nil { + t.Error("expected timeout error") + } +} + +func TestQueryServices_ErrorIQ(t *testing.T) { + fs := &fakeSender{ + reply: func(req *stanza.IQ) stanza.IQ { + respPtr, _ := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeError, + From: req.To, + To: req.From, + Id: req.Id, + }) + return *respPtr + }, + } + if _, err := QueryServices(fs, "tlgrm.example.com", "example.com", time.Second); err == nil { + t.Error("expected error on ") + } +} diff --git a/calls/signaling/xmppsig/pion_loop_test.go b/calls/signaling/xmppsig/pion_loop_test.go new file mode 100644 index 0000000..89c9e5e --- /dev/null +++ b/calls/signaling/xmppsig/pion_loop_test.go @@ -0,0 +1,181 @@ +package xmppsig + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + + "github.com/pion/webrtc/v4" + "gosrc.io/xmpp/stanza" +) + +// every Send dispatches into the other Manager's HandlePacket on a fresh +// goroutine, surfacing trickle/ordering bugs naturally +type linkedSender struct{ target *jingle.Manager } + +func (ls *linkedSender) Send(p stanza.Packet) error { + go ls.target.HandlePacket(nil, p) + return nil +} +func (ls *linkedSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + return make(chan stanza.IQ, 1), ls.Send(iq) +} + +type calleeBundle struct { + callee *Callee + pcConnected <-chan struct{} +} + +// full xmppsig path against itself: Caller on A, Callee on B, paired via +// linkedSenders. Both PCs must reach Connected and both ends must see the +// peer's track. Track-attachment timing matches setupAudio: attach after +// SetRemoteDescription (pre-negotiation ReplaceTrack doesn't wire pion's encoder). +func TestXmppsigPionLoop_TwoSidesConnectAndDeliverTrack(t *testing.T) { + if testing.Short() { + t.Skip("e2e ICE handshake") + } + + mA := &jingle.Manager{LocalJID: "a@gw/telegabber"} + mB := &jingle.Manager{LocalJID: "b@gw/telegabber"} + mA.Sender = &linkedSender{target: mB} + mB.Sender = &linkedSender{target: mA} + + adapterA := NewAdapter(AdapterConfig{ + Sender: mA.Sender, LocalJID: mA.LocalJID, Manager: mA, + PCFactory: DefaultPCFactory, + }) + adapterB := NewAdapter(AdapterConfig{ + Sender: mB.Sender, LocalJID: mB.LocalJID, Manager: mB, + PCFactory: DefaultPCFactory, + }) + + calleeReady := make(chan calleeBundle, 1) + mB.OnProposal = func(p jingle.IncomingProposal) (*jingle.Session, func(), error) { + callee, sess, err := adapterB.NewCallee(p) + if err != nil { + return nil, nil, err + } + pc := callee.PeerConnection() + // production pattern: on OnRemoteSDP, slot a track into the + // sender DefaultPCFactory pre-created + callee.SetOnRemoteSDP(func(string) { + tr := newSilenceTrack(t, "b-stream") + if err := replaceFirstEmptySender(pc, tr); err != nil { + t.Errorf("B replaceFirstEmptySender: %v", err) + return + } + startSilencePump(t, tr) + }) + calleeReady <- calleeBundle{callee: callee, pcConnected: connectedSignal(pc)} + go func() { + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && sess.State() != jingle.StateRinging { + time.Sleep(5 * time.Millisecond) + } + callee.Accept() + }() + return sess, nil, nil + } + + caller, err := adapterA.NewCaller("b@gw/telegabber", 42) + if err != nil { + t.Fatalf("NewCaller: %v", err) + } + pcA := caller.PeerConnection() + caller.SetOnRemoteSDP(func(string) { + tr := newSilenceTrack(t, "a-stream") + if err := replaceFirstEmptySender(pcA, tr); err != nil { + t.Errorf("A replaceFirstEmptySender: %v", err) + return + } + startSilencePump(t, tr) + }) + connA := connectedSignal(pcA) + + caller.Start() + + var bundle calleeBundle + select { + case bundle = <-calleeReady: + case <-time.After(5 * time.Second): + t.Fatal("OnProposal never fired on B side") + } + + // install handlers AFTER Start so tracks landing during + // SetRemoteDescription go through hookTrack's buffered path + var aGot, bGot atomic.Int64 + caller.SetTrackHandler(func(*webrtc.TrackRemote) { aGot.Add(1) }) + bundle.callee.SetTrackHandler(func(*webrtc.TrackRemote) { bGot.Add(1) }) + + const deadline = 15 * time.Second + select { + case <-connA: + case <-time.After(deadline): + t.Fatalf("Caller PC never Connected (state=%s)", pcA.ConnectionState()) + } + select { + case <-bundle.pcConnected: + case <-time.After(deadline): + t.Fatalf("Callee PC never Connected (state=%s)", + bundle.callee.PeerConnection().ConnectionState()) + } + + waitUntilTrue(t, "A received B's track", 10*time.Second, func() bool { return aGot.Load() > 0 }) + waitUntilTrue(t, "B received A's track", 10*time.Second, func() bool { return bGot.Load() > 0 }) +} + +// fresh opus track; caller attaches and starts the pump at the right +// point in the negotiation +func newSilenceTrack(t *testing.T, streamID string) *webrtc.TrackLocalStaticSample { + t.Helper() + tr, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2}, + "audio", streamID, + ) + if err != nil { + t.Fatalf("NewTrackLocalStaticSample: %v", err) + } + return tr +} + +// mirrors orchestrator.attachOutgoingTrack; falls back to AddTrack for +// bare PCFactory tests +func replaceFirstEmptySender(pc *webrtc.PeerConnection, tr webrtc.TrackLocal) error { + for _, s := range pc.GetSenders() { + if s == nil { + continue + } + if err := s.ReplaceTrack(tr); err == nil { + return nil + } + } + _, err := pc.AddTrack(tr) + return err +} + +// pushes 20ms opus silence into tr; stops at test cleanup +func startSilencePump(t *testing.T, tr *webrtc.TrackLocalStaticSample) { + t.Helper() + stop := make(chan struct{}) + t.Cleanup(func() { + defer func() { _ = recover() }() // tolerate double-close in failure paths + close(stop) + }) + go pumpSilenceOpus(tr, stop) +} + +// closes once pc reaches Connected; must be installed before pc could reach +// Connected because pion fires on state change, not on register +func connectedSignal(pc *webrtc.PeerConnection) <-chan struct{} { + ch := make(chan struct{}) + var once atomic.Bool + pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { + if s == webrtc.PeerConnectionStateConnected && once.CompareAndSwap(false, true) { + close(ch) + } + }) + return ch +} diff --git a/calls/signaling/xmppsig/track_buffer_test.go b/calls/signaling/xmppsig/track_buffer_test.go new file mode 100644 index 0000000..6f9d06f --- /dev/null +++ b/calls/signaling/xmppsig/track_buffer_test.go @@ -0,0 +1,175 @@ +package xmppsig + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media" +) + +// pion OnTrack fires inside SetRemoteDescription, but setupAudio runs +// later (from OnRemoteDescriptionApplied) - so hookTrack must install +// a permanent forwarder at PC construction and buffer until +// SetTrackHandler. This drives a real pion<->pion roundtrip with the +// handler installed after the track has already landed. +func TestHookTrack_BuffersTrackThatArrivesBeforeSetHandler(t *testing.T) { + if testing.Short() { + t.Skip("e2e ICE handshake") + } + + // gateway-side PC, same shape as xmppsig.NewCaller + pcGateway, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("gateway PC: %v", err) + } + defer pcGateway.Close() + if _, err := pcGateway.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendrecv, + }); err != nil { + t.Fatalf("gateway AddTransceiver: %v", err) + } + + // the forwarder under test + var base xmppBase + base.hookTrack(pcGateway) + + // remote pion PC with an audio track to send + pcPeer, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("peer PC: %v", err) + } + defer pcPeer.Close() + peerTrack, err := webrtc.NewTrackLocalStaticSample( + webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2}, + "audio", "peer-stream", + ) + if err != nil { + t.Fatalf("peer track: %v", err) + } + if _, err := pcPeer.AddTrack(peerTrack); err != nil { + t.Fatalf("peer AddTrack: %v", err) + } + + // pion offer/answer + ICE-complete handshake + gatewayConnected := make(chan struct{}) + pcGateway.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { + if s == webrtc.PeerConnectionStateConnected { + select { + case <-gatewayConnected: + default: + close(gatewayConnected) + } + } + }) + completePionHandshake(t, pcGateway, pcPeer) + select { + case <-gatewayConnected: + case <-time.After(15 * time.Second): + t.Fatalf("gateway PC never reached Connected (state=%s)", pcGateway.ConnectionState()) + } + + // real RTP so OnTrack fires (pion needs the first packet to dispatch) + stopRTP := make(chan struct{}) + go pumpSilenceOpus(peerTrack, stopRTP) + defer close(stopRTP) + + // wait for pion to dispatch the first packet through hookTrack + waitUntilTrue(t, "track buffered by hookTrack", 5*time.Second, func() bool { + base.mu.Lock() + defer base.mu.Unlock() + return len(base.pendingTracks) > 0 + }) + + // install handler late, like setupAudio from OnRemoteDescriptionApplied + var delivered atomic.Int64 + base.SetTrackHandler(func(t *webrtc.TrackRemote) { + delivered.Add(1) + }) + if delivered.Load() != 1 { + t.Errorf("SetTrackHandler did not drain pendingTracks: delivered=%d, want 1", delivered.Load()) + } + + // subsequent track events should go direct to the handler + base.mu.Lock() + if len(base.pendingTracks) != 0 { + t.Errorf("pendingTracks not cleared after drain: %d entries", len(base.pendingTracks)) + } + base.mu.Unlock() +} + +// completePionHandshake drives a pion offer/answer + trickle ICE between +// two PeerConnections, with the gateway as the answerer (matching the +// xmpp-originated incoming call shape - Dino's session-initiate carries +// the offer, the gateway sends the answer). +func completePionHandshake(t *testing.T, pcAnswerer, pcOfferer *webrtc.PeerConnection) { + t.Helper() + // trickle ICE: forward each side's candidates to the other + pcOfferer.OnICECandidate(func(c *webrtc.ICECandidate) { + if c == nil { + return + } + if err := pcAnswerer.AddICECandidate(c.ToJSON()); err != nil { + t.Logf("answerer AddICECandidate: %v", err) + } + }) + pcAnswerer.OnICECandidate(func(c *webrtc.ICECandidate) { + if c == nil { + return + } + if err := pcOfferer.AddICECandidate(c.ToJSON()); err != nil { + t.Logf("offerer AddICECandidate: %v", err) + } + }) + + offer, err := pcOfferer.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + if err := pcOfferer.SetLocalDescription(offer); err != nil { + t.Fatal(err) + } + if err := pcAnswerer.SetRemoteDescription(*pcOfferer.LocalDescription()); err != nil { + t.Fatal(err) + } + answer, err := pcAnswerer.CreateAnswer(nil) + if err != nil { + t.Fatal(err) + } + if err := pcAnswerer.SetLocalDescription(answer); err != nil { + t.Fatal(err) + } + if err := pcOfferer.SetRemoteDescription(*pcAnswerer.LocalDescription()); err != nil { + t.Fatal(err) + } +} + +// writes 20ms opus silence every 20ms until stop closes +func pumpSilenceOpus(track *webrtc.TrackLocalStaticSample, stop <-chan struct{}) { + // minimal opus silence frame: TOC byte + 1-byte SILK + silence := []byte{0xF8, 0xFF, 0xFE} + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-stop: + return + case <-tick.C: + _ = track.WriteSample(media.Sample{Data: silence, Duration: 20 * time.Millisecond}) + } + } +} + +// local poller; duplicates the jingle one to avoid test-file ordering deps +func waitUntilTrue(t *testing.T, what string, d time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("waitUntilTrue(%s): timed out after %v", what, d) +} diff --git a/docker/isolate_ntgcalls_crypto.sh b/docker/isolate_ntgcalls_crypto.sh new file mode 100755 index 0000000..4ee7cc8 --- /dev/null +++ b/docker/isolate_ntgcalls_crypto.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Rename every symbol that libntgcalls.a defines AND that the system OpenSSL +# (libssl/libcrypto) also exports, so the two crypto libraries don't fight at +# static link time. After this, TDLib's calls (compiled against system OpenSSL +# headers) resolve to libcrypto.so.3, while ntgcalls/webrtc's internal calls +# stay wired to its bundled BoringSSL via the renamed names. + +set -euo pipefail + +LIB=/usr/local/lib/libntgcalls.a +SYS_CRYPTO=/usr/lib/x86_64-linux-gnu/libcrypto.so.3 +SYS_SSL=/usr/lib/x86_64-linux-gnu/libssl.so.3 +PREFIX=ntgssl_ + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +nm --defined-only --extern-only "$LIB" 2>/dev/null \ + | awk 'NF>=3 {print $NF}' | sort -u > "$work/ntg_defined.txt" + +nm -D --defined-only "$SYS_CRYPTO" "$SYS_SSL" \ + | awk 'NF>=3 {n=$NF; sub(/@@.*/, "", n); print n}' | sort -u > "$work/sys_crypto.txt" + +comm -12 "$work/ntg_defined.txt" "$work/sys_crypto.txt" > "$work/clash.txt" +test -s "$work/clash.txt" +awk -v p="$PREFIX" '{print $1, p $1}' "$work/clash.txt" > "$work/redefine.txt" + +# objcopy operates on archives natively, transforming each member in place. +# This avoids ar x / ar rcs roundtripping, which would flatten the archive's +# directory structure and silently drop members with duplicate basenames. +objcopy --redefine-syms="$work/redefine.txt" "$LIB" +ranlib "$LIB" + +printf 'Isolated %d crypto symbols in libntgcalls.a\n' "$(wc -l < "$work/clash.txt")" diff --git a/docker/resolv_shim.c b/docker/resolv_shim.c index 8ca302f..054ea5a 100644 --- a/docker/resolv_shim.c +++ b/docker/resolv_shim.c @@ -1,13 +1,13 @@ -// resolv_shim.c — Wrappers for glibc-internal resolver symbols. +// resolv_shim.c -- Wrappers for glibc-internal resolver symbols. // // ntgcalls' CMake (cmake/FindGLib.cmake) downloads a pre-built static glib // from pytgcalls/glib GitHub releases. That binary was compiled on an older // glibc (manylinux2014 / CentOS 7) where __dn_expand and __res_nquery were // linkable symbols. On bookworm's glibc 2.36 they are no longer exposed to -// the linker — only the public POSIX names (dn_expand, res_nquery) are. +// the linker; only the public POSIX names (dn_expand, res_nquery) are. // -// Since we don't control the glib build (it's downloaded, not compiled by us), -// the simplest fix is to forward the internal names to the public ones. +// The glib build is downloaded rather than compiled here, so the simplest +// fix is to forward the internal names to the public ones. #include #include diff --git a/go.mod b/go.mod index 837a02a..d90b867 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,16 @@ go 1.24.2 require ( github.com/dgraph-io/badger/v4 v4.1.0 - github.com/google/uuid v1.1.1 + github.com/google/uuid v1.6.0 + github.com/pion/interceptor v0.1.44 + github.com/pion/rtp v1.10.1 + github.com/pion/webrtc/v4 v4.2.12 github.com/pkg/errors v0.9.1 github.com/santhosh-tekuri/jsonschema v1.2.4 github.com/sirupsen/logrus v1.4.2 github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e github.com/zelenin/go-tdlib v0.5.2 + gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 gopkg.in/yaml.v2 v2.2.4 gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1 gotgcalls v0.0.0-00010101000000-000000000000 @@ -39,12 +43,28 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.2 // indirect + github.com/pion/ice/v4 v4.2.5 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/sctp v1.9.5 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.10 // indirect + github.com/pion/stun/v3 v3.1.2 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v5 v5.0.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opencensus.io v0.22.5 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect nhooyr.io/websocket v1.6.5 // indirect ) diff --git a/go.sum b/go.sum index a257e03..5706780 100644 --- a/go.sum +++ b/go.sum @@ -73,8 +73,9 @@ github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -88,8 +89,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxv github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -111,6 +113,42 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs= +github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0= +github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= +github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= +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/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.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= +github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag= +github.com/pion/sctp v1.9.5/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.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= +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.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak= +github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w= +github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY= +github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -132,9 +170,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= +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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -150,6 +191,8 @@ golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= @@ -169,8 +212,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -193,14 +236,15 @@ golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -227,11 +271,15 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= +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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/telegram/client.go b/telegram/client.go index 4ef78c9..9ec5839 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -7,9 +7,13 @@ import ( "sync" "time" + "dev.narayana.im/narayana/telegabber/calls/audio/ntgadapter" + "dev.narayana.im/narayana/telegabber/calls/signaling/tgsig" + "dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig" "dev.narayana.im/narayana/telegabber/config" "dev.narayana.im/narayana/telegabber/persistence" "dev.narayana.im/narayana/telegabber/telegram/cache" + "gotgcalls/ntgcalls" "github.com/zelenin/go-tdlib/client" "gosrc.io/xmpp" @@ -102,10 +106,57 @@ type Client struct { MessageIdChanges map[int64]map[int64]*newId MessageIdChangesLock sync.Mutex + // guards the call trio. Connect() builds it after c.client is live; + // close() tears it down before c.client is closed. Accessors return + // nil when no live session. + ntgCallsLock sync.Mutex + ntgCalls *ntgcalls.Client + tgCallAdapter *tgsig.Adapter + xmppCallAdapter *xmppsig.Adapter + + // captured at adapter construction time + callDeps CallDeps + locks clientLocks SendMessageLock sync.Mutex } +// per-process call wiring needed to build per-session adapters +type CallDeps struct { + TgManager *tgsig.Manager + XmppConfig xmppsig.AdapterConfig +} + +// must be called before Connect() +func (c *Client) SetCallDeps(deps CallDeps) { + c.callDeps = deps +} + +// nil if no live session +func (c *Client) CallAdapter() *tgsig.Adapter { + c.ntgCallsLock.Lock() + defer c.ntgCallsLock.Unlock() + return c.tgCallAdapter +} + +// nil if no live session +func (c *Client) XmppCallAdapter() *xmppsig.Adapter { + c.ntgCallsLock.Lock() + defer c.ntgCallsLock.Unlock() + return c.xmppCallAdapter +} + +// pure construction; caller assigns and locks +func buildCallAdapters(tdClient *client.Client, jid string, deps CallDeps) ( + *ntgcalls.Client, *tgsig.Adapter, *xmppsig.Adapter, +) { + ntg := ntgcalls.NTgCalls() + audioNtg := ntgadapter.New(ntg) + tgA := tgsig.New(tdClient, ntg, audioNtg, jid, deps.TgManager) + xmppA := xmppsig.NewAdapter(deps.XmppConfig) + return ntg, tgA, xmppA +} + type clientLocks struct { authorizationReady sync.Mutex chatMessageLocks map[int64]*sync.Mutex diff --git a/telegram/connect.go b/telegram/connect.go index 27d63c7..699043e 100644 --- a/telegram/connect.go +++ b/telegram/connect.go @@ -135,6 +135,14 @@ func (c *Client) Connect(resource string) error { c.client = tdlibClient + // build the call trio after c.client is live; close() tears it down + // before c.client is closed + ntg, tgA, xmppA := buildCallAdapters(c.client, c.jid, c.callDeps) + c.ntgCallsLock.Lock() + c.ntgCalls, c.tgCallAdapter, c.xmppCallAdapter = ntg, tgA, xmppA + c.ntgCallsLock.Unlock() + log.Info("ntgcalls client initialized") + // stage 3: if a client is succesfully created, AuthorizationStateReady is already reached log.Warn("Authorization successful!") @@ -306,6 +314,24 @@ func (c *Client) close() { } c.locks.authorizerWriteLock.Unlock() + // tear the call trio down before c.client so accessors see the + // adapter's closed lifecycle (withCallContext bails) instead of a + // stale c.client; the lock serializes with the accessors + c.ntgCallsLock.Lock() + if c.ntgCalls != nil { + if c.tgCallAdapter != nil { + // terminates active calls (closes audio halves synchronously) + // and marks the adapter closed; after this returns, ntgcalls.Free is safe + c.tgCallAdapter.Close() + c.tgCallAdapter = nil + } + c.xmppCallAdapter = nil + + c.ntgCalls.Free() + c.ntgCalls = nil + } + c.ntgCallsLock.Unlock() + if c.client != nil { _, err := c.client.Close() if err != nil { diff --git a/telegram/handlers.go b/telegram/handlers.go index d7d0823..e6cdf5c 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -86,6 +86,10 @@ func (c *Client) updateHandler() { for update := range listener.Updates { if update.GetClass() == client.ClassUpdate { + // debug probe: did UpdateCall reach the listener at all? + if t := update.GetType(); t == client.TypeUpdateCall || t == client.TypeUpdateNewCallSignalingData { + log.WithField("type", t).Debug("updateHandler: call-related update received") + } switch update.GetType() { case client.TypeUpdateUser: typedUpdate, ok := update.(*client.UpdateUser) @@ -172,6 +176,18 @@ func (c *Client) updateHandler() { uhOh() } c.updateChatReadOutbox(typedUpdate) + case client.TypeUpdateCall: + typedUpdate, ok := update.(*client.UpdateCall) + if !ok { + uhOh() + } + c.callDeps.TgManager.OnUpdateCall(c.jid, typedUpdate) + case client.TypeUpdateNewCallSignalingData: + typedUpdate, ok := update.(*client.UpdateNewCallSignalingData) + if !ok { + uhOh() + } + c.callDeps.TgManager.OnNewSignalingData(c.jid, typedUpdate) default: // log only handled types continue diff --git a/telegram/utils.go b/telegram/utils.go index 48e4b5c..88b82ad 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -1202,6 +1202,12 @@ func (c *Client) getPrefixSeparator(chatId int64) string { // ProcessIncomingMessage transfers a message to XMPP side and marks it as read on Telegram side func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { + // drop tdlib's end-of-call audit message; the xmpp client already saw + // the call lifecycle via Jingle + if message.Content != nil && message.Content.MessageContentType() == client.TypeMessageCall { + return + } + isCarbon := c.isCarbonsEnabled() && message.IsOutgoing jids := c.getCarbonFullJids(isCarbon, "") diff --git a/xmpp/component.go b/xmpp/component.go index 79fe103..7606873 100644 --- a/xmpp/component.go +++ b/xmpp/component.go @@ -4,16 +4,22 @@ import ( "github.com/pkg/errors" "regexp" "strconv" + "strings" "sync" "sync/atomic" "time" "dev.narayana.im/narayana/telegabber/badger" + "dev.narayana.im/narayana/telegabber/calls" + "dev.narayana.im/narayana/telegabber/calls/signaling/tgsig" + "dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig" "dev.narayana.im/narayana/telegabber/config" "dev.narayana.im/narayana/telegabber/persistence" "dev.narayana.im/narayana/telegabber/telegram" "dev.narayana.im/narayana/telegabber/xmpp/gateway" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" + "github.com/pion/webrtc/v4" log "github.com/sirupsen/logrus" "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" @@ -28,6 +34,15 @@ var db *persistence.SessionsYamlDB var componentEverConnected atomic.Bool var sessionLock sync.Mutex +// call infrastructure, built in NewComponent +var jingleManager *jingle.Manager +var tgManager *tgsig.Manager +var orchestrator *calls.Orchestrator +var callDeps telegram.CallDeps + +// latest XEP-0215 result; nil means no extdisco yet, pion uses host candidates only +var iceServers atomic.Pointer[[]webrtc.ICEServer] + const ( B uint64 = 1 KB = B << 10 @@ -93,6 +108,35 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri return nil, nil, err } + // call routers + orchestrator before loadSessions so per-session + // telegram.Clients get wired with both at construction + jingleManager = &jingle.Manager{ + LocalJID: gateway.Jid.Bare(), + Sender: component, + } + tgManager = tgsig.NewManager(nil) // incoming hook set after orchestrator exists + orchestrator = calls.New(calls.Config{ + JingleManager: jingleManager, + TgManager: tgManager, + Lookup: sessionLookup, + }) + // close the cycle; jingleManager.OnProposal was wired by calls.New + tgManager.SetIncomingHandler(orchestrator.NewFromTelegram) + callDeps = telegram.CallDeps{ + TgManager: tgManager, + XmppConfig: xmppsig.AdapterConfig{ + Sender: component, + LocalJID: gateway.Jid.Bare(), + Manager: jingleManager, + PCFactory: xmppsig.MakePCFactory(func() []webrtc.ICEServer { + if p := iceServers.Load(); p != nil { + return *p + } + return nil + }), + }, + } + // probe all known sessions err = loadSessions(conf.Db, component) if err != nil { @@ -102,11 +146,43 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri sm := xmpp.NewStreamManager(component, func(s xmpp.Sender) { componentEverConnected.Store(true) go heartbeat(component) + go refreshICEServers(s) }) return sm, component, nil } +// fetch XEP-0215 STUN/TURN list from the parent server (the c2s server, i.e. +// the component domain with its leftmost label stripped) and store it for +// PCFactory; runs on every (re)connect because TURN creds are short-lived +func refreshICEServers(s xmpp.Sender) { + parent := parentDomain(gateway.Jid.Domain) + if parent == "" { + log.Warnf("extdisco: cannot derive parent server from component JID %q; skipping", gateway.Jid.Domain) + return + } + services, err := xmppsig.QueryServices(s, gateway.Jid.Bare(), parent, 10*time.Second) + if err != nil { + log.Warnf("extdisco: query %s -> %s failed: %v", gateway.Jid.Bare(), parent, err) + return + } + servers := xmppsig.ToICEServers(services) + iceServers.Store(&servers) + log.Infof("extdisco: loaded %d ICE servers from %s (raw services: %d)", len(servers), parent, len(services)) + for i, srv := range servers { + log.Debugf("extdisco[%d]: urls=%v hasUser=%t hasCred=%t", i, srv.URLs, srv.Username != "", srv.Credential != nil && srv.Credential != "") + } +} + +// strips the leftmost label, e.g. tlgrm.example.com -> example.com; +// "" if there's no dot +func parentDomain(domain string) string { + if i := strings.IndexByte(domain, '.'); i > 0 && i < len(domain)-1 { + return domain[i+1:] + } + return "" +} + func heartbeat(component *xmpp.Component) { var err error probeType := gateway.SPType("probe") @@ -224,6 +300,7 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen log.Error(errors.Wrap(err, "TDlib initialization failure")) return session, false } + session.SetCallDeps(callDeps) if savedSession.KeepOnline { if err = session.Connect(""); err != nil { log.Error(err) @@ -238,6 +315,17 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen return session, true } +// orchestrator's SessionLookup callback +func sessionLookup(bareJID string) (*tgsig.Adapter, *xmppsig.Adapter, bool) { + sessionLock.Lock() + cl, ok := sessions[bareJID] + sessionLock.Unlock() + if !ok || cl == nil { + return nil, nil, false + } + return cl.CallAdapter(), cl.XmppCallAdapter(), true +} + // SaveSessions dumps current sessions to the file func SaveSessions() { sessionLock.Lock() @@ -251,9 +339,10 @@ func SaveSessions() { }, persistence.SessionMarshaller) } -// Close gracefully terminates the component and saves active sessions. -// If the component never reached steady state, SaveSessions is skipped -// to avoid overwriting persisted state with the empty-at-startup map. +// gracefully terminate the component and save active sessions. +// SaveSessions is skipped when componentEverConnected is false to avoid +// overwriting persisted YAML with the empty-at-startup map (seen during +// a tunnel-down crash-loop) func Close(component *xmpp.Component) { log.Error("Disconnecting...") diff --git a/xmpp/extensions/extensions.go b/xmpp/extensions/extensions.go index 2509cf8..81c2732 100644 --- a/xmpp/extensions/extensions.go +++ b/xmpp/extensions/extensions.go @@ -5,6 +5,9 @@ import ( "strconv" "gosrc.io/xmpp/stanza" + + // side-effect: register Jingle stanza types with stanza.TypeRegistry + _ "dev.narayana.im/narayana/telegabber/xmpp/jingle" ) // PresenceNickExtension is from XEP-0172 diff --git a/xmpp/handlers.go b/xmpp/handlers.go index 4d5dcda..5befa68 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -14,6 +14,7 @@ import ( "dev.narayana.im/narayana/telegabber/telegram" "dev.narayana.im/narayana/telegabber/xmpp/extensions" "dev.narayana.im/narayana/telegabber/xmpp/gateway" + "dev.narayana.im/narayana/telegabber/xmpp/jingle" "github.com/google/uuid" log "github.com/sirupsen/logrus" @@ -35,6 +36,19 @@ func logPacketType(p stanza.Packet) { log.Warnf("Ignoring packet: %T\n", p) } +// short-circuits JMI traffic into the call-signaling adapter before the +// body/reply handler runs +func hasJMIExtension(exts []stanza.MsgExtension) bool { + for _, e := range exts { + switch e.(type) { + case *jingle.JMIPropose, *jingle.JMIProceed, *jingle.JMIRinging, + *jingle.JMIReject, *jingle.JMIRetract, *jingle.JMIFinish: + return true + } + } + return false +} + // HandleIq processes an incoming XMPP iq func HandleIq(s xmpp.Sender, p stanza.Packet) { iq, ok := p.(*stanza.IQ) @@ -43,6 +57,14 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) { return } + // route Jingle IQ stanzas to the jingle manager + if jingleManager != nil { + if _, isJingle := iq.Payload.(*jingle.JingleIQ); isJingle { + jingleManager.HandlePacket(s, p) + return + } + } + log.Debugf("%#v", iq) if iq.Type == stanza.IQTypeGet { _, ok := iq.Payload.(*extensions.IqVcardTemp) @@ -114,6 +136,14 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { return } + // Route JMI (XEP-0353) messages to the process-wide jingle manager first. + // If any of the message's extensions are JMI, the manager consumes it + // and the regular body/reply handling below would be irrelevant. + if jingleManager != nil && hasJMIExtension(msg.Extensions) { + jingleManager.HandlePacket(s, &msg) + return + } + component, ok := s.(*xmpp.Component) if !ok { log.Error("Not a component") @@ -649,6 +679,19 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { disco.AddIdentity("", "account", "registered") disco.AddFeatures(stanza.NSMsgChatMarkers) disco.AddFeatures(stanza.NSMsgReceipts) + // Jingle / JMI features so clients (e.g. Dino) treat the contact + // as call-capable. Without these, JMI never fires: + // the client disco-info's the caller before engaging its call + // state machine and silently drops the call when it sees no RTP + // support advertised. + disco.AddFeatures( + "urn:xmpp:jingle:1", + "urn:xmpp:jingle-message:0", + "urn:xmpp:jingle:apps:rtp:1", + "urn:xmpp:jingle:apps:rtp:audio", + "urn:xmpp:jingle:transports:ice-udp:1", + "urn:xmpp:jingle:apps:dtls:0", + ) } else { disco.AddIdentity("Telegram Gateway", "gateway", "telegram") disco.AddFeatures("jabber:iq:register") diff --git a/xmpp/jingle/candidate_pion.go b/xmpp/jingle/candidate_pion.go new file mode 100644 index 0000000..df1930e --- /dev/null +++ b/xmpp/jingle/candidate_pion.go @@ -0,0 +1,66 @@ +package jingle + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/pion/webrtc/v4" +) + +// strips the "candidate:" prefix so candidateFromSDP gets the same shape +// it does on SDP import +func pionToCandidate(c *webrtc.ICECandidate) (*Candidate, error) { + if c == nil { + return nil, errors.New("nil ICECandidate") + } + init := c.ToJSON() + body := init.Candidate + body = strings.TrimPrefix(body, "candidate:") + cand, err := candidateFromSDP(body) + if err != nil { + return nil, err + } + if cand == nil { + return nil, fmt.Errorf("unrecognised pion candidate %q", init.Candidate) + } + return cand, nil +} + +// fills SDPMid and parsed SDPMLineIndex when mid is numeric +func candidateToPionInit(c *Candidate, mid string) (webrtc.ICECandidateInit, error) { + sdpBody, err := candidateToSDP(c) + if err != nil { + return webrtc.ICECandidateInit{}, err + } + init := webrtc.ICECandidateInit{ + Candidate: "candidate:" + sdpBody, + } + if mid != "" { + m := mid + init.SDPMid = &m + } + if n, err := strconv.Atoi(mid); err == nil && n >= 0 && n <= 0xffff { + idx := uint16(n) + init.SDPMLineIndex = &idx + } + return init, nil +} + +// initiator/responder stay unset; Session stamps them per role before send +func buildTransportInfo(sid, mid, ufrag, pwd, creator string, cand *Candidate) *JingleIQ { + return &JingleIQ{ + Action: ActionTransportInfo, + SID: sid, + Contents: []Content{{ + Creator: creator, + Name: mid, + Transport: &Transport{ + Ufrag: ufrag, + Pwd: pwd, + Candidates: []Candidate{*cand}, + }, + }}, + } +} diff --git a/xmpp/jingle/convert.go b/xmpp/jingle/convert.go new file mode 100644 index 0000000..22cebd4 --- /dev/null +++ b/xmpp/jingle/convert.go @@ -0,0 +1,225 @@ +// XEP-0166 Jingle stanzas <-> SDP strings +package jingle + +import ( + "errors" + "fmt" + "strings" +) + +const ( + hardcodedMediaProtocol = "UDP/TLS/RTP/SAVPF" + hardcodedMediaPort = "9" + hardcodedConnection = "IN IP4 0.0.0.0" + hardcodedOrigin = "- 8770656990916039506 2 IN IP4 127.0.0.1" +) + +var ( + defaultICEOptions = []string{"trickle"} + wellKnownICEOptions = map[string]bool{"trickle": true, "renomination": true} +) + +// direction-flag resolution and metadata for SDPToJingle +type ConvertOpts struct { + // governs how senders=initiator/responder maps to sendonly/recvonly + Initiator bool + // value; defaults to "initiator" + LocalCreator string + // value + SessionID string +} + +func JingleToSDP(j *JingleIQ, opts ConvertOpts) (string, error) { + if j == nil { + return "", errors.New("nil JingleIQ") + } + b := &sdpBuilder{} + b.line("v=0") + b.line("o=" + hardcodedOrigin) + b.line("s=-") + b.line("t=0 0") + + sessAttrs, err := buildSessionAttrs(j) + if err != nil { + return "", err + } + b.appendAttrs(sessAttrs) + + bundled := j.Group != nil && j.Group.Semantics != "" + + for _, c := range j.Contents { + if c.Description == nil || c.Transport == nil { + continue + } + mediaAttrs, err := buildMediaAttrs(&c, bundled, opts) + if err != nil { + return "", err + } + formats := make([]string, 0, len(c.Description.PayloadTypes)) + for _, pt := range c.Description.PayloadTypes { + formats = append(formats, pt.ID) + } + mline := "m=" + c.Description.Media + " " + hardcodedMediaPort + " " + hardcodedMediaProtocol + if len(formats) > 0 { + mline += " " + strings.Join(formats, " ") + } + b.line(mline) + b.line("c=" + hardcodedConnection) + b.appendAttrs(mediaAttrs) + } + return b.String(), nil +} + +func SDPToJingle(sdp string, opts ConvertOpts) (*JingleIQ, error) { + doc, err := parseSDP(sdp) + if err != nil { + return nil, err + } + creator := opts.LocalCreator + if creator == "" { + creator = "initiator" + } + j := &JingleIQ{SID: opts.SessionID} + + if groupVal := mmFirst(doc.sessionAttrs, "group", ""); groupVal != "" { + parts := strings.Fields(groupVal) + if len(parts) >= 1 { + sem := parts[0] + if strings.ContainsAny(sem, " \t\r\n") { + return nil, fmt.Errorf("group semantics contains whitespace: %q", sem) + } + g := &Group{Semantics: sem} + for _, name := range parts[1:] { + if strings.ContainsAny(name, " \t\r\n") { + return nil, fmt.Errorf("group content name contains whitespace: %q", name) + } + g.Contents = append(g.Contents, GroupContent{Name: name}) + } + j.Group = g + } + } + + for _, m := range doc.media { + content, err := buildJingleContent(&m, doc.sessionAttrs, creator, opts.Initiator) + if err != nil { + return nil, err + } + j.Contents = append(j.Contents, *content) + } + return j, nil +} + +func buildSessionAttrs(j *JingleIQ) ([]kv, error) { + var attrs []kv + if j.Group != nil && j.Group.Semantics != "" { + sem := j.Group.Semantics + if strings.ContainsAny(sem, " \t\r\n") { + return nil, fmt.Errorf("group semantics contains whitespace: %q", sem) + } + tags := make([]string, 0, len(j.Group.Contents)) + for _, gc := range j.Group.Contents { + if strings.ContainsAny(gc.Name, " \t\r\n") { + return nil, fmt.Errorf("group content name contains whitespace: %q", gc.Name) + } + tags = append(tags, gc.Name) + } + val := sem + if len(tags) > 0 { + val += " " + strings.Join(tags, " ") + } + attrs = append(attrs, kv{"group", val}) + } + attrs = append(attrs, kv{"msid-semantic", " WMS my-media-stream"}) + return attrs, nil +} + +// attr order matches jinglesdp.tcl: transport, payload/feedback/ssrc, +// mid/direction/rtcp/candidates +func buildMediaAttrs(c *Content, bundled bool, opts ConvertOpts) ([]kv, error) { + var attrs []kv + if err := appendTransportAttrs(c.Transport, &attrs); err != nil { + return nil, err + } + if err := appendDescriptionAttrs(c.Description, &attrs); err != nil { + return nil, err + } + attrs = append(attrs, kv{"mid", c.Name}) + attrs = append(attrs, kv{sendersToSDP(c.Senders, opts.Initiator), ""}) + if c.Description.RtcpMux != nil || bundled { + attrs = append(attrs, kv{"rtcp-mux", ""}) + } + attrs = append(attrs, kv{"rtcp", "9 IN IP4 0.0.0.0"}) + for _, cand := range c.Transport.Candidates { + s, err := candidateToSDP(&cand) + if err != nil { + return nil, err + } + attrs = append(attrs, kv{"candidate", s}) + } + return attrs, nil +} + +func buildJingleContent(m *mediaSection, sessAttrs []kv, creator string, initiator bool) (*Content, error) { + name := mmFirst(m.attrs, "mid", m.media) + c := &Content{ + Creator: creator, + Name: name, + } + if s := sendersFromMM(m.attrs, initiator); s != "both" { + c.Senders = s + } + desc, err := buildJingleDescription(m) + if err != nil { + return nil, err + } + c.Description = desc + transport, err := buildJingleTransport(m, sessAttrs) + if err != nil { + return nil, err + } + c.Transport = transport + return c, nil +} + +// SDP direction attr name; value is empty because a=sendrecv is bare +func sendersToSDP(senders string, initiator bool) string { + switch senders { + case "", "both": + return "sendrecv" + case "none": + return "inactive" + case "initiator": + if initiator { + return "sendonly" + } + return "recvonly" + case "responder": + if initiator { + return "recvonly" + } + return "sendonly" + } + return "sendrecv" +} + +func sendersFromMM(mm []kv, initiator bool) string { + if mmHas(mm, "sendrecv") { + return "both" + } + if mmHas(mm, "inactive") { + return "none" + } + if mmHas(mm, "sendonly") { + if initiator { + return "initiator" + } + return "responder" + } + if mmHas(mm, "recvonly") { + if initiator { + return "responder" + } + return "initiator" + } + return "both" +} diff --git a/xmpp/jingle/convert_payload.go b/xmpp/jingle/convert_payload.go new file mode 100644 index 0000000..dfeb29b --- /dev/null +++ b/xmpp/jingle/convert_payload.go @@ -0,0 +1,268 @@ +package jingle + +import ( + "errors" + "fmt" + "strings" +) + +// attr order follows jinglesdp.tcl +func appendDescriptionAttrs(d *Description, attrs *[]kv) error { + if d == nil { + return errors.New("content missing description") + } + if d.Media == "" { + return errors.New("description missing media") + } + for _, pt := range d.PayloadTypes { + rtpmap, err := payloadTypeToSDP(&pt) + if err != nil { + return err + } + *attrs = append(*attrs, kv{"rtpmap", rtpmap}) + if len(pt.Parameters) >= 1 { + fmtp, err := paramsToFmtp(pt.ID, pt.Parameters) + if err != nil { + return err + } + *attrs = append(*attrs, kv{"fmtp", fmtp}) + } + for _, fb := range pt.RTCPFbs { + s, err := fbToSDP(pt.ID, &fb) + if err != nil { + return err + } + *attrs = append(*attrs, kv{"rtcp-fb", s}) + } + for _, fb := range pt.RTCPFbTrrInts { + *attrs = append(*attrs, kv{"rtcp-fb", pt.ID + " trr-int " + fb.Value}) + } + } + for _, fb := range d.RTCPFbs { + s, err := fbToSDP("*", &fb) + if err != nil { + return err + } + *attrs = append(*attrs, kv{"rtcp-fb", s}) + } + for _, fb := range d.RTCPFbTrrInts { + *attrs = append(*attrs, kv{"rtcp-fb", "* trr-int " + fb.Value}) + } + for _, ext := range d.RTPHdrexts { + if ext.ID == "" || ext.URI == "" { + return errors.New("rtp-hdrext missing id or uri") + } + *attrs = append(*attrs, kv{"extmap", ext.ID + " " + ext.URI}) + } + if d.ExtmapAllowMixed != nil { + *attrs = append(*attrs, kv{"extmap-allow-mixed", ""}) + } + for _, sg := range d.SSRCGroups { + if strings.ContainsAny(sg.Semantics, " \t\r\n") { + return fmt.Errorf("ssrc-group semantics contains whitespace: %q", sg.Semantics) + } + ssrcs := make([]string, 0, len(sg.Sources)) + for _, s := range sg.Sources { + ssrcs = append(ssrcs, s.SSRC) + } + *attrs = append(*attrs, kv{"ssrc-group", sg.Semantics + " " + strings.Join(ssrcs, " ")}) + } + for _, src := range d.Sources { + for _, p := range src.Parameters { + if p.Name == "" { + return errors.New("source parameter missing name") + } + if strings.ContainsAny(p.Value, "\r\n") { + return errors.New("ssrc parameter value contains CR/LF") + } + if p.Value == "" { + *attrs = append(*attrs, kv{"ssrc", src.SSRC + " " + p.Name}) + } else { + *attrs = append(*attrs, kv{"ssrc", src.SSRC + " " + p.Name + ":" + p.Value}) + } + } + } + return nil +} + +func buildJingleDescription(m *mediaSection) (*Description, error) { + desc := &Description{Media: m.media} + + // "*" is the description-level key + fbMap := map[string][]RTCPFb{} + trrMap := map[string][]RTCPFbTrrInt{} + for _, val := range mmGetAll(m.attrs, "rtcp-fb") { + parts := strings.SplitN(val, " ", 3) + if len(parts) < 2 { + continue + } + id := parts[0] + typ := parts[1] + var sub string + if len(parts) >= 3 { + sub = parts[2] + } + if typ == "trr-int" { + trrMap[id] = append(trrMap[id], RTCPFbTrrInt{Value: sub}) + } else { + fbMap[id] = append(fbMap[id], RTCPFb{Type: typ, Subtype: sub}) + } + } + + fmtpMap := map[string][]Parameter{} + for _, val := range mmGetAll(m.attrs, "fmtp") { + sp := strings.SplitN(val, " ", 2) + if len(sp) < 2 { + continue + } + id := sp[0] + rest := sp[1] + var params []Parameter + for _, pair := range strings.Split(rest, ";") { + kvSplit := strings.SplitN(pair, "=", 2) + if len(kvSplit) == 2 { + params = append(params, Parameter{Name: kvSplit[0], Value: kvSplit[1]}) + } + } + fmtpMap[id] = params + } + + // description-level rtcp-fb (id="*") goes first per Tcl/Java + for _, fb := range fbMap["*"] { + desc.RTCPFbs = append(desc.RTCPFbs, fb) + } + delete(fbMap, "*") + for _, fb := range trrMap["*"] { + desc.RTCPFbTrrInts = append(desc.RTCPFbTrrInts, fb) + } + delete(trrMap, "*") + + for _, rtpmap := range mmGetAll(m.attrs, "rtpmap") { + pair := strings.SplitN(rtpmap, " ", 2) + if len(pair) < 2 { + continue + } + id := pair[0] + sp := strings.Split(pair[1], "/") + if len(sp) < 2 { + continue + } + pt := PayloadType{ID: id, Name: sp[0], Clockrate: sp[1]} + if len(sp) >= 3 && sp[2] != "1" { + pt.Channels = sp[2] + } + if params, ok := fmtpMap[id]; ok { + pt.Parameters = params + } + if fbs, ok := fbMap[id]; ok { + pt.RTCPFbs = fbs + } + if trrs, ok := trrMap[id]; ok { + pt.RTCPFbTrrInts = trrs + } + desc.PayloadTypes = append(desc.PayloadTypes, pt) + } + + for _, extval := range mmGetAll(m.attrs, "extmap") { + sp := strings.SplitN(extval, " ", 2) + if len(sp) < 2 { + continue + } + desc.RTPHdrexts = append(desc.RTPHdrexts, RTPHdrext{ID: sp[0], URI: sp[1]}) + } + if mmHas(m.attrs, "extmap-allow-mixed") { + desc.ExtmapAllowMixed = &ExtmapAllowMixed{} + } + + for _, val := range mmGetAll(m.attrs, "ssrc-group") { + sp := strings.Fields(val) + if len(sp) < 2 { + continue + } + if strings.ContainsAny(sp[0], " \t\r\n") { + return nil, fmt.Errorf("ssrc-group semantics contains whitespace: %q", sp[0]) + } + sg := SSRCGroup{Semantics: sp[0]} + for _, ssrc := range sp[1:] { + sg.Sources = append(sg.Sources, SSRCGroupSource{SSRC: ssrc}) + } + desc.SSRCGroups = append(desc.SSRCGroups, sg) + } + + // aggregate ssrc attrs by ssrc id, preserving first-seen order + sourceMap := map[string][]Parameter{} + var sourceOrder []string + for _, val := range mmGetAll(m.attrs, "ssrc") { + sp := strings.SplitN(val, " ", 2) + if len(sp) < 2 { + continue + } + sid := sp[0] + rest := sp[1] + kvParts := strings.SplitN(rest, ":", 2) + pn := kvParts[0] + var pv string + if len(kvParts) >= 2 { + pv = kvParts[1] + } + if pn == "" { + return nil, errors.New("ssrc attribute missing parameter name") + } + if strings.ContainsAny(pv, "\r\n") { + return nil, errors.New("ssrc parameter value contains CR/LF") + } + if _, ok := sourceMap[sid]; !ok { + sourceOrder = append(sourceOrder, sid) + } + sourceMap[sid] = append(sourceMap[sid], Parameter{Name: pn, Value: pv}) + } + for _, sid := range sourceOrder { + desc.Sources = append(desc.Sources, Source{SSRC: sid, Parameters: sourceMap[sid]}) + } + + if mmHas(m.attrs, "rtcp-mux") { + desc.RtcpMux = &RtcpMux{} + } + return desc, nil +} + +func payloadTypeToSDP(pt *PayloadType) (string, error) { + if pt.ID == "" { + return "", errors.New("payload-type missing id") + } + if pt.Name == "" { + return "", errors.New("payload-type missing name") + } + if pt.Channels == "" || pt.Channels == "1" { + return pt.ID + " " + pt.Name + "/" + pt.Clockrate, nil + } + return pt.ID + " " + pt.Name + "/" + pt.Clockrate + "/" + pt.Channels, nil +} + +func fbToSDP(id string, fb *RTCPFb) (string, error) { + if fb.Type == "" { + return "", errors.New("rtcp-fb missing type") + } + if fb.Subtype == "" { + return id + " " + fb.Type, nil + } + return id + " " + fb.Type + " " + fb.Subtype, nil +} + +func paramsToFmtp(id string, params []Parameter) (string, error) { + if len(params) == 1 { + p := params[0] + if p.Name == "" { + return id + " " + p.Value, nil + } + return id + " " + p.Name + "=" + p.Value, nil + } + parts := make([]string, 0, len(params)) + for _, p := range params { + if p.Name == "" || p.Value == "" { + return "", errors.New("fmtp parameter missing name or value") + } + parts = append(parts, p.Name+"="+p.Value) + } + return id + " " + strings.Join(parts, ";"), nil +} diff --git a/xmpp/jingle/convert_test.go b/xmpp/jingle/convert_test.go new file mode 100644 index 0000000..a89a782 --- /dev/null +++ b/xmpp/jingle/convert_test.go @@ -0,0 +1,231 @@ +package jingle + +import ( + "bytes" + "encoding/xml" + "reflect" + "testing" +) + +// exercises BUNDLE + opus + fmtp + extmap + ssrc + DTLS-SRTP + ICE-UDP +const sampleSDP = "v=0\r\n" + + "o=- 0 0 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=group:BUNDLE 0\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:0\r\n" + + "a=sendrecv\r\n" + + "a=rtcp-mux\r\n" + + "a=ice-ufrag:abc\r\n" + + "a=ice-pwd:thispasswordisatleast22chars\r\n" + + "a=ice-options:trickle\r\n" + + "a=fingerprint:sha-256 AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89\r\n" + + "a=setup:actpass\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + + "a=fmtp:111 minptime=10;useinbandfec=1\r\n" + + "a=rtcp-fb:111 transport-cc\r\n" + + "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" + + "a=ssrc:12345 cname:abcdef\r\n" + + "a=ssrc:12345 msid:stream track\r\n" + + "a=candidate:1 1 udp 2113937151 192.0.2.1 10000 typ host generation 0\r\n" + +func sampleJingle() *JingleIQ { + j, err := SDPToJingle(sampleSDP, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: "abc123"}) + if err != nil { + panic(err) + } + j.Action = "session-initiate" + j.Initiator = "alice@example.com/foo" + return j +} + +func TestRoundtripJingleStructViaSDP(t *testing.T) { + in := sampleJingle() + opts := ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: in.SID} + sdp, err := JingleToSDP(in, opts) + if err != nil { + t.Fatalf("JingleToSDP: %v", err) + } + out, err := SDPToJingle(sdp, opts) + if err != nil { + t.Fatalf("SDPToJingle: %v", err) + } + // Action / Initiator / Responder live on the IQ envelope and are not in + // SDP, so the roundtrip can only restore Contents, Group, and SID. + if out.SID != in.SID { + t.Errorf("SID lost: %q -> %q", in.SID, out.SID) + } + if !reflect.DeepEqual(in.Group, out.Group) { + t.Errorf("Group mismatch:\nin: %+v\nout: %+v", in.Group, out.Group) + } + if len(in.Contents) != len(out.Contents) { + t.Fatalf("content count: in=%d out=%d", len(in.Contents), len(out.Contents)) + } + for i := range in.Contents { + assertContentEqual(t, &in.Contents[i], &out.Contents[i]) + } +} + +// only fields that survive a round trip; RtcpMux gets set unconditionally +// when BUNDLE is in play, candidate id gets dropped, etc. +func assertContentEqual(t *testing.T, a, b *Content) { + t.Helper() + if a.Name != b.Name || a.Creator != b.Creator { + t.Errorf("content header: %+v vs %+v", a, b) + } + // empty input means "both", which sendersFromMM omits + if want, got := normalizeSenders(a.Senders), normalizeSenders(b.Senders); want != got { + t.Errorf("senders: %q vs %q", want, got) + } + if !reflect.DeepEqual(a.Description, b.Description) { + t.Errorf("description differs:\nin: %+v\nout: %+v", a.Description, b.Description) + } + assertTransportEqual(t, a.Transport, b.Transport) +} + +func normalizeSenders(s string) string { + if s == "" { + return "both" + } + return s +} + +func assertTransportEqual(t *testing.T, a, b *Transport) { + t.Helper() + if a.Ufrag != b.Ufrag || a.Pwd != b.Pwd { + t.Errorf("ufrag/pwd: in=(%q,%q) out=(%q,%q)", a.Ufrag, a.Pwd, b.Ufrag, b.Pwd) + } + if !reflect.DeepEqual(a.Fingerprint, b.Fingerprint) { + t.Errorf("fingerprint differs:\nin: %+v\nout: %+v", a.Fingerprint, b.Fingerprint) + } + if (a.Trickle == nil) != (b.Trickle == nil) { + t.Errorf("trickle presence differs: in=%v out=%v", a.Trickle != nil, b.Trickle != nil) + } + if (a.Renomination == nil) != (b.Renomination == nil) { + t.Errorf("renomination presence differs: in=%v out=%v", a.Renomination != nil, b.Renomination != nil) + } + if len(a.Candidates) != len(b.Candidates) { + t.Fatalf("candidate count: in=%d out=%d", len(a.Candidates), len(b.Candidates)) + } + for i := range a.Candidates { + ac, bc := a.Candidates[i], b.Candidates[i] + // id and network are not present in SDP candidate form; ignore. + ac.ID, bc.ID = "", "" + ac.Network, bc.Network = "", "" + if !reflect.DeepEqual(ac, bc) { + t.Errorf("candidate %d differs:\nin: %+v\nout: %+v", i, ac, bc) + } + } +} + +func TestJingleXMLRoundtrip(t *testing.T) { + in := sampleJingle() + raw1, err := xml.MarshalIndent(in, "", " ") + if err != nil { + t.Fatalf("marshal in: %v", err) + } + var mid JingleIQ + if err := xml.Unmarshal(raw1, &mid); err != nil { + t.Fatalf("unmarshal: %v\nraw:\n%s", err, raw1) + } + raw2, err := xml.MarshalIndent(&mid, "", " ") + if err != nil { + t.Fatalf("marshal mid: %v", err) + } + if !bytes.Equal(raw1, raw2) { + t.Errorf("XML not idempotent under marshal/unmarshal\n---first---\n%s\n---second---\n%s", raw1, raw2) + } +} + +func TestParseSDP_AttrsAndMedia(t *testing.T) { + sdp := "v=0\r\n" + + "o=- 0 0 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=group:BUNDLE 0\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=mid:0\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + doc, err := parseSDP(sdp) + if err != nil { + t.Fatalf("parseSDP: %v", err) + } + if mmFirst(doc.sessionAttrs, "group", "") != "BUNDLE 0" { + t.Errorf("session group: %q", mmFirst(doc.sessionAttrs, "group", "")) + } + if len(doc.media) != 1 { + t.Fatalf("expected 1 m=block, got %d", len(doc.media)) + } + m := doc.media[0] + if m.media != "audio" || m.port != "9" || m.protocol != "UDP/TLS/RTP/SAVPF" { + t.Errorf("m= header: %+v", m) + } + if !reflect.DeepEqual(m.formats, []string{"111"}) { + t.Errorf("formats: %v", m.formats) + } + if m.connection != "IN IP4 0.0.0.0" { + t.Errorf("connection: %q", m.connection) + } + if mmFirst(m.attrs, "mid", "") != "0" { + t.Errorf("mid: %q", mmFirst(m.attrs, "mid", "")) + } + if mmFirst(m.attrs, "rtpmap", "") != "111 opus/48000/2" { + t.Errorf("rtpmap: %q", mmFirst(m.attrs, "rtpmap", "")) + } +} + +func TestJingleToSDP_RejectsMalformed(t *testing.T) { + cases := []struct { + name string + mutate func(*JingleIQ) + }{ + {"missing fingerprint", func(j *JingleIQ) { j.Contents[0].Transport.Fingerprint = nil }}, + {"non-UDP candidate", func(j *JingleIQ) { j.Contents[0].Transport.Candidates[0].Protocol = "tcp" }}, + {"CR/LF in ssrc param value", func(j *JingleIQ) { + j.Contents[0].Description.Sources[0].Parameters[0].Value = "bad\nvalue" + }}, + {"whitespace in group semantics", func(j *JingleIQ) { j.Group.Semantics = "BUNDLE BAD" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + j := sampleJingle() + tc.mutate(j) + if _, err := JingleToSDP(j, ConvertOpts{Initiator: true}); err == nil { + t.Fatalf("expected error") + } + }) + } +} + +func TestSDPToJingle_FingerprintSessionFallback(t *testing.T) { + // Media block omits fingerprint+setup; session block provides them. + sdp := "v=0\r\n" + + "o=- 0 0 IN IP4 0.0.0.0\r\n" + + "s=-\r\n" + + "t=0 0\r\n" + + "a=fingerprint:sha-256 AA:BB:CC\r\n" + + "a=setup:actpass\r\n" + + "m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" + + "c=IN IP4 0.0.0.0\r\n" + + "a=ice-ufrag:u\r\n" + + "a=ice-pwd:p\r\n" + + "a=mid:0\r\n" + + "a=rtpmap:111 opus/48000/2\r\n" + j, err := SDPToJingle(sdp, ConvertOpts{Initiator: true}) + if err != nil { + t.Fatalf("SDPToJingle: %v", err) + } + if len(j.Contents) != 1 { + t.Fatalf("contents: %d", len(j.Contents)) + } + fp := j.Contents[0].Transport.Fingerprint + if fp == nil { + t.Fatal("fingerprint not pulled up from session level") + } + if fp.Hash != "sha-256" || fp.Text != "AA:BB:CC" || fp.Setup != "actpass" { + t.Errorf("fingerprint: %+v", fp) + } +} diff --git a/xmpp/jingle/convert_transport.go b/xmpp/jingle/convert_transport.go new file mode 100644 index 0000000..2ed388a --- /dev/null +++ b/xmpp/jingle/convert_transport.go @@ -0,0 +1,167 @@ +package jingle + +import ( + "errors" + "fmt" + "strings" +) + +func appendTransportAttrs(t *Transport, attrs *[]kv) error { + if t == nil { + return errors.New("content missing transport") + } + if t.Ufrag == "" { + return errors.New("transport missing ufrag") + } + if t.Pwd == "" { + return errors.New("transport missing pwd") + } + *attrs = append(*attrs, kv{"ice-ufrag", t.Ufrag}) + *attrs = append(*attrs, kv{"ice-pwd", t.Pwd}) + + var iceOptions []string + if t.Trickle != nil { + iceOptions = append(iceOptions, "trickle") + } + if t.Renomination != nil { + iceOptions = append(iceOptions, "renomination") + } + if len(iceOptions) == 0 { + iceOptions = defaultICEOptions + } + *attrs = append(*attrs, kv{"ice-options", strings.Join(iceOptions, " ")}) + + fp := t.Fingerprint + if fp == nil { + return errors.New("transport missing DTLS fingerprint") + } + if fp.Hash == "" || fp.Text == "" { + return errors.New("fingerprint missing hash or body") + } + if strings.ContainsAny(fp.Hash, " \t\r\n") { + return fmt.Errorf("fingerprint hash contains whitespace: %q", fp.Hash) + } + *attrs = append(*attrs, kv{"fingerprint", fp.Hash + " " + fp.Text}) + if fp.Setup != "" { + *attrs = append(*attrs, kv{"setup", fp.Setup}) + } + return nil +} + +// falls back to session-level fingerprint/setup when the media block omits +// them (matches Conversations SessionDescription) +func buildJingleTransport(m *mediaSection, sessAttrs []kv) (*Transport, error) { + t := &Transport{ + Ufrag: mmFirst(m.attrs, "ice-ufrag", ""), + Pwd: mmFirst(m.attrs, "ice-pwd", ""), + } + + fpRaw := mmFirst(m.attrs, "fingerprint", mmFirst(sessAttrs, "fingerprint", "")) + setupVal := mmFirst(m.attrs, "setup", mmFirst(sessAttrs, "setup", "")) + if fpRaw != "" && setupVal != "" { + sp := strings.SplitN(fpRaw, " ", 2) + if len(sp) >= 2 { + t.Fingerprint = &Fingerprint{ + Hash: sp[0], + Setup: setupVal, + Text: sp[1], + } + } + } + + if iceOptions := mmFirst(m.attrs, "ice-options", ""); iceOptions != "" { + for _, opt := range strings.Fields(iceOptions) { + if !wellKnownICEOptions[opt] { + continue + } + switch opt { + case "trickle": + t.Trickle = &ICEOptionTrickle{} + case "renomination": + t.Renomination = &ICEOptionRenomination{} + } + } + } + + for _, val := range mmGetAll(m.attrs, "candidate") { + cand, err := candidateFromSDP(val) + if err != nil { + return nil, err + } + if cand != nil { + t.Candidates = append(t.Candidates, *cand) + } + } + return t, nil +} + +func candidateToSDP(c *Candidate) (string, error) { + for _, kv := range []struct{ name, val string }{ + {"foundation", c.Foundation}, + {"component", c.Component}, + {"protocol", c.Protocol}, + {"priority", c.Priority}, + {"ip", c.IP}, + {"port", c.Port}, + } { + if kv.val == "" { + return "", fmt.Errorf("candidate missing %s", kv.name) + } + } + proto := strings.ToLower(c.Protocol) + if proto != "udp" { + return "", fmt.Errorf("'%s' is not a supported protocol", proto) + } + var extra []string + if c.Type != "" { + extra = append(extra, "typ", c.Type) + } + if c.RelAddr != "" { + extra = append(extra, "raddr", c.RelAddr) + } + if c.RelPort != "" { + extra = append(extra, "rport", c.RelPort) + } + if c.Generation != "" { + extra = append(extra, "generation", c.Generation) + } + line := c.Foundation + " " + c.Component + " " + proto + " " + c.Priority + " " + c.IP + " " + c.Port + if len(extra) > 0 { + line += " " + strings.Join(extra, " ") + } + return line, nil +} + +func candidateFromSDP(value string) (*Candidate, error) { + parts := strings.Fields(value) + if len(parts) < 6 { + return nil, nil + } + cand := &Candidate{ + Foundation: parts[0], + Component: parts[1], + Protocol: strings.ToLower(parts[2]), + Priority: parts[3], + IP: parts[4], + Port: parts[5], + } + if cand.Protocol != "udp" { + return nil, fmt.Errorf("'%s' is not a supported protocol", cand.Protocol) + } + extra := parts[6:] + for i := 0; i+1 < len(extra); i += 2 { + k := extra[i] + v := extra[i+1] + switch k { + case "typ": + cand.Type = v + case "raddr": + cand.RelAddr = v + case "rport": + cand.RelPort = v + case "generation": + cand.Generation = v + } + } + return cand, nil +} diff --git a/xmpp/jingle/jmi.go b/xmpp/jingle/jmi.go new file mode 100644 index 0000000..4fea5a5 --- /dev/null +++ b/xmpp/jingle/jmi.go @@ -0,0 +1,81 @@ +package jingle + +import ( + "encoding/xml" + + "gosrc.io/xmpp/stanza" +) + +// Jingle Message Initiation, XEP-0353 +const NSJMI = "urn:xmpp:jingle-message:0" + +// - advertises media the caller wants +type JMIPropose struct { + XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 propose"` + ID string `xml:"id,attr"` + Descriptions []JMIDescription `xml:"urn:xmpp:jingle:apps:rtp:1 description"` +} + +// slim XEP-0167 used inside JMI; only media is meaningful here +type JMIDescription struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 description"` + Media string `xml:"media,attr"` +} + +// - callee picked up, caller may send session-initiate +type JMIProceed struct { + XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 proceed"` + ID string `xml:"id,attr"` +} + +// - callee alerting +type JMIRinging struct { + XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 ringing"` + ID string `xml:"id,attr"` +} + +// - callee declined before media setup +type JMIReject struct { + XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 reject"` + ID string `xml:"id,attr"` + Reason *Reason `xml:"urn:xmpp:jingle:1 reason,omitempty"` +} + +// - caller cancelled before proceed +type JMIRetract struct { + XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 retract"` + ID string `xml:"id,attr"` +} + +// - terminal marker, parsed only so the parser doesn't fall back to Node +type JMIFinish struct { + XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 finish"` + ID string `xml:"id,attr"` +} + +// gosrc.io/xmpp MsgExtension boilerplate +func (JMIPropose) Namespace() string { return NSJMI } +func (JMIProceed) Namespace() string { return NSJMI } +func (JMIRinging) Namespace() string { return NSJMI } +func (JMIReject) Namespace() string { return NSJMI } +func (JMIRetract) Namespace() string { return NSJMI } +func (JMIFinish) Namespace() string { return NSJMI } + +func init() { + for _, m := range []struct { + local string + empty stanza.MsgExtension + }{ + {"propose", JMIPropose{}}, + {"proceed", JMIProceed{}}, + {"ringing", JMIRinging{}}, + {"reject", JMIReject{}}, + {"retract", JMIRetract{}}, + {"finish", JMIFinish{}}, + } { + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + Space: NSJMI, + Local: m.local, + }, m.empty) + } +} diff --git a/xmpp/jingle/manager.go b/xmpp/jingle/manager.go new file mode 100644 index 0000000..d20cc9b --- /dev/null +++ b/xmpp/jingle/manager.go @@ -0,0 +1,197 @@ +package jingle + +import ( + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + "gosrc.io/xmpp/stanza" +) + +type IncomingProposal struct { + SID string + From string + To string + Media []string +} + +// runs when JMI arrives for an unknown SID. +// - (s, postReady, nil): accept; Manager registers s and calls postReady +// (if non-nil) AFTER HandleJMI(propose) has reached StateRinging. Useful +// for tg-side goroutines that could race back in via the bridge. +// - (nil, _, nil): silently ignore. +// - (nil, _, err): logged, propose dropped. +type ProposalHandler func(IncomingProposal) (*Session, func(), error) + +// dispatches inbound Jingle IQ and JMI message stanzas to matching Sessions; +// one Manager per gateway-side endpoint +type Manager struct { + LocalJID string // `from=` on IQ result/error replies + Sender Sender // IQ replies only; Sessions own outbound traffic + OnProposal ProposalHandler + + sessions sync.Map // sid -> *Session + // stops two concurrent s for the same sid from both + // invoking OnProposal + pendingPropose sync.Map +} + +func (m *Manager) Register(s *Session) { + if s == nil { + return + } + m.sessions.Store(s.SID(), s) +} + +func (m *Manager) Unregister(sid string) { + m.sessions.Delete(sid) +} + +func (m *Manager) session(sid string) *Session { + v, ok := m.sessions.Load(sid) + if !ok { + return nil + } + return v.(*Session) +} + +// entrypoint for gosrc.io/xmpp's router (message and iq routes) +func (m *Manager) HandlePacket(_ interface{}, p stanza.Packet) { + // gosrc.io delivers *stanza.IQ but stanza.Message as value + switch pkt := p.(type) { + case *stanza.IQ: + m.handleIQ(pkt) + case stanza.Message: + m.handleMessage(&pkt) + case *stanza.Message: + m.handleMessage(pkt) + } +} + +func (m *Manager) HandleIQ(iq *stanza.IQ) { m.handleIQ(iq) } +func (m *Manager) HandleMessage(msg *stanza.Message) { m.handleMessage(msg) } + +func (m *Manager) handleIQ(iq *stanza.IQ) { + j, ok := iq.Payload.(*JingleIQ) + if !ok || j == nil { + return + } + sess := m.session(j.SID) + if sess == nil { + m.replyError(iq, "item-not-found") + return + } + _, errCondition := sess.HandleJingleIQ(iq, j) + if errCondition != "" { + m.replyError(iq, errCondition) + return + } + if iq.Type == stanza.IQTypeSet || iq.Type == stanza.IQTypeGet { + m.replyResult(iq) + } + if j.Action == ActionSessionTerminate { + m.Unregister(j.SID) + } +} + +func (m *Manager) handleMessage(msg *stanza.Message) { + for _, ext := range msg.Extensions { + switch x := ext.(type) { + case *JMIPropose: + m.dispatchPropose(msg, x) + case *JMIProceed: + m.dispatchJMI(x.ID, msg.From, x) + case *JMIRinging: + m.dispatchJMI(x.ID, msg.From, x) + case *JMIReject: + m.dispatchJMI(x.ID, msg.From, x) + m.Unregister(x.ID) + case *JMIRetract: + m.dispatchJMI(x.ID, msg.From, x) + m.Unregister(x.ID) + case *JMIFinish: + // terminal, ignore + } + } +} + +func (m *Manager) dispatchPropose(msg *stanza.Message, p *JMIPropose) { + if existing := m.session(p.ID); existing != nil { + return + } + // concurrent s for the same sid: only one dispatcher proceeds + if _, loaded := m.pendingPropose.LoadOrStore(p.ID, struct{}{}); loaded { + return + } + defer m.pendingPropose.Delete(p.ID) + if m.OnProposal == nil { + return + } + media := make([]string, 0, len(p.Descriptions)) + for _, d := range p.Descriptions { + media = append(media, d.Media) + } + sess, postReady, err := m.OnProposal(IncomingProposal{SID: p.ID, From: msg.From, To: msg.To, Media: media}) + if err != nil || sess == nil { + if err != nil { + log.WithFields(log.Fields{"sid": p.ID, "from": msg.From}).WithError(err). + Warn("jingle.Manager.dispatchPropose: OnProposal returned err") + } + return + } + m.Register(sess) + sess.HandleJMI(msg.From, p) + if postReady != nil { + postReady() + } +} + +func (m *Manager) dispatchJMI(sid, from string, ext stanza.MsgExtension) { + sess := m.session(sid) + if sess == nil { + log.WithFields(log.Fields{ + "sid": sid, + "from": from, + "ext_type": fmt.Sprintf("%T", ext), + }).Warn("jingle.Manager.dispatchJMI: no session for sid; dropping (stray / from SID mismatch?)") + return + } + sess.HandleJMI(from, ext) +} + +func (m *Manager) replyResult(iq *stanza.IQ) { + if m.Sender == nil { + return + } + out, err := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeResult, + From: iq.To, + To: iq.From, + Id: iq.Id, + }) + if err != nil { + return + } + _ = m.Sender.Send(out) +} + +// ; condition from urn:ietf:params:xml:ns:xmpp-stanzas +func (m *Manager) replyError(iq *stanza.IQ, condition string) { + if m.Sender == nil { + return + } + out, err := stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeError, + From: iq.To, + To: iq.From, + Id: iq.Id, + }) + if err != nil { + return + } + out.Error = &stanza.Err{ + Type: "cancel", + Reason: condition, + } + _ = m.Sender.Send(out) +} diff --git a/xmpp/jingle/namespaces.go b/xmpp/jingle/namespaces.go new file mode 100644 index 0000000..92340d8 --- /dev/null +++ b/xmpp/jingle/namespaces.go @@ -0,0 +1,13 @@ +package jingle + +const ( + NSJingle = "urn:xmpp:jingle:1" + NSRTP = "urn:xmpp:jingle:apps:rtp:1" + NSRTPHdrext = "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" + NSRTPFeedback = "urn:xmpp:jingle:apps:rtp:rtcp-fb:0" + NSRTPSSMA = "urn:xmpp:jingle:apps:rtp:ssma:0" + NSDTLS = "urn:xmpp:jingle:apps:dtls:0" + NSGrouping = "urn:xmpp:jingle:apps:grouping:0" + NSICEUDP = "urn:xmpp:jingle:transports:ice-udp:1" + NSICEOption = "http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option" +) diff --git a/xmpp/jingle/sdp.go b/xmpp/jingle/sdp.go new file mode 100644 index 0000000..d43cb0d --- /dev/null +++ b/xmpp/jingle/sdp.go @@ -0,0 +1,141 @@ +package jingle + +import ( + "fmt" + "strings" +) + +const sdpLineDivider = "\r\n" + +// one SDP "a=" attribute line +type kv struct { + k, v string +} + +// one m= block + its connection line and ordered attrs +type mediaSection struct { + media string + port string + protocol string + formats []string + connection string + attrs []kv +} + +type sdpDoc struct { + sessionAttrs []kv + media []mediaSection +} + +// preserves attribute order (SDP repeats keys; order matters for +// rtpmap/candidate) +func parseSDP(s string) (*sdpDoc, error) { + doc := &sdpDoc{} + var cur *mediaSection + + for _, raw := range strings.Split(s, "\n") { + line := strings.TrimRight(raw, "\r") + if len(line) < 2 || line[1] != '=' { + continue + } + key := line[0] + value := line[2:] + switch key { + case 'm': + if cur != nil { + doc.media = append(doc.media, *cur) + } + parts := strings.Split(value, " ") + if len(parts) < 3 { + return nil, fmt.Errorf("malformed m= line: %q", value) + } + ms := mediaSection{ + media: parts[0], + port: parts[1], + protocol: parts[2], + } + if len(parts) > 3 { + ms.formats = parts[3:] + } + cur = &ms + case 'c': + if cur != nil { + cur.connection = value + } + case 'a': + k, v := splitAttr(value) + if cur == nil { + doc.sessionAttrs = append(doc.sessionAttrs, kv{k, v}) + } else { + cur.attrs = append(cur.attrs, kv{k, v}) + } + } + } + if cur != nil { + doc.media = append(doc.media, *cur) + } + return doc, nil +} + +// value is everything after the first colon; later colons are preserved +func splitAttr(body string) (string, string) { + if i := strings.IndexByte(body, ':'); i >= 0 { + return body[:i], body[i+1:] + } + return body, "" +} + +func mmGetAll(mm []kv, key string) []string { + var out []string + for _, p := range mm { + if p.k == key { + out = append(out, p.v) + } + } + return out +} + +func mmFirst(mm []kv, key, def string) string { + for _, p := range mm { + if p.k == key { + return p.v + } + } + return def +} + +func mmHas(mm []kv, key string) bool { + for _, p := range mm { + if p.k == key { + return true + } + } + return false +} + +type sdpBuilder struct { + lines []string +} + +func (b *sdpBuilder) line(s string) { + b.lines = append(b.lines, s) +} + +// empty value emits "a=key" (no colon) +func (b *sdpBuilder) attr(k, v string) { + if v == "" { + b.lines = append(b.lines, "a="+k) + } else { + b.lines = append(b.lines, "a="+k+":"+v) + } +} + +func (b *sdpBuilder) appendAttrs(mm []kv) { + for _, p := range mm { + b.attr(p.k, p.v) + } +} + +func (b *sdpBuilder) String() string { + return strings.Join(b.lines, sdpLineDivider) + sdpLineDivider +} diff --git a/xmpp/jingle/session.go b/xmpp/jingle/session.go new file mode 100644 index 0000000..0cbf9b8 --- /dev/null +++ b/xmpp/jingle/session.go @@ -0,0 +1,736 @@ +// state machine binding one pion PeerConnection to the XMPP signalling +// channel (XEP-0166 + XEP-0353 JMI preamble) +package jingle + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/pion/webrtc/v4" + "gosrc.io/xmpp/stanza" +) + +type Role uint8 + +const ( + RoleInitiator Role = iota // local side proposed the call + RoleResponder // peer proposed the call +) + +// high-level call lifecycle; pion's ConnectionState owns media substates +type State uint8 + +const ( + StateNew State = iota + StateProposed // caller: sent , awaiting proceed/reject/retract + StateRinging // callee: got , sent , awaiting user accept/decline + StateProceeded // proceed exchanged; media setup either pending or in flight + StateActive // session-accept exchanged; pion drives ICE/DTLS from here + StateTerminated +) + +func (s State) String() string { + switch s { + case StateNew: + return "new" + case StateProposed: + return "proposed" + case StateRinging: + return "ringing" + case StateProceeded: + return "proceeded" + case StateActive: + return "active" + case StateTerminated: + return "terminated" + } + return fmt.Sprintf("state(%d)", uint8(s)) +} + +// slice of gosrc.io/xmpp.Sender that Session needs; *xmpp.Client and +// *xmpp.Component both satisfy it +type Sender interface { + Send(packet stanza.Packet) error + SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) +} + +// JMI/Jingle lifecycle callbacks. Methods may fire under the session mutex, +// so observers must not call back into the session synchronously - defer +// to a goroutine. When set, Session does NOT auto-send on +// incoming ; the observer drives that. +type SessionObserver interface { + OnRinging(from string) + OnProceeded(from string) + // fires after SetRemoteDescription, before CreateAnswer on the responder, + // so a same-stack ReplaceTrack lands in the answer SDP + OnRemoteDescriptionApplied(sdp string) + // reason: XEP-0166 condition local-name, or "rejected"/"retracted" + OnTerminated(reason string) +} + +// PC and Sender are required +type SessionOpts struct { + PC *webrtc.PeerConnection + Sender Sender + LocalJID string + RemoteJID string // bare is fine for initial ; latched to full on + SID string + Role Role + Media []string // only audio supported + Observer SessionObserver +} + +// one call. Public methods are safe from any goroutine; mu is held only +// briefly and never across pion calls or Sender I/O. +type Session struct { + opts SessionOpts + // latched from opts so it's readable without mu + observer SessionObserver + + mu sync.Mutex + state State + remoteCandQueue []remoteCandidate // candidates from before SetRemoteDescription + remoteDescApplied bool + localCandQueue []webrtc.ICECandidate // gathered before session-initiate/accept + signaled bool + // captured from the local SDP after SetLocalDescription + ufrag string + pwd string + mids []string + closed bool +} + +type remoteCandidate struct { + mid string + init webrtc.ICECandidateInit +} + +// human-readable so XML dumps are easy to grep +var stanzaIDCounter atomic.Uint64 + +func newStanzaID() string { + return fmt.Sprintf("jng-%d", stanzaIDCounter.Add(1)) +} + +// caller adds transceivers/tracks to PC before Propose/AcceptProposal +func New(opts SessionOpts) (*Session, error) { + if opts.PC == nil { + return nil, errors.New("jingle: PC required") + } + if opts.Sender == nil { + return nil, errors.New("jingle: Sender required") + } + if opts.SID == "" { + return nil, errors.New("jingle: SID required") + } + if opts.LocalJID == "" || opts.RemoteJID == "" { + return nil, errors.New("jingle: LocalJID and RemoteJID required") + } + if len(opts.Media) == 0 { + opts.Media = []string{"audio"} + } + s := &Session{opts: opts, observer: opts.Observer} + opts.PC.OnICECandidate(s.onICECandidate) + return s, nil +} + +func (s *Session) State() State { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +func (s *Session) SID() string { return s.opts.SID } + +// after arrives this is the full JID of the device that picked up +func (s *Session) RemoteJID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.opts.RemoteJID +} + +// +func (s *Session) Propose(ctx context.Context) error { + s.mu.Lock() + if s.state != StateNew || s.opts.Role != RoleInitiator { + st := s.state + s.mu.Unlock() + return fmt.Errorf("propose: bad state %s", st) + } + to := s.opts.RemoteJID + from := s.opts.LocalJID + media := append([]string(nil), s.opts.Media...) + s.mu.Unlock() + + msg := newJMIMessage(from, to, &JMIPropose{ + ID: s.opts.SID, + Descriptions: mediaDescs(media), + }) + if err := s.sendStanza(msg); err != nil { + return fmt.Errorf("propose: send: %w", err) + } + s.setStateIf(StateNew, StateProposed) + return nil +} + +// - callee tells caller the device is alerting +func (s *Session) Ringing(ctx context.Context) error { + s.mu.Lock() + if s.state != StateRinging || s.opts.Role != RoleResponder { + st := s.state + s.mu.Unlock() + return fmt.Errorf("ringing: bad state %s", st) + } + to, from := s.opts.RemoteJID, s.opts.LocalJID + s.mu.Unlock() + + msg := newJMIMessage(from, to, &JMIRinging{ID: s.opts.SID}) + return s.sendStanza(msg) +} + +// ; media setup waits for session-initiate +func (s *Session) AcceptProposal(ctx context.Context) error { + s.mu.Lock() + if s.state != StateRinging || s.opts.Role != RoleResponder { + st := s.state + s.mu.Unlock() + return fmt.Errorf("accept: bad state %s", st) + } + to, from := s.opts.RemoteJID, s.opts.LocalJID + s.mu.Unlock() + + msg := newJMIMessage(from, to, &JMIProceed{ID: s.opts.SID}) + if err := s.sendStanza(msg); err != nil { + return fmt.Errorf("accept: send: %w", err) + } + s.setStateIf(StateRinging, StateProceeded) + return nil +} + +// (callee, pre-media) +func (s *Session) Decline(ctx context.Context, reason string) error { + s.mu.Lock() + if s.state != StateRinging || s.opts.Role != RoleResponder { + st := s.state + s.mu.Unlock() + return fmt.Errorf("decline: bad state %s", st) + } + to, from := s.opts.RemoteJID, s.opts.LocalJID + s.mu.Unlock() + + r := &JMIReject{ID: s.opts.SID} + if reason != "" { + r.Reason = &Reason{Condition: &ReasonCondition{XMLName: xml.Name{Space: NSJingle, Local: reason}}} + } + msg := newJMIMessage(from, to, r) + err := s.sendStanza(msg) + s.terminate() + return err +} + +// (caller, pre-) +func (s *Session) Retract(ctx context.Context) error { + s.mu.Lock() + if s.state != StateProposed || s.opts.Role != RoleInitiator { + st := s.state + s.mu.Unlock() + return fmt.Errorf("retract: bad state %s", st) + } + to, from := s.opts.RemoteJID, s.opts.LocalJID + s.mu.Unlock() + + msg := newJMIMessage(from, to, &JMIRetract{ID: s.opts.SID}) + err := s.sendStanza(msg) + s.terminate() + return err +} + +// ; falls through to Retract/Decline if +// invoked pre-media +func (s *Session) Terminate(ctx context.Context, reason string) error { + s.mu.Lock() + switch s.state { + case StateTerminated: + s.mu.Unlock() + return nil + case StateProposed, StateRinging: + role := s.opts.Role + s.mu.Unlock() + if role == RoleInitiator { + return s.Retract(ctx) + } + return s.Decline(ctx, reason) + } + to, from := s.opts.RemoteJID, s.opts.LocalJID + role := s.opts.Role + s.mu.Unlock() + + j := &JingleIQ{ + Action: ActionSessionTerminate, + SID: s.opts.SID, + } + if role == RoleInitiator { + j.Initiator = from + } else { + j.Responder = from + } + if reason != "" { + j.Reason = &Reason{Condition: &ReasonCondition{XMLName: xml.Name{Space: NSJingle, Local: reason}}} + } + iq, err := newJingleIQ(from, to, stanza.IQTypeSet, j) + if err != nil { + s.terminate() + return err + } + sendErr := s.sendStanza(iq) + s.terminate() + return sendErr +} + +// dispatches an incoming JMI payload; Manager has matched the SID +func (s *Session) HandleJMI(from string, ext stanza.MsgExtension) { + switch m := ext.(type) { + case *JMIPropose: + s.handleJMIPropose(from, *m) + case *JMIProceed: + s.handleJMIProceed(from) + case *JMIRinging: + s.handleJMIRinging(from) + case *JMIReject: + s.handleJMIReject(from) + case *JMIRetract: + s.handleJMIRetract(from) + case *JMIFinish: + // terminal informational marker; ignored + } +} + +func (s *Session) handleJMIPropose(from string, m JMIPropose) { + s.mu.Lock() + if s.state != StateNew || s.opts.Role != RoleResponder { + s.mu.Unlock() + return + } + s.opts.RemoteJID = from + s.state = StateRinging + s.mu.Unlock() + // auto- when no observer is wired + if s.observer == nil { + _ = s.Ringing(context.Background()) + } +} + +func (s *Session) handleJMIProceed(from string) { + s.mu.Lock() + if s.state != StateProposed || s.opts.Role != RoleInitiator { + s.mu.Unlock() + return + } + s.opts.RemoteJID = from // latch full JID + s.state = StateProceeded + s.mu.Unlock() + if s.observer != nil { + s.observer.OnProceeded(from) + } + // off the dispatcher so pion's gather doesn't block JMI routing + go s.startOutgoingMedia() +} + +func (s *Session) handleJMIRinging(from string) { + if s.observer != nil { + s.observer.OnRinging(from) + } +} + +func (s *Session) handleJMIReject(from string) { + s.mu.Lock() + if s.state == StateTerminated { + s.mu.Unlock() + return + } + s.mu.Unlock() + // terminate before observer; observers re-entering via the bridge then + // see StateTerminated and short-circuit their own teardown + s.terminate() + if s.observer != nil { + s.observer.OnTerminated("rejected") + } +} + +func (s *Session) handleJMIRetract(from string) { + // caller cancelled before responder proceeded + s.mu.Lock() + if s.state == StateTerminated { + s.mu.Unlock() + return + } + s.mu.Unlock() + s.terminate() + if s.observer != nil { + s.observer.OnTerminated("retracted") + } +} + +// processes an inbound Jingle IQ; Manager has matched the SID. Returns the +// result/error condition for the IQ reply. +func (s *Session) HandleJingleIQ(iq *stanza.IQ, j *JingleIQ) (resultPayload stanza.IQPayload, errCondition string) { + switch j.Action { + case ActionSessionInitiate: + if err := s.handleSessionInitiate(iq, j); err != nil { + return nil, "bad-request" + } + case ActionSessionAccept: + if err := s.handleSessionAccept(iq, j); err != nil { + return nil, "bad-request" + } + case ActionSessionTerminate: + s.handleSessionTerminate(iq, j) + case ActionTransportInfo: + s.handleTransportInfo(iq, j) + default: + // unsupported action: ack but no-op + } + return nil, "" +} + +func (s *Session) handleSessionInitiate(iq *stanza.IQ, j *JingleIQ) error { + s.mu.Lock() + if s.state != StateProceeded || s.opts.Role != RoleResponder { + st := s.state + s.mu.Unlock() + return fmt.Errorf("out-of-order in state %s", st) + } + s.mu.Unlock() + + sdp, err := JingleToSDP(j, ConvertOpts{Initiator: false, LocalCreator: "initiator", SessionID: s.opts.SID}) + if err != nil { + return fmt.Errorf("convert offer: %w", err) + } + if err := s.opts.PC.SetRemoteDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeOffer, + SDP: sdp, + }); err != nil { + return fmt.Errorf("SetRemoteDescription(offer): %w", err) + } + s.drainRemoteCandidates() + s.notifyRemoteDescriptionApplied(sdp) + + answer, err := s.opts.PC.CreateAnswer(nil) + if err != nil { + return fmt.Errorf("CreateAnswer: %w", err) + } + if err := s.opts.PC.SetLocalDescription(answer); err != nil { + return fmt.Errorf("SetLocalDescription(answer): %w", err) + } + s.captureLocalMeta() + + ja, err := SDPToJingle(answer.SDP, ConvertOpts{Initiator: false, LocalCreator: "initiator", SessionID: s.opts.SID}) + if err != nil { + return fmt.Errorf("convert answer: %w", err) + } + ja.Action = ActionSessionAccept + ja.Responder = s.opts.LocalJID + + out, err := newJingleIQ(s.opts.LocalJID, s.opts.RemoteJID, stanza.IQTypeSet, ja) + if err != nil { + return err + } + if err := s.sendStanza(out); err != nil { + return fmt.Errorf("send session-accept: %w", err) + } + s.mu.Lock() + s.state = StateActive + s.mu.Unlock() + s.drainLocalCandidates() + return nil +} + +func (s *Session) handleSessionAccept(iq *stanza.IQ, j *JingleIQ) error { + s.mu.Lock() + if s.state != StateProceeded || s.opts.Role != RoleInitiator { + st := s.state + s.mu.Unlock() + return fmt.Errorf("out-of-order in state %s", st) + } + s.mu.Unlock() + + sdp, err := JingleToSDP(j, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: s.opts.SID}) + if err != nil { + return fmt.Errorf("convert answer: %w", err) + } + if err := s.opts.PC.SetRemoteDescription(webrtc.SessionDescription{ + Type: webrtc.SDPTypeAnswer, + SDP: sdp, + }); err != nil { + return fmt.Errorf("SetRemoteDescription(answer): %w", err) + } + s.drainRemoteCandidates() + s.notifyRemoteDescriptionApplied(sdp) + s.mu.Lock() + s.state = StateActive + s.mu.Unlock() + return nil +} + +// dispatched inline so an answerer-side ReplaceTrack from the observer +// lands before CreateAnswer runs +func (s *Session) notifyRemoteDescriptionApplied(sdp string) { + if s.observer != nil { + s.observer.OnRemoteDescriptionApplied(sdp) + } +} + +func (s *Session) handleTransportInfo(iq *stanza.IQ, j *JingleIQ) { + for _, c := range j.Contents { + if c.Transport == nil { + continue + } + for _, cand := range c.Transport.Candidates { + candCopy := cand + init, err := candidateToPionInit(&candCopy, c.Name) + if err != nil { + continue + } + s.applyRemoteCandidate(c.Name, init) + } + } +} + +func (s *Session) handleSessionTerminate(iq *stanza.IQ, j *JingleIQ) { + reason := "" + if j != nil && j.Reason != nil && j.Reason.Condition != nil { + reason = j.Reason.Condition.XMLName.Local + } + // terminate before observer; observers re-entering via the bridge then + // see StateTerminated and short-circuit their own teardown + s.terminate() + if s.observer != nil { + s.observer.OnTerminated(reason) + } +} + +func (s *Session) startOutgoingMedia() { + offer, err := s.opts.PC.CreateOffer(nil) + if err != nil { + s.terminate() + return + } + if err := s.opts.PC.SetLocalDescription(offer); err != nil { + s.terminate() + return + } + s.captureLocalMeta() + + j, err := SDPToJingle(offer.SDP, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: s.opts.SID}) + if err != nil { + s.terminate() + return + } + j.Action = ActionSessionInitiate + j.Initiator = s.opts.LocalJID + + iq, err := newJingleIQ(s.opts.LocalJID, s.opts.RemoteJID, stanza.IQTypeSet, j) + if err != nil { + s.terminate() + return + } + if err := s.sendStanza(iq); err != nil { + s.terminate() + return + } + s.drainLocalCandidates() +} + +// ICE candidates are buffered until SetLocalDescription/SetRemoteDescription +// has run; otherwise pion drops them + +func (s *Session) onICECandidate(c *webrtc.ICECandidate) { + if c == nil { + return // end-of-gathering; no wire marker + } + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + if !s.signaled { + s.localCandQueue = append(s.localCandQueue, *c) + s.mu.Unlock() + return + } + s.mu.Unlock() + _ = s.sendTransportInfo(c) +} + +func (s *Session) drainLocalCandidates() { + s.mu.Lock() + s.signaled = true + queue := s.localCandQueue + s.localCandQueue = nil + s.mu.Unlock() + for i := range queue { + _ = s.sendTransportInfo(&queue[i]) + } +} + +func (s *Session) applyRemoteCandidate(mid string, init webrtc.ICECandidateInit) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + if !s.remoteDescApplied { + s.remoteCandQueue = append(s.remoteCandQueue, remoteCandidate{mid: mid, init: init}) + s.mu.Unlock() + return + } + s.mu.Unlock() + _ = s.opts.PC.AddICECandidate(init) +} + +func (s *Session) drainRemoteCandidates() { + s.mu.Lock() + s.remoteDescApplied = true + queue := s.remoteCandQueue + s.remoteCandQueue = nil + s.mu.Unlock() + for _, rc := range queue { + _ = s.opts.PC.AddICECandidate(rc.init) + } +} + +func (s *Session) sendTransportInfo(c *webrtc.ICECandidate) error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + ufrag, pwd := s.ufrag, s.pwd + mids := s.mids + role := s.opts.Role + localJID, remoteJID := s.opts.LocalJID, s.opts.RemoteJID + s.mu.Unlock() + + cand, err := pionToCandidate(c) + if err != nil { + return err + } + mid := c.SDPMid + if mid == "" && len(mids) > 0 { + mid = mids[0] + } + j := buildTransportInfo(s.opts.SID, mid, ufrag, pwd, "initiator", cand) + if role == RoleInitiator { + j.Initiator = localJID + } else { + j.Responder = localJID + } + iq, err := newJingleIQ(localJID, remoteJID, stanza.IQTypeSet, j) + if err != nil { + return err + } + return s.sendStanza(iq) +} + +func (s *Session) captureLocalMeta() { + local := s.opts.PC.LocalDescription() + if local == nil { + return + } + ufrag, pwd, mids := extractSDPMeta(local.SDP) + s.mu.Lock() + s.ufrag, s.pwd, s.mids = ufrag, pwd, mids + s.mu.Unlock() +} + +// with BUNDLE, ufrag/pwd are identical across m= blocks; take the first +func extractSDPMeta(s string) (ufrag, pwd string, mids []string) { + doc, err := parseSDP(s) + if err != nil { + return + } + for _, m := range doc.media { + if ufrag == "" { + ufrag = mmFirst(m.attrs, "ice-ufrag", "") + } + if pwd == "" { + pwd = mmFirst(m.attrs, "ice-pwd", "") + } + if mid := mmFirst(m.attrs, "mid", ""); mid != "" { + mids = append(mids, mid) + } + } + return +} + +func (s *Session) setStateIf(want, next State) { + s.mu.Lock() + if s.state == want { + s.state = next + } + s.mu.Unlock() +} + +func (s *Session) terminate() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.state = StateTerminated + s.remoteCandQueue = nil + s.localCandQueue = nil + pc := s.opts.PC + s.mu.Unlock() + if pc != nil { + _ = pc.Close() + } +} + +// release pion + buffers without sending anything on the wire +func (s *Session) Close() error { + s.terminate() + return nil +} + +func mediaDescs(media []string) []JMIDescription { + out := make([]JMIDescription, 0, len(media)) + for _, m := range media { + out = append(out, JMIDescription{Media: m}) + } + return out +} + +func newJMIMessage(from, to string, ext stanza.MsgExtension) *stanza.Message { + msg := stanza.NewMessage(stanza.Attrs{ + Type: stanza.MessageTypeChat, + From: from, + To: to, + Id: newStanzaID(), + }) + msg.Extensions = []stanza.MsgExtension{ext} + return &msg +} + +func (s *Session) sendStanza(p stanza.Packet) error { + return s.opts.Sender.Send(p) +} + +func newJingleIQ(from, to string, t stanza.StanzaType, j *JingleIQ) (*stanza.IQ, error) { + iq, err := stanza.NewIQ(stanza.Attrs{ + Type: t, + From: from, + To: to, + Id: newStanzaID(), + }) + if err != nil { + return nil, err + } + iq.Payload = j + return iq, nil +} diff --git a/xmpp/jingle/session_test.go b/xmpp/jingle/session_test.go new file mode 100644 index 0000000..682dc17 --- /dev/null +++ b/xmpp/jingle/session_test.go @@ -0,0 +1,260 @@ +package jingle + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/pion/webrtc/v4" + "gosrc.io/xmpp/stanza" +) + +// wires one Manager's outbound into the other's HandlePacket without +// sockets; each Send fires on a fresh goroutine so trickle/ordering +// bugs surface naturally +type linkedSender struct { + target *Manager + wg sync.WaitGroup +} + +func (ls *linkedSender) Send(p stanza.Packet) error { + ls.wg.Add(1) + go func() { + defer ls.wg.Done() + ls.target.HandlePacket(nil, p) + }() + return nil +} + +func (ls *linkedSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) { + ch := make(chan stanza.IQ, 1) + return ch, ls.Send(iq) +} + +func (ls *linkedSender) SendRaw(string) error { return nil } + +func (ls *linkedSender) wait() { ls.wg.Wait() } + +// two Managers cross-linked through linkedSenders +func pair() (*Manager, *Manager, *linkedSender, *linkedSender) { + mA := &Manager{LocalJID: "a@x/r"} + mB := &Manager{LocalJID: "b@x/r"} + sA := &linkedSender{target: mB} + sB := &linkedSender{target: mA} + mA.Sender = sA + mB.Sender = sB + return mA, mB, sA, sB +} + +// pion PC with t.Cleanup +func makePC(t *testing.T) *webrtc.PeerConnection { + t.Helper() + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatalf("NewPeerConnection: %v", err) + } + t.Cleanup(func() { _ = pc.Close() }) + return pc +} + +func waitFor(t *testing.T, what string, d time.Duration, fn func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("waitFor(%s): timeout after %v", what, d) +} + +// JMI preamble without media setup +func TestJMIProposeRingProceed(t *testing.T) { + mA, mB, sA, sB := pair() + + var bSess *Session + var bSessMu sync.Mutex + mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { + s, err := New(SessionOpts{ + PC: makePC(t), Sender: sB, + LocalJID: mB.LocalJID, RemoteJID: p.From, + SID: p.SID, Role: RoleResponder, Media: p.Media, + }) + if err != nil { + return nil, nil, err + } + bSessMu.Lock() + bSess = s + bSessMu.Unlock() + return s, nil, nil + } + + aSess, err := New(SessionOpts{ + PC: makePC(t), Sender: sA, + LocalJID: mA.LocalJID, RemoteJID: "b@x", + SID: "sid-1", Role: RoleInitiator, Media: []string{"audio"}, + }) + if err != nil { + t.Fatal(err) + } + mA.Register(aSess) + + if err := aSess.Propose(context.Background()); err != nil { + t.Fatalf("Propose: %v", err) + } + + waitFor(t, "B reach ringing", 2*time.Second, func() bool { + bSessMu.Lock() + defer bSessMu.Unlock() + return bSess != nil && bSess.State() == StateRinging + }) + + if err := bSess.AcceptProposal(context.Background()); err != nil { + t.Fatalf("AcceptProposal: %v", err) + } + + // A enters StateProceeded; media setup will then drive it forward + // (CreateOffer, etc.) - that's tested elsewhere. Allow either + // StateProceeded or StateActive since the goroutine may have raced + // ahead, depending on how fast pion gathers. + waitFor(t, "A leave Proposed", 2*time.Second, func() bool { + s := aSess.State() + return s == StateProceeded || s == StateActive || s == StateTerminated + }) + + aSess.Close() + bSess.Close() + sA.wait() + sB.wait() +} + +// : caller -> Terminated when callee declines while ringing +func TestJMIRejectFromCallee(t *testing.T) { + mA, mB, sA, sB := pair() + + var bSess *Session + var bSessMu sync.Mutex + mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { + s, _ := New(SessionOpts{ + PC: makePC(t), Sender: sB, + LocalJID: mB.LocalJID, RemoteJID: p.From, + SID: p.SID, Role: RoleResponder, Media: p.Media, + }) + bSessMu.Lock() + bSess = s + bSessMu.Unlock() + return s, nil, nil + } + + aSess, _ := New(SessionOpts{ + PC: makePC(t), Sender: sA, + LocalJID: mA.LocalJID, RemoteJID: "b@x", + SID: "sid-rej", Role: RoleInitiator, Media: []string{"audio"}, + }) + mA.Register(aSess) + if err := aSess.Propose(context.Background()); err != nil { + t.Fatal(err) + } + + waitFor(t, "B reach ringing", 2*time.Second, func() bool { + bSessMu.Lock() + defer bSessMu.Unlock() + return bSess != nil && bSess.State() == StateRinging + }) + + if err := bSess.Decline(context.Background(), ReasonDecline); err != nil { + t.Fatalf("Decline: %v", err) + } + waitFor(t, "A terminated", 2*time.Second, func() bool { + return aSess.State() == StateTerminated + }) +} + +// : callee -> Terminated when caller cancels while ringing +func TestJMIRetractByCaller(t *testing.T) { + mA, mB, sA, sB := pair() + + var bSess *Session + var bSessMu sync.Mutex + mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { + s, _ := New(SessionOpts{ + PC: makePC(t), Sender: sB, + LocalJID: mB.LocalJID, RemoteJID: p.From, + SID: p.SID, Role: RoleResponder, Media: p.Media, + }) + bSessMu.Lock() + bSess = s + bSessMu.Unlock() + return s, nil, nil + } + + aSess, _ := New(SessionOpts{ + PC: makePC(t), Sender: sA, + LocalJID: mA.LocalJID, RemoteJID: "b@x", + SID: "sid-ret", Role: RoleInitiator, Media: []string{"audio"}, + }) + mA.Register(aSess) + if err := aSess.Propose(context.Background()); err != nil { + t.Fatal(err) + } + waitFor(t, "B reach ringing", 2*time.Second, func() bool { + bSessMu.Lock() + defer bSessMu.Unlock() + return bSess != nil && bSess.State() == StateRinging + }) + + if err := aSess.Retract(context.Background()); err != nil { + t.Fatalf("Retract: %v", err) + } + waitFor(t, "B terminated", 2*time.Second, func() bool { + return bSess.State() == StateTerminated + }) +} + +// duplicate-SID guard +func TestJMIDuplicateProposeIgnored(t *testing.T) { + mA, mB, sA, sB := pair() + + calls := 0 + var mu sync.Mutex + mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) { + mu.Lock() + calls++ + mu.Unlock() + s, _ := New(SessionOpts{ + PC: makePC(t), Sender: sB, + LocalJID: mB.LocalJID, RemoteJID: p.From, + SID: p.SID, Role: RoleResponder, Media: p.Media, + }) + return s, nil, nil + } + + aSess, _ := New(SessionOpts{ + PC: makePC(t), Sender: sA, + LocalJID: mA.LocalJID, RemoteJID: "b@x", + SID: "dup-sid", Role: RoleInitiator, Media: []string{"audio"}, + }) + mA.Register(aSess) + _ = aSess.Propose(context.Background()) + _ = aSess.Propose(context.Background()) // second one rejected by state guard, but if forced through... + // To make the test actually drive a duplicate, send a raw second propose: + dup := newJMIMessage(mA.LocalJID, mB.LocalJID, &JMIPropose{ + ID: "dup-sid", + Descriptions: []JMIDescription{{Media: "audio"}}, + }) + if err := sA.Send(dup); err != nil { + t.Fatal(err) + } + + // Allow dispatch to complete. + sA.wait() + sB.wait() + + mu.Lock() + defer mu.Unlock() + if calls != 1 { + t.Errorf("OnProposal called %d times, want 1", calls) + } +} diff --git a/xmpp/jingle/stanzas.go b/xmpp/jingle/stanzas.go new file mode 100644 index 0000000..efa1cc5 --- /dev/null +++ b/xmpp/jingle/stanzas.go @@ -0,0 +1,210 @@ +package jingle + +import ( + "encoding/xml" + + "gosrc.io/xmpp/stanza" +) + +// supported Jingle actions; content-*, security-info, transport-*, +// description-info are intentionally not supported +const ( + ActionSessionInitiate = "session-initiate" + ActionSessionAccept = "session-accept" + ActionSessionTerminate = "session-terminate" + ActionTransportInfo = "transport-info" +) + +const ( + ReasonSuccess = "success" + ReasonBusy = "busy" + ReasonDecline = "decline" + ReasonCancel = "cancel" + ReasonGone = "gone" + ReasonFailedTransport = "failed-transport" + ReasonFailedApplication = "failed-application" + ReasonIncompatibleParameters = "incompatible-parameters" + ReasonUnsupportedTransports = "unsupported-transports" + ReasonUnsupportedApplications = "unsupported-applications" + ReasonGeneralError = "general-error" + ReasonTimeout = "timeout" + ReasonConnectivityError = "connectivity-error" +) + +type JingleIQ struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:1 jingle"` + Action string `xml:"action,attr"` + Initiator string `xml:"initiator,attr,omitempty"` + Responder string `xml:"responder,attr,omitempty"` + SID string `xml:"sid,attr"` + Contents []Content `xml:"urn:xmpp:jingle:1 content"` + Group *Group `xml:"urn:xmpp:jingle:apps:grouping:0 group,omitempty"` + Reason *Reason `xml:"urn:xmpp:jingle:1 reason,omitempty"` +} + +func (j JingleIQ) Namespace() string { return j.XMLName.Space } +func (j JingleIQ) GetSet() *stanza.ResultSet { return nil } + +type Content struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:1 content"` + Creator string `xml:"creator,attr"` + Senders string `xml:"senders,attr,omitempty"` + Name string `xml:"name,attr"` + Disposition string `xml:"disposition,attr,omitempty"` + Description *Description `xml:"urn:xmpp:jingle:apps:rtp:1 description,omitempty"` + Transport *Transport `xml:"urn:xmpp:jingle:transports:ice-udp:1 transport,omitempty"` +} + +type Group struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:grouping:0 group"` + Semantics string `xml:"semantics,attr"` + Contents []GroupContent `xml:"urn:xmpp:jingle:apps:grouping:0 content"` +} + +type GroupContent struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:grouping:0 content"` + Name string `xml:"name,attr"` +} + +// preserves the inner condition element name and optional text; +// semantics handled in callers +type Reason struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:1 reason"` + Condition *ReasonCondition `xml:",any"` + Text string `xml:"urn:xmpp:jingle:1 text,omitempty"` +} + +// whichever element appears under ; local name is the condition +type ReasonCondition struct { + XMLName xml.Name +} + +type Description struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 description"` + Media string `xml:"media,attr"` + SSRC string `xml:"ssrc,attr,omitempty"` + PayloadTypes []PayloadType `xml:"urn:xmpp:jingle:apps:rtp:1 payload-type"` + RTPHdrexts []RTPHdrext `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 rtp-hdrext"` + ExtmapAllowMixed *ExtmapAllowMixed `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 extmap-allow-mixed,omitempty"` + RtcpMux *RtcpMux `xml:"urn:xmpp:jingle:apps:rtp:1 rtcp-mux,omitempty"` + RTCPFbs []RTCPFb `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb"` + RTCPFbTrrInts []RTCPFbTrrInt `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb-trr-int"` + Sources []Source `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"` + SSRCGroups []SSRCGroup `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 ssrc-group"` +} + +type PayloadType struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 payload-type"` + ID string `xml:"id,attr"` + Name string `xml:"name,attr,omitempty"` + Clockrate string `xml:"clockrate,attr,omitempty"` + Channels string `xml:"channels,attr,omitempty"` + Ptime string `xml:"ptime,attr,omitempty"` + Maxptime string `xml:"maxptime,attr,omitempty"` + Parameters []Parameter `xml:"urn:xmpp:jingle:apps:rtp:1 parameter"` + RTCPFbs []RTCPFb `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb"` + RTCPFbTrrInts []RTCPFbTrrInt `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb-trr-int"` +} + +// name/value pair used under (rtp:1) and (ssma); +// namespace is governed by the parent's tag, so XMLName has none +type Parameter struct { + XMLName xml.Name `xml:"parameter"` + Name string `xml:"name,attr"` + Value string `xml:"value,attr,omitempty"` +} + +type RTCPFb struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb"` + Type string `xml:"type,attr"` + Subtype string `xml:"subtype,attr,omitempty"` +} + +type RTCPFbTrrInt struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb-trr-int"` + Value string `xml:"value,attr"` +} + +type RTPHdrext struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 rtp-hdrext"` + ID string `xml:"id,attr"` + URI string `xml:"uri,attr"` + Senders string `xml:"senders,attr,omitempty"` +} + +// presence flag, RFC 8285 §6 +type ExtmapAllowMixed struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 extmap-allow-mixed"` +} + +// presence flag for RTCP mux +type RtcpMux struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 rtcp-mux"` +} + +type Source struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"` + SSRC string `xml:"ssrc,attr"` + Parameters []Parameter `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 parameter"` +} + +type SSRCGroup struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 ssrc-group"` + Semantics string `xml:"semantics,attr"` + Sources []SSRCGroupSource `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"` +} + +type SSRCGroupSource struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"` + SSRC string `xml:"ssrc,attr"` +} + +type Transport struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:transports:ice-udp:1 transport"` + Ufrag string `xml:"ufrag,attr,omitempty"` + Pwd string `xml:"pwd,attr,omitempty"` + Candidates []Candidate `xml:"urn:xmpp:jingle:transports:ice-udp:1 candidate"` + Fingerprint *Fingerprint `xml:"urn:xmpp:jingle:apps:dtls:0 fingerprint,omitempty"` + + // well-known ICE options under gultsch.de namespace + Trickle *ICEOptionTrickle `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option trickle,omitempty"` + Renomination *ICEOptionRenomination `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option renomination,omitempty"` +} + +type Candidate struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:transports:ice-udp:1 candidate"` + Foundation string `xml:"foundation,attr"` + Component string `xml:"component,attr"` + Protocol string `xml:"protocol,attr"` + Priority string `xml:"priority,attr"` + IP string `xml:"ip,attr"` + Port string `xml:"port,attr"` + Type string `xml:"type,attr,omitempty"` + Network string `xml:"network,attr,omitempty"` + Generation string `xml:"generation,attr,omitempty"` + ID string `xml:"id,attr,omitempty"` + RelAddr string `xml:"rel-addr,attr,omitempty"` + RelPort string `xml:"rel-port,attr,omitempty"` +} + +type Fingerprint struct { + XMLName xml.Name `xml:"urn:xmpp:jingle:apps:dtls:0 fingerprint"` + Hash string `xml:"hash,attr"` + Setup string `xml:"setup,attr,omitempty"` + Text string `xml:",chardata"` +} + +type ICEOptionTrickle struct { + XMLName xml.Name `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option trickle"` +} + +type ICEOptionRenomination struct { + XMLName xml.Name `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option renomination"` +} + +func init() { + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + Space: NSJingle, + Local: "jingle", + }, JingleIQ{}) +} From 886cda08f953c1954dac600daf358b3f1c44883a Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Wed, 8 Jul 2026 02:50:16 -0700 Subject: [PATCH 6/7] calls: bound playout latency and add call telemetry --- calls/audio/latency_drift_test.go | 117 ++++++++++++++++++++++++++++ calls/audio/playout.go | 112 +++++++++++++++++++++++++- calls/audio/playout_test.go | 52 ++++++++++++- calls/audio/tg_to_xmpp.go | 94 +++++++++++++++++++++- calls/audio/xmpp_to_tg.go | 125 +++++++++++++++++++++++++++++- 5 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 calls/audio/latency_drift_test.go diff --git a/calls/audio/latency_drift_test.go b/calls/audio/latency_drift_test.go new file mode 100644 index 0000000..042d300 --- /dev/null +++ b/calls/audio/latency_drift_test.go @@ -0,0 +1,117 @@ +package audio + +import ( + "testing" + + "github.com/pion/rtp" +) + +// Deterministic playout-latency tests: drive the xmpp->tg path in simulated +// real time (1 step = 10ms), no ticker or goroutines. Producer pushes a 20ms +// packet every 2 steps; consumer runs processTick each step unless "stalled" +// (models a starved ticker). Each test checks latency drains back to baseline. + +// callSim drives producer + consumer in lockstep over discrete 10ms steps. +type callSim struct { + h *XmppToTg + st *tickState + payload []byte // one reusable 20ms opus packet; decodes fine repeatedly + seq uint16 + step int // wall-clock step counter (10ms each) +} + +func newCallSim(t *testing.T) *callSim { + t.Helper() + h, _ := newTestXmppToTg(t) + enc, err := NewEncoder() + if err != nil { + t.Fatalf("NewEncoder: %v", err) + } + return &callSim{ + h: h, + st: newTickState(), + payload: encodeOpusFrame(t, enc), + seq: 1000, + } +} + +// advance runs `steps` 10ms steps. The producer always pushes on schedule +// (every other step). The consumer runs processTick only when consume is true; +// when false the ticker goroutine is considered stalled for that step. +func (s *callSim) advance(steps int, consume bool) { + for i := 0; i < steps; i++ { + if s.step%2 == 0 { // one 20ms packet per 20ms of wall clock + s.h.playout.Push(&rtp.Packet{ + Header: rtp.Header{SequenceNumber: s.seq, PayloadType: opusPayloadType}, + Payload: s.payload, + }) + s.seq++ + } + if consume { + s.h.processTick(s.st) + } + s.step++ + } +} + +// depthMs is the current playout latency: buffered packets * 20ms. +func (s *callSim) depthMs() int { return s.h.playout.Depth() * OpusFrameMs } + +// A single stall must not permanently raise latency; the backlog has to drain +// back to baseline. +func TestPlayoutLatencyRecoversAfterStall(t *testing.T) { + s := newCallSim(t) + + // Warm up to steady state and record the baseline latency. + s.advance(600, true) // 6s + baseline := s.depthMs() + t.Logf("baseline latency after warmup: %dms", baseline) + + // One 600ms stall: the ticker goroutine misses ticks while RTP keeps + // arriving. ~30 packets (600ms of audio) pile up in the jitter buffer. + s.advance(60, false) + afterStall := s.depthMs() + t.Logf("latency right after 600ms stall: %dms", afterStall) + + // A full minute of healthy steady-state playout to recover. + s.advance(6000, true) // 60s + settled := s.depthMs() + t.Logf("latency after 60s of recovery: %dms", settled) + + // Allow one packet of slop around the baseline; ShedOne should have walked + // the stall's backlog back down. Pre-fix, settled stayed up near afterStall. + if settled > baseline+OpusFrameMs { + t.Errorf("playout latency did not recover: baseline=%dms, settled=%dms "+ + "(stall added ~%dms that never drained)", + baseline, settled, afterStall-baseline) + } +} + +// Many small stalls must not ratchet latency upward. Pre-fix, delay climbed +// monotonically (the reported 1s -> 8-10s); assert it stays bounded. +func TestPlayoutLatencyRatchetsUnderRepeatedStalls(t *testing.T) { + s := newCallSim(t) + s.advance(600, true) // warm up + baseline := s.depthMs() + + const bound = 500 // ms; a sane jitter buffer should never exceed this + + // 30 cycles of {200ms stall, 20s healthy playout}. In wall-clock terms + // that's ~10 minutes with a stall every 20s - a light, realistic hiccup + // rate for a loaded host. + worst := baseline + for cycle := 0; cycle < 30; cycle++ { + s.advance(20, false) // 200ms stall + s.advance(2000, true) // 20s recovery + if d := s.depthMs(); d > worst { + worst = d + } + } + t.Logf("baseline=%dms, worst latency over run=%dms", baseline, worst) + + if worst > bound { + t.Errorf("playout latency ratcheted past %dms (reached %dms); "+ + "the trim/shed mechanism is not bounding accumulated stalls", + bound, worst) + } +} diff --git a/calls/audio/playout.go b/calls/audio/playout.go index 2188b2e..a8a2ce1 100644 --- a/calls/audio/playout.go +++ b/calls/audio/playout.go @@ -8,8 +8,16 @@ import ( "github.com/pion/rtp" ) +// ceiling on buffered packets before Push force-trims back to the target. +// 20 packets is ~400ms at 20ms framing - past this we're just adding delay. +const DefaultMaxDepth = 20 + // pion's jitterbuffer + late-drop on Push + skip-ahead on persistent gap; -// PopOrSkip reports gap counts so the caller can run PLC over them +// PopOrSkip reports gap counts so the caller can run PLC over them. +// +// TargetDepth/MaxDepth bound playout latency: Push hard-trims to TargetDepth +// once depth exceeds MaxDepth; ShedOne walks depth back down one packet per +// pop. Dropped packets skip the audio forward briefly. type Playout struct { mu sync.Mutex jb *jitterbuffer.JitterBuffer @@ -17,6 +25,22 @@ type Playout struct { headSeq uint16 // expected next seq // caps the scan in PopOrSkip's slow path MaxSkip uint16 + // trim target; also the floor ShedOne won't drop below. Defaults to prime. + TargetDepth int + // depth ceiling; Push trims to TargetDepth once exceeded + MaxDepth int + // buffered packets (pushed minus popped); jitterbuffer exposes no length. + // Playout latency in packets: depth*OpusFrameMs ms of audio waiting. + depth int + // lifetime counters for telemetry (see Stats) + cPushed, cLateDrop, cPopped, cTrimDrop, cTrimEvents, cShedDrop int64 + maxDepth int // high-water depth +} + +// PlayoutStats snapshots a Playout's lifetime counters and current/peak depth. +type PlayoutStats struct { + Depth, MaxDepthSeen int + Pushed, LateDrop, Popped, TrimDrop, TrimEvents, ShedDrop int64 } var ErrEmpty = errors.New("playout: empty") @@ -27,8 +51,10 @@ func NewPlayout(primePackets uint16) *Playout { primePackets = 2 } return &Playout{ - jb: jitterbuffer.New(jitterbuffer.WithMinimumPacketCount(primePackets)), - MaxSkip: 64, + jb: jitterbuffer.New(jitterbuffer.WithMinimumPacketCount(primePackets)), + MaxSkip: 64, + TargetDepth: int(primePackets), + MaxDepth: DefaultMaxDepth, } } @@ -41,9 +67,85 @@ func (p *Playout) Push(pkt *rtp.Packet) { defer p.mu.Unlock() // int16 cast -> signed seq distance if p.started && int16(pkt.SequenceNumber-p.headSeq) < 0 { + p.cLateDrop++ return } p.jb.Push(pkt) + p.depth++ + p.cPushed++ + if p.depth > p.maxDepth { + p.maxDepth = p.depth + } + // hard cap: keep bounding latency even while the consumer is stalled + // (Push runs on the reader goroutine, independent of the ticker) + if p.MaxDepth > 0 && p.depth > p.MaxDepth { + p.cTrimEvents++ + p.trimLocked(p.TargetDepth) + } +} + +// Stats snapshots the lifetime counters and current/peak depth. +func (p *Playout) Stats() PlayoutStats { + p.mu.Lock() + defer p.mu.Unlock() + return PlayoutStats{ + Depth: p.depth, + MaxDepthSeen: p.maxDepth, + Pushed: p.cPushed, + LateDrop: p.cLateDrop, + Popped: p.cPopped, + TrimDrop: p.cTrimDrop, + TrimEvents: p.cTrimEvents, + ShedDrop: p.cShedDrop, + } +} + +// trimLocked drops the oldest buffered packets until at most target remain. +// Caller holds p.mu. +func (p *Playout) trimLocked(target int) { + // budget guards against spinning across a long run of missing seqs + budget := p.depth + int(p.MaxSkip) + for p.depth > target && budget > 0 { + budget-- + if pkt, err := p.jb.Pop(); err == nil { + p.started = true + p.headSeq = pkt.SequenceNumber + 1 + p.depth-- + p.cTrimDrop++ + } else { + // hole at the head; step over it toward the next present packet + p.jb.SetPlayoutHead(p.jb.PlayoutHead() + 1) + } + } +} + +// ShedOne drops the oldest buffered packet if depth exceeds target, returning +// whether it dropped. Called once per pop so post-stall latency drains back to +// target gradually rather than surfacing as call-long lag. +func (p *Playout) ShedOne(target int) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.depth <= target { + return false + } + if pkt, err := p.jb.Pop(); err == nil { + p.started = true + p.headSeq = pkt.SequenceNumber + 1 + p.depth-- + p.cShedDrop++ + return true + } + // hole at the head; step past it and let the next pop try again + p.jb.SetPlayoutHead(p.jb.PlayoutHead() + 1) + return false +} + +// Depth reports the number of buffered packets not yet popped, i.e. the +// current playout latency in packets (depth*OpusFrameMs ms of audio). +func (p *Playout) Depth() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.depth } // returns (next playable packet, gap count to PLC over, err); @@ -55,6 +157,8 @@ func (p *Playout) PopOrSkip() (*rtp.Packet, int, error) { if pkt, err := p.jb.Pop(); err == nil { p.started = true p.headSeq = pkt.SequenceNumber + 1 + p.depth-- + p.cPopped++ return pkt, 0, nil } else if errors.Is(err, jitterbuffer.ErrPopWhileBuffering) { return nil, 0, ErrEmpty @@ -69,6 +173,8 @@ func (p *Playout) PopOrSkip() (*rtp.Packet, int, error) { p.jb.SetPlayoutHead(pkt.SequenceNumber + 1) p.started = true p.headSeq = pkt.SequenceNumber + 1 + p.depth-- + p.cPopped++ return pkt, int(i), nil } } diff --git a/calls/audio/playout_test.go b/calls/audio/playout_test.go index 24a509b..65d142c 100644 --- a/calls/audio/playout_test.go +++ b/calls/audio/playout_test.go @@ -70,6 +70,57 @@ func TestPlayoutSkipAhead(t *testing.T) { } } +func TestPlayoutHardCapTrim(t *testing.T) { + p := NewPlayout(2) + p.MaxDepth = 10 + p.TargetDepth = 3 + // Push one past the ceiling; the crossing push trims the oldest back to + // TargetDepth in one shot. + for seq := uint16(0); seq <= uint16(p.MaxDepth); seq++ { // seqs 0..10 + p.Push(pkt(seq)) + } + if got := p.Depth(); got != p.TargetDepth { + t.Fatalf("after overflow: depth=%d, want %d", got, p.TargetDepth) + } + // The three survivors are the newest packets (8, 9, 10); the next pop + // returns 8, proving the oldest were the ones dropped. + got, _, err := p.PopOrSkip() + if err != nil { + t.Fatalf("pop after trim: %v", err) + } + if got.SequenceNumber != 8 { + t.Errorf("oldest survivor seq=%d, want 8", got.SequenceNumber) + } +} + +func TestPlayoutShedOne(t *testing.T) { + p := NewPlayout(2) + p.TargetDepth = 2 + for seq := uint16(0); seq < 5; seq++ { + p.Push(pkt(seq)) + } + // Depth 5, target 2: ShedOne drops the oldest and reports true until depth + // reaches the target, then leaves the buffer alone. + if !p.ShedOne(p.TargetDepth) || p.Depth() != 4 { + t.Fatalf("first shed: dropped=%v depth=%d, want true/4", true, p.Depth()) + } + if !p.ShedOne(p.TargetDepth) || p.Depth() != 3 { + t.Fatalf("second shed: depth=%d, want 3", p.Depth()) + } + p.ShedOne(p.TargetDepth) // depth 3 -> 2 + if shed := p.ShedOne(p.TargetDepth); shed || p.Depth() != 2 { + t.Errorf("at target: shed=%v depth=%d, want false/2", shed, p.Depth()) + } + // Shedding drops from the front: the oldest remaining is seq 3. + got, _, err := p.PopOrSkip() + if err != nil { + t.Fatalf("pop after shed: %v", err) + } + if got.SequenceNumber != 3 { + t.Errorf("oldest survivor seq=%d, want 3", got.SequenceNumber) + } +} + func TestPlayoutSeqWraparound(t *testing.T) { p := NewPlayout(2) // Push packets straddling the uint16 boundary: 65534, 65535, 0, 1. @@ -91,4 +142,3 @@ func TestPlayoutSeqWraparound(t *testing.T) { } } } - diff --git a/calls/audio/tg_to_xmpp.go b/calls/audio/tg_to_xmpp.go index 2e9c714..e065e21 100644 --- a/calls/audio/tg_to_xmpp.go +++ b/calls/audio/tg_to_xmpp.go @@ -43,9 +43,37 @@ type TgToXmpp struct { unregister func() // returned by Ntg.OnFrame; called from Close + // telemetry counters (atomic; sampled by the periodic stats log) + cFramesIn atomic.Int64 + cFramesWrongChat atomic.Int64 + cFramesWrongSSRC atomic.Int64 + cFramesBadSize atomic.Int64 + cOpusOut atomic.Int64 + cWriteErrs atomic.Int64 + + statsMu sync.Mutex + lastLog time.Time + callStart time.Time // first frame seen; anchor for cumulative drift + prevSnap tgToXmppSnap + capture *captureWriter } +type tgToXmppSnap struct { + framesIn, wrongChat, wrongSSRC, badSize, opusOut, writeErrs int64 +} + +func (h *TgToXmpp) snap() tgToXmppSnap { + return tgToXmppSnap{ + framesIn: h.cFramesIn.Load(), + wrongChat: h.cFramesWrongChat.Load(), + wrongSSRC: h.cFramesWrongSSRC.Load(), + badSize: h.cFramesBadSize.Load(), + opusOut: h.cOpusOut.Load(), + writeErrs: h.cWriteErrs.Load(), + } +} + // builds the encoder, local opus track, registers ntgcalls frame handler; // Start() flips on the ntgcalls source func NewTgToXmpp(opts TgToXmppOptions) (*TgToXmpp, error) { @@ -135,18 +163,79 @@ func (h *TgToXmpp) Close() error { } func (h *TgToXmpp) onNtgFrames(chatID int64, frames []PCMFrame) { - if h.closed.Load() || chatID != h.opts.ChatID { + if h.closed.Load() { return } + if chatID != h.opts.ChatID { + h.cFramesWrongChat.Add(int64(len(frames))) + return + } + h.cFramesIn.Add(int64(len(frames))) for _, f := range frames { h.capture.writePCM(f.SSRC, f.Data) h.feedFrame(f) } + h.maybeLogStats() +} + +// maybeLogStats emits one aggregated line per statsInterval from the ntg +// callback goroutine (this direction has no ticker to hang it off). +func (h *TgToXmpp) maybeLogStats() { + now := time.Now() + h.statsMu.Lock() + if h.lastLog.IsZero() { + h.lastLog = now + h.callStart = now + h.prevSnap = h.snap() + h.statsMu.Unlock() + return + } + callStart := h.callStart + elapsed := now.Sub(h.lastLog) + if elapsed < statsInterval { + h.statsMu.Unlock() + return + } + prev := h.prevSnap + cur := h.snap() + h.lastLog = now + h.prevSnap = cur + h.statsMu.Unlock() + + secs := elapsed.Seconds() + if secs <= 0 { + secs = 1 + } + rate := func(d int64) float64 { return float64(d) / secs } + h.mu.Lock() + accBytes := len(h.acc) + ssrc := h.primarySSRC + h.mu.Unlock() + // deliver_drift_ms = frames ntgcalls delivered minus what real time allows. + // Grows if ntgcalls over-delivers: that excess is audio we forward to pion + // faster than real time, piling up in the XMPP client's jitter buffer. + sinceStart := now.Sub(callStart) + expected := sinceStart.Milliseconds() / NtgFrameMs + driftMs := (cur.framesIn - expected) * NtgFrameMs + h.log.WithFields(log.Fields{ + "interval_ms": elapsed.Milliseconds(), + "frames_in_per_s": rate(cur.framesIn - prev.framesIn), + "opus_out_per_s": rate(cur.opusOut - prev.opusOut), + "deliver_drift_ms": driftMs, + "call_secs": int64(sinceStart.Seconds()), + "wrong_chat": cur.wrongChat - prev.wrongChat, + "wrong_ssrc": cur.wrongSSRC - prev.wrongSSRC, + "bad_size": cur.badSize - prev.badSize, + "write_errs": cur.writeErrs - prev.writeErrs, + "acc_bytes": accBytes, + "primary_ssrc": ssrc, + }).Debug("tg->xmpp stats") } // returns number of opus packets emitted (0 or 1) func (h *TgToXmpp) feedFrame(f PCMFrame) int { if len(f.Data) != NtgFrameBytes { + h.cFramesBadSize.Add(1) h.log.WithField("len", len(f.Data)).Warn("unexpected ntg frame size") return 0 } @@ -156,6 +245,7 @@ func (h *TgToXmpp) feedFrame(f PCMFrame) int { if h.primarySSRC == 0 { h.primarySSRC = f.SSRC } else if f.SSRC != h.primarySSRC { + h.cFramesWrongSSRC.Add(1) return 0 } @@ -180,8 +270,10 @@ func (h *TgToXmpp) feedFrame(f PCMFrame) int { Data: payload, Duration: OpusFrameMs * time.Millisecond, }); err != nil { + h.cWriteErrs.Add(1) h.log.WithError(err).Warn("WriteSample failed") return 0 } + h.cOpusOut.Add(1) return 1 } diff --git a/calls/audio/xmpp_to_tg.go b/calls/audio/xmpp_to_tg.go index 842dbf3..c2f0865 100644 --- a/calls/audio/xmpp_to_tg.go +++ b/calls/audio/xmpp_to_tg.go @@ -40,12 +40,38 @@ type XmppToTg struct { // pion's per-track queue but NOT enqueued - otherwise every packet // from the setup window would back up and the ticker would replay // it at real time, manifesting as call-long lag. - livePush atomic.Bool - preLiveDiscards atomic.Int64 + livePush atomic.Bool + preLiveDiscards atomic.Int64 + + // telemetry counters (atomic; sampled by the periodic stats log) + cRtpIn atomic.Int64 // RTP packets read from pion + cRtpSkippedPT atomic.Int64 // dropped: payload type != negotiated opus PT + cShipTotal atomic.Int64 // 10ms frames handed to ntgcalls (== ntg feed rate) + cShipSilence atomic.Int64 // of those, silence frames (underrun/priming) + cPlcFrames atomic.Int64 // PLC 10ms frames synthesised over gaps + cGapEvents atomic.Int64 // ticks where a seq gap was skipped + cSendErrs atomic.Int64 // SendMicrophonePCM errors capture *captureWriter } +// counter snapshot for computing per-interval deltas in the stats log +type xmppToTgSnap struct { + rtpIn, rtpSkippedPT, shipTotal, shipSilence, plcFrames, gapEvents, sendErrs int64 +} + +func (h *XmppToTg) snap() xmppToTgSnap { + return xmppToTgSnap{ + rtpIn: h.cRtpIn.Load(), + rtpSkippedPT: h.cRtpSkippedPT.Load(), + shipTotal: h.cShipTotal.Load(), + shipSilence: h.cShipSilence.Load(), + plcFrames: h.cPlcFrames.Load(), + gapEvents: h.cGapEvents.Load(), + sendErrs: h.cSendErrs.Load(), + } +} + func NewXmppToTg(opts XmppToTgOptions) (*XmppToTg, error) { if opts.Ntg == nil { return nil, errors.New("audio: Ntg required") @@ -96,8 +122,13 @@ func (h *XmppToTg) Start() error { h.livePush.Store(true) h.wg.Add(1) go h.tickerLoop() - h.log.WithField("pre_live_discards", h.preLiveDiscards.Load()). - Info("xmpp->tg half started") + h.log.WithFields(log.Fields{ + "pre_live_discards": h.preLiveDiscards.Load(), + "prime_packets": h.playout.TargetDepth, + "target_depth": h.playout.TargetDepth, + "max_depth": h.playout.MaxDepth, + "max_depth_ms": h.playout.MaxDepth * OpusFrameMs, + }).Info("xmpp->tg half started") return nil } @@ -141,8 +172,10 @@ func (h *XmppToTg) reader(t *webrtc.TrackRemote) { if err != nil { return } + h.cRtpIn.Add(1) if pkt.PayloadType != expectedPT { // CN/DTMF/RED can share an SSRC; libopus rejects them + h.cRtpSkippedPT.Add(1) continue } if !h.livePush.Load() { @@ -167,42 +200,124 @@ func newTickState() *tickState { return &tickState{lastDecodedSamples: SamplesPerOpusFrame} } +// how often the aggregated stats line is emitted +const statsInterval = 5 * time.Second + +// a tick gap over this means the goroutine was descheduled (GC, blocking CGO, +// scheduler pressure) and missed ticks. 25ms = missed a 10ms tick with margin. +const tickStallThreshold = 25 * time.Millisecond + func (h *XmppToTg) tickerLoop() { defer h.wg.Done() ticker := time.NewTicker(NtgFrameMs * time.Millisecond) defer ticker.Stop() st := newTickState() + loopStart := time.Now() + last := loopStart + lastLog := loopStart + prev := h.snap() + var ticks, stalls, maxGapMs int64 for { select { case <-h.stop: return case <-ticker.C: } + now := time.Now() + gap := now.Sub(last) + last = now + ticks++ + if gap > tickStallThreshold { + stalls++ + if ms := gap.Milliseconds(); ms > maxGapMs { + maxGapMs = ms + } + } + h.processTick(st) + + if now.Sub(lastLog) >= statsInterval { + h.logStats(now.Sub(lastLog), now.Sub(loopStart), ticks, stalls, maxGapMs, &prev) + ticks, stalls, maxGapMs = 0, 0, 0 + lastLog = now + } } } +// logStats emits one aggregated telemetry line per statsInterval, with +// per-second rates over the interval and the current playout depth. +func (h *XmppToTg) logStats(elapsed, sinceStart time.Duration, ticks, stalls, maxGapMs int64, prev *xmppToTgSnap) { + cur := h.snap() + secs := elapsed.Seconds() + if secs <= 0 { + secs = 1 + } + rate := func(d int64) float64 { return float64(d) / secs } + ps := h.playout.Stats() + // feed_drift_ms = frames shipped to ntgcalls minus what real time allows. + // Ticker-paced, so ~0 normally; a growing value means the ticker is starved + // and we're under-feeding ntgcalls' capture. + expected := sinceStart.Milliseconds() / NtgFrameMs + feedDriftMs := (cur.shipTotal - expected) * NtgFrameMs + h.log.WithFields(log.Fields{ + "interval_ms": elapsed.Milliseconds(), + "call_secs": int64(sinceStart.Seconds()), + "ticks": ticks, + "tick_stalls": stalls, + "max_tick_gap_ms": maxGapMs, + "rtp_in_per_s": rate(cur.rtpIn - prev.rtpIn), + "rtp_skipped_pt": cur.rtpSkippedPT - prev.rtpSkippedPT, + "ship_per_s": rate(cur.shipTotal - prev.shipTotal), + "silence_per_s": rate(cur.shipSilence - prev.shipSilence), + "feed_drift_ms": feedDriftMs, + "plc_frames": cur.plcFrames - prev.plcFrames, + "gap_events": cur.gapEvents - prev.gapEvents, + "send_errs": cur.sendErrs - prev.sendErrs, + "depth": ps.Depth, + "depth_ms": ps.Depth * OpusFrameMs, + "peak_depth_ms": ps.MaxDepthSeen * OpusFrameMs, + "jb_pushed": ps.Pushed, + "jb_popped": ps.Popped, + "jb_late_drop": ps.LateDrop, + "jb_trim_drop": ps.TrimDrop, + "jb_trim_events": ps.TrimEvents, + "jb_shed_drop": ps.ShedDrop, + }).Debug("xmpp->tg stats") + *prev = cur +} + // one iteration of the playout loop func (h *XmppToTg) processTick(st *tickState) { if len(st.pending10ms) == 0 { + // walk any accumulated latency back toward the target depth, one + // packet per pop, so recovery after a stall is gradual rather than a + // single large skip + h.playout.ShedOne(h.playout.TargetDepth) pkt, plcCount, err := h.playout.PopOrSkip() if err != nil { // empty or still priming - feed silence + h.cShipSilence.Add(1) h.sendToNtg(h.silence) return } + if plcCount > 0 { + h.cGapEvents.Add(1) + } for i := 0; i < plcCount; i++ { plcOut := h.pcmBuf[:st.lastDecodedSamples*Channels] if err := h.dec.DecodePLC(plcOut); err != nil { h.log.WithError(err).Warn("DecodePLC failed") break } + before := len(st.pending10ms) st.pending10ms = appendChunks10ms(st.pending10ms, plcOut) + h.cPlcFrames.Add(int64(len(st.pending10ms) - before)) } n, err := h.dec.Decode(pkt.Payload, h.pcmBuf) if err != nil { h.log.WithError(err).Warn("opus decode failed") + h.cShipSilence.Add(1) h.sendToNtg(h.silence) return } @@ -227,7 +342,9 @@ func appendChunks10ms(dst [][]byte, pcm []int16) [][]byte { } func (h *XmppToTg) sendToNtg(pcm []byte) { + h.cShipTotal.Add(1) if err := h.opts.Ntg.SendMicrophonePCM(h.opts.ChatID, pcm); err != nil { + h.cSendErrs.Add(1) h.log.WithError(err).Debug("SendMicrophonePCM failed") } } From 14b44c91bd95d72217ba0a81a35754b4409a79e0 Mon Sep 17 00:00:00 2001 From: pounceandmiss Date: Wed, 8 Jul 2026 03:08:50 -0700 Subject: [PATCH 7/7] calls: treat empty opus packets as DTX, not decode errors --- calls/audio/xmpp_to_tg.go | 35 +++++++++++++++++++++++++--------- calls/audio/xmpp_to_tg_test.go | 29 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/calls/audio/xmpp_to_tg.go b/calls/audio/xmpp_to_tg.go index c2f0865..5a3996d 100644 --- a/calls/audio/xmpp_to_tg.go +++ b/calls/audio/xmpp_to_tg.go @@ -50,6 +50,7 @@ type XmppToTg struct { cShipSilence atomic.Int64 // of those, silence frames (underrun/priming) cPlcFrames atomic.Int64 // PLC 10ms frames synthesised over gaps cGapEvents atomic.Int64 // ticks where a seq gap was skipped + cDtxFrames atomic.Int64 // empty (DTX) packets concealed as comfort noise cSendErrs atomic.Int64 // SendMicrophonePCM errors capture *captureWriter @@ -57,7 +58,7 @@ type XmppToTg struct { // counter snapshot for computing per-interval deltas in the stats log type xmppToTgSnap struct { - rtpIn, rtpSkippedPT, shipTotal, shipSilence, plcFrames, gapEvents, sendErrs int64 + rtpIn, rtpSkippedPT, shipTotal, shipSilence, plcFrames, gapEvents, dtxFrames, sendErrs int64 } func (h *XmppToTg) snap() xmppToTgSnap { @@ -68,6 +69,7 @@ func (h *XmppToTg) snap() xmppToTgSnap { shipSilence: h.cShipSilence.Load(), plcFrames: h.cPlcFrames.Load(), gapEvents: h.cGapEvents.Load(), + dtxFrames: h.cDtxFrames.Load(), sendErrs: h.cSendErrs.Load(), } } @@ -273,6 +275,7 @@ func (h *XmppToTg) logStats(elapsed, sinceStart time.Duration, ticks, stalls, ma "feed_drift_ms": feedDriftMs, "plc_frames": cur.plcFrames - prev.plcFrames, "gap_events": cur.gapEvents - prev.gapEvents, + "dtx_frames": cur.dtxFrames - prev.dtxFrames, "send_errs": cur.sendErrs - prev.sendErrs, "depth": ps.Depth, "depth_ms": ps.Depth * OpusFrameMs, @@ -314,15 +317,29 @@ func (h *XmppToTg) processTick(st *tickState) { st.pending10ms = appendChunks10ms(st.pending10ms, plcOut) h.cPlcFrames.Add(int64(len(st.pending10ms) - before)) } - n, err := h.dec.Decode(pkt.Payload, h.pcmBuf) - if err != nil { - h.log.WithError(err).Warn("opus decode failed") - h.cShipSilence.Add(1) - h.sendToNtg(h.silence) - return + if len(pkt.Payload) == 0 { + // empty payload = opus DTX/comfort-noise frame during silence, not + // a decode error. Conceal for the last frame's duration (libopus + // synthesises comfort noise) rather than emitting hard silence. + h.cDtxFrames.Add(1) + plcOut := h.pcmBuf[:st.lastDecodedSamples*Channels] + if err := h.dec.DecodePLC(plcOut); err != nil { + h.cShipSilence.Add(1) + h.sendToNtg(h.silence) + return + } + st.pending10ms = appendChunks10ms(st.pending10ms, plcOut) + } else { + n, err := h.dec.Decode(pkt.Payload, h.pcmBuf) + if err != nil { + h.log.WithError(err).Warn("opus decode failed") + h.cShipSilence.Add(1) + h.sendToNtg(h.silence) + return + } + st.lastDecodedSamples = n + st.pending10ms = appendChunks10ms(st.pending10ms, h.pcmBuf[:n*Channels]) } - st.lastDecodedSamples = n - st.pending10ms = appendChunks10ms(st.pending10ms, h.pcmBuf[:n*Channels]) } chunk := st.pending10ms[0] st.pending10ms = st.pending10ms[1:] diff --git a/calls/audio/xmpp_to_tg_test.go b/calls/audio/xmpp_to_tg_test.go index 93bfeca..41a7105 100644 --- a/calls/audio/xmpp_to_tg_test.go +++ b/calls/audio/xmpp_to_tg_test.go @@ -119,6 +119,35 @@ func TestProcessTickDecodesAndSplits(t *testing.T) { } } +func TestProcessTickConcealsDtxEmptyPacket(t *testing.T) { + h, n := newTestXmppToTg(t) + enc, err := NewEncoder() + if err != nil { + t.Fatalf("NewEncoder: %v", err) + } + // Prime with a real packet, then an empty (DTX) packet the peer sends + // during silence. + h.playout.Push(rtpWithSeq(200, encodeOpusFrame(t, enc))) + h.playout.Push(rtpWithSeq(201, nil)) + + st := newTickState() + // real packet -> 2 chunks (ticks 1-2), DTX packet -> concealed (ticks 3+). + for i := 0; i < 4; i++ { + h.processTick(st) + } + + if got := h.cDtxFrames.Load(); got != 1 { + t.Errorf("dtx frames = %d, want 1 (empty packet must be concealed, not errored)", got) + } + // DTX must not fall through to the silence path, and every tick ships a frame. + if got := h.cShipSilence.Load(); got != 0 { + t.Errorf("ship_silence = %d, want 0 (DTX conceals rather than emitting silence)", got) + } + if got := n.sentCount(); got != 4 { + t.Errorf("shipped %d frames, want 4", got) + } +} + func TestProcessTickRunsPLCBeforeGapRecovery(t *testing.T) { h, _ := newTestXmppToTg(t) enc, err := NewEncoder()