mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 04:07:07 +00:00
Merge branch 'pounceandmiss-mycalls' into omemo
This commit is contained in:
commit
1702892c25
68 changed files with 9509 additions and 30 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "ntgcalls"]
|
||||||
|
path = ntgcalls
|
||||||
|
url = https://github.com/pytgcalls/ntgcalls
|
||||||
58
Dockerfile
58
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 update
|
||||||
run apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git libomemo-c-dev
|
run apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git libomemo-c-dev
|
||||||
|
|
@ -15,18 +15,64 @@ RUN cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/compiled/ /src/
|
||||||
RUN cmake --build . ${MAKEOPTS}
|
RUN cmake --build . ${MAKEOPTS}
|
||||||
RUN make install
|
RUN make install
|
||||||
|
|
||||||
FROM base AS cache
|
FROM base AS ntgcalls-build
|
||||||
ARG VERSION
|
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 deps
|
||||||
COPY --from=tdlib /compiled/ /usr/local/
|
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
|
COPY ./ /src
|
||||||
RUN git -C /src checkout "${VERSION}"
|
RUN git -C /src submodule update --init
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
RUN go get
|
RUN --mount=type=cache,target=/go/pkg/mod go get
|
||||||
|
|
||||||
FROM cache AS build
|
FROM cache AS build
|
||||||
ARG MAKEOPTS
|
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
|
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
|
FROM scratch AS telegabber
|
||||||
COPY --from=build /src/release/telegabber /usr/local/bin/
|
COPY --from=build /src/release/telegabber /usr/local/bin/
|
||||||
|
|
|
||||||
18
Makefile
18
Makefile
|
|
@ -12,18 +12,32 @@ MAKEOPTS := "-j4"
|
||||||
# doesn't have).
|
# doesn't have).
|
||||||
GOTAGS :=
|
GOTAGS :=
|
||||||
|
|
||||||
|
NTG_LIB_DIR ?= /usr/local/lib
|
||||||
|
NTG_INC_DIR ?= /usr/local/include
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
all:
|
all:
|
||||||
mkdir -p release
|
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 -fuse-ld=mold" \
|
||||||
go build -ldflags "-X main.commit=${COMMIT}" -tags "${GOTAGS}" -o release/telegabber
|
go build -ldflags "-X main.commit=${COMMIT}" -tags "${GOTAGS}" -o release/telegabber
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -tags "${GOTAGS}" -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./xmpp/extensions ./persistence ./telegram/formatter ./badger ./e2ee/...
|
go test -race -short -tags "${GOTAGS}" -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./xmpp/jingle ./xmpp/extensions ./persistence ./telegram/formatter ./badger ./e2ee/... ./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:
|
lint:
|
||||||
$(GOPATH)/bin/golint ./...
|
$(GOPATH)/bin/golint ./...
|
||||||
|
|
||||||
build_indocker:
|
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:
|
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 .
|
DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --network host --output=release --target binaries -f staging.Dockerfile .
|
||||||
|
|
|
||||||
15
calls/DESIGN.md
Normal file
15
calls/DESIGN.md
Normal file
|
|
@ -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)
|
||||||
|
```
|
||||||
160
calls/audio/capture.go
Normal file
160
calls/audio/capture.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
142
calls/audio/fmtp.go
Normal file
142
calls/audio/fmtp.go
Normal file
|
|
@ -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=<bps>, 0 = unspecified
|
||||||
|
MaxPlaybackRate int // maxplaybackrate=<Hz>, 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 {
|
||||||
|
// "<pt> 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
|
||||||
|
}
|
||||||
131
calls/audio/fmtp_test.go
Normal file
131
calls/audio/fmtp_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
117
calls/audio/latency_drift_test.go
Normal file
117
calls/audio/latency_drift_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
calls/audio/ntg.go
Normal file
29
calls/audio/ntg.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
97
calls/audio/ntgadapter/adapter.go
Normal file
97
calls/audio/ntgadapter/adapter.go
Normal file
|
|
@ -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)
|
||||||
127
calls/audio/opus.go
Normal file
127
calls/audio/opus.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
calls/audio/opus_test.go
Normal file
48
calls/audio/opus_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
182
calls/audio/playout.go
Normal file
182
calls/audio/playout.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/interceptor/pkg/jitterbuffer"
|
||||||
|
"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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
started bool // first Pop succeeded -> headSeq is meaningful
|
||||||
|
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")
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
TargetDepth: int(primePackets),
|
||||||
|
MaxDepth: DefaultMaxDepth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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);
|
||||||
|
// 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
|
||||||
|
p.depth--
|
||||||
|
p.cPopped++
|
||||||
|
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
|
||||||
|
p.depth--
|
||||||
|
p.cPopped++
|
||||||
|
return pkt, int(i), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, 0, ErrEmpty
|
||||||
|
}
|
||||||
144
calls/audio/playout_test.go
Normal file
144
calls/audio/playout_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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 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.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
calls/audio/tg_to_xmpp.go
Normal file
279
calls/audio/tg_to_xmpp.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
if h.primarySSRC == 0 {
|
||||||
|
h.primarySSRC = f.SSRC
|
||||||
|
} else if f.SSRC != h.primarySSRC {
|
||||||
|
h.cFramesWrongSSRC.Add(1)
|
||||||
|
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.cWriteErrs.Add(1)
|
||||||
|
h.log.WithError(err).Warn("WriteSample failed")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
h.cOpusOut.Add(1)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
125
calls/audio/tg_to_xmpp_test.go
Normal file
125
calls/audio/tg_to_xmpp_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
367
calls/audio/xmpp_to_tg.go
Normal file
367
calls/audio/xmpp_to_tg.go
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
cDtxFrames atomic.Int64 // empty (DTX) packets concealed as comfort noise
|
||||||
|
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, dtxFrames, 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(),
|
||||||
|
dtxFrames: h.cDtxFrames.Load(),
|
||||||
|
sendErrs: h.cSendErrs.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
"dtx_frames": cur.dtxFrames - prev.dtxFrames,
|
||||||
|
"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))
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
181
calls/audio/xmpp_to_tg_test.go
Normal file
181
calls/audio/xmpp_to_tg_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
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 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()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
274
calls/orchestrator.go
Normal file
274
calls/orchestrator.go
Normal file
|
|
@ -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 <propose>; postReady fires after the session is in StateRinging
|
||||||
|
// so a fast tdlib Pending{IsReceived:true} doesn't drop the JMI <ringing/>
|
||||||
|
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
|
||||||
|
}
|
||||||
376
calls/orchestrator_test.go
Normal file
376
calls/orchestrator_test.go
Normal file
|
|
@ -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 <ringing> 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-<ringing>; 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; <retract/> 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 <retract>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <reject>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
249
calls/signaling/bridge.go
Normal file
249
calls/signaling/bridge.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
277
calls/signaling/bridge_test.go
Normal file
277
calls/signaling/bridge_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
249
calls/signaling/tgsig/adapter.go
Normal file
249
calls/signaling/tgsig/adapter.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
217
calls/signaling/tgsig/adapter_race_test.go
Normal file
217
calls/signaling/tgsig/adapter_race_test.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
130
calls/signaling/tgsig/base.go
Normal file
130
calls/signaling/tgsig/base.go
Normal file
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
34
calls/signaling/tgsig/callee.go
Normal file
34
calls/signaling/tgsig/callee.go
Normal file
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
36
calls/signaling/tgsig/caller.go
Normal file
36
calls/signaling/tgsig/caller.go
Normal file
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
48
calls/signaling/tgsig/convert.go
Normal file
48
calls/signaling/tgsig/convert.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
207
calls/signaling/tgsig/manager.go
Normal file
207
calls/signaling/tgsig/manager.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
304
calls/signaling/tgsig/manager_test.go
Normal file
304
calls/signaling/tgsig/manager_test.go
Normal file
|
|
@ -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 <ringing> 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 <ringing>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
calls/signaling/timers.go
Normal file
48
calls/signaling/timers.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
344
calls/signaling/xmppsig/adapter.go
Normal file
344
calls/signaling/xmppsig/adapter.go
Normal file
|
|
@ -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 <proceed> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
calls/signaling/xmppsig/base.go
Normal file
128
calls/signaling/xmppsig/base.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
calls/signaling/xmppsig/callee.go
Normal file
75
calls/signaling/xmppsig/callee.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
224
calls/signaling/xmppsig/callee_test.go
Normal file
224
calls/signaling/xmppsig/callee_test.go
Normal file
|
|
@ -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 <retract> 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 <ringing>/<proceed> `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 <message>: 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 <ringing>")
|
||||||
|
}
|
||||||
|
if !sawProceed {
|
||||||
|
t.Error("never observed an outbound <proceed>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// <ringing> 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: <proceed> must appear, <ringing> 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 <ringing>; <ringing> 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
|
||||||
|
}
|
||||||
86
calls/signaling/xmppsig/caller.go
Normal file
86
calls/signaling/xmppsig/caller.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
217
calls/signaling/xmppsig/caller_test.go
Normal file
217
calls/signaling/xmppsig/caller_test.go
Normal file
|
|
@ -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 <proceed>
|
||||||
|
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 <propose> must use a full JID (userID@gw/telegabber). XEP-0353
|
||||||
|
// requires it so the recipient can route <proceed>/<reject> 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 <propose>")
|
||||||
|
}
|
||||||
|
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 <propose> in JMI ns with a <description media="audio">
|
||||||
|
xmlBytes, err := xml.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
s := string(xmlBytes)
|
||||||
|
if !strings.Contains(s, `<propose xmlns="urn:xmpp:jingle-message:0"`) {
|
||||||
|
t.Errorf("propose missing JMI namespace: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio"`) {
|
||||||
|
t.Errorf("propose missing rtp description with media=audio: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cross-side teardown for TG-originated calls:
|
||||||
|
// inbound <reject>/<retract>/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
|
||||||
|
}
|
||||||
167
calls/signaling/xmppsig/extdisco.go
Normal file
167
calls/signaling/xmppsig/extdisco.go
Normal file
|
|
@ -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 <error> 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 <services> 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 <services> 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
|
||||||
|
}
|
||||||
181
calls/signaling/xmppsig/extdisco_test.go
Normal file
181
calls/signaling/xmppsig/extdisco_test.go
Normal file
|
|
@ -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(`<iq xmlns="jabber:client" type="result" from="localhost" to="probe@localhost/abc" id="x">` +
|
||||||
|
`<services xmlns="urn:xmpp:extdisco:2">` +
|
||||||
|
`<service transport="udp" port="3478" host="172.18.0.1" type="stun"/>` +
|
||||||
|
`<service restricted="1" port="3478" transport="udp" username="1779481470" password="F5aXytaIwbEJWt6FJ66OqeEQtvw=" host="172.18.0.1" type="turn"/>` +
|
||||||
|
`</services>` +
|
||||||
|
`</iq>`)
|
||||||
|
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 <iq type='error'>")
|
||||||
|
}
|
||||||
|
}
|
||||||
181
calls/signaling/xmppsig/pion_loop_test.go
Normal file
181
calls/signaling/xmppsig/pion_loop_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
175
calls/signaling/xmppsig/track_buffer_test.go
Normal file
175
calls/signaling/xmppsig/track_buffer_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
34
docker/isolate_ntgcalls_crypto.sh
Executable file
34
docker/isolate_ntgcalls_crypto.sh
Executable file
|
|
@ -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")"
|
||||||
22
docker/resolv_shim.c
Normal file
22
docker/resolv_shim.c
Normal file
|
|
@ -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.
|
||||||
|
//
|
||||||
|
// The glib build is downloaded rather than compiled here, so the simplest
|
||||||
|
// fix is to forward the internal names to the public ones.
|
||||||
|
#include <resolv.h>
|
||||||
|
#include <arpa/nameser.h>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
49
go.mod
49
go.mod
|
|
@ -1,23 +1,36 @@
|
||||||
module dev.narayana.im/narayana/telegabber
|
module dev.narayana.im/narayana/telegabber
|
||||||
|
|
||||||
go 1.19
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dgraph-io/badger/v4 v4.1.0
|
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/pkg/errors v0.9.1
|
||||||
github.com/santhosh-tekuri/jsonschema v1.2.4
|
github.com/santhosh-tekuri/jsonschema v1.2.4
|
||||||
github.com/sirupsen/logrus v1.4.2
|
github.com/sirupsen/logrus v1.4.2
|
||||||
github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e
|
github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e
|
||||||
github.com/xdg-go/stringprep v1.0.4
|
github.com/xdg-go/stringprep v1.0.4
|
||||||
github.com/zelenin/go-tdlib v0.5.2
|
github.com/zelenin/go-tdlib v0.5.2
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
|
golang.org/x/crypto v0.48.0
|
||||||
|
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
|
||||||
gopkg.in/yaml.v2 v2.2.4
|
gopkg.in/yaml.v2 v2.2.4
|
||||||
gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1
|
gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1
|
||||||
|
gotgcalls v0.0.0-00010101000000-000000000000
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/dgraph-io/ristretto v0.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
|
@ -28,10 +41,32 @@ require (
|
||||||
github.com/google/flatbuffers v1.12.1 // indirect
|
github.com/google/flatbuffers v1.12.1 // indirect
|
||||||
github.com/klauspost/compress v1.12.3 // indirect
|
github.com/klauspost/compress v1.12.3 // indirect
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // 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/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
|
go.opencensus.io v0.22.5 // indirect
|
||||||
golang.org/x/net v0.7.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sys v0.5.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.7.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
nhooyr.io/websocket v1.6.5 // indirect
|
nhooyr.io/websocket v1.6.5 // indirect
|
||||||
)
|
)
|
||||||
|
|
@ -39,3 +74,5 @@ require (
|
||||||
replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4
|
replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4
|
||||||
|
|
||||||
replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061
|
replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061
|
||||||
|
|
||||||
|
replace gotgcalls => ./ntgcalls/examples/go
|
||||||
|
|
|
||||||
104
go.sum
104
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-20250823114312-ed4011fc17e4 h1:HQT33Zp3iRkbCiijWDo943K//wQgzoMccIP7Vb2uEfY=
|
dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4 h1:HQT33Zp3iRkbCiijWDo943K//wQgzoMccIP7Vb2uEfY=
|
||||||
dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
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/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.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
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/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-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-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw=
|
||||||
github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0=
|
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.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM=
|
||||||
github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4=
|
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
@ -54,10 +70,12 @@ 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.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.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 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-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/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.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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
|
@ -71,8 +89,11 @@ 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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
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/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.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=
|
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-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
|
|
@ -83,15 +104,59 @@ 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.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.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.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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.8.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/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.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/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=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/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 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
|
||||||
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
|
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=
|
github.com/sirupsen/logrus v1.0.5/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
|
||||||
|
|
@ -105,11 +170,16 @@ 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/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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
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.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/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/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
|
@ -124,9 +194,12 @@ 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
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-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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
|
@ -146,8 +219,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
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/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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|
@ -174,18 +247,22 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
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.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-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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
|
@ -210,13 +287,18 @@ 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 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-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 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/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/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/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.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.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
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.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 v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
|
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=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
|
|
||||||
1
ntgcalls
Submodule
1
ntgcalls
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 17f755231a3dab27c121153eab4b155639d24fcb
|
||||||
|
|
@ -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 update
|
||||||
RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php libsignal-protocol-c-dev
|
RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php libsignal-protocol-c-dev
|
||||||
|
|
@ -19,18 +19,45 @@ WORKDIR /build/
|
||||||
RUN cmake --build . ${MAKEOPTS}
|
RUN cmake --build . ${MAKEOPTS}
|
||||||
RUN make install
|
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
|
FROM base AS cache
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
COPY --from=tdlib /compiled/ /usr/local/
|
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
|
WORKDIR /src
|
||||||
RUN go env -w GOCACHE=/go-cache
|
RUN go env -w GOCACHE=/go-cache
|
||||||
RUN go env -w GOMODCACHE=/gomod-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 \
|
RUN --mount=type=cache,target=/gomod-cache \
|
||||||
--mount=type=bind,source=./,target=/src,rw \
|
--mount=type=bind,source=./,target=/src,rw \
|
||||||
/bin/bash -c 'go mod tidy; go get'
|
/bin/bash -c 'go mod tidy; go get'
|
||||||
|
|
||||||
FROM cache AS build
|
FROM cache AS build
|
||||||
ARG MAKEOPTS
|
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
|
WORKDIR /src
|
||||||
# Bullseye only has the pre-fork libsignal-protocol-c packaged (libomemo-c
|
# Bullseye only has the pre-fork libsignal-protocol-c packaged (libomemo-c
|
||||||
# is bookworm+) - GOTAGS=signal_legacy builds telegabber's OMEMO bindings
|
# is bookworm+) - GOTAGS=signal_legacy builds telegabber's OMEMO bindings
|
||||||
|
|
|
||||||
|
|
@ -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 update
|
||||||
RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php
|
RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/config"
|
"dev.narayana.im/narayana/telegabber/config"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp"
|
"dev.narayana.im/narayana/telegabber/xmpp"
|
||||||
|
"gotgcalls/ntgcalls"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/zelenin/go-tdlib/client"
|
"github.com/zelenin/go-tdlib/client"
|
||||||
|
|
@ -70,6 +71,7 @@ func main() {
|
||||||
SetLogrusLevel(config.XMPP.Loglevel)
|
SetLogrusLevel(config.XMPP.Loglevel)
|
||||||
|
|
||||||
log.Infof("Starting telegabber version %v", version)
|
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, *e2eeDbPath)
|
sm, component, err = xmpp.NewComponent(config.XMPP, config.Telegram, *idsPath, version, *e2eeDbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,13 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/config"
|
||||||
"dev.narayana.im/narayana/telegabber/persistence"
|
"dev.narayana.im/narayana/telegabber/persistence"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram/cache"
|
"dev.narayana.im/narayana/telegabber/telegram/cache"
|
||||||
|
"gotgcalls/ntgcalls"
|
||||||
|
|
||||||
"github.com/zelenin/go-tdlib/client"
|
"github.com/zelenin/go-tdlib/client"
|
||||||
"gosrc.io/xmpp"
|
"gosrc.io/xmpp"
|
||||||
|
|
@ -161,10 +165,57 @@ type Client struct {
|
||||||
MessageIdChanges map[int64]map[int64]*newId
|
MessageIdChanges map[int64]map[int64]*newId
|
||||||
MessageIdChangesLock sync.Mutex
|
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
|
locks clientLocks
|
||||||
SendMessageLock sync.Mutex
|
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 {
|
type clientLocks struct {
|
||||||
authorizationReady sync.Mutex
|
authorizationReady sync.Mutex
|
||||||
chatMessageLocks map[int64]*sync.Mutex
|
chatMessageLocks map[int64]*sync.Mutex
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,14 @@ func (c *Client) Connect(resource string, wasSessionLoginEmpty bool) error {
|
||||||
|
|
||||||
c.client = tdlibClient
|
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
|
// stage 3: if a client is succesfully created, AuthorizationStateReady is already reached
|
||||||
log.Warn("Authorization successful!")
|
log.Warn("Authorization successful!")
|
||||||
|
|
||||||
|
|
@ -331,6 +339,24 @@ func (c *Client) close() {
|
||||||
}
|
}
|
||||||
c.locks.authorizerWriteLock.Unlock()
|
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 {
|
if c.client != nil {
|
||||||
_, err := c.client.Close()
|
_, err := c.client.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ func (c *Client) updateHandler() {
|
||||||
|
|
||||||
for update := range listener.Updates {
|
for update := range listener.Updates {
|
||||||
if update.GetClass() == client.ClassUpdate {
|
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() {
|
switch update.GetType() {
|
||||||
case client.TypeUpdateUser:
|
case client.TypeUpdateUser:
|
||||||
typedUpdate, _ := update.(*client.UpdateUser)
|
typedUpdate, _ := update.(*client.UpdateUser)
|
||||||
|
|
@ -141,6 +145,18 @@ func (c *Client) updateHandler() {
|
||||||
case client.TypeUpdateFile:
|
case client.TypeUpdateFile:
|
||||||
typedUpdate, _ := update.(*client.UpdateFile)
|
typedUpdate, _ := update.(*client.UpdateFile)
|
||||||
c.updateFile(typedUpdate)
|
c.updateFile(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:
|
default:
|
||||||
// log only handled types
|
// log only handled types
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1825,6 +1825,12 @@ func (c *Client) getPrefixSeparator(chatId int64) string {
|
||||||
|
|
||||||
// ProcessIncomingMessage is a legacy wrapper for SendMessageToGateway aiming only PM messages
|
// ProcessIncomingMessage is a legacy wrapper for SendMessageToGateway aiming only PM messages
|
||||||
func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
chat, _, _ := c.GetContactByID(chatId, nil, true)
|
chat, _, _ := c.GetContactByID(chatId, nil, true)
|
||||||
safeToSend := true
|
safeToSend := true
|
||||||
groupChatFrom := ""
|
groupChatFrom := ""
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dev.narayana.im/narayana/telegabber/badger"
|
"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/config"
|
||||||
"dev.narayana.im/narayana/telegabber/e2ee"
|
"dev.narayana.im/narayana/telegabber/e2ee"
|
||||||
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
|
"dev.narayana.im/narayana/telegabber/e2ee/omemo"
|
||||||
|
|
@ -16,7 +21,9 @@ import (
|
||||||
"dev.narayana.im/narayana/telegabber/persistence"
|
"dev.narayana.im/narayana/telegabber/persistence"
|
||||||
"dev.narayana.im/narayana/telegabber/telegram"
|
"dev.narayana.im/narayana/telegabber/telegram"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
||||||
|
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"gosrc.io/xmpp"
|
"gosrc.io/xmpp"
|
||||||
"gosrc.io/xmpp/stanza"
|
"gosrc.io/xmpp/stanza"
|
||||||
|
|
@ -25,8 +32,21 @@ import (
|
||||||
var tgConf config.TelegramConfig
|
var tgConf config.TelegramConfig
|
||||||
var sessions map[string]*telegram.Client
|
var sessions map[string]*telegram.Client
|
||||||
var db *persistence.SessionsYamlDB
|
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
|
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 (
|
const (
|
||||||
B uint64 = 1
|
B uint64 = 1
|
||||||
KB = B << 10
|
KB = B << 10
|
||||||
|
|
@ -98,6 +118,35 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
|
||||||
return nil, nil, err
|
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
|
// probe all known sessions
|
||||||
err = loadSessions(conf.Db, component)
|
err = loadSessions(conf.Db, component)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -105,7 +154,9 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
|
||||||
}
|
}
|
||||||
|
|
||||||
sm := xmpp.NewStreamManager(component, func(s xmpp.Sender) {
|
sm := xmpp.NewStreamManager(component, func(s xmpp.Sender) {
|
||||||
|
componentEverConnected.Store(true)
|
||||||
go heartbeat(component)
|
go heartbeat(component)
|
||||||
|
go refreshICEServers(s)
|
||||||
})
|
})
|
||||||
|
|
||||||
return sm, component, nil
|
return sm, component, nil
|
||||||
|
|
@ -139,6 +190,37 @@ func setupE2EE(dbPath string, ec config.E2EEConfig) error {
|
||||||
return nil
|
return 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) {
|
func heartbeat(component *xmpp.Component) {
|
||||||
var err error
|
var err error
|
||||||
probeType := gateway.SPType("probe")
|
probeType := gateway.SPType("probe")
|
||||||
|
|
@ -256,6 +338,7 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen
|
||||||
log.Error(errors.Wrap(err, "TDlib initialization failure"))
|
log.Error(errors.Wrap(err, "TDlib initialization failure"))
|
||||||
return session, false
|
return session, false
|
||||||
}
|
}
|
||||||
|
session.SetCallDeps(callDeps)
|
||||||
if savedSession.KeepOnline {
|
if savedSession.KeepOnline {
|
||||||
if err = session.Connect("", false); err != nil {
|
if err = session.Connect("", false); err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
|
|
@ -270,6 +353,17 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen
|
||||||
return session, true
|
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
|
// SaveSessions dumps current sessions to the file
|
||||||
func SaveSessions() {
|
func SaveSessions() {
|
||||||
sessionLock.Lock()
|
sessionLock.Lock()
|
||||||
|
|
@ -283,7 +377,10 @@ func SaveSessions() {
|
||||||
}, persistence.SessionMarshaller)
|
}, persistence.SessionMarshaller)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close gracefully terminates the component and saves active sessions
|
// 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) {
|
func Close(component *xmpp.Component) {
|
||||||
log.Error("Disconnecting...")
|
log.Error("Disconnecting...")
|
||||||
|
|
||||||
|
|
@ -299,8 +396,11 @@ func Close(component *xmpp.Component) {
|
||||||
}
|
}
|
||||||
sessionLock.Unlock()
|
sessionLock.Unlock()
|
||||||
|
|
||||||
// save sessions
|
if componentEverConnected.Load() {
|
||||||
SaveSessions()
|
SaveSessions()
|
||||||
|
} else {
|
||||||
|
log.Warn("Close: component never connected, skipping SaveSessions to preserve on-disk state")
|
||||||
|
}
|
||||||
|
|
||||||
// flush the ids database
|
// flush the ids database
|
||||||
gateway.IdsDB.Close()
|
gateway.IdsDB.Close()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gosrc.io/xmpp/stanza"
|
"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
|
// PresenceNickExtension is from XEP-0172
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"dev.narayana.im/narayana/telegabber/telegram"
|
"dev.narayana.im/narayana/telegabber/telegram"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
||||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
||||||
|
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
@ -38,6 +39,19 @@ func logPacketType(p stanza.Packet) {
|
||||||
log.Warnf("Ignoring packet: %T\n", p)
|
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
|
// HandleIq processes an incoming XMPP iq
|
||||||
func HandleIq(s xmpp.Sender, p stanza.Packet) {
|
func HandleIq(s xmpp.Sender, p stanza.Packet) {
|
||||||
iq, ok := p.(*stanza.IQ)
|
iq, ok := p.(*stanza.IQ)
|
||||||
|
|
@ -46,6 +60,14 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) {
|
||||||
return
|
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)
|
log.Debugf("%#v", iq)
|
||||||
if iq.Type == stanza.IQTypeGet {
|
if iq.Type == stanza.IQTypeGet {
|
||||||
_, ok := iq.Payload.(*extensions.IqVcardTemp)
|
_, ok := iq.Payload.(*extensions.IqVcardTemp)
|
||||||
|
|
@ -180,6 +202,14 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
|
||||||
return
|
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)
|
component, ok := s.(*xmpp.Component)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Error("Not a component")
|
log.Error("Not a component")
|
||||||
|
|
@ -1151,6 +1181,19 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
|
||||||
}
|
}
|
||||||
disco.AddFeatures(stanza.NSMsgChatMarkers)
|
disco.AddFeatures(stanza.NSMsgChatMarkers)
|
||||||
disco.AddFeatures(stanza.NSMsgReceipts)
|
disco.AddFeatures(stanza.NSMsgReceipts)
|
||||||
|
// Jingle / JMI features so clients (e.g. Dino) treat the contact
|
||||||
|
// as call-capable. Without these, JMI <proceed> 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 {
|
} else {
|
||||||
disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
|
disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
|
||||||
disco.AddFeatures("jabber:iq:register")
|
disco.AddFeatures("jabber:iq:register")
|
||||||
|
|
|
||||||
66
xmpp/jingle/candidate_pion.go
Normal file
66
xmpp/jingle/candidate_pion.go
Normal file
|
|
@ -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},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
225
xmpp/jingle/convert.go
Normal file
225
xmpp/jingle/convert.go
Normal file
|
|
@ -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
|
||||||
|
// <content creator='...'/> value; defaults to "initiator"
|
||||||
|
LocalCreator string
|
||||||
|
// <jingle sid='...'/> 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"
|
||||||
|
}
|
||||||
268
xmpp/jingle/convert_payload.go
Normal file
268
xmpp/jingle/convert_payload.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
231
xmpp/jingle/convert_test.go
Normal file
231
xmpp/jingle/convert_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
167
xmpp/jingle/convert_transport.go
Normal file
167
xmpp/jingle/convert_transport.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
81
xmpp/jingle/jmi.go
Normal file
81
xmpp/jingle/jmi.go
Normal file
|
|
@ -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"
|
||||||
|
|
||||||
|
// <propose/> - 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 <description> 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// <proceed/> - 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// <ringing/> - callee alerting
|
||||||
|
type JMIRinging struct {
|
||||||
|
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 ringing"`
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// <reject/> - 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// <retract/> - caller cancelled before proceed
|
||||||
|
type JMIRetract struct {
|
||||||
|
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 retract"`
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// <finish/> - 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
197
xmpp/jingle/manager.go
Normal file
197
xmpp/jingle/manager.go
Normal file
|
|
@ -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 <propose> 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 <propose>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 <propose>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 <proceed>/<reject> 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// <iq type='error'>; 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)
|
||||||
|
}
|
||||||
13
xmpp/jingle/namespaces.go
Normal file
13
xmpp/jingle/namespaces.go
Normal file
|
|
@ -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"
|
||||||
|
)
|
||||||
141
xmpp/jingle/sdp.go
Normal file
141
xmpp/jingle/sdp.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
736
xmpp/jingle/session.go
Normal file
736
xmpp/jingle/session.go
Normal file
|
|
@ -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 <propose>, awaiting proceed/reject/retract
|
||||||
|
StateRinging // callee: got <propose>, sent <ringing>, 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 <ringing/> on
|
||||||
|
// incoming <propose>; 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 <propose>; latched to full on <proceed>
|
||||||
|
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 <proceed> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// <message><propose/></message>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// <message><ringing/></message> - 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// <message><proceed/></message>; 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// <message><reject/></message> (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
|
||||||
|
}
|
||||||
|
|
||||||
|
// <message><retract/></message> (caller, pre-<proceed>)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// <jingle action=session-terminate>; 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-<ringing/> 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
|
||||||
|
}
|
||||||
260
xmpp/jingle/session_test.go
Normal file
260
xmpp/jingle/session_test.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// <reject>: 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// <retract>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
210
xmpp/jingle/stanzas.go
Normal file
210
xmpp/jingle/stanzas.go
Normal file
|
|
@ -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 <reason>; 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 <payload-type> (rtp:1) and <source> (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{})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue