add calls support

This commit is contained in:
pounceandmiss 2026-05-24 08:14:55 -07:00
parent 9766f6cc7f
commit 4fe5b99c6e
62 changed files with 8827 additions and 40 deletions

View file

@ -31,19 +31,26 @@ RUN cmake -DCMAKE_BUILD_TYPE=Release \
/ntgcalls-src/
RUN cmake --build . ${MAKEOPTS}
FROM base AS cache
ARG VERSION
FROM base AS deps
COPY --from=tdlib /compiled/ /usr/local/
COPY --from=ntgcalls-build /ntgcalls-src/static-output/lib/libntgcalls.a /usr/local/lib/
COPY --from=ntgcalls-build /ntgcalls-src/static-output/include/ /usr/local/include/
COPY docker/resolv_shim.c /tmp/resolv_shim.c
RUN gcc -c -o /tmp/resolv_shim.o /tmp/resolv_shim.c && \
ar rcs /usr/local/lib/libresolv_shim.a /tmp/resolv_shim.o
# Needed by gopkg.in/hraban/opus.v2 (cgo pkg-config) and faster final link.
# Installed here (not in `base`) to keep tdlib/ntgcalls layers cached.
RUN apt-get install -y libopus-dev libopusfile-dev mold
# Isolate libntgcalls.a's bundled BoringSSL so its symbols don't displace
# system OpenSSL at link time (TDLib was built against the latter).
COPY docker/isolate_ntgcalls_crypto.sh /tmp/isolate_ntgcalls_crypto.sh
RUN bash /tmp/isolate_ntgcalls_crypto.sh
FROM deps AS cache
COPY ./ /src
RUN git -C /src checkout "${VERSION}"
RUN git -C /src submodule update --init
WORKDIR /src
RUN go get
RUN --mount=type=cache,target=/go/pkg/mod go get
FROM cache AS build
ARG MAKEOPTS
@ -51,7 +58,21 @@ ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-I/usr/local/include"
ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv"
WORKDIR /src
RUN make ${MAKEOPTS}
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
make ${MAKEOPTS}
FROM deps AS test
COPY ./ /src
WORKDIR /src
RUN git submodule update --init
RUN --mount=type=cache,target=/go/pkg/mod go get
ENV CGO_ENABLED=1
ENV CGO_CFLAGS="-I/usr/local/include"
ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv"
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
make test
FROM scratch AS telegabber
COPY --from=build /src/release/telegabber /usr/local/bin/

View file

@ -12,17 +12,24 @@ all:
mkdir -p release
CGO_ENABLED=1 \
CGO_CFLAGS="-I$(NTG_INC_DIR)" \
CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" \
CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv -fuse-ld=mold" \
go build -ldflags "-X main.commit=${COMMIT}" -o release/telegabber
test:
go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger
go test -race -short -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./xmpp/jingle ./persistence ./telegram/formatter ./badger ./calls ./calls/signaling ./calls/signaling/tgsig ./calls/signaling/xmppsig ./calls/audio
# Pion-loopback test
test_loop:
go test -race -v -count=1 -run '^(TestXmppsigPionLoop_|TestHookTrack_|TestSetTrackHandler_)' ./calls/signaling/xmppsig
test_indocker:
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --target test .
lint:
$(GOPATH)/bin/golint ./...
build_indocker:
docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries .
DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries .
build_indocker_staging:
DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --network host --output=release --target binaries -f staging.Dockerfile .

15
calls/DESIGN.md Normal file
View 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
View 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
View 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
View 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)
}
})
}
}

29
calls/audio/ntg.go Normal file
View 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
}

View 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
View 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
View 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)
}
}

76
calls/audio/playout.go Normal file
View file

@ -0,0 +1,76 @@
package audio
import (
"errors"
"sync"
"github.com/pion/interceptor/pkg/jitterbuffer"
"github.com/pion/rtp"
)
// pion's jitterbuffer + late-drop on Push + skip-ahead on persistent gap;
// PopOrSkip reports gap counts so the caller can run PLC over them
type Playout struct {
mu sync.Mutex
jb *jitterbuffer.JitterBuffer
started bool // first Pop succeeded -> headSeq is meaningful
headSeq uint16 // expected next seq
// caps the scan in PopOrSkip's slow path
MaxSkip uint16
}
var ErrEmpty = errors.New("playout: empty")
// primePackets=2 is ~40ms at 20ms framing
func NewPlayout(primePackets uint16) *Playout {
if primePackets == 0 {
primePackets = 2
}
return &Playout{
jb: jitterbuffer.New(jitterbuffer.WithMinimumPacketCount(primePackets)),
MaxSkip: 64,
}
}
// late packets (seq behind headSeq) are dropped
func (p *Playout) Push(pkt *rtp.Packet) {
if pkt == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
// int16 cast -> signed seq distance
if p.started && int16(pkt.SequenceNumber-p.headSeq) < 0 {
return
}
p.jb.Push(pkt)
}
// returns (next playable packet, gap count to PLC over, err);
// ErrEmpty means priming or genuinely empty
func (p *Playout) PopOrSkip() (*rtp.Packet, int, error) {
p.mu.Lock()
defer p.mu.Unlock()
if pkt, err := p.jb.Pop(); err == nil {
p.started = true
p.headSeq = pkt.SequenceNumber + 1
return pkt, 0, nil
} else if errors.Is(err, jitterbuffer.ErrPopWhileBuffering) {
return nil, 0, ErrEmpty
}
// scan forward for the next available packet
head := p.jb.PlayoutHead()
for i := uint16(1); i <= p.MaxSkip; i++ {
pkt, err := p.jb.PopAtSequence(head + i)
if err == nil {
// PopAtSequence advances by 1; head must skip past the gap
p.jb.SetPlayoutHead(pkt.SequenceNumber + 1)
p.started = true
p.headSeq = pkt.SequenceNumber + 1
return pkt, int(i), nil
}
}
return nil, 0, ErrEmpty
}

View file

@ -0,0 +1,94 @@
package audio
import (
"errors"
"testing"
"github.com/pion/rtp"
)
func pkt(seq uint16) *rtp.Packet {
return &rtp.Packet{
Header: rtp.Header{SequenceNumber: seq, PayloadType: opusPayloadType},
Payload: []byte{0x00},
}
}
func TestPlayoutLateDrop(t *testing.T) {
p := NewPlayout(2)
p.Push(pkt(500))
p.Push(pkt(501))
// Drain to set the playout head past 500.
if _, _, err := p.PopOrSkip(); err != nil {
t.Fatalf("first pop: %v", err)
}
if _, _, err := p.PopOrSkip(); err != nil {
t.Fatalf("second pop: %v", err)
}
// A late packet with seq 499 must be dropped (not block, not surface
// later).
p.Push(pkt(499))
if _, _, err := p.PopOrSkip(); !errors.Is(err, ErrEmpty) {
t.Fatalf("late packet should not have been buffered; got err=%v", err)
}
}
func TestPlayoutSkipAhead(t *testing.T) {
p := NewPlayout(2)
// Push 600, 601 to prime, then drain so playoutHead = 602.
p.Push(pkt(600))
p.Push(pkt(601))
if _, _, err := p.PopOrSkip(); err != nil {
t.Fatalf("first pop: %v", err)
}
if _, _, err := p.PopOrSkip(); err != nil {
t.Fatalf("second pop: %v", err)
}
// Now skip seq 602, 603; deliver 604, 605.
p.Push(pkt(604))
p.Push(pkt(605))
got, gap, err := p.PopOrSkip()
if err != nil {
t.Fatalf("skip-ahead pop: %v", err)
}
if got.SequenceNumber != 604 {
t.Errorf("got seq=%d, want 604", got.SequenceNumber)
}
if gap != 2 {
t.Errorf("got gap=%d, want 2 (skipped 602, 603)", gap)
}
// Next pop should be 605 with no gap.
got, gap, err = p.PopOrSkip()
if err != nil {
t.Fatalf("follow-up pop: %v", err)
}
if got.SequenceNumber != 605 {
t.Errorf("got seq=%d, want 605", got.SequenceNumber)
}
if gap != 0 {
t.Errorf("follow-up gap=%d, want 0", gap)
}
}
func TestPlayoutSeqWraparound(t *testing.T) {
p := NewPlayout(2)
// Push packets straddling the uint16 boundary: 65534, 65535, 0, 1.
for _, s := range []uint16{65534, 65535, 0, 1} {
p.Push(pkt(s))
}
// Should pop in correct order despite the wrap.
wantOrder := []uint16{65534, 65535, 0, 1}
for _, want := range wantOrder {
got, gap, err := p.PopOrSkip()
if err != nil {
t.Fatalf("Pop want=%d: %v", want, err)
}
if got.SequenceNumber != want {
t.Errorf("got seq=%d, want %d", got.SequenceNumber, want)
}
if gap != 0 {
t.Errorf("seq=%d gap=%d, want 0", want, gap)
}
}
}

187
calls/audio/tg_to_xmpp.go Normal file
View file

@ -0,0 +1,187 @@
package audio
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
log "github.com/sirupsen/logrus"
)
// pion's default MediaEngine PT for opus; production reads the actual
// negotiated PT from TrackRemote, this is for tests with synthetic RTP
const opusPayloadType = 111
type TgToXmppOptions struct {
Ntg NtgClient
ChatID int64 // P2P: equals the Telegram peer's user_id
// params half of the peer's opus a=fmtp; empty -> defaults
RemoteFmtp string
Logger *log.Entry // nil -> default logrus
}
// tg->xmpp half: ntgcalls Playback -> opus encode -> pion LocalTrack;
// callback-driven, no goroutines
type TgToXmpp struct {
opts TgToXmppOptions
log *log.Entry
enc *Encoder
track *webrtc.TrackLocalStaticSample
mu sync.Mutex
acc []byte // accumulates 10ms ntg frames until 20ms
primarySSRC uint32 // first SSRC wins; mixing is wrong for 1:1
opusOut []byte // reusable encode destination
closed atomic.Bool
started atomic.Bool
unregister func() // returned by Ntg.OnFrame; called from Close
capture *captureWriter
}
// builds the encoder, local opus track, registers ntgcalls frame handler;
// Start() flips on the ntgcalls source
func NewTgToXmpp(opts TgToXmppOptions) (*TgToXmpp, error) {
if opts.Ntg == nil {
return nil, errors.New("audio: Ntg required")
}
if opts.ChatID == 0 {
return nil, errors.New("audio: ChatID required")
}
logger := opts.Logger
if logger == nil {
logger = log.WithField("module", "audio")
}
logger = logger.WithFields(log.Fields{"dir": "tg->xmpp", "chat_id": opts.ChatID})
enc, err := NewEncoder()
if err != nil {
return nil, err
}
if err := ParseFmtp(opts.RemoteFmtp).ApplyTo(enc); err != nil {
return nil, fmt.Errorf("apply fmtp: %w", err)
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus,
ClockRate: SampleRate,
Channels: Channels,
SDPFmtpLine: "minptime=10;useinbandfec=1",
},
"audio",
fmt.Sprintf("telegabber-%d", opts.ChatID),
)
if err != nil {
return nil, fmt.Errorf("new track: %w", err)
}
h := &TgToXmpp{
opts: opts,
log: logger,
enc: enc,
track: track,
acc: make([]byte, 0, OpusInputBytes),
opusOut: make([]byte, MaxOpusPacketBytes),
}
if cap, err := newCaptureWriter(
fmt.Sprintf("tg-to-xmpp-%d-%d.bin", opts.ChatID, time.Now().Unix()),
magicPCM,
); err != nil {
logger.WithError(err).Warn("capture: tg->xmpp writer disabled")
} else if cap != nil {
logger.Info("capture: tg->xmpp PCM capture enabled")
h.capture = cap
}
// adapter pre-filters to (Playback, Microphone); handler also filters
// by chatID. Close calls the returned cancel to drop our dispatch slot.
h.unregister = opts.Ntg.OnFrame(h.onNtgFrames)
return h, nil
}
func (h *TgToXmpp) LocalTrack() *webrtc.TrackLocalStaticSample { return h.track }
func (h *TgToXmpp) Start() error {
if !h.started.CompareAndSwap(false, true) {
return nil
}
if err := h.opts.Ntg.SetExternalMicrophone(h.opts.ChatID, false, SampleRate, Channels); err != nil {
return fmt.Errorf("SetExternalMicrophone(playback): %w", err)
}
h.log.Info("tg->xmpp half started")
return nil
}
// flips the closed flag and releases the capture writer; ntg sources
// are released by the xmpp->tg half's Close
func (h *TgToXmpp) Close() error {
if !h.closed.CompareAndSwap(false, true) {
return nil
}
if h.unregister != nil {
h.unregister()
}
h.capture.close()
h.log.Info("tg->xmpp half closed")
return nil
}
func (h *TgToXmpp) onNtgFrames(chatID int64, frames []PCMFrame) {
if h.closed.Load() || chatID != h.opts.ChatID {
return
}
for _, f := range frames {
h.capture.writePCM(f.SSRC, f.Data)
h.feedFrame(f)
}
}
// returns number of opus packets emitted (0 or 1)
func (h *TgToXmpp) feedFrame(f PCMFrame) int {
if len(f.Data) != NtgFrameBytes {
h.log.WithField("len", len(f.Data)).Warn("unexpected ntg frame size")
return 0
}
h.mu.Lock()
defer h.mu.Unlock()
if h.primarySSRC == 0 {
h.primarySSRC = f.SSRC
} else if f.SSRC != h.primarySSRC {
return 0
}
h.acc = append(h.acc, f.Data...)
if len(h.acc) < OpusInputBytes {
return 0
}
pcm := make([]int16, SamplesPerOpusFrame*Channels)
PCMBytesToInt16(h.acc[:OpusInputBytes], pcm)
h.acc = h.acc[:0]
n, err := h.enc.Encode(pcm, h.opusOut)
if err != nil {
h.log.WithError(err).Warn("opus encode failed")
return 0
}
// copy because the next encode reuses h.opusOut
payload := make([]byte, n)
copy(payload, h.opusOut[:n])
if err := h.track.WriteSample(media.Sample{
Data: payload,
Duration: OpusFrameMs * time.Millisecond,
}); err != nil {
h.log.WithError(err).Warn("WriteSample failed")
return 0
}
return 1
}

View 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)
}
}

233
calls/audio/xmpp_to_tg.go Normal file
View file

@ -0,0 +1,233 @@
package audio
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
)
type XmppToTgOptions struct {
Ntg NtgClient
ChatID int64 // P2P: equals the Telegram peer's user_id
PrimePackets uint16 // buffered before playout starts; default 2 (~40ms)
Logger *log.Entry
}
// xmpp->tg half: pion RemoteTrack RTP -> opus decode -> ntgcalls
// SendMicrophonePCM. One reader goroutine per HandleTrack, plus one
// playout ticker spawned by Start.
type XmppToTg struct {
opts XmppToTgOptions
log *log.Entry
dec *Decoder
playout *Playout
stop chan struct{}
wg sync.WaitGroup
pcmBuf []int16 // decoder output (worst-case 120ms stereo)
silence []byte // 10ms silence for underrun
closed atomic.Bool
started atomic.Bool
// false until Start runs. Audio between pion OnTrack (during
// SetRemoteDescription) and bridge OnEstablished is drained from
// pion's per-track queue but NOT enqueued - otherwise every packet
// from the setup window would back up and the ticker would replay
// it at real time, manifesting as call-long lag.
livePush atomic.Bool
preLiveDiscards atomic.Int64
capture *captureWriter
}
func NewXmppToTg(opts XmppToTgOptions) (*XmppToTg, error) {
if opts.Ntg == nil {
return nil, errors.New("audio: Ntg required")
}
if opts.ChatID == 0 {
return nil, errors.New("audio: ChatID required")
}
logger := opts.Logger
if logger == nil {
logger = log.WithField("module", "audio")
}
logger = logger.WithFields(log.Fields{"dir": "xmpp->tg", "chat_id": opts.ChatID})
dec, err := NewDecoder()
if err != nil {
return nil, err
}
h := &XmppToTg{
opts: opts,
log: logger,
dec: dec,
playout: NewPlayout(opts.PrimePackets),
pcmBuf: make([]int16, MaxDecodeSamplesPerChannel*Channels),
silence: make([]byte, NtgFrameBytes),
stop: make(chan struct{}),
}
if cap, err := newCaptureWriter(
fmt.Sprintf("xmpp-to-tg-%d-%d.bin", opts.ChatID, time.Now().Unix()),
magicRTP,
); err != nil {
logger.WithError(err).Warn("capture: xmpp->tg writer disabled")
} else if cap != nil {
logger.Info("capture: xmpp->tg RTP capture enabled")
h.capture = cap
}
return h, nil
}
// flips on the ntgcalls Capture source, the playout ticker, and livePush
func (h *XmppToTg) Start() error {
if !h.started.CompareAndSwap(false, true) {
return nil
}
if err := h.opts.Ntg.SetExternalMicrophone(h.opts.ChatID, true, SampleRate, Channels); err != nil {
return fmt.Errorf("SetExternalMicrophone(capture): %w", err)
}
h.livePush.Store(true)
h.wg.Add(1)
go h.tickerLoop()
h.log.WithField("pre_live_discards", h.preLiveDiscards.Load()).
Info("xmpp->tg half started")
return nil
}
// spawns a reader for each pc.OnTrack
func (h *XmppToTg) HandleTrack(t *webrtc.TrackRemote) {
if t == nil || t.Kind() != webrtc.RTPCodecTypeAudio {
return
}
h.log.WithFields(log.Fields{
"codec": t.Codec().MimeType,
"ssrc": t.SSRC(),
}).Info("xmpp->tg: remote audio track received from pion")
h.wg.Add(1)
go h.reader(t)
}
// stops both pumps; ntgcalls Capture+Playback are released by Call.Close
func (h *XmppToTg) Close() error {
if !h.closed.CompareAndSwap(false, true) {
return nil
}
close(h.stop)
h.wg.Wait()
h.capture.close()
h.log.Info("xmpp->tg half closed")
return nil
}
func (h *XmppToTg) reader(t *webrtc.TrackRemote) {
defer h.wg.Done()
// negotiated PT for this track; peer is free to pick anything in 96-127,
// hardcoding 111 would silently drop opus from peers that picked another
expectedPT := uint8(t.PayloadType())
for {
select {
case <-h.stop:
return
default:
}
pkt, _, err := t.ReadRTP()
if err != nil {
return
}
if pkt.PayloadType != expectedPT {
// CN/DTMF/RED can share an SSRC; libopus rejects them
continue
}
if !h.livePush.Load() {
// pre-Start: drain pion's per-track queue but don't enqueue;
// see livePush docstring for the lag rationale
h.preLiveDiscards.Add(1)
continue
}
h.capture.writeRTP(pkt.SSRC, pkt.SequenceNumber, pkt.Timestamp, pkt.PayloadType, pkt.Payload)
h.playout.Push(pkt)
}
}
// cross-tick playout state; separated so tests can drive processTick
// step by step without a time.Ticker
type tickState struct {
lastDecodedSamples int // samples/ch from last Decode; DecodePLC output matches
pending10ms [][]byte // 10ms PCM chunks awaiting shipment, one per tick
}
func newTickState() *tickState {
return &tickState{lastDecodedSamples: SamplesPerOpusFrame}
}
func (h *XmppToTg) tickerLoop() {
defer h.wg.Done()
ticker := time.NewTicker(NtgFrameMs * time.Millisecond)
defer ticker.Stop()
st := newTickState()
for {
select {
case <-h.stop:
return
case <-ticker.C:
}
h.processTick(st)
}
}
// one iteration of the playout loop
func (h *XmppToTg) processTick(st *tickState) {
if len(st.pending10ms) == 0 {
pkt, plcCount, err := h.playout.PopOrSkip()
if err != nil {
// empty or still priming - feed silence
h.sendToNtg(h.silence)
return
}
for i := 0; i < plcCount; i++ {
plcOut := h.pcmBuf[:st.lastDecodedSamples*Channels]
if err := h.dec.DecodePLC(plcOut); err != nil {
h.log.WithError(err).Warn("DecodePLC failed")
break
}
st.pending10ms = appendChunks10ms(st.pending10ms, plcOut)
}
n, err := h.dec.Decode(pkt.Payload, h.pcmBuf)
if err != nil {
h.log.WithError(err).Warn("opus decode failed")
h.sendToNtg(h.silence)
return
}
st.lastDecodedSamples = n
st.pending10ms = appendChunks10ms(st.pending10ms, h.pcmBuf[:n*Channels])
}
chunk := st.pending10ms[0]
st.pending10ms = st.pending10ms[1:]
h.sendToNtg(chunk)
}
// slice interleaved stereo PCM into 10ms chunks; partial trailing chunks
// are dropped (libopus's 2.5/5ms frames aren't multiples of 10ms)
func appendChunks10ms(dst [][]byte, pcm []int16) [][]byte {
samplesPerChunk := SamplesPerNtgFrame * Channels
for off := 0; off+samplesPerChunk <= len(pcm); off += samplesPerChunk {
buf := make([]byte, NtgFrameBytes)
PCMInt16ToBytes(pcm[off:off+samplesPerChunk], buf)
dst = append(dst, buf)
}
return dst
}
func (h *XmppToTg) sendToNtg(pcm []byte) {
if err := h.opts.Ntg.SendMicrophonePCM(h.opts.ChatID, pcm); err != nil {
h.log.WithError(err).Debug("SendMicrophonePCM failed")
}
}

View file

@ -0,0 +1,152 @@
package audio
import (
"testing"
"github.com/pion/rtp"
)
func newTestXmppToTg(t *testing.T) (*XmppToTg, *fakeNtg) {
t.Helper()
n := &fakeNtg{}
h, err := NewXmppToTg(XmppToTgOptions{
Ntg: n,
ChatID: 12345,
})
if err != nil {
t.Fatalf("NewXmppToTg: %v", err)
}
return h, n
}
func TestAppendChunks10ms(t *testing.T) {
// One 20ms decode -> two 10ms chunks.
pcm := make([]int16, SamplesPerOpusFrame*Channels)
got := appendChunks10ms(nil, pcm)
if len(got) != 2 {
t.Fatalf("got %d chunks for 20ms, want 2", len(got))
}
for i, c := range got {
if len(c) != NtgFrameBytes {
t.Errorf("chunk %d len=%d, want %d", i, len(c), NtgFrameBytes)
}
}
// Partial trailing samples are dropped (a 15ms slice yields one 10ms chunk).
partial := make([]int16, (SamplesPerNtgFrame+SamplesPerNtgFrame/2)*Channels)
got = appendChunks10ms(nil, partial)
if len(got) != 1 {
t.Errorf("partial slice: got %d chunks, want 1", len(got))
}
}
// encodeOpusFrame returns a real opus packet encoding a 440Hz sine wave.
func encodeOpusFrame(t *testing.T, enc *Encoder) []byte {
t.Helper()
pcm := makeSineStereo(440)
buf := make([]byte, MaxOpusPacketBytes)
n, err := enc.Encode(pcm, buf)
if err != nil {
t.Fatalf("encode: %v", err)
}
return buf[:n]
}
// rtpWithSeq wraps payload in an RTP packet with the given seq.
func rtpWithSeq(seq uint16, payload []byte) *rtp.Packet {
return &rtp.Packet{
Header: rtp.Header{
SequenceNumber: seq,
PayloadType: opusPayloadType,
},
Payload: payload,
}
}
func TestProcessTickSilenceWhenEmpty(t *testing.T) {
h, n := newTestXmppToTg(t)
st := newTickState()
// Buffer is empty + not primed: each tick should ship one silence frame.
for i := 0; i < 3; i++ {
h.processTick(st)
}
if got := n.sentCount(); got != 3 {
t.Errorf("expected 3 silence frames, got %d", got)
}
// All sent buffers should equal h.silence.
n.mu.Lock()
defer n.mu.Unlock()
for i, p := range n.sentPCM {
for j, x := range p {
if x != 0 {
t.Fatalf("frame %d byte %d = %x, want 0 (silence)", i, j, x)
return
}
}
}
}
func TestProcessTickDecodesAndSplits(t *testing.T) {
h, n := newTestXmppToTg(t)
// Push enough packets to prime the buffer (default 2).
enc, err := NewEncoder()
if err != nil {
t.Fatalf("NewEncoder: %v", err)
}
for seq := uint16(100); seq < 102; seq++ {
h.playout.Push(rtpWithSeq(seq, encodeOpusFrame(t, enc)))
}
st := newTickState()
// First tick: pops packet 100 (20ms PCM = 2 chunks queued), ships chunk 1.
h.processTick(st)
// Second tick: ships chunk 2 from same decode (still pending).
h.processTick(st)
// Third tick: pops packet 101, ships its first chunk.
h.processTick(st)
// Fourth tick: ships packet 101's second chunk.
h.processTick(st)
if got := n.sentCount(); got != 4 {
t.Errorf("expected 4 PCM frames shipped, got %d", got)
}
// Verify each is the right size.
n.mu.Lock()
defer n.mu.Unlock()
for i, p := range n.sentPCM {
if len(p) != NtgFrameBytes {
t.Errorf("frame %d len=%d, want %d", i, len(p), NtgFrameBytes)
}
}
}
func TestProcessTickRunsPLCBeforeGapRecovery(t *testing.T) {
h, _ := newTestXmppToTg(t)
enc, err := NewEncoder()
if err != nil {
t.Fatalf("NewEncoder: %v", err)
}
// Prime with two packets, drain them to leave the buffer empty but
// established (playoutHead = 502).
h.playout.Push(rtpWithSeq(500, encodeOpusFrame(t, enc)))
h.playout.Push(rtpWithSeq(501, encodeOpusFrame(t, enc)))
st := newTickState()
for i := 0; i < 4; i++ { // drain 2 packets * 2 chunks each
h.processTick(st)
}
// Now skip 502, 503 entirely; deliver 504.
h.playout.Push(rtpWithSeq(504, encodeOpusFrame(t, enc)))
// Next tick should detect the gap, run 2 PLC decodes (502 + 503),
// then decode 504. That's 6 chunks queued (2 PLC * 2 chunks + 1 real * 2).
// processTick ships one per tick, so 6 ticks are required to drain.
h.processTick(st) // ships first chunk
// pending should still hold 5 more chunks
if len(st.pending10ms) != 5 {
t.Errorf("after first post-gap tick: pending=%d, want 5", len(st.pending10ms))
}
for i := 0; i < 5; i++ {
h.processTick(st)
}
if len(st.pending10ms) != 0 {
t.Errorf("pending should be drained, got %d", len(st.pending10ms))
}
}

274
calls/orchestrator.go Normal file
View 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
View 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
View 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()
}
}

View 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)
}
})
}
}

View 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()
}
}

View 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()
}

View 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)")
}
})
}

View 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")
})
}

View 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")
})
}

View 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
}

View 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
}
}

View 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
View 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)
}
}

View 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
}
}
}

View 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)
}
}

View 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()
}

View 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
}

View 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()
}

View 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
}

View 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
}

View 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'>")
}
}

View 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
}

View 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)
}

View 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")"

View file

@ -1,13 +1,13 @@
// resolv_shim.c Wrappers for glibc-internal resolver symbols.
// resolv_shim.c -- Wrappers for glibc-internal resolver symbols.
//
// ntgcalls' CMake (cmake/FindGLib.cmake) downloads a pre-built static glib
// from pytgcalls/glib GitHub releases. That binary was compiled on an older
// glibc (manylinux2014 / CentOS 7) where __dn_expand and __res_nquery were
// linkable symbols. On bookworm's glibc 2.36 they are no longer exposed to
// the linker only the public POSIX names (dn_expand, res_nquery) are.
// the linker; only the public POSIX names (dn_expand, res_nquery) are.
//
// Since we don't control the glib build (it's downloaded, not compiled by us),
// the simplest fix is to forward the internal names to the public ones.
// The glib build is downloaded rather than compiled here, so the simplest
// fix is to forward the internal names to the public ones.
#include <resolv.h>
#include <arpa/nameser.h>

30
go.mod
View file

@ -4,13 +4,17 @@ go 1.24.2
require (
github.com/dgraph-io/badger/v4 v4.1.0
github.com/google/uuid v1.1.1
github.com/google/uuid v1.6.0
github.com/pion/interceptor v0.1.44
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v4 v4.2.12
github.com/pkg/errors v0.9.1
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.4.2
github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e
github.com/xdg-go/stringprep v1.0.4
github.com/zelenin/go-tdlib v0.5.2
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
gopkg.in/yaml.v2 v2.2.4
gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1
gotgcalls v0.0.0-00010101000000-000000000000
@ -40,13 +44,29 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect
github.com/pion/ice/v4 v4.2.5 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/sctp v1.9.5 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v5 v5.0.3 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.22.5 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
nhooyr.io/websocket v1.6.5 // indirect
)

80
go.sum
View file

@ -1,10 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 h1:CWAQT74LwQne/3Po5KXDvudu3N0FBWm3XZZZhtl5j2w=
dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU=
dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 h1:29/NjOGOUDceO73Hk4Nj4uVa1je8MULJlsDSvKxSN/k=
dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
dev.narayana.im/narayana/go-xmpp v0.0.0-20250818040038-376b5d77528a h1:9PPqmhy6HbhhCS5EZzw+sdi4EpWW+LOwnz+/JXTcHjQ=
dev.narayana.im/narayana/go-xmpp v0.0.0-20250818040038-376b5d77528a/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
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=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -77,8 +73,9 @@ github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@ -92,8 +89,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxv
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@ -115,6 +113,42 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs=
github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY=
github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -136,9 +170,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@ -146,8 +183,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zelenin/go-tdlib v0.5.2 h1:inEATEM0Pz6/HBI3wTlhd+brDHpmoXGgwdSb8/V6GiA=
github.com/zelenin/go-tdlib v0.5.2/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU=
go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
@ -160,6 +195,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/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-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
@ -182,9 +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-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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -211,22 +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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/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.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -254,11 +290,15 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -7,9 +7,13 @@ import (
"sync"
"time"
"dev.narayana.im/narayana/telegabber/calls/audio/ntgadapter"
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
"dev.narayana.im/narayana/telegabber/config"
"dev.narayana.im/narayana/telegabber/persistence"
"dev.narayana.im/narayana/telegabber/telegram/cache"
"gotgcalls/ntgcalls"
"github.com/zelenin/go-tdlib/client"
"gosrc.io/xmpp"
@ -161,10 +165,57 @@ type Client struct {
MessageIdChanges map[int64]map[int64]*newId
MessageIdChangesLock sync.Mutex
// guards the call trio. Connect() builds it after c.client is live;
// close() tears it down before c.client is closed. Accessors return
// nil when no live session.
ntgCallsLock sync.Mutex
ntgCalls *ntgcalls.Client
tgCallAdapter *tgsig.Adapter
xmppCallAdapter *xmppsig.Adapter
// captured at adapter construction time
callDeps CallDeps
locks clientLocks
SendMessageLock sync.Mutex
}
// per-process call wiring needed to build per-session adapters
type CallDeps struct {
TgManager *tgsig.Manager
XmppConfig xmppsig.AdapterConfig
}
// must be called before Connect()
func (c *Client) SetCallDeps(deps CallDeps) {
c.callDeps = deps
}
// nil if no live session
func (c *Client) CallAdapter() *tgsig.Adapter {
c.ntgCallsLock.Lock()
defer c.ntgCallsLock.Unlock()
return c.tgCallAdapter
}
// nil if no live session
func (c *Client) XmppCallAdapter() *xmppsig.Adapter {
c.ntgCallsLock.Lock()
defer c.ntgCallsLock.Unlock()
return c.xmppCallAdapter
}
// pure construction; caller assigns and locks
func buildCallAdapters(tdClient *client.Client, jid string, deps CallDeps) (
*ntgcalls.Client, *tgsig.Adapter, *xmppsig.Adapter,
) {
ntg := ntgcalls.NTgCalls()
audioNtg := ntgadapter.New(ntg)
tgA := tgsig.New(tdClient, ntg, audioNtg, jid, deps.TgManager)
xmppA := xmppsig.NewAdapter(deps.XmppConfig)
return ntg, tgA, xmppA
}
type clientLocks struct {
authorizationReady sync.Mutex
chatMessageLocks map[int64]*sync.Mutex

View file

@ -135,6 +135,14 @@ func (c *Client) Connect(resource string, wasSessionLoginEmpty bool) error {
c.client = tdlibClient
// build the call trio after c.client is live; close() tears it down
// before c.client is closed
ntg, tgA, xmppA := buildCallAdapters(c.client, c.jid, c.callDeps)
c.ntgCallsLock.Lock()
c.ntgCalls, c.tgCallAdapter, c.xmppCallAdapter = ntg, tgA, xmppA
c.ntgCallsLock.Unlock()
log.Info("ntgcalls client initialized")
// stage 3: if a client is succesfully created, AuthorizationStateReady is already reached
log.Warn("Authorization successful!")
@ -331,6 +339,24 @@ func (c *Client) close() {
}
c.locks.authorizerWriteLock.Unlock()
// tear the call trio down before c.client so accessors see the
// adapter's closed lifecycle (withCallContext bails) instead of a
// stale c.client; the lock serializes with the accessors
c.ntgCallsLock.Lock()
if c.ntgCalls != nil {
if c.tgCallAdapter != nil {
// terminates active calls (closes audio halves synchronously)
// and marks the adapter closed; after this returns, ntgcalls.Free is safe
c.tgCallAdapter.Close()
c.tgCallAdapter = nil
}
c.xmppCallAdapter = nil
c.ntgCalls.Free()
c.ntgCalls = nil
}
c.ntgCallsLock.Unlock()
if c.client != nil {
_, err := c.client.Close()
if err != nil {

View file

@ -83,6 +83,10 @@ func (c *Client) updateHandler() {
for update := range listener.Updates {
if update.GetClass() == client.ClassUpdate {
// debug probe: did UpdateCall reach the listener at all?
if t := update.GetType(); t == client.TypeUpdateCall || t == client.TypeUpdateNewCallSignalingData {
log.WithField("type", t).Debug("updateHandler: call-related update received")
}
switch update.GetType() {
case client.TypeUpdateUser:
typedUpdate, _ := update.(*client.UpdateUser)
@ -139,6 +143,12 @@ func (c *Client) updateHandler() {
case client.TypeUpdateFile:
typedUpdate, _ := update.(*client.UpdateFile)
c.updateFile(typedUpdate)
case client.TypeUpdateCall:
typedUpdate, _ := update.(*client.UpdateCall)
c.callDeps.TgManager.OnUpdateCall(c.jid, typedUpdate)
case client.TypeUpdateNewCallSignalingData:
typedUpdate, _ := update.(*client.UpdateNewCallSignalingData)
c.callDeps.TgManager.OnNewSignalingData(c.jid, typedUpdate)
default:
// log only handled types
continue

View file

@ -1824,6 +1824,11 @@ func (c *Client) getPrefixSeparator(chatId int64) string {
// ProcessIncomingMessage is a legacy wrapper for SendMessageToGateway aiming only PM messages
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)
safeToSend := true
groupChatFrom := ""

View file

@ -4,16 +4,22 @@ import (
"github.com/pkg/errors"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"dev.narayana.im/narayana/telegabber/badger"
"dev.narayana.im/narayana/telegabber/calls"
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
"dev.narayana.im/narayana/telegabber/config"
"dev.narayana.im/narayana/telegabber/persistence"
"dev.narayana.im/narayana/telegabber/telegram"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
"github.com/pion/webrtc/v4"
log "github.com/sirupsen/logrus"
"gosrc.io/xmpp"
"gosrc.io/xmpp/stanza"
@ -28,6 +34,15 @@ var db *persistence.SessionsYamlDB
var componentEverConnected atomic.Bool
var sessionLock sync.Mutex
// call infrastructure, built in NewComponent
var jingleManager *jingle.Manager
var tgManager *tgsig.Manager
var orchestrator *calls.Orchestrator
var callDeps telegram.CallDeps
// latest XEP-0215 result; nil means no extdisco yet, pion uses host candidates only
var iceServers atomic.Pointer[[]webrtc.ICEServer]
const (
B uint64 = 1
KB = B << 10
@ -95,6 +110,35 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
return nil, nil, err
}
// call routers + orchestrator before loadSessions so per-session
// telegram.Clients get wired with both at construction
jingleManager = &jingle.Manager{
LocalJID: gateway.Jid.Bare(),
Sender: component,
}
tgManager = tgsig.NewManager(nil) // incoming hook set after orchestrator exists
orchestrator = calls.New(calls.Config{
JingleManager: jingleManager,
TgManager: tgManager,
Lookup: sessionLookup,
})
// close the cycle; jingleManager.OnProposal was wired by calls.New
tgManager.SetIncomingHandler(orchestrator.NewFromTelegram)
callDeps = telegram.CallDeps{
TgManager: tgManager,
XmppConfig: xmppsig.AdapterConfig{
Sender: component,
LocalJID: gateway.Jid.Bare(),
Manager: jingleManager,
PCFactory: xmppsig.MakePCFactory(func() []webrtc.ICEServer {
if p := iceServers.Load(); p != nil {
return *p
}
return nil
}),
},
}
// probe all known sessions
err = loadSessions(conf.Db, component)
if err != nil {
@ -104,11 +148,43 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
sm := xmpp.NewStreamManager(component, func(s xmpp.Sender) {
componentEverConnected.Store(true)
go heartbeat(component)
go refreshICEServers(s)
})
return sm, component, nil
}
// fetch XEP-0215 STUN/TURN list from the parent server (the c2s server, i.e.
// the component domain with its leftmost label stripped) and store it for
// PCFactory; runs on every (re)connect because TURN creds are short-lived
func refreshICEServers(s xmpp.Sender) {
parent := parentDomain(gateway.Jid.Domain)
if parent == "" {
log.Warnf("extdisco: cannot derive parent server from component JID %q; skipping", gateway.Jid.Domain)
return
}
services, err := xmppsig.QueryServices(s, gateway.Jid.Bare(), parent, 10*time.Second)
if err != nil {
log.Warnf("extdisco: query %s -> %s failed: %v", gateway.Jid.Bare(), parent, err)
return
}
servers := xmppsig.ToICEServers(services)
iceServers.Store(&servers)
log.Infof("extdisco: loaded %d ICE servers from %s (raw services: %d)", len(servers), parent, len(services))
for i, srv := range servers {
log.Debugf("extdisco[%d]: urls=%v hasUser=%t hasCred=%t", i, srv.URLs, srv.Username != "", srv.Credential != nil && srv.Credential != "")
}
}
// strips the leftmost label, e.g. tlgrm.example.com -> example.com;
// "" if there's no dot
func parentDomain(domain string) string {
if i := strings.IndexByte(domain, '.'); i > 0 && i < len(domain)-1 {
return domain[i+1:]
}
return ""
}
func heartbeat(component *xmpp.Component) {
var err error
probeType := gateway.SPType("probe")
@ -226,6 +302,7 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen
log.Error(errors.Wrap(err, "TDlib initialization failure"))
return session, false
}
session.SetCallDeps(callDeps)
if savedSession.KeepOnline {
if err = session.Connect("", false); err != nil {
log.Error(err)
@ -240,6 +317,17 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen
return session, true
}
// orchestrator's SessionLookup callback
func sessionLookup(bareJID string) (*tgsig.Adapter, *xmppsig.Adapter, bool) {
sessionLock.Lock()
cl, ok := sessions[bareJID]
sessionLock.Unlock()
if !ok || cl == nil {
return nil, nil, false
}
return cl.CallAdapter(), cl.XmppCallAdapter(), true
}
// SaveSessions dumps current sessions to the file
func SaveSessions() {
sessionLock.Lock()
@ -253,9 +341,10 @@ func SaveSessions() {
}, persistence.SessionMarshaller)
}
// Close gracefully terminates the component and saves active sessions.
// If the component never reached steady state, SaveSessions is skipped
// to avoid overwriting persisted state with the empty-at-startup map.
// gracefully terminate the component and save active sessions.
// SaveSessions is skipped when componentEverConnected is false to avoid
// overwriting persisted YAML with the empty-at-startup map (seen during
// a tunnel-down crash-loop)
func Close(component *xmpp.Component) {
log.Error("Disconnecting...")

View file

@ -6,6 +6,9 @@ import (
"time"
"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

View file

@ -14,6 +14,7 @@ import (
"dev.narayana.im/narayana/telegabber/telegram"
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@ -37,6 +38,19 @@ func logPacketType(p stanza.Packet) {
log.Warnf("Ignoring packet: %T\n", p)
}
// short-circuits JMI traffic into the call-signaling adapter before the
// body/reply handler runs
func hasJMIExtension(exts []stanza.MsgExtension) bool {
for _, e := range exts {
switch e.(type) {
case *jingle.JMIPropose, *jingle.JMIProceed, *jingle.JMIRinging,
*jingle.JMIReject, *jingle.JMIRetract, *jingle.JMIFinish:
return true
}
}
return false
}
// HandleIq processes an incoming XMPP iq
func HandleIq(s xmpp.Sender, p stanza.Packet) {
iq, ok := p.(*stanza.IQ)
@ -45,6 +59,14 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) {
return
}
// route Jingle IQ stanzas to the jingle manager
if jingleManager != nil {
if _, isJingle := iq.Payload.(*jingle.JingleIQ); isJingle {
jingleManager.HandlePacket(s, p)
return
}
}
log.Debugf("%#v", iq)
if iq.Type == stanza.IQTypeGet {
_, ok := iq.Payload.(*extensions.IqVcardTemp)
@ -171,6 +193,14 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
return
}
// Route JMI (XEP-0353) messages to the process-wide jingle manager first.
// If any of the message's extensions are JMI, the manager consumes it
// and the regular body/reply handling below would be irrelevant.
if jingleManager != nil && hasJMIExtension(msg.Extensions) {
jingleManager.HandlePacket(s, &msg)
return
}
component, ok := s.(*xmpp.Component)
if !ok {
log.Error("Not a component")
@ -1077,6 +1107,19 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
}
disco.AddFeatures(stanza.NSMsgChatMarkers)
disco.AddFeatures(stanza.NSMsgReceipts)
// Jingle / JMI features so clients (e.g. Dino) treat the contact
// as call-capable. Without these, JMI <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 {
disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
disco.AddFeatures("jabber:iq:register")

View 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
View 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"
}

View 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
View 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)
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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{})
}