mirror of
https://dev.narayana.im/narayana/telegabber.git
synced 2026-08-05 04:07:07 +00:00
add calls support
This commit is contained in:
parent
1e57a14a19
commit
0e01dab3c1
62 changed files with 8831 additions and 29 deletions
31
Dockerfile
31
Dockerfile
|
|
@ -31,19 +31,26 @@ RUN cmake -DCMAKE_BUILD_TYPE=Release \
|
|||
/ntgcalls-src/
|
||||
RUN cmake --build . ${MAKEOPTS}
|
||||
|
||||
FROM base AS cache
|
||||
ARG VERSION
|
||||
FROM base AS deps
|
||||
COPY --from=tdlib /compiled/ /usr/local/
|
||||
COPY --from=ntgcalls-build /ntgcalls-src/static-output/lib/libntgcalls.a /usr/local/lib/
|
||||
COPY --from=ntgcalls-build /ntgcalls-src/static-output/include/ /usr/local/include/
|
||||
COPY docker/resolv_shim.c /tmp/resolv_shim.c
|
||||
RUN gcc -c -o /tmp/resolv_shim.o /tmp/resolv_shim.c && \
|
||||
ar rcs /usr/local/lib/libresolv_shim.a /tmp/resolv_shim.o
|
||||
# Needed by gopkg.in/hraban/opus.v2 (cgo pkg-config) and faster final link.
|
||||
# Installed here (not in `base`) to keep tdlib/ntgcalls layers cached.
|
||||
RUN apt-get install -y libopus-dev libopusfile-dev mold
|
||||
# Isolate libntgcalls.a's bundled BoringSSL so its symbols don't displace
|
||||
# system OpenSSL at link time (TDLib was built against the latter).
|
||||
COPY docker/isolate_ntgcalls_crypto.sh /tmp/isolate_ntgcalls_crypto.sh
|
||||
RUN bash /tmp/isolate_ntgcalls_crypto.sh
|
||||
|
||||
FROM deps AS cache
|
||||
COPY ./ /src
|
||||
RUN git -C /src checkout "${VERSION}"
|
||||
RUN git -C /src submodule update --init
|
||||
WORKDIR /src
|
||||
RUN go get
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go get
|
||||
|
||||
FROM cache AS build
|
||||
ARG MAKEOPTS
|
||||
|
|
@ -51,7 +58,21 @@ ENV CGO_ENABLED=1
|
|||
ENV CGO_CFLAGS="-I/usr/local/include"
|
||||
ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv"
|
||||
WORKDIR /src
|
||||
RUN make ${MAKEOPTS}
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
make ${MAKEOPTS}
|
||||
|
||||
FROM deps AS test
|
||||
COPY ./ /src
|
||||
WORKDIR /src
|
||||
RUN git submodule update --init
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go get
|
||||
ENV CGO_ENABLED=1
|
||||
ENV CGO_CFLAGS="-I/usr/local/include"
|
||||
ENV CGO_LDFLAGS="-L/usr/local/lib -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
make test
|
||||
|
||||
FROM scratch AS telegabber
|
||||
COPY --from=build /src/release/telegabber /usr/local/bin/
|
||||
|
|
|
|||
13
Makefile
13
Makefile
|
|
@ -12,17 +12,24 @@ all:
|
|||
mkdir -p release
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="-I$(NTG_INC_DIR)" \
|
||||
CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv" \
|
||||
CGO_LDFLAGS="-L$(NTG_LIB_DIR) -lntgcalls -lresolv_shim -lstdc++ -lm -ldl -lrt -lpthread -lz -lresolv -fuse-ld=mold" \
|
||||
go build -ldflags "-X main.commit=${COMMIT}" -o release/telegabber
|
||||
|
||||
test:
|
||||
go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger
|
||||
go test -race -short -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./xmpp/jingle ./persistence ./telegram/formatter ./badger ./calls ./calls/signaling ./calls/signaling/tgsig ./calls/signaling/xmppsig ./calls/audio
|
||||
|
||||
# Pion-loopback test
|
||||
test_loop:
|
||||
go test -race -v -count=1 -run '^(TestXmppsigPionLoop_|TestHookTrack_|TestSetTrackHandler_)' ./calls/signaling/xmppsig
|
||||
|
||||
test_indocker:
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --target test .
|
||||
|
||||
lint:
|
||||
$(GOPATH)/bin/golint ./...
|
||||
|
||||
build_indocker:
|
||||
docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries .
|
||||
DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries .
|
||||
|
||||
build_indocker_staging:
|
||||
DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --network host --output=release --target binaries -f staging.Dockerfile .
|
||||
|
|
|
|||
15
calls/DESIGN.md
Normal file
15
calls/DESIGN.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Overall flow looks like:
|
||||
```
|
||||
telegram->ntgcalls(opus dec under the hood)->opus enc->pion->xmpp
|
||||
xmpp->pion->opus dec->ntgcalls(opus enc under the hood)->telegram
|
||||
```
|
||||
|
||||
In code:
|
||||
```
|
||||
Call
|
||||
├── Bridge (call state)
|
||||
│ ├── Callee: tgsig|xmppsig.Callee (signaling)
|
||||
│ └── Caller: tgsig|xmppsig.Caller (signaling)
|
||||
├── TgToXmpp (audio)
|
||||
└── XmppToTg (audio)
|
||||
```
|
||||
160
calls/audio/capture.go
Normal file
160
calls/audio/capture.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// optional offline-replay dump of the two media streams; gated by
|
||||
// CALLS_CAPTURE_DIR (no-op when unset). big-endian throughout, ts is ms
|
||||
// since writer construction.
|
||||
// header: [magic 8B][ver u8=1][reserved 7B]
|
||||
// PCM rec (tg->xmpp): [ts_ms i64][ssrc u32][len u32][pcm...]
|
||||
// RTP rec (xmpp->tg): [ts_ms i64][ssrc u32][seq u16][rtp_ts u32][pt u8][len u32][opus...]
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
captureEnvVar = "CALLS_CAPTURE_DIR"
|
||||
captureVer = byte(1)
|
||||
)
|
||||
|
||||
var (
|
||||
magicPCM = [8]byte{'T', 'G', 'C', 'A', 'P', 'P', 'C', 'M'}
|
||||
magicRTP = [8]byte{'T', 'G', 'C', 'A', 'P', 'R', 'T', 'P'}
|
||||
)
|
||||
|
||||
// per-call file sink; nil-safe so call sites can stay branchless
|
||||
type captureWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
start time.Time
|
||||
closed bool
|
||||
}
|
||||
|
||||
// returns (nil, nil) when CALLS_CAPTURE_DIR is unset
|
||||
func newCaptureWriter(name string, magic [8]byte) (*captureWriter, error) {
|
||||
dir := os.Getenv(captureEnvVar)
|
||||
if dir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("capture: mkdir %s: %w", dir, err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capture: create %s: %w", path, err)
|
||||
}
|
||||
var hdr [16]byte
|
||||
copy(hdr[0:8], magic[:])
|
||||
hdr[8] = captureVer
|
||||
if _, err := f.Write(hdr[:]); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("capture: write header: %w", err)
|
||||
}
|
||||
return &captureWriter{f: f, start: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (w *captureWriter) writePCM(ssrc uint32, data []byte) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return
|
||||
}
|
||||
var hdr [16]byte
|
||||
binary.BigEndian.PutUint64(hdr[0:8], uint64(time.Since(w.start).Milliseconds()))
|
||||
binary.BigEndian.PutUint32(hdr[8:12], ssrc)
|
||||
binary.BigEndian.PutUint32(hdr[12:16], uint32(len(data)))
|
||||
_, _ = w.f.Write(hdr[:])
|
||||
_, _ = w.f.Write(data)
|
||||
}
|
||||
|
||||
func (w *captureWriter) writeRTP(ssrc uint32, seq uint16, rtpTS uint32, pt uint8, data []byte) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return
|
||||
}
|
||||
var hdr [23]byte
|
||||
binary.BigEndian.PutUint64(hdr[0:8], uint64(time.Since(w.start).Milliseconds()))
|
||||
binary.BigEndian.PutUint32(hdr[8:12], ssrc)
|
||||
binary.BigEndian.PutUint16(hdr[12:14], seq)
|
||||
binary.BigEndian.PutUint32(hdr[14:18], rtpTS)
|
||||
hdr[18] = pt
|
||||
binary.BigEndian.PutUint32(hdr[19:23], uint32(len(data)))
|
||||
_, _ = w.f.Write(hdr[:])
|
||||
_, _ = w.f.Write(data)
|
||||
}
|
||||
|
||||
func (w *captureWriter) close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
_ = w.f.Close()
|
||||
}
|
||||
|
||||
// one tg->xmpp record from a PCM capture file
|
||||
type CapturedPCM struct {
|
||||
TimestampMs int64
|
||||
SSRC uint32
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func ReadPCMCapture(r io.Reader) ([]CapturedPCM, error) {
|
||||
if err := verifyMagic(r, magicPCM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []CapturedPCM
|
||||
for {
|
||||
var hdr [16]byte
|
||||
_, err := io.ReadFull(r, hdr[:])
|
||||
if errors.Is(err, io.EOF) {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := CapturedPCM{
|
||||
TimestampMs: int64(binary.BigEndian.Uint64(hdr[0:8])),
|
||||
SSRC: binary.BigEndian.Uint32(hdr[8:12]),
|
||||
}
|
||||
length := binary.BigEndian.Uint32(hdr[12:16])
|
||||
rec.Data = make([]byte, length)
|
||||
if _, err := io.ReadFull(r, rec.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyMagic(r io.Reader, want [8]byte) error {
|
||||
var hdr [16]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return fmt.Errorf("capture: read header: %w", err)
|
||||
}
|
||||
var got [8]byte
|
||||
copy(got[:], hdr[0:8])
|
||||
if got != want {
|
||||
return fmt.Errorf("capture: bad magic %q, want %q", string(got[:]), string(want[:]))
|
||||
}
|
||||
if hdr[8] != captureVer {
|
||||
return fmt.Errorf("capture: unsupported version %d", hdr[8])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
142
calls/audio/fmtp.go
Normal file
142
calls/audio/fmtp.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// opus fmtp params from peer SDP.
|
||||
// ApplyTo honors UseInBandFEC, UseDTX, MaxAverageBitrate, MaxPlaybackRate.
|
||||
// CBR/Stereo/Ptime are parsed but not applied (pipeline is fixed shape and
|
||||
// hraban/opus.v2 doesn't wrap OPUS_SET_VBR).
|
||||
type Fmtp struct {
|
||||
UseInBandFEC bool // useinbandfec=1
|
||||
UseDTX bool // usedtx=1
|
||||
CBR bool // cbr=1 (not applied; see type doc)
|
||||
MaxAverageBitrate int // maxaveragebitrate=<bps>, 0 = unspecified
|
||||
MaxPlaybackRate int // maxplaybackrate=<Hz>, 0 = unspecified
|
||||
SpropStereo bool // sprop-stereo=1 (not applied)
|
||||
Stereo bool // stereo=1 (not applied)
|
||||
MinPtime int // ms (not applied)
|
||||
MaxPtime int // ms (not applied)
|
||||
}
|
||||
|
||||
// params half of the opus a=fmtp line; empty if no opus fmtp.
|
||||
// Two-pass because pion may emit rtpmap and fmtp in any order.
|
||||
func ExtractOpusFmtp(sdp string) string {
|
||||
opusPTs := make(map[string]bool)
|
||||
type fmtpEntry struct {
|
||||
pt string
|
||||
params string
|
||||
}
|
||||
var fmtps []fmtpEntry
|
||||
for _, raw := range strings.Split(sdp, "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
if !strings.HasPrefix(line, "a=") {
|
||||
continue
|
||||
}
|
||||
body := line[2:]
|
||||
if rest, ok := strings.CutPrefix(body, "rtpmap:"); ok {
|
||||
// "<pt> opus/48000/2"
|
||||
sp := strings.IndexByte(rest, ' ')
|
||||
if sp < 0 {
|
||||
continue
|
||||
}
|
||||
pt, codec := rest[:sp], rest[sp+1:]
|
||||
if strings.HasPrefix(strings.ToLower(codec), "opus/") {
|
||||
opusPTs[pt] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(body, "fmtp:"); ok {
|
||||
sp := strings.IndexByte(rest, ' ')
|
||||
if sp < 0 {
|
||||
continue
|
||||
}
|
||||
fmtps = append(fmtps, fmtpEntry{pt: rest[:sp], params: rest[sp+1:]})
|
||||
}
|
||||
}
|
||||
for _, f := range fmtps {
|
||||
if opusPTs[f.pt] {
|
||||
return f.params
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unknown keys and malformed numerics are silently ignored; weird SDP
|
||||
// shouldn't kill the call
|
||||
func ParseFmtp(s string) Fmtp {
|
||||
var f Fmtp
|
||||
for _, kv := range strings.Split(s, ";") {
|
||||
kv = strings.TrimSpace(kv)
|
||||
if kv == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(kv[:eq]))
|
||||
val := strings.TrimSpace(kv[eq+1:])
|
||||
switch key {
|
||||
case "useinbandfec":
|
||||
f.UseInBandFEC = val == "1"
|
||||
case "usedtx":
|
||||
f.UseDTX = val == "1"
|
||||
case "cbr":
|
||||
f.CBR = val == "1"
|
||||
case "maxaveragebitrate":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
f.MaxAverageBitrate = n
|
||||
}
|
||||
case "maxplaybackrate":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
f.MaxPlaybackRate = n
|
||||
}
|
||||
case "sprop-stereo":
|
||||
f.SpropStereo = val == "1"
|
||||
case "stereo":
|
||||
f.Stereo = val == "1"
|
||||
case "minptime":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
f.MinPtime = n
|
||||
}
|
||||
case "maxptime":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
f.MaxPtime = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f Fmtp) ApplyTo(e *Encoder) error {
|
||||
bitrate := f.MaxAverageBitrate
|
||||
if bitrate <= 0 {
|
||||
bitrate = DefaultBitrate
|
||||
}
|
||||
if err := e.SetBitrate(bitrate); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.MaxPlaybackRate > 0 {
|
||||
if err := e.SetMaxBandwidth(BandwidthForPlaybackRate(f.MaxPlaybackRate)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if f.UseInBandFEC {
|
||||
if err := e.SetInBandFEC(true); err != nil {
|
||||
return err
|
||||
}
|
||||
// hint to opus, not a guarantee
|
||||
if err := e.SetPacketLossPerc(5); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if f.UseDTX {
|
||||
if err := e.SetDTX(true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
131
calls/audio/fmtp_test.go
Normal file
131
calls/audio/fmtp_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyFmtpConfiguresEncoder(t *testing.T) {
|
||||
// Verify the encoder's libopus state actually reflects the requested
|
||||
// settings, not just that ApplyTo returned nil.
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
// checks accept the encoder and report any mismatches.
|
||||
check func(t *testing.T, e *Encoder)
|
||||
}{
|
||||
{
|
||||
"default bitrate when missing",
|
||||
"",
|
||||
func(t *testing.T, e *Encoder) {
|
||||
br, err := e.Bitrate()
|
||||
if err != nil {
|
||||
t.Fatalf("Bitrate: %v", err)
|
||||
}
|
||||
if br != DefaultBitrate {
|
||||
t.Errorf("Bitrate=%d, want %d", br, DefaultBitrate)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"useinbandfec sets FEC and loss perc",
|
||||
"useinbandfec=1",
|
||||
func(t *testing.T, e *Encoder) {
|
||||
fec, err := e.InBandFEC()
|
||||
if err != nil {
|
||||
t.Fatalf("InBandFEC: %v", err)
|
||||
}
|
||||
if !fec {
|
||||
t.Error("InBandFEC=false, want true")
|
||||
}
|
||||
lp, err := e.PacketLossPerc()
|
||||
if err != nil {
|
||||
t.Fatalf("PacketLossPerc: %v", err)
|
||||
}
|
||||
if lp != 5 {
|
||||
t.Errorf("PacketLossPerc=%d, want 5", lp)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"usedtx sets DTX",
|
||||
"usedtx=1",
|
||||
func(t *testing.T, e *Encoder) {
|
||||
dtx, err := e.DTX()
|
||||
if err != nil {
|
||||
t.Fatalf("DTX: %v", err)
|
||||
}
|
||||
if !dtx {
|
||||
t.Error("DTX=false, want true")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"maxaveragebitrate sets bitrate",
|
||||
"maxaveragebitrate=20000",
|
||||
func(t *testing.T, e *Encoder) {
|
||||
br, err := e.Bitrate()
|
||||
if err != nil {
|
||||
t.Fatalf("Bitrate: %v", err)
|
||||
}
|
||||
if br != 20000 {
|
||||
t.Errorf("Bitrate=%d, want 20000", br)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
enc, err := NewEncoder()
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncoder: %v", err)
|
||||
}
|
||||
if err := ParseFmtp(tc.in).ApplyTo(enc); err != nil {
|
||||
t.Fatalf("Apply(%q): %v", tc.in, err)
|
||||
}
|
||||
tc.check(t, enc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractOpusFmtp(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sdp string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"matched opus pt",
|
||||
"v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 minptime=10;useinbandfec=1\r\n",
|
||||
"minptime=10;useinbandfec=1",
|
||||
},
|
||||
{
|
||||
"rtpmap after fmtp",
|
||||
"a=fmtp:111 minptime=10;useinbandfec=1\r\na=rtpmap:111 opus/48000/2\r\n",
|
||||
"minptime=10;useinbandfec=1",
|
||||
},
|
||||
{
|
||||
"multiple codecs, opus matched",
|
||||
"a=rtpmap:0 PCMU/8000\r\na=rtpmap:111 opus/48000/2\r\na=fmtp:111 stereo=1;sprop-stereo=1\r\na=fmtp:0 unrelated=1\r\n",
|
||||
"stereo=1;sprop-stereo=1",
|
||||
},
|
||||
{
|
||||
"no opus -> empty",
|
||||
"a=rtpmap:0 PCMU/8000\r\na=fmtp:0 unrelated=1\r\n",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"opus without fmtp -> empty",
|
||||
"a=rtpmap:111 opus/48000/2\r\n",
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ExtractOpusFmtp(tc.sdp)
|
||||
if got != tc.want {
|
||||
t.Errorf("ExtractOpusFmtp:\n got %q\nwant %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
29
calls/audio/ntg.go
Normal file
29
calls/audio/ntg.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package audio
|
||||
|
||||
// audio doesn't import ntgcalls directly so it builds and tests without
|
||||
// CGO + libntgcalls; ntgadapter bridges *ntgcalls.Client to NtgClient
|
||||
|
||||
// one 10ms s16-LE interleaved PCM frame from ntgcalls
|
||||
type PCMFrame struct {
|
||||
SSRC uint32
|
||||
Data []byte // 1920 bytes for 48k stereo 10ms
|
||||
}
|
||||
|
||||
// batch handler for PCM frames already filtered down to
|
||||
// (PlaybackStream, MicrophoneStream) by the adapter
|
||||
type NtgFrameHandler func(chatID int64, frames []PCMFrame)
|
||||
|
||||
// bridge's view of ntgcalls
|
||||
type NtgClient interface {
|
||||
// returned cancel drops the handler; the Go ntgcalls binding has no
|
||||
// unregister, so we own the dispatch list ourselves
|
||||
OnFrame(NtgFrameHandler) func()
|
||||
|
||||
// capture=true: gateway sends to peer; capture=false: gateway receives
|
||||
SetExternalMicrophone(chatID int64, capture bool, sampleRate uint32, channels uint8) error
|
||||
|
||||
ClearStreams(chatID int64) error
|
||||
|
||||
// ships one 10ms s16-LE stereo PCM frame
|
||||
SendMicrophonePCM(chatID int64, pcm []byte) error
|
||||
}
|
||||
97
calls/audio/ntgadapter/adapter.go
Normal file
97
calls/audio/ntgadapter/adapter.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// adapts *ntgcalls.Client to audio.NtgClient; sub-package so audio/ stays
|
||||
// buildable without libntgcalls/CGO locally
|
||||
package ntgadapter
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/audio"
|
||||
"gotgcalls/ntgcalls"
|
||||
)
|
||||
|
||||
// wraps *ntgcalls.Client and filters OnFrame to (Playback, Microphone) -
|
||||
// the bridge only wants the peer's audio coming back via the external mic
|
||||
type Adapter struct {
|
||||
c *ntgcalls.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
handlers map[uint64]audio.NtgFrameHandler
|
||||
nextID uint64
|
||||
wired bool
|
||||
}
|
||||
|
||||
func New(c *ntgcalls.Client) *Adapter {
|
||||
return &Adapter{c: c, handlers: make(map[uint64]audio.NtgFrameHandler)}
|
||||
}
|
||||
|
||||
// lazily wires the ntgcalls callback on first call; cancel drops from
|
||||
// the adapter-owned dispatch table (C++ binding's OnFrame is append-only)
|
||||
func (a *Adapter) OnFrame(h audio.NtgFrameHandler) func() {
|
||||
a.mu.Lock()
|
||||
id := a.nextID
|
||||
a.nextID++
|
||||
a.handlers[id] = h
|
||||
wire := !a.wired
|
||||
a.wired = true
|
||||
a.mu.Unlock()
|
||||
|
||||
if wire {
|
||||
a.c.OnFrame(func(chatID int64, mode ntgcalls.StreamMode, device ntgcalls.StreamDevice, frames []ntgcalls.Frame) {
|
||||
if mode != ntgcalls.PlaybackStream || device != ntgcalls.MicrophoneStream {
|
||||
return
|
||||
}
|
||||
pcm := make([]audio.PCMFrame, 0, len(frames))
|
||||
for _, f := range frames {
|
||||
pcm = append(pcm, audio.PCMFrame{SSRC: f.Ssrc, Data: f.Data})
|
||||
}
|
||||
a.mu.RLock()
|
||||
hs := make([]audio.NtgFrameHandler, 0, len(a.handlers))
|
||||
for _, h := range a.handlers {
|
||||
hs = append(hs, h)
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
for _, h := range hs {
|
||||
h(chatID, pcm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return func() {
|
||||
a.mu.Lock()
|
||||
delete(a.handlers, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// uses the Microphone device slot in both directions (ntgcalls P2P pattern)
|
||||
func (a *Adapter) SetExternalMicrophone(chatID int64, capture bool, sampleRate uint32, channels uint8) error {
|
||||
desc := ntgcalls.MediaDescription{
|
||||
Microphone: &ntgcalls.AudioDescription{
|
||||
MediaSource: ntgcalls.MediaSourceExternal,
|
||||
SampleRate: sampleRate,
|
||||
ChannelCount: channels,
|
||||
},
|
||||
}
|
||||
mode := ntgcalls.PlaybackStream
|
||||
if capture {
|
||||
mode = ntgcalls.CaptureStream
|
||||
}
|
||||
return a.c.SetStreamSources(chatID, mode, desc)
|
||||
}
|
||||
|
||||
// errors only if both clears fail; partial cleanup is best-effort
|
||||
func (a *Adapter) ClearStreams(chatID int64) error {
|
||||
empty := ntgcalls.MediaDescription{}
|
||||
capErr := a.c.SetStreamSources(chatID, ntgcalls.CaptureStream, empty)
|
||||
pbErr := a.c.SetStreamSources(chatID, ntgcalls.PlaybackStream, empty)
|
||||
if capErr != nil && pbErr != nil {
|
||||
return capErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) SendMicrophonePCM(chatID int64, pcm []byte) error {
|
||||
return a.c.SendExternalFrame(chatID, ntgcalls.MicrophoneStream, pcm, ntgcalls.FrameData{})
|
||||
}
|
||||
|
||||
var _ audio.NtgClient = (*Adapter)(nil)
|
||||
127
calls/audio/opus.go
Normal file
127
calls/audio/opus.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// bridges PCM frames between an ntgcalls P2P call and pion's opus-over-RTP
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/hraban/opus.v2"
|
||||
)
|
||||
|
||||
// hardcoded for 48kHz / stereo / 20ms / VoIP
|
||||
const (
|
||||
SampleRate = 48000
|
||||
Channels = 2
|
||||
|
||||
OpusFrameMs = 20
|
||||
NtgFrameMs = 10
|
||||
|
||||
// samples per channel
|
||||
SamplesPerOpusFrame = SampleRate * OpusFrameMs / 1000 // 960
|
||||
SamplesPerNtgFrame = SampleRate * NtgFrameMs / 1000 // 480
|
||||
|
||||
// libwebrtc may emit up to 120ms opus frames under load
|
||||
MaxDecodeSamplesPerChannel = SampleRate * 120 / 1000 // 5760
|
||||
|
||||
MaxOpusPacketBytes = 1500
|
||||
NtgFrameBytes = SamplesPerNtgFrame * Channels * 2 // 1920 (s16 stereo)
|
||||
OpusInputBytes = SamplesPerOpusFrame * Channels * 2 // 3840 (s16 stereo)
|
||||
|
||||
// fallback when remote SDP omits maxaveragebitrate
|
||||
DefaultBitrate = 128000
|
||||
)
|
||||
|
||||
// thin opus.v2 wrapper fixed to the 48k/stereo/20ms/VoIP shape; fmtp.go
|
||||
// layers SDP tuning on top. Keeps the CGO opus dep confined to this file.
|
||||
type Encoder struct {
|
||||
enc *opus.Encoder
|
||||
}
|
||||
|
||||
func NewEncoder() (*Encoder, error) {
|
||||
e, err := opus.NewEncoder(SampleRate, Channels, opus.AppVoIP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opus.NewEncoder: %w", err)
|
||||
}
|
||||
if err := e.SetBitrate(DefaultBitrate); err != nil {
|
||||
return nil, fmt.Errorf("opus SetBitrate: %w", err)
|
||||
}
|
||||
return &Encoder{enc: e}, nil
|
||||
}
|
||||
|
||||
// one 20ms stereo frame into out; len(pcm) must be SamplesPerOpusFrame*Channels
|
||||
func (e *Encoder) Encode(pcm []int16, out []byte) (int, error) {
|
||||
if len(pcm) != SamplesPerOpusFrame*Channels {
|
||||
return 0, fmt.Errorf("opus encode: pcm len %d, want %d", len(pcm), SamplesPerOpusFrame*Channels)
|
||||
}
|
||||
return e.enc.Encode(pcm, out)
|
||||
}
|
||||
|
||||
func (e *Encoder) SetInBandFEC(on bool) error { return e.enc.SetInBandFEC(on) }
|
||||
func (e *Encoder) SetDTX(on bool) error { return e.enc.SetDTX(on) }
|
||||
func (e *Encoder) SetBitrate(bps int) error { return e.enc.SetBitrate(bps) }
|
||||
func (e *Encoder) SetPacketLossPerc(p int) error { return e.enc.SetPacketLossPerc(p) }
|
||||
func (e *Encoder) SetMaxBandwidth(bw opus.Bandwidth) error {
|
||||
return e.enc.SetMaxBandwidth(bw)
|
||||
}
|
||||
|
||||
// getters for tests asserting fmtp took effect
|
||||
func (e *Encoder) InBandFEC() (bool, error) { return e.enc.InBandFEC() }
|
||||
func (e *Encoder) DTX() (bool, error) { return e.enc.DTX() }
|
||||
func (e *Encoder) Bitrate() (int, error) { return e.enc.Bitrate() }
|
||||
func (e *Encoder) PacketLossPerc() (int, error) { return e.enc.PacketLossPerc() }
|
||||
func (e *Encoder) MaxBandwidth() (opus.Bandwidth, error) { return e.enc.MaxBandwidth() }
|
||||
|
||||
type Decoder struct {
|
||||
dec *opus.Decoder
|
||||
}
|
||||
|
||||
func NewDecoder() (*Decoder, error) {
|
||||
d, err := opus.NewDecoder(SampleRate, Channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opus.NewDecoder: %w", err)
|
||||
}
|
||||
return &Decoder{dec: d}, nil
|
||||
}
|
||||
|
||||
// one opus packet -> interleaved s16 stereo PCM; out sized for max frame
|
||||
func (d *Decoder) Decode(data []byte, out []int16) (int, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, errors.New("opus decode: empty packet")
|
||||
}
|
||||
return d.dec.Decode(data, out)
|
||||
}
|
||||
|
||||
// one PLC frame; out sized to the last successful Decode's frame
|
||||
func (d *Decoder) DecodePLC(out []int16) error {
|
||||
return d.dec.DecodePLC(out)
|
||||
}
|
||||
|
||||
// SDP maxplaybackrate -> opus.Bandwidth (RFC 7587 §6.1)
|
||||
func BandwidthForPlaybackRate(hz int) opus.Bandwidth {
|
||||
switch {
|
||||
case hz <= 8000:
|
||||
return opus.Narrowband
|
||||
case hz <= 12000:
|
||||
return opus.Mediumband
|
||||
case hz <= 16000:
|
||||
return opus.Wideband
|
||||
case hz <= 24000:
|
||||
return opus.SuperWideband
|
||||
default:
|
||||
return opus.Fullband
|
||||
}
|
||||
}
|
||||
|
||||
// interleaved s16-LE bytes <-> int16 samples; dst must be sized correctly
|
||||
func PCMBytesToInt16(src []byte, dst []int16) {
|
||||
for i := range dst {
|
||||
dst[i] = int16(binary.LittleEndian.Uint16(src[i*2:]))
|
||||
}
|
||||
}
|
||||
|
||||
func PCMInt16ToBytes(src []int16, dst []byte) {
|
||||
for i, s := range src {
|
||||
binary.LittleEndian.PutUint16(dst[i*2:], uint16(s))
|
||||
}
|
||||
}
|
||||
48
calls/audio/opus_test.go
Normal file
48
calls/audio/opus_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 20ms interleaved stereo sine; both channels identical
|
||||
func makeSineStereo(freqHz float64) []int16 {
|
||||
pcm := make([]int16, SamplesPerOpusFrame*Channels)
|
||||
for i := 0; i < SamplesPerOpusFrame; i++ {
|
||||
v := int16(math.Sin(2*math.Pi*freqHz*float64(i)/SampleRate) * 16000)
|
||||
pcm[i*2] = v
|
||||
pcm[i*2+1] = v
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
|
||||
func TestEncodeDecodeRoundtrip(t *testing.T) {
|
||||
enc, err := NewEncoder()
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncoder: %v", err)
|
||||
}
|
||||
dec, err := NewDecoder()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDecoder: %v", err)
|
||||
}
|
||||
|
||||
in := makeSineStereo(440)
|
||||
pkt := make([]byte, MaxOpusPacketBytes)
|
||||
n, err := enc.Encode(in, pkt)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode: %v", err)
|
||||
}
|
||||
if n <= 0 || n > MaxOpusPacketBytes {
|
||||
t.Fatalf("Encode returned %d bytes", n)
|
||||
}
|
||||
|
||||
out := make([]int16, MaxDecodeSamplesPerChannel*Channels)
|
||||
samplesPerCh, err := dec.Decode(pkt[:n], out)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if samplesPerCh != SamplesPerOpusFrame {
|
||||
t.Errorf("Decode produced %d samples/ch, want %d", samplesPerCh, SamplesPerOpusFrame)
|
||||
}
|
||||
}
|
||||
|
||||
76
calls/audio/playout.go
Normal file
76
calls/audio/playout.go
Normal 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
|
||||
}
|
||||
94
calls/audio/playout_test.go
Normal file
94
calls/audio/playout_test.go
Normal 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
187
calls/audio/tg_to_xmpp.go
Normal 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
|
||||
}
|
||||
125
calls/audio/tg_to_xmpp_test.go
Normal file
125
calls/audio/tg_to_xmpp_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package audio
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// shared between tg->xmpp and xmpp->tg tests
|
||||
type fakeNtg struct {
|
||||
mu sync.Mutex
|
||||
handler NtgFrameHandler
|
||||
unregistered int // count of cancel() invocations returned by OnFrame
|
||||
sentPCM [][]byte
|
||||
micCalls int
|
||||
clears int
|
||||
micCallCapture []bool // ordered record of capture-flag arg per SetExternalMicrophone call
|
||||
sendErr error // if set, SendMicrophonePCM returns this
|
||||
}
|
||||
|
||||
// stashes handler so tests can dispatch directly; cancel just bumps a
|
||||
// counter (handler stays reachable so tests can exercise closed-flag guards)
|
||||
func (f *fakeNtg) OnFrame(h NtgFrameHandler) func() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.handler = h
|
||||
return func() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.unregistered++
|
||||
}
|
||||
}
|
||||
func (f *fakeNtg) SetExternalMicrophone(_ int64, capture bool, _ uint32, _ uint8) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.micCalls++
|
||||
f.micCallCapture = append(f.micCallCapture, capture)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNtg) ClearStreams(int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.clears++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNtg) SendMicrophonePCM(_ int64, pcm []byte) error {
|
||||
cp := make([]byte, len(pcm))
|
||||
copy(cp, pcm)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
err := f.sendErr
|
||||
f.sentPCM = append(f.sentPCM, cp)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeNtg) sentCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.sentPCM)
|
||||
}
|
||||
|
||||
func newTestTgToXmpp(t *testing.T) (*TgToXmpp, *fakeNtg) {
|
||||
t.Helper()
|
||||
n := &fakeNtg{}
|
||||
h, err := NewTgToXmpp(TgToXmppOptions{
|
||||
Ntg: n,
|
||||
ChatID: 12345,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTgToXmpp: %v", err)
|
||||
}
|
||||
return h, n
|
||||
}
|
||||
|
||||
func TestTgToXmppStartConfiguresPlayback(t *testing.T) {
|
||||
h, n := newTestTgToXmpp(t)
|
||||
if err := h.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer h.Close()
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.micCalls != 1 {
|
||||
t.Errorf("SetExternalMicrophone called %d times, want 1 (playback only)", n.micCalls)
|
||||
}
|
||||
if len(n.micCallCapture) != 1 || n.micCallCapture[0] {
|
||||
t.Errorf("expected capture=false (playback) for tg->xmpp, got %v", n.micCallCapture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgToXmppCloseStopsCallbackHandling(t *testing.T) {
|
||||
h, n := newTestTgToXmpp(t)
|
||||
if err := h.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := h.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Post-close dispatches must be ignored.
|
||||
frame := PCMFrame{SSRC: 1, Data: make([]byte, NtgFrameBytes)}
|
||||
n.handler(h.opts.ChatID, []PCMFrame{frame, frame, frame, frame})
|
||||
if got := len(h.acc); got != 0 {
|
||||
t.Errorf("closed half accumulated %d bytes, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// mirrors ntgcalls' callback-per-goroutine dispatch; run with -race
|
||||
func TestTgToXmppFeedFrameConcurrent(t *testing.T) {
|
||||
h, _ := newTestTgToXmpp(t)
|
||||
const N = 100
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
f := PCMFrame{SSRC: 42, Data: make([]byte, NtgFrameBytes)}
|
||||
h.feedFrame(f)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if got := len(h.acc); got != 0 && got != NtgFrameBytes {
|
||||
t.Errorf("acc len=%d after %d concurrent feeds; want 0 or %d", got, N, NtgFrameBytes)
|
||||
}
|
||||
}
|
||||
233
calls/audio/xmpp_to_tg.go
Normal file
233
calls/audio/xmpp_to_tg.go
Normal 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")
|
||||
}
|
||||
}
|
||||
152
calls/audio/xmpp_to_tg_test.go
Normal file
152
calls/audio/xmpp_to_tg_test.go
Normal 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
274
calls/orchestrator.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// turns protocol events into bridged calls
|
||||
package calls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/audio"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// look up per-session adapters by bare jid; ok=false drops the call
|
||||
type SessionLookup func(bareJID string) (tg *tgsig.Adapter, xmpp *xmppsig.Adapter, ok bool)
|
||||
|
||||
type Config struct {
|
||||
JingleManager *jingle.Manager
|
||||
TgManager *tgsig.Manager
|
||||
Lookup SessionLookup
|
||||
|
||||
// zero falls through to signaling defaults (60s/20s/15s)
|
||||
RingTimeout time.Duration
|
||||
ExchangeTimeout time.Duration
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
type Orchestrator struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(cfg Config) *Orchestrator {
|
||||
o := &Orchestrator{cfg: cfg}
|
||||
if cfg.JingleManager != nil {
|
||||
cfg.JingleManager.OnProposal = o.onProposal
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// per-side surfaces the audio layer needs
|
||||
type tgAudioSide interface {
|
||||
NtgClient() audio.NtgClient
|
||||
ChatID() int64
|
||||
}
|
||||
|
||||
type xmppAudioSide interface {
|
||||
PeerConnection() *webrtc.PeerConnection
|
||||
// swap in the orchestrator's track handler and drain buffered tracks
|
||||
SetTrackHandler(fn func(t *webrtc.TrackRemote))
|
||||
}
|
||||
|
||||
type Call struct {
|
||||
bridge *signaling.Bridge
|
||||
|
||||
mu sync.Mutex
|
||||
tgSide tgAudioSide
|
||||
xmppSide xmppAudioSide
|
||||
audioTgToXmpp *audio.TgToXmpp
|
||||
audioXmppToTg *audio.XmppToTg
|
||||
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (c *Call) Bridge() *signaling.Bridge { return c.bridge }
|
||||
|
||||
// build the audio halves once the negotiated opus fmtp is in the SDP
|
||||
func (c *Call) setupAudio(remoteSDP string) {
|
||||
remoteFmtp := audio.ExtractOpusFmtp(remoteSDP)
|
||||
c.mu.Lock()
|
||||
if c.audioTgToXmpp != nil || c.audioXmppToTg != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
tgSide, xmppSide := c.tgSide, c.xmppSide
|
||||
c.mu.Unlock()
|
||||
if tgSide == nil || xmppSide == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ntg := tgSide.NtgClient()
|
||||
pc := xmppSide.PeerConnection()
|
||||
if ntg == nil || pc == nil {
|
||||
// signaling-only test setup
|
||||
return
|
||||
}
|
||||
chatID := tgSide.ChatID()
|
||||
|
||||
tgToXmpp, err := audio.NewTgToXmpp(audio.TgToXmppOptions{
|
||||
Ntg: ntg,
|
||||
ChatID: chatID,
|
||||
RemoteFmtp: remoteFmtp,
|
||||
})
|
||||
if err != nil {
|
||||
log.WithError(err).Error("setupAudio: NewTgToXmpp")
|
||||
return
|
||||
}
|
||||
xmppToTg, err := audio.NewXmppToTg(audio.XmppToTgOptions{
|
||||
Ntg: ntg,
|
||||
ChatID: chatID,
|
||||
})
|
||||
if err != nil {
|
||||
_ = tgToXmpp.Close()
|
||||
log.WithError(err).Error("setupAudio: NewXmppToTg")
|
||||
return
|
||||
}
|
||||
|
||||
if err := attachOutgoingTrack(pc, tgToXmpp.LocalTrack()); err != nil {
|
||||
log.WithField("chat_id", chatID).WithError(err).Warn("setupAudio: attach outgoing track failed")
|
||||
}
|
||||
xmppSide.SetTrackHandler(xmppToTg.HandleTrack)
|
||||
log.WithFields(log.Fields{
|
||||
"chat_id": chatID,
|
||||
"remote_fmtp": remoteFmtp,
|
||||
}).Info("setupAudio: halves wired; awaiting startAudio on bridge OnEstablished")
|
||||
|
||||
c.mu.Lock()
|
||||
c.audioTgToXmpp = tgToXmpp
|
||||
c.audioXmppToTg = xmppToTg
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// swap the real outgoing track into the placeholder sender from buildPC
|
||||
func attachOutgoingTrack(pc *webrtc.PeerConnection, track *webrtc.TrackLocalStaticSample) error {
|
||||
for _, s := range pc.GetSenders() {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.ReplaceTrack(track); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no sender accepted track")
|
||||
}
|
||||
|
||||
// fired by Bridge.OnEstablished
|
||||
func (c *Call) startAudio() {
|
||||
c.mu.Lock()
|
||||
tgHalf := c.audioTgToXmpp
|
||||
xmppHalf := c.audioXmppToTg
|
||||
c.mu.Unlock()
|
||||
if tgHalf == nil || xmppHalf == nil {
|
||||
return
|
||||
}
|
||||
if err := tgHalf.Start(); err != nil {
|
||||
log.WithError(err).Warn("tg->xmpp audio Start failed")
|
||||
}
|
||||
if err := xmppHalf.Start(); err != nil {
|
||||
log.WithError(err).Warn("xmpp->tg audio Start failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Call) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
tgHalf := c.audioTgToXmpp
|
||||
xmppHalf := c.audioXmppToTg
|
||||
tgSide := c.tgSide
|
||||
c.audioTgToXmpp = nil
|
||||
c.audioXmppToTg = nil
|
||||
c.mu.Unlock()
|
||||
if tgHalf != nil {
|
||||
_ = tgHalf.Close()
|
||||
}
|
||||
if xmppHalf != nil {
|
||||
_ = xmppHalf.Close()
|
||||
}
|
||||
// release ntg streams set in setupAudio; both halves are built
|
||||
// together, skipped in signaling-only test setups
|
||||
if (tgHalf != nil || xmppHalf != nil) && tgSide != nil {
|
||||
if ntg := tgSide.NtgClient(); ntg != nil {
|
||||
_ = ntg.ClearStreams(tgSide.ChatID())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// inbound JMI <propose>; postReady fires after the session is in StateRinging
|
||||
// so a fast tdlib Pending{IsReceived:true} doesn't drop the JMI <ringing/>
|
||||
func (o *Orchestrator) onProposal(p jingle.IncomingProposal) (*jingle.Session, func(), error) {
|
||||
j, err := stanza.NewJid(p.From)
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
tg, xmpp, ok := o.cfg.Lookup(j.Bare())
|
||||
if !ok || tg == nil || xmpp == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
userID, ok := xmppsig.ParseTargetJID(p.To)
|
||||
if !ok {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
callee, sess, err := xmpp.NewCallee(p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
caller := tg.NewCaller(userID, false) // audio only
|
||||
|
||||
call := &Call{tgSide: caller, xmppSide: callee}
|
||||
br := o.newBridge(caller, callee, call)
|
||||
call.bridge = br
|
||||
callee.Bind(br)
|
||||
caller.Bind(br)
|
||||
|
||||
// defer audio half construction until SDP lands so a pre-SDP cancel
|
||||
// doesn't spin up ntgcalls
|
||||
callee.SetOnRemoteSDP(call.setupAudio)
|
||||
|
||||
// off the dispatcher goroutine - Caller.Start hits tdlib CreateCall
|
||||
return sess, func() { go br.Start() }, nil
|
||||
}
|
||||
|
||||
// IncomingCallHandler for tgsig.Manager
|
||||
func (o *Orchestrator) NewFromTelegram(jid string, callID int32, userID int64, isVideo bool) (ok bool) {
|
||||
entry := log.WithFields(log.Fields{
|
||||
"jid": jid,
|
||||
"call_id": callID,
|
||||
"user_id": userID,
|
||||
"is_video": isVideo,
|
||||
})
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
entry.WithField("panic", r).Error("NewFromTelegram: panic recovered")
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
|
||||
tg, xmpp, lookupOk := o.cfg.Lookup(jid)
|
||||
if !lookupOk || tg == nil || xmpp == nil {
|
||||
entry.Warn("NewFromTelegram: no live session for jid")
|
||||
return false
|
||||
}
|
||||
|
||||
// xmpp side first - it's the only fallible step, otherwise a failure here
|
||||
// would leak the tg side until tdlib timed the call out
|
||||
caller, err := xmpp.NewCaller(jid, userID)
|
||||
if err != nil {
|
||||
entry.WithError(err).Error("NewFromTelegram: NewCaller failed")
|
||||
return false
|
||||
}
|
||||
callee := tg.NewCallee(callID, userID, isVideo)
|
||||
|
||||
call := &Call{tgSide: callee, xmppSide: caller}
|
||||
br := o.newBridge(caller, callee, call)
|
||||
call.bridge = br
|
||||
callee.Bind(br)
|
||||
caller.Bind(br)
|
||||
|
||||
caller.SetOnRemoteSDP(call.setupAudio)
|
||||
br.Start()
|
||||
return true
|
||||
}
|
||||
|
||||
func (o *Orchestrator) newBridge(caller signaling.Caller, callee signaling.Callee, call *Call) *signaling.Bridge {
|
||||
timers := signaling.NewStdTimers()
|
||||
br := signaling.New(signaling.Config{
|
||||
Caller: caller,
|
||||
Callee: callee,
|
||||
Timers: timers,
|
||||
RingTimeout: o.cfg.RingTimeout,
|
||||
ExchangeTimeout: o.cfg.ExchangeTimeout,
|
||||
ConnectTimeout: o.cfg.ConnectTimeout,
|
||||
OnEstablished: call.startAudio,
|
||||
OnTerminated: call.Close,
|
||||
})
|
||||
timers.SetBridge(br)
|
||||
return br
|
||||
}
|
||||
376
calls/orchestrator_test.go
Normal file
376
calls/orchestrator_test.go
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
package calls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
"gotgcalls/ntgcalls"
|
||||
)
|
||||
|
||||
type captureSender struct {
|
||||
mu sync.Mutex
|
||||
sent []stanza.Packet
|
||||
}
|
||||
|
||||
func (c *captureSender) Send(p stanza.Packet) error {
|
||||
c.mu.Lock()
|
||||
c.sent = append(c.sent, p)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
|
||||
ch := make(chan stanza.IQ, 1)
|
||||
return ch, c.Send(iq)
|
||||
}
|
||||
|
||||
func (c *captureSender) sentCopy() []stanza.Packet {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := make([]stanza.Packet, len(c.sent))
|
||||
copy(out, c.sent)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *captureSender) hasExt(match func(stanza.MsgExtension) bool) bool {
|
||||
for _, p := range c.sentCopy() {
|
||||
msg, ok := p.(*stanza.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, ext := range msg.Extensions {
|
||||
if match(ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeTdlib struct {
|
||||
nextCallID atomic.Int32
|
||||
|
||||
created chan *client.CreateCallRequest
|
||||
accepted chan *client.AcceptCallRequest
|
||||
discard chan *client.DiscardCallRequest
|
||||
}
|
||||
|
||||
func newFakeTdlib() *fakeTdlib {
|
||||
return &fakeTdlib{
|
||||
created: make(chan *client.CreateCallRequest, 4),
|
||||
accepted: make(chan *client.AcceptCallRequest, 4),
|
||||
discard: make(chan *client.DiscardCallRequest, 4),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeTdlib) CreateCall(req *client.CreateCallRequest) (*client.CallId, error) {
|
||||
id := f.nextCallID.Add(1)
|
||||
select {
|
||||
case f.created <- req:
|
||||
default:
|
||||
}
|
||||
return &client.CallId{Id: id}, nil
|
||||
}
|
||||
func (f *fakeTdlib) AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) {
|
||||
select {
|
||||
case f.accepted <- req:
|
||||
default:
|
||||
}
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
func (f *fakeTdlib) DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) {
|
||||
select {
|
||||
case f.discard <- req:
|
||||
default:
|
||||
}
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
func (f *fakeTdlib) SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) {
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
|
||||
// fakeNtg is a no-op: orchestrator tests don't exercise media setup.
|
||||
type fakeNtg struct{}
|
||||
|
||||
func (f *fakeNtg) OnSignal(ntgcalls.SignalCallback) {}
|
||||
func (f *fakeNtg) OnConnectionChange(ntgcalls.ConnectionChangeCallback) {}
|
||||
func (f *fakeNtg) CreateP2PCall(int64) error { return nil }
|
||||
func (f *fakeNtg) SkipExchange(int64, []byte, bool) error { return nil }
|
||||
func (f *fakeNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNtg) SendSignalingData(int64, []byte) error { return nil }
|
||||
func (f *fakeNtg) Stop(int64) error { return nil }
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
sender *captureSender
|
||||
tdlib *fakeTdlib
|
||||
ntg *fakeNtg
|
||||
jingleMgr *jingle.Manager
|
||||
tgMgr *tgsig.Manager
|
||||
tgAdapter *tgsig.Adapter
|
||||
xmppAd *xmppsig.Adapter
|
||||
orch *Orchestrator
|
||||
userBare string
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, opts ...func(*Config)) *fixture {
|
||||
t.Helper()
|
||||
sender := &captureSender{}
|
||||
tdlib := newFakeTdlib()
|
||||
ntg := &fakeNtg{}
|
||||
|
||||
jm := &jingle.Manager{LocalJID: "gw.example", Sender: sender}
|
||||
tgMgr := tgsig.NewManager(nil)
|
||||
|
||||
const userBare = "user@example"
|
||||
tgAdapter := tgsig.New(tdlib, ntg, nil, userBare, tgMgr)
|
||||
xmppAd := xmppsig.NewAdapter(xmppsig.AdapterConfig{
|
||||
Sender: sender,
|
||||
LocalJID: "gw.example",
|
||||
Manager: jm,
|
||||
PCFactory: testPCFactory(t),
|
||||
})
|
||||
|
||||
lookup := func(jid string) (*tgsig.Adapter, *xmppsig.Adapter, bool) {
|
||||
if jid != userBare {
|
||||
return nil, nil, false
|
||||
}
|
||||
return tgAdapter, xmppAd, true
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
JingleManager: jm,
|
||||
TgManager: tgMgr,
|
||||
Lookup: lookup,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
orch := New(cfg)
|
||||
tgMgr.SetIncomingHandler(orch.NewFromTelegram)
|
||||
|
||||
return &fixture{
|
||||
t: t,
|
||||
sender: sender,
|
||||
tdlib: tdlib,
|
||||
ntg: ntg,
|
||||
jingleMgr: jm,
|
||||
tgMgr: tgMgr,
|
||||
tgAdapter: tgAdapter,
|
||||
xmppAd: xmppAd,
|
||||
orch: orch,
|
||||
userBare: userBare,
|
||||
}
|
||||
}
|
||||
|
||||
func testPCFactory(t *testing.T) func() (*webrtc.PeerConnection, error) {
|
||||
return func() (*webrtc.PeerConnection, error) {
|
||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Cleanup(func() { _ = pc.Close() })
|
||||
return pc, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingProposeFiresTelegramCreateCall(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-in-1"))
|
||||
|
||||
select {
|
||||
case req := <-fx.tdlib.created:
|
||||
if req.UserId != 12345 {
|
||||
t.Fatalf("CreateCall userID = %d, want 12345", req.UserId)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("tdlib.CreateCall never invoked")
|
||||
}
|
||||
|
||||
// No <ringing> should have been auto-sent: Observer is wired.
|
||||
if fx.sender.hasExt(func(e stanza.MsgExtension) bool { _, ok := e.(*jingle.JMIRinging); return ok }) {
|
||||
t.Fatalf("expected no auto-<ringing>; observer should gate it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetractTearsDownTelegramPeer(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-rj-1"))
|
||||
|
||||
select {
|
||||
case <-fx.tdlib.created:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("tdlib.CreateCall never invoked")
|
||||
}
|
||||
|
||||
// Caller retracts (they originated; <retract/> is the cancel verb).
|
||||
fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIRetract{ID: "sid-rj-1"}))
|
||||
|
||||
select {
|
||||
case <-fx.tdlib.discard:
|
||||
// good
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("tdlib.DiscardCall never invoked after <retract>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingTimeoutTearsDownBothSides(t *testing.T) {
|
||||
fx := newFixture(t, func(c *Config) { c.RingTimeout = 100 * time.Millisecond })
|
||||
|
||||
fx.jingleMgr.HandlePacket(nil, buildProposeMessage(fx.userBare+"/r", "12345@gw.example", "sid-to-1"))
|
||||
|
||||
select {
|
||||
case <-fx.tdlib.created:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("tdlib.CreateCall never invoked")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-fx.tdlib.discard:
|
||||
// good
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("tdlib.DiscardCall never invoked on ring timeout")
|
||||
}
|
||||
|
||||
waitForCond(t, "reject emitted", 1*time.Second, func() bool {
|
||||
return fx.sender.hasExt(func(e stanza.MsgExtension) bool {
|
||||
_, ok := e.(*jingle.JMIReject)
|
||||
return ok
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewFromTelegramSendsPropose(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: 42,
|
||||
UserId: 67890,
|
||||
IsOutgoing: false,
|
||||
IsVideo: false,
|
||||
State: &client.CallStatePending{},
|
||||
},
|
||||
})
|
||||
|
||||
waitForCond(t, "propose emitted", 1*time.Second, func() bool {
|
||||
return fx.sender.hasExt(func(e stanza.MsgExtension) bool {
|
||||
_, ok := e.(*jingle.JMIPropose)
|
||||
return ok
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTelegramOriginatedProceedFiresTdlibAccept(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: 42,
|
||||
UserId: 67890,
|
||||
IsOutgoing: false,
|
||||
State: &client.CallStatePending{},
|
||||
},
|
||||
})
|
||||
|
||||
sid := waitForProposedSID(t, fx.sender, 1*time.Second)
|
||||
|
||||
fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIProceed{ID: sid}))
|
||||
|
||||
select {
|
||||
case req := <-fx.tdlib.accepted:
|
||||
if req.CallId != 42 {
|
||||
t.Fatalf("AcceptCall callID = %d, want 42", req.CallId)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("tdlib.AcceptCall never invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramOriginatedRejectDiscardsTelegramSide(t *testing.T) {
|
||||
fx := newFixture(t)
|
||||
|
||||
fx.tgMgr.OnUpdateCall(fx.userBare, &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: 99,
|
||||
UserId: 67890,
|
||||
IsOutgoing: false,
|
||||
State: &client.CallStatePending{},
|
||||
},
|
||||
})
|
||||
|
||||
sid := waitForProposedSID(t, fx.sender, 1*time.Second)
|
||||
|
||||
fx.jingleMgr.HandlePacket(nil, buildJMIMessage(fx.userBare+"/r", "gw.example", &jingle.JMIReject{ID: sid}))
|
||||
|
||||
select {
|
||||
case req := <-fx.tdlib.discard:
|
||||
if req.CallId != 99 {
|
||||
t.Fatalf("DiscardCall callID = %d, want 99", req.CallId)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("tdlib.DiscardCall never invoked after <reject>")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProposedSID(t *testing.T, s *captureSender, d time.Duration) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, p := range s.sentCopy() {
|
||||
msg, ok := p.(*stanza.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, ext := range msg.Extensions {
|
||||
if pr, ok := ext.(*jingle.JMIPropose); ok {
|
||||
return pr.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waitForProposedSID: timeout after %v", d)
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForCond(t *testing.T, what string, d time.Duration, fn func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if fn() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waitForCond(%s): timeout after %v", what, d)
|
||||
}
|
||||
|
||||
func buildProposeMessage(from, to, sid string) *stanza.Message {
|
||||
m := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, From: from, To: to, Id: "m-" + sid})
|
||||
m.Extensions = []stanza.MsgExtension{&jingle.JMIPropose{
|
||||
ID: sid,
|
||||
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
|
||||
}}
|
||||
return &m
|
||||
}
|
||||
|
||||
func buildJMIMessage(from, to string, ext stanza.MsgExtension) *stanza.Message {
|
||||
m := stanza.NewMessage(stanza.Attrs{Type: stanza.MessageTypeChat, From: from, To: to, Id: "m-jmi"})
|
||||
m.Extensions = []stanza.MsgExtension{ext}
|
||||
return &m
|
||||
}
|
||||
249
calls/signaling/bridge.go
Normal file
249
calls/signaling/bridge.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// coordinates one bridged call between a caller and a callee
|
||||
package signaling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type State uint8
|
||||
|
||||
const (
|
||||
StateIdle State = iota
|
||||
StateOriginating
|
||||
StateRinging
|
||||
StateAccepted
|
||||
StateEstablished
|
||||
StateTerminated
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
names := [...]string{"idle", "originating", "ringing", "accepted", "established", "terminated"}
|
||||
if int(s) < len(names) {
|
||||
return names[s]
|
||||
}
|
||||
return fmt.Sprintf("state(%d)", uint8(s))
|
||||
}
|
||||
|
||||
type TerminationReason uint8
|
||||
|
||||
const (
|
||||
ReasonUnknown TerminationReason = iota
|
||||
ReasonHangup
|
||||
ReasonDecline
|
||||
ReasonPeerGone
|
||||
ReasonRingTimeout
|
||||
ReasonExchangeTimeout
|
||||
ReasonConnectTimeout
|
||||
ReasonMediaFailed
|
||||
ReasonBusy
|
||||
)
|
||||
|
||||
type TimerKind uint8
|
||||
|
||||
const (
|
||||
TimerRing TimerKind = iota
|
||||
TimerExchange
|
||||
TimerConnect
|
||||
)
|
||||
|
||||
// which transport reported a media event; bridge waits for both
|
||||
// before going to Established (otherwise the slow side lags the call)
|
||||
type Side uint8
|
||||
|
||||
const (
|
||||
CallerSide Side = iota
|
||||
CalleeSide
|
||||
)
|
||||
|
||||
type Caller interface {
|
||||
Start()
|
||||
Terminate(reason TerminationReason)
|
||||
}
|
||||
|
||||
// Ringing tells the peer the callee is alerting; tg side is a no-op
|
||||
type Callee interface {
|
||||
Ringing()
|
||||
Accept()
|
||||
Terminate(reason TerminationReason)
|
||||
}
|
||||
|
||||
// must call Bridge.Timeout on fire; Cancel is a no-op if not set
|
||||
type Timers interface {
|
||||
Set(kind TimerKind, dur time.Duration)
|
||||
Cancel(kind TimerKind)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Caller Caller
|
||||
Callee Callee
|
||||
Timers Timers
|
||||
|
||||
RingTimeout time.Duration // default 60s
|
||||
ExchangeTimeout time.Duration // default 20s
|
||||
ConnectTimeout time.Duration // default 15s
|
||||
|
||||
// fires once after both sides reported MediaConnected; invoked unlocked
|
||||
OnEstablished func()
|
||||
// fires once on transition into Terminated
|
||||
OnTerminated func()
|
||||
}
|
||||
|
||||
// caller/callee impls must not call back into Bridge synchronously
|
||||
// from a method Bridge invoked - that deadlocks
|
||||
type Bridge struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex
|
||||
state State
|
||||
callerMediaUp bool
|
||||
calleeMediaUp bool
|
||||
establishedAt time.Time
|
||||
}
|
||||
|
||||
func New(cfg Config) *Bridge {
|
||||
if cfg.RingTimeout == 0 {
|
||||
cfg.RingTimeout = 60 * time.Second
|
||||
}
|
||||
if cfg.ExchangeTimeout == 0 {
|
||||
cfg.ExchangeTimeout = 20 * time.Second
|
||||
}
|
||||
if cfg.ConnectTimeout == 0 {
|
||||
cfg.ConnectTimeout = 15 * time.Second
|
||||
}
|
||||
return &Bridge{cfg: cfg, state: StateIdle}
|
||||
}
|
||||
|
||||
func (b *Bridge) State() State {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.state
|
||||
}
|
||||
|
||||
// time both endpoints have been MediaConnected, or 0 if never established;
|
||||
// tgsig passes this to tdlib DiscardCall so the call isn't classified as missed
|
||||
func (b *Bridge) Duration() time.Duration {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.establishedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(b.establishedAt)
|
||||
}
|
||||
|
||||
func (b *Bridge) Start() {
|
||||
b.mu.Lock()
|
||||
if b.state != StateIdle {
|
||||
st := b.state
|
||||
b.mu.Unlock()
|
||||
log.WithField("state", st).Warn("Bridge.Start: not idle, skipping")
|
||||
return
|
||||
}
|
||||
b.state = StateOriginating
|
||||
b.cfg.Timers.Set(TimerRing, b.cfg.RingTimeout)
|
||||
b.mu.Unlock()
|
||||
b.cfg.Caller.Start()
|
||||
}
|
||||
|
||||
func (b *Bridge) Ringing() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.state != StateOriginating {
|
||||
return
|
||||
}
|
||||
b.state = StateRinging
|
||||
b.cfg.Callee.Ringing()
|
||||
}
|
||||
|
||||
func (b *Bridge) CalleeAccepted() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.state != StateOriginating && b.state != StateRinging {
|
||||
return
|
||||
}
|
||||
b.state = StateAccepted
|
||||
b.cfg.Timers.Cancel(TimerRing)
|
||||
b.cfg.Timers.Set(TimerExchange, b.cfg.ExchangeTimeout)
|
||||
b.cfg.Callee.Accept()
|
||||
}
|
||||
|
||||
// one transport endpoint is up; transition to Established only after both
|
||||
func (b *Bridge) MediaConnected(side Side) {
|
||||
var fireEstablished func()
|
||||
b.mu.Lock()
|
||||
if b.state == StateAccepted {
|
||||
switch side {
|
||||
case CallerSide:
|
||||
b.callerMediaUp = true
|
||||
case CalleeSide:
|
||||
b.calleeMediaUp = true
|
||||
}
|
||||
if b.callerMediaUp && b.calleeMediaUp {
|
||||
b.state = StateEstablished
|
||||
b.establishedAt = time.Now()
|
||||
b.cfg.Timers.Cancel(TimerExchange)
|
||||
b.cfg.Timers.Cancel(TimerConnect)
|
||||
fireEstablished = b.cfg.OnEstablished
|
||||
} else {
|
||||
// first side up; arm connect timer for the laggard
|
||||
b.cfg.Timers.Cancel(TimerExchange)
|
||||
b.cfg.Timers.Set(TimerConnect, b.cfg.ConnectTimeout)
|
||||
}
|
||||
}
|
||||
// already Established: further reports no-op here, ConnectionState Failed
|
||||
// still goes through MediaFailed
|
||||
b.mu.Unlock()
|
||||
if fireEstablished != nil {
|
||||
fireEstablished()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) MediaFailed(reason TerminationReason) {
|
||||
b.terminate(reason)
|
||||
}
|
||||
|
||||
func (b *Bridge) Terminate(reason TerminationReason) {
|
||||
b.terminate(reason)
|
||||
}
|
||||
|
||||
func (b *Bridge) Timeout(kind TimerKind) {
|
||||
var reason TerminationReason
|
||||
switch kind {
|
||||
case TimerRing:
|
||||
reason = ReasonRingTimeout
|
||||
case TimerExchange:
|
||||
reason = ReasonExchangeTimeout
|
||||
case TimerConnect:
|
||||
reason = ReasonConnectTimeout
|
||||
default:
|
||||
return
|
||||
}
|
||||
b.terminate(reason)
|
||||
}
|
||||
|
||||
func (b *Bridge) terminate(reason TerminationReason) {
|
||||
b.mu.Lock()
|
||||
if b.state == StateIdle || b.state == StateTerminated {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"prev_state": b.state,
|
||||
"reason": reason,
|
||||
}).Info("Bridge terminating")
|
||||
b.state = StateTerminated
|
||||
b.cfg.Timers.Cancel(TimerRing)
|
||||
b.cfg.Timers.Cancel(TimerExchange)
|
||||
b.cfg.Timers.Cancel(TimerConnect)
|
||||
b.mu.Unlock()
|
||||
|
||||
// callbacks run unlocked; re-entrant bridge methods bail on StateTerminated
|
||||
b.cfg.Caller.Terminate(reason)
|
||||
b.cfg.Callee.Terminate(reason)
|
||||
if b.cfg.OnTerminated != nil {
|
||||
b.cfg.OnTerminated()
|
||||
}
|
||||
}
|
||||
277
calls/signaling/bridge_test.go
Normal file
277
calls/signaling/bridge_test.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
package signaling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeCaller struct {
|
||||
started bool
|
||||
terminated bool
|
||||
terminateReason TerminationReason
|
||||
}
|
||||
|
||||
func (f *fakeCaller) Start() { f.started = true }
|
||||
func (f *fakeCaller) Terminate(r TerminationReason) {
|
||||
f.terminated = true
|
||||
f.terminateReason = r
|
||||
}
|
||||
|
||||
type fakeCallee struct {
|
||||
ringing bool
|
||||
accepted bool
|
||||
terminated bool
|
||||
terminateReason TerminationReason
|
||||
}
|
||||
|
||||
func (f *fakeCallee) Ringing() { f.ringing = true }
|
||||
func (f *fakeCallee) Accept() { f.accepted = true }
|
||||
func (f *fakeCallee) Terminate(r TerminationReason) {
|
||||
f.terminated = true
|
||||
f.terminateReason = r
|
||||
}
|
||||
|
||||
type fakeTimers struct {
|
||||
set map[TimerKind]time.Duration
|
||||
cancel map[TimerKind]int
|
||||
}
|
||||
|
||||
func newFakeTimers() *fakeTimers {
|
||||
return &fakeTimers{set: map[TimerKind]time.Duration{}, cancel: map[TimerKind]int{}}
|
||||
}
|
||||
func (f *fakeTimers) Set(k TimerKind, d time.Duration) { f.set[k] = d }
|
||||
func (f *fakeTimers) Cancel(k TimerKind) { f.cancel[k]++ }
|
||||
|
||||
type fakes struct {
|
||||
caller *fakeCaller
|
||||
callee *fakeCallee
|
||||
timers *fakeTimers
|
||||
}
|
||||
|
||||
func newBridge() (*Bridge, *fakes) {
|
||||
f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()}
|
||||
b := New(Config{Caller: f.caller, Callee: f.callee, Timers: f.timers})
|
||||
return b, f
|
||||
}
|
||||
|
||||
func driveTo(t *testing.T, target State) (*Bridge, *fakes) {
|
||||
t.Helper()
|
||||
b, f := newBridge()
|
||||
if target == StateIdle {
|
||||
return b, f
|
||||
}
|
||||
b.Start()
|
||||
if target == StateOriginating {
|
||||
return b, f
|
||||
}
|
||||
b.Ringing()
|
||||
if target == StateRinging {
|
||||
return b, f
|
||||
}
|
||||
b.CalleeAccepted()
|
||||
if target == StateAccepted {
|
||||
return b, f
|
||||
}
|
||||
b.MediaConnected(CallerSide)
|
||||
b.MediaConnected(CalleeSide)
|
||||
if target == StateEstablished {
|
||||
return b, f
|
||||
}
|
||||
t.Fatalf("driveTo: unknown target %s", target)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestTerminalStateIgnoresEvents(t *testing.T) {
|
||||
b, _ := driveTo(t, StateRinging)
|
||||
b.Timeout(TimerRing)
|
||||
if b.State() != StateTerminated {
|
||||
t.Fatalf("setup: state = %s", b.State())
|
||||
}
|
||||
b.Start()
|
||||
b.Ringing()
|
||||
b.CalleeAccepted()
|
||||
b.MediaConnected(CallerSide)
|
||||
b.MediaConnected(CalleeSide)
|
||||
b.Terminate(ReasonHangup)
|
||||
b.Timeout(TimerExchange)
|
||||
if b.State() != StateTerminated {
|
||||
t.Errorf("state drifted to %s", b.State())
|
||||
}
|
||||
}
|
||||
|
||||
// teardown matrix: every call-ending event tears down both sides via
|
||||
// Terminate, leaves the bridge in StateTerminated, and cancels every timer
|
||||
|
||||
func checkTerminated(t *testing.T, b *Bridge, f *fakes, wantReason TerminationReason) {
|
||||
t.Helper()
|
||||
if b.State() != StateTerminated {
|
||||
t.Fatalf("bridge state = %s, want terminated", b.State())
|
||||
}
|
||||
if !f.caller.terminated {
|
||||
t.Errorf("caller.terminated = false, want true")
|
||||
}
|
||||
if !f.callee.terminated {
|
||||
t.Errorf("callee.terminated = false, want true")
|
||||
}
|
||||
if f.caller.terminateReason != wantReason {
|
||||
t.Errorf("caller.terminateReason = %v, want %v", f.caller.terminateReason, wantReason)
|
||||
}
|
||||
if f.callee.terminateReason != wantReason {
|
||||
t.Errorf("callee.terminateReason = %v, want %v", f.callee.terminateReason, wantReason)
|
||||
}
|
||||
for _, k := range []TimerKind{TimerRing, TimerExchange, TimerConnect} {
|
||||
if f.timers.cancel[k] == 0 {
|
||||
t.Errorf("timer %v not cancelled on terminate", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTeardown_TearsDownBoth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
states []State
|
||||
trigger func(*Bridge)
|
||||
want TerminationReason
|
||||
}{
|
||||
{"Terminate",
|
||||
[]State{StateOriginating, StateRinging, StateAccepted, StateEstablished},
|
||||
func(b *Bridge) { b.Terminate(ReasonHangup) }, ReasonHangup},
|
||||
{"MediaFailed",
|
||||
[]State{StateAccepted, StateEstablished},
|
||||
func(b *Bridge) { b.MediaFailed(ReasonMediaFailed) }, ReasonMediaFailed},
|
||||
{"TimerRing",
|
||||
[]State{StateOriginating, StateRinging},
|
||||
func(b *Bridge) { b.Timeout(TimerRing) }, ReasonRingTimeout},
|
||||
{"TimerExchange",
|
||||
[]State{StateAccepted},
|
||||
func(b *Bridge) { b.Timeout(TimerExchange) }, ReasonExchangeTimeout},
|
||||
{"TimerConnect",
|
||||
[]State{StateEstablished},
|
||||
func(b *Bridge) { b.Timeout(TimerConnect) }, ReasonConnectTimeout},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
for _, st := range tc.states {
|
||||
t.Run(tc.name+"/"+st.String(), func(t *testing.T) {
|
||||
b, f := driveTo(t, st)
|
||||
tc.trigger(b)
|
||||
checkTerminated(t, b, f, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// further teardowns after the first must not re-fire Terminate
|
||||
func TestBridgeTeardown_Idempotent(t *testing.T) {
|
||||
b, f := driveTo(t, StateEstablished)
|
||||
b.Terminate(ReasonHangup)
|
||||
if !f.caller.terminated || !f.callee.terminated {
|
||||
t.Fatalf("setup: both sides should be torn down")
|
||||
}
|
||||
// subsequent Terminates must no-op; recorded reasons must not change
|
||||
f.caller.terminateReason = ReasonUnknown // sentinel
|
||||
f.callee.terminateReason = ReasonUnknown // sentinel
|
||||
b.Terminate(ReasonMediaFailed)
|
||||
b.Terminate(ReasonDecline)
|
||||
b.MediaFailed(ReasonMediaFailed)
|
||||
b.Timeout(TimerRing)
|
||||
if f.caller.terminateReason != ReasonUnknown {
|
||||
t.Errorf("caller.Terminate re-fired (reason now %v)", f.caller.terminateReason)
|
||||
}
|
||||
if f.callee.terminateReason != ReasonUnknown {
|
||||
t.Errorf("callee.Terminate re-fired (reason now %v)", f.callee.terminateReason)
|
||||
}
|
||||
}
|
||||
|
||||
// 0 before Established, positive after; needed by tgsig.DiscardCall
|
||||
func TestBridge_Duration(t *testing.T) {
|
||||
b, _ := newBridge()
|
||||
if d := b.Duration(); d != 0 {
|
||||
t.Errorf("Duration before Start = %v, want 0", d)
|
||||
}
|
||||
b.Start()
|
||||
b.Ringing()
|
||||
b.CalleeAccepted()
|
||||
b.MediaConnected(CallerSide)
|
||||
if d := b.Duration(); d != 0 {
|
||||
t.Errorf("Duration after one-sided MediaConnected = %v, want 0 (not yet Established)", d)
|
||||
}
|
||||
b.MediaConnected(CalleeSide)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if d := b.Duration(); d < 10*time.Millisecond {
|
||||
t.Errorf("Duration after Established = %v, want >= 10ms", d)
|
||||
}
|
||||
}
|
||||
|
||||
// only one side up: cancel TimerExchange, arm TimerConnect, don't fire OnEstablished
|
||||
func TestBridge_MediaConnectedOneSide_DoesNotEstablish(t *testing.T) {
|
||||
f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()}
|
||||
var established int
|
||||
b := New(Config{
|
||||
Caller: f.caller, Callee: f.callee, Timers: f.timers,
|
||||
OnEstablished: func() { established++ },
|
||||
})
|
||||
b.Start()
|
||||
b.Ringing()
|
||||
b.CalleeAccepted()
|
||||
b.MediaConnected(CallerSide)
|
||||
if b.State() != StateAccepted {
|
||||
t.Errorf("state after one-sided MediaConnected = %s, want accepted", b.State())
|
||||
}
|
||||
if established != 0 {
|
||||
t.Errorf("OnEstablished fired prematurely (%d times) with only one side up", established)
|
||||
}
|
||||
if _, ok := f.timers.set[TimerConnect]; !ok {
|
||||
t.Errorf("TimerConnect not armed after first MediaConnected")
|
||||
}
|
||||
if f.timers.cancel[TimerExchange] == 0 {
|
||||
t.Errorf("TimerExchange not cancelled after first MediaConnected")
|
||||
}
|
||||
}
|
||||
|
||||
// OnEstablished / OnTerminated fire exactly once
|
||||
func TestBridge_LifecycleHooks(t *testing.T) {
|
||||
f := &fakes{caller: &fakeCaller{}, callee: &fakeCallee{}, timers: newFakeTimers()}
|
||||
var established, terminated int
|
||||
b := New(Config{
|
||||
Caller: f.caller, Callee: f.callee, Timers: f.timers,
|
||||
OnEstablished: func() { established++ },
|
||||
OnTerminated: func() { terminated++ },
|
||||
})
|
||||
b.Start()
|
||||
b.Ringing()
|
||||
b.CalleeAccepted()
|
||||
b.MediaConnected(CallerSide)
|
||||
b.MediaConnected(CalleeSide)
|
||||
b.MediaConnected(CallerSide) // duplicate post-Established; must be no-op
|
||||
b.Terminate(ReasonHangup)
|
||||
b.Terminate(ReasonHangup) // duplicate; must not re-fire OnTerminated
|
||||
if established != 1 {
|
||||
t.Errorf("OnEstablished fired %d times, want 1", established)
|
||||
}
|
||||
if terminated != 1 {
|
||||
t.Errorf("OnTerminated fired %d times, want 1", terminated)
|
||||
}
|
||||
}
|
||||
|
||||
// CalleeAccepted outside Originating/Ringing must be a no-op
|
||||
func TestBridge_CalleeAcceptedStateGuard(t *testing.T) {
|
||||
for _, st := range []State{StateIdle, StateAccepted, StateEstablished, StateTerminated} {
|
||||
t.Run(st.String(), func(t *testing.T) {
|
||||
var b *Bridge
|
||||
var f *fakes
|
||||
if st == StateTerminated {
|
||||
b, f = driveTo(t, StateRinging)
|
||||
b.Terminate(ReasonHangup)
|
||||
} else {
|
||||
b, f = driveTo(t, st)
|
||||
}
|
||||
// driveTo invokes CalleeAccepted along the way; reset so we
|
||||
// observe only the upcoming call
|
||||
f.callee.accepted = false
|
||||
b.CalleeAccepted()
|
||||
if f.callee.accepted {
|
||||
t.Errorf("Callee.Accept fired from state %s", st)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
249
calls/signaling/tgsig/adapter.go
Normal file
249
calls/signaling/tgsig/adapter.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// telegram side of signaling.Bridge.
|
||||
// Adapter is per-session; Manager is keyed by (jid, callID).
|
||||
// Caller = gateway issued CreateCall, Callee = gateway received Pending.
|
||||
package tgsig
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/audio"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"gotgcalls/ntgcalls"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
// past this point ntgcalls is probably stuck and the user hears silence -
|
||||
// audio sent before Connected is dropped
|
||||
const ntgConnectStallThreshold = 7 * time.Second
|
||||
|
||||
// narrow upstream APIs to what we actually use, so tests can fake them
|
||||
|
||||
type TdlibClient interface {
|
||||
CreateCall(req *client.CreateCallRequest) (*client.CallId, error)
|
||||
AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error)
|
||||
DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error)
|
||||
SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error)
|
||||
}
|
||||
|
||||
type NtgCallsClient interface {
|
||||
OnSignal(callback ntgcalls.SignalCallback)
|
||||
OnConnectionChange(callback ntgcalls.ConnectionChangeCallback)
|
||||
CreateP2PCall(chatId int64) error
|
||||
SkipExchange(chatId int64, encryptionKey []byte, isOutgoing bool) error
|
||||
ConnectP2P(chatId int64, rtcServers []ntgcalls.RTCServer, versions []string, p2pAllowed bool) error
|
||||
SendSignalingData(chatId int64, data []byte) error
|
||||
Stop(chatId int64) error
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
tdlib TdlibClient
|
||||
ntg NtgCallsClient
|
||||
audioNtg audio.NtgClient // nil disables audio (signaling-only tests)
|
||||
jid string
|
||||
mgr *Manager
|
||||
|
||||
mu sync.Mutex
|
||||
byUserID map[int64]*callBase
|
||||
|
||||
// gates ntg/tdlib touches against Close()/Free() on the C++ object.
|
||||
// dispatchers take RLock via withCallContext; Close takes Lock.
|
||||
lifecycleLock sync.RWMutex
|
||||
closed bool
|
||||
|
||||
// per-chatID stall warning while ntgcalls is in Connecting
|
||||
ntgConnectTimersMu sync.Mutex
|
||||
ntgConnectTimers map[int64]*time.Timer
|
||||
}
|
||||
|
||||
// runs fn under the lifecycle RLock; returns false (and skips fn) if closed
|
||||
func (a *Adapter) withCallContext(fn func()) bool {
|
||||
a.lifecycleLock.RLock()
|
||||
defer a.lifecycleLock.RUnlock()
|
||||
if a.closed {
|
||||
return false
|
||||
}
|
||||
fn()
|
||||
return true
|
||||
}
|
||||
|
||||
func New(tdlib TdlibClient, ntg NtgCallsClient, audioNtg audio.NtgClient, jid string, mgr *Manager) *Adapter {
|
||||
a := &Adapter{
|
||||
tdlib: tdlib,
|
||||
ntg: ntg,
|
||||
audioNtg: audioNtg,
|
||||
jid: jid,
|
||||
mgr: mgr,
|
||||
byUserID: make(map[int64]*callBase),
|
||||
}
|
||||
a.attachNtgCallbacks()
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Adapter) JID() string { return a.jid }
|
||||
|
||||
// outbound call; callID is unknown until CreateCall returns, so
|
||||
// the entry registers with Manager from Caller.Start
|
||||
func (a *Adapter) NewCaller(userID int64, isVideo bool) *Caller {
|
||||
return &Caller{callBase: callBase{
|
||||
adapter: a, userID: userID, isVideo: isVideo, side: signaling.CallerSide,
|
||||
}}
|
||||
}
|
||||
|
||||
// inbound call; callID is known so the entry registers immediately
|
||||
func (a *Adapter) NewCallee(callID int32, userID int64, isVideo bool) *Callee {
|
||||
c := &Callee{callBase: callBase{
|
||||
adapter: a, userID: userID, isVideo: isVideo, callID: callID, side: signaling.CalleeSide,
|
||||
}}
|
||||
a.registerSide(userID, &c.callBase)
|
||||
a.mgr.Register(a.jid, callID, &c.callBase)
|
||||
return c
|
||||
}
|
||||
|
||||
func (a *Adapter) registerSide(userID int64, s *callBase) {
|
||||
a.mu.Lock()
|
||||
a.byUserID[userID] = s
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Adapter) dropSide(userID int64) {
|
||||
a.mu.Lock()
|
||||
delete(a.byUserID, userID)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Adapter) lookupSideByUser(userID int64) (*callBase, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
s, ok := a.byUserID[userID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// terminates every active call and marks the adapter closed; after this
|
||||
// returns no goroutine is mid-ntg/tdlib through here, safe to Free() the
|
||||
// C++ ntgcalls object. Bridge.Terminate runs outside the lock and is
|
||||
// synchronous so audio halves close (and ClearStreams runs) while C++
|
||||
// is still alive
|
||||
func (a *Adapter) Close() {
|
||||
a.lifecycleLock.Lock()
|
||||
if a.closed {
|
||||
a.lifecycleLock.Unlock()
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
sides := make([]*callBase, 0, len(a.byUserID))
|
||||
for _, s := range a.byUserID {
|
||||
sides = append(sides, s)
|
||||
}
|
||||
a.byUserID = make(map[int64]*callBase)
|
||||
a.mu.Unlock()
|
||||
a.closed = true
|
||||
a.lifecycleLock.Unlock()
|
||||
|
||||
for _, s := range sides {
|
||||
if b := s.getBridge(); b != nil {
|
||||
b.Terminate(signaling.ReasonHangup)
|
||||
}
|
||||
if cid := s.getCallID(); cid != 0 {
|
||||
a.mgr.Drop(a.jid, cid)
|
||||
}
|
||||
}
|
||||
for _, s := range sides {
|
||||
if err := a.ntg.Stop(s.userID); err != nil {
|
||||
log.WithField("user_id", s.userID).WithError(err).Debug("ntgcalls Stop during teardown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) attachNtgCallbacks() {
|
||||
a.ntg.OnSignal(func(chatID int64, data []byte) {
|
||||
// invoked from the ntgcalls C++ thread; guard against Close()/Free()
|
||||
a.withCallContext(func() {
|
||||
s, ok := a.lookupSideByUser(chatID)
|
||||
if !ok {
|
||||
log.WithFields(log.Fields{
|
||||
"jid": a.jid,
|
||||
"user_id": chatID,
|
||||
}).Warn("ntgcalls OnSignal for unknown user")
|
||||
return
|
||||
}
|
||||
if _, err := a.tdlib.SendCallSignalingData(&client.SendCallSignalingDataRequest{
|
||||
CallId: s.getCallID(),
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"call_id": s.getCallID(),
|
||||
"user_id": chatID,
|
||||
}).WithError(err).Error("Failed to send signaling data to TDLib")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
a.ntg.OnConnectionChange(func(chatID int64, info ntgcalls.NetworkInfo) {
|
||||
entry := log.WithFields(log.Fields{
|
||||
"jid": a.jid,
|
||||
"user_id": chatID,
|
||||
"state": info.State,
|
||||
"kind": info.Kind,
|
||||
})
|
||||
entry.Info("ntgcalls connection state changed")
|
||||
|
||||
// the TG peer can hear nothing while ntgcalls is still negotiating
|
||||
// with TG's TURN relays even though the XMPP side has RTP flowing
|
||||
switch info.State {
|
||||
case ntgcalls.Connecting:
|
||||
a.startNtgConnectStallTimer(chatID)
|
||||
case ntgcalls.Connected, ntgcalls.Failed, ntgcalls.Timeout, ntgcalls.Closed:
|
||||
a.stopNtgConnectStallTimer(chatID)
|
||||
}
|
||||
|
||||
s, ok := a.lookupSideByUser(chatID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b := s.getBridge()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
switch info.State {
|
||||
case ntgcalls.Connected:
|
||||
b.MediaConnected(s.side)
|
||||
case ntgcalls.Failed:
|
||||
b.MediaFailed(signaling.ReasonMediaFailed)
|
||||
case ntgcalls.Timeout:
|
||||
b.MediaFailed(signaling.ReasonConnectTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// (re)arms the stall timer; Connecting can fire more than once
|
||||
func (a *Adapter) startNtgConnectStallTimer(chatID int64) {
|
||||
a.stopNtgConnectStallTimer(chatID)
|
||||
t := time.AfterFunc(ntgConnectStallThreshold, func() {
|
||||
log.WithFields(log.Fields{
|
||||
"jid": a.jid,
|
||||
"user_id": chatID,
|
||||
"after": ntgConnectStallThreshold,
|
||||
}).Warn("ntgcalls still Connecting - TG peer is likely hearing silence")
|
||||
})
|
||||
a.ntgConnectTimersMu.Lock()
|
||||
if a.ntgConnectTimers == nil {
|
||||
a.ntgConnectTimers = make(map[int64]*time.Timer)
|
||||
}
|
||||
a.ntgConnectTimers[chatID] = t
|
||||
a.ntgConnectTimersMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Adapter) stopNtgConnectStallTimer(chatID int64) {
|
||||
a.ntgConnectTimersMu.Lock()
|
||||
t, ok := a.ntgConnectTimers[chatID]
|
||||
if ok {
|
||||
delete(a.ntgConnectTimers, chatID)
|
||||
}
|
||||
a.ntgConnectTimersMu.Unlock()
|
||||
if t != nil {
|
||||
t.Stop()
|
||||
}
|
||||
}
|
||||
217
calls/signaling/tgsig/adapter_race_test.go
Normal file
217
calls/signaling/tgsig/adapter_race_test.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package tgsig
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"gotgcalls/ntgcalls"
|
||||
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
// concurrent-safe variant of fakeNtg; vanilla fakeNtg appends to slices
|
||||
// without locking, unusable under -race
|
||||
type raceNtg struct {
|
||||
mu sync.Mutex
|
||||
onSignal ntgcalls.SignalCallback
|
||||
onConnCh ntgcalls.ConnectionChangeCallback
|
||||
stopCount atomic.Int64
|
||||
signalSent atomic.Int64
|
||||
}
|
||||
|
||||
func (f *raceNtg) OnSignal(cb ntgcalls.SignalCallback) {
|
||||
f.mu.Lock()
|
||||
f.onSignal = cb
|
||||
f.mu.Unlock()
|
||||
}
|
||||
func (f *raceNtg) OnConnectionChange(cb ntgcalls.ConnectionChangeCallback) {
|
||||
f.mu.Lock()
|
||||
f.onConnCh = cb
|
||||
f.mu.Unlock()
|
||||
}
|
||||
func (f *raceNtg) CreateP2PCall(int64) error { return nil }
|
||||
func (f *raceNtg) SkipExchange(int64, []byte, bool) error { return nil }
|
||||
func (f *raceNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (f *raceNtg) SendSignalingData(int64, []byte) error {
|
||||
f.signalSent.Add(1)
|
||||
return nil
|
||||
}
|
||||
func (f *raceNtg) Stop(int64) error {
|
||||
f.stopCount.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// simulates ntgcalls' C++ thread reaching into Go
|
||||
func (f *raceNtg) fire(chatID int64, data []byte) {
|
||||
f.mu.Lock()
|
||||
cb := f.onSignal
|
||||
f.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(chatID, data)
|
||||
}
|
||||
}
|
||||
|
||||
// concurrent-safe variant of fakeTdlib
|
||||
type raceTdlib struct {
|
||||
created atomic.Int64
|
||||
accepted atomic.Int64
|
||||
discarded atomic.Int64
|
||||
signalSent atomic.Int64
|
||||
nextCallID atomic.Int32
|
||||
}
|
||||
|
||||
func (f *raceTdlib) CreateCall(*client.CreateCallRequest) (*client.CallId, error) {
|
||||
f.created.Add(1)
|
||||
id := f.nextCallID.Add(1)
|
||||
return &client.CallId{Id: id}, nil
|
||||
}
|
||||
func (f *raceTdlib) AcceptCall(*client.AcceptCallRequest) (*client.Ok, error) {
|
||||
f.accepted.Add(1)
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
func (f *raceTdlib) DiscardCall(*client.DiscardCallRequest) (*client.Ok, error) {
|
||||
f.discarded.Add(1)
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
func (f *raceTdlib) SendCallSignalingData(*client.SendCallSignalingDataRequest) (*client.Ok, error) {
|
||||
f.signalSent.Add(1)
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
|
||||
// many dispatchers in parallel with Adapter.Close; -race must not flag
|
||||
// the ntg/tdlib touch sites, post-Close dispatches must be skipped, and
|
||||
// every withCallContext must release its RLock or Close blocks forever
|
||||
func TestAdapter_NoRaceUnderConcurrentDispatchAndClose(t *testing.T) {
|
||||
tdlib := &raceTdlib{}
|
||||
ntg := &raceNtg{}
|
||||
mgr := NewManager(nil)
|
||||
a := New(tdlib, ntg, nil, "jid@gw", mgr)
|
||||
|
||||
// pre-register a side so dispatchers find something
|
||||
userID := int64(42)
|
||||
callee := a.NewCallee(7, userID, false)
|
||||
cb := &callee.callBase
|
||||
|
||||
bridgeSide := &recCaller{}
|
||||
calleeSide := &recCallee{}
|
||||
br := signaling.New(signaling.Config{
|
||||
Caller: bridgeSide, Callee: calleeSide, Timers: noopTimers{},
|
||||
})
|
||||
cb.Bind(br)
|
||||
br.Start()
|
||||
br.Ringing()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// Spammer 1: OnSignal callback (simulates ntgcalls C++ thread).
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
ntg.fire(userID, []byte("data"))
|
||||
}
|
||||
}()
|
||||
|
||||
// spammer 2: OnNewSignalingData (tdlib updateHandler)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
mgr.OnNewSignalingData("jid@gw", &client.UpdateNewCallSignalingData{
|
||||
CallId: 7,
|
||||
Data: []byte("data"),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// spammer 3: callBase.Terminate (bridge tear-down attempts)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
cb.Terminate(signaling.ReasonHangup)
|
||||
}
|
||||
}()
|
||||
|
||||
// let spammers warm up
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// close concurrently with the spammers
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
a.Close()
|
||||
close(closeDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-closeDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Close blocked > 2s - a withCallContext somewhere is not releasing its RLock")
|
||||
}
|
||||
|
||||
// snapshot counters at the moment Close returned
|
||||
postCloseSignals := tdlib.signalSent.Load()
|
||||
postCloseDiscards := tdlib.discarded.Load()
|
||||
postCloseNtgSignals := ntg.signalSent.Load()
|
||||
postCloseStops := ntg.stopCount.Load()
|
||||
|
||||
// let spammers run a bit more - counters must stay flat
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if tdlib.signalSent.Load() != postCloseSignals {
|
||||
t.Errorf("tdlib.signalSent advanced after Close: %d -> %d",
|
||||
postCloseSignals, tdlib.signalSent.Load())
|
||||
}
|
||||
if tdlib.discarded.Load() != postCloseDiscards {
|
||||
t.Errorf("tdlib.discarded advanced after Close: %d -> %d",
|
||||
postCloseDiscards, tdlib.discarded.Load())
|
||||
}
|
||||
if ntg.signalSent.Load() != postCloseNtgSignals {
|
||||
t.Errorf("ntg.signalSent advanced after Close: %d -> %d",
|
||||
postCloseNtgSignals, ntg.signalSent.Load())
|
||||
}
|
||||
if ntg.stopCount.Load() != postCloseStops {
|
||||
t.Errorf("ntg.stopCount advanced after Close: %d -> %d",
|
||||
postCloseStops, ntg.stopCount.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// Close twice must not panic or race
|
||||
func TestAdapter_CloseIsIdempotent(t *testing.T) {
|
||||
tdlib := &raceTdlib{}
|
||||
ntg := &raceNtg{}
|
||||
a := New(tdlib, ntg, nil, "jid@gw", NewManager(nil))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
a.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
130
calls/signaling/tgsig/base.go
Normal file
130
calls/signaling/tgsig/base.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package tgsig
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/audio"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
// per-call tg-side state shared by Caller and Callee
|
||||
type callBase struct {
|
||||
adapter *Adapter
|
||||
userID int64
|
||||
isVideo bool
|
||||
// CallerSide / CalleeSide - lets ntg callbacks fire MediaConnected per-side
|
||||
side signaling.Side
|
||||
|
||||
mu sync.Mutex
|
||||
bridge *signaling.Bridge
|
||||
callID int32
|
||||
}
|
||||
|
||||
// must run before any callback that re-enters the bridge
|
||||
func (b *callBase) Bind(br *signaling.Bridge) {
|
||||
b.mu.Lock()
|
||||
b.bridge = br
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *callBase) getBridge() *signaling.Bridge {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.bridge
|
||||
}
|
||||
|
||||
func (b *callBase) getCallID() int32 {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.callID
|
||||
}
|
||||
|
||||
func (b *callBase) setCallID(id int32) {
|
||||
b.mu.Lock()
|
||||
b.callID = id
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// audio-layer view of the ntgcalls client; nil for signaling-only tests
|
||||
func (b *callBase) NtgClient() audio.NtgClient { return b.adapter.audioNtg }
|
||||
|
||||
// for P2P calls this is ntgcalls' "chatId" - the remote user_id
|
||||
func (b *callBase) ChatID() int64 { return b.userID }
|
||||
|
||||
// ask tdlib to discard; map removal happens in cleanup from
|
||||
// Manager.endCall once tdlib echoes Discarded.
|
||||
//
|
||||
// Duration matters: with 0 the call shows up as missed on other tdlib
|
||||
// clients of the same account.
|
||||
func (b *callBase) Terminate(reason signaling.TerminationReason) {
|
||||
callID := b.getCallID()
|
||||
if callID == 0 {
|
||||
// CreateCall never succeeded
|
||||
return
|
||||
}
|
||||
var duration int32
|
||||
if br := b.getBridge(); br != nil {
|
||||
duration = int32(br.Duration().Seconds())
|
||||
}
|
||||
b.adapter.withCallContext(func() {
|
||||
if _, err := b.adapter.tdlib.DiscardCall(&client.DiscardCallRequest{
|
||||
CallId: callID,
|
||||
IsDisconnected: reason == signaling.ReasonMediaFailed,
|
||||
Duration: duration,
|
||||
IsVideo: b.isVideo,
|
||||
ConnectionId: 0,
|
||||
}); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"call_id": callID,
|
||||
"user_id": b.userID,
|
||||
"reason": reason,
|
||||
"duration": duration,
|
||||
}).WithError(err).Error("DiscardCall failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// drives the ntgcalls P2P setup on tdlib's CallStateReady
|
||||
func (b *callBase) onReady(state *client.CallStateReady, isOutgoing bool, entry *log.Entry) {
|
||||
b.adapter.withCallContext(func() {
|
||||
a := b.adapter
|
||||
if err := a.ntg.CreateP2PCall(b.userID); err != nil {
|
||||
entry.WithError(err).Error("Failed to create P2P call")
|
||||
return
|
||||
}
|
||||
if err := a.ntg.SkipExchange(b.userID, state.EncryptionKey, isOutgoing); err != nil {
|
||||
entry.WithError(err).Error("Failed to skip exchange")
|
||||
_ = a.ntg.Stop(b.userID)
|
||||
return
|
||||
}
|
||||
rtcServers := convertCallServers(state.Servers)
|
||||
versions := state.Protocol.LibraryVersions
|
||||
if err := a.ntg.ConnectP2P(b.userID, rtcServers, versions, state.AllowP2p); err != nil {
|
||||
entry.WithError(err).Error("Failed to connect P2P")
|
||||
_ = a.ntg.Stop(b.userID)
|
||||
return
|
||||
}
|
||||
entry.Info("P2P connection established")
|
||||
})
|
||||
}
|
||||
|
||||
// drops the side from both maps and stops ntgcalls
|
||||
func (b *callBase) cleanup() {
|
||||
b.adapter.withCallContext(func() {
|
||||
a := b.adapter
|
||||
callID := b.getCallID()
|
||||
if callID != 0 {
|
||||
a.mgr.Drop(a.jid, callID)
|
||||
}
|
||||
a.dropSide(b.userID)
|
||||
if err := a.ntg.Stop(b.userID); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"call_id": callID,
|
||||
"user_id": b.userID,
|
||||
}).WithError(err).Debug("ntgcalls Stop returned error (call may not have started)")
|
||||
}
|
||||
})
|
||||
}
|
||||
34
calls/signaling/tgsig/callee.go
Normal file
34
calls/signaling/tgsig/callee.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package tgsig
|
||||
|
||||
import (
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
// tg-side Callee for TG-originated calls
|
||||
type Callee struct {
|
||||
callBase
|
||||
}
|
||||
|
||||
// no-op on the tg side; tdlib drives Pending{IsReceived:true} instead
|
||||
func (c *Callee) Ringing() {}
|
||||
|
||||
func (c *Callee) Accept() {
|
||||
callID := c.getCallID()
|
||||
entry := log.WithFields(log.Fields{"call_id": callID, "user_id": c.userID})
|
||||
c.adapter.withCallContext(func() {
|
||||
if _, err := c.adapter.tdlib.AcceptCall(&client.AcceptCallRequest{
|
||||
CallId: callID,
|
||||
Protocol: callProtocol(),
|
||||
}); err != nil {
|
||||
entry.WithError(err).Error("tgsig.Callee.Accept: AcceptCall failed")
|
||||
if b := c.getBridge(); b != nil {
|
||||
go b.MediaFailed(signaling.ReasonUnknown)
|
||||
}
|
||||
return
|
||||
}
|
||||
entry.Info("AcceptCall issued")
|
||||
})
|
||||
}
|
||||
36
calls/signaling/tgsig/caller.go
Normal file
36
calls/signaling/tgsig/caller.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package tgsig
|
||||
|
||||
import (
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
// tg-side Caller for XMPP-originated calls
|
||||
type Caller struct {
|
||||
callBase
|
||||
}
|
||||
|
||||
// invoked by Bridge under its mutex; bridge re-entry must be deferred
|
||||
func (c *Caller) Start() {
|
||||
entry := log.WithFields(log.Fields{"user_id": c.userID, "is_video": c.isVideo, "jid": c.adapter.jid})
|
||||
c.adapter.withCallContext(func() {
|
||||
resp, err := c.adapter.tdlib.CreateCall(&client.CreateCallRequest{
|
||||
UserId: c.userID,
|
||||
Protocol: callProtocol(),
|
||||
IsVideo: c.isVideo,
|
||||
})
|
||||
if err != nil {
|
||||
entry.WithError(err).Error("tgsig.Caller.Start: CreateCall failed")
|
||||
if b := c.getBridge(); b != nil {
|
||||
go b.Terminate(signaling.ReasonUnknown)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.setCallID(resp.Id)
|
||||
c.adapter.registerSide(c.userID, &c.callBase)
|
||||
c.adapter.mgr.Register(c.adapter.jid, resp.Id, &c.callBase)
|
||||
entry.WithField("call_id", resp.Id).Info("CreateCall issued")
|
||||
})
|
||||
}
|
||||
48
calls/signaling/tgsig/convert.go
Normal file
48
calls/signaling/tgsig/convert.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// tdlib <-> ntgcalls type conversions for call setup
|
||||
package tgsig
|
||||
|
||||
import (
|
||||
"gotgcalls/ntgcalls"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
func callProtocol() *client.CallProtocol {
|
||||
p := ntgcalls.GetProtocol()
|
||||
return &client.CallProtocol{
|
||||
UdpP2p: p.UdpP2P,
|
||||
UdpReflector: p.UdpReflector,
|
||||
MinLayer: p.MinLayer,
|
||||
MaxLayer: p.MaxLayer,
|
||||
LibraryVersions: p.Versions,
|
||||
}
|
||||
}
|
||||
|
||||
func convertCallServers(servers []*client.CallServer) []ntgcalls.RTCServer {
|
||||
out := make([]ntgcalls.RTCServer, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
rtc := ntgcalls.RTCServer{
|
||||
ID: int64(s.Id),
|
||||
Ipv4: s.IpAddress,
|
||||
Ipv6: s.Ipv6Address,
|
||||
Port: s.Port,
|
||||
}
|
||||
switch t := s.Type.(type) {
|
||||
case *client.CallServerTypeTelegramReflector:
|
||||
rtc.PeerTag = t.PeerTag
|
||||
rtc.Tcp = t.IsTcp
|
||||
rtc.Turn = true
|
||||
case *client.CallServerTypeWebrtc:
|
||||
rtc.Username = t.Username
|
||||
rtc.Password = t.Password
|
||||
rtc.Turn = t.SupportsTurn
|
||||
rtc.Stun = t.SupportsStun
|
||||
default:
|
||||
log.WithField("type", s.Type.CallServerTypeType()).Warn("Unknown call server type")
|
||||
continue
|
||||
}
|
||||
out = append(out, rtc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
207
calls/signaling/tgsig/manager.go
Normal file
207
calls/signaling/tgsig/manager.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package tgsig
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
// runs for a brand-new inbound Pending; must build the bridge, register
|
||||
// the Callee with Manager.Register, and start the bridge; false drops the call
|
||||
type IncomingCallHandler func(jid string, callID int32, userID int64, isVideo bool) (ok bool)
|
||||
|
||||
// router for tdlib call updates; callIDs are per-tdlib-client so key is (jid, callID)
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
sides map[managerKey]*callBase
|
||||
incoming IncomingCallHandler
|
||||
}
|
||||
|
||||
type managerKey struct {
|
||||
jid string
|
||||
callID int32
|
||||
}
|
||||
|
||||
// h may be nil and set later via SetIncomingHandler
|
||||
func NewManager(h IncomingCallHandler) *Manager {
|
||||
return &Manager{
|
||||
sides: make(map[managerKey]*callBase),
|
||||
incoming: h,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SetIncomingHandler(h IncomingCallHandler) {
|
||||
m.mu.Lock()
|
||||
m.incoming = h
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) incomingHandler() IncomingCallHandler {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.incoming
|
||||
}
|
||||
|
||||
func (m *Manager) Register(jid string, callID int32, s *callBase) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.sides[managerKey{jid, callID}] = s
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) Drop(jid string, callID int32) {
|
||||
m.mu.Lock()
|
||||
delete(m.sides, managerKey{jid, callID})
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) lookup(jid string, callID int32) *callBase {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.sides[managerKey{jid, callID}]
|
||||
}
|
||||
|
||||
func (m *Manager) OnUpdateCall(jid string, u *client.UpdateCall) {
|
||||
call := u.Call
|
||||
entry := log.WithFields(log.Fields{
|
||||
"jid": jid,
|
||||
"call_id": call.Id,
|
||||
"user_id": call.UserId,
|
||||
"is_outgoing": call.IsOutgoing,
|
||||
"is_video": call.IsVideo,
|
||||
})
|
||||
|
||||
switch call.State.CallStateType() {
|
||||
case client.TypeCallStatePending:
|
||||
entry.Info("Call pending")
|
||||
if call.IsOutgoing {
|
||||
// Pending fires twice: server-created (IsReceived=false) and
|
||||
// peer-notified (IsReceived=true); only the second is ringing.
|
||||
// Near-instant pickups may skip the second one - the bridge
|
||||
// state guard at ExchangingKeys covers that.
|
||||
pending, _ := call.State.(*client.CallStatePending)
|
||||
if pending == nil || !pending.IsReceived {
|
||||
return
|
||||
}
|
||||
if b := m.bridgeFor(jid, call.Id); b != nil {
|
||||
b.Ringing()
|
||||
}
|
||||
return
|
||||
}
|
||||
if existing := m.lookup(jid, call.Id); existing != nil {
|
||||
// duplicate Pending
|
||||
return
|
||||
}
|
||||
h := m.incomingHandler()
|
||||
if h == nil {
|
||||
entry.Warn("Inbound call but no incoming-call handler wired; ignoring")
|
||||
return
|
||||
}
|
||||
if ok := h(jid, call.Id, call.UserId, call.IsVideo); !ok {
|
||||
entry.Warn("Incoming-call handler dropped the call")
|
||||
}
|
||||
|
||||
case client.TypeCallStateExchangingKeys:
|
||||
entry.Info("Call exchanging keys")
|
||||
// outbound: peer accepted, bridge fires Callee.Accept on xmpp side
|
||||
// inbound: AcceptCall already ran, state guard makes this a no-op
|
||||
if b := m.bridgeFor(jid, call.Id); b != nil {
|
||||
b.CalleeAccepted()
|
||||
}
|
||||
|
||||
case client.TypeCallStateReady:
|
||||
state, ok := call.State.(*client.CallStateReady)
|
||||
if !ok {
|
||||
entry.Error("Failed to cast CallStateReady")
|
||||
return
|
||||
}
|
||||
entry.Info("Call ready, setting up P2P connection")
|
||||
s := m.lookup(jid, call.Id)
|
||||
if s == nil {
|
||||
entry.Warn("Ready for unknown call")
|
||||
return
|
||||
}
|
||||
s.onReady(state, call.IsOutgoing, entry)
|
||||
|
||||
case client.TypeCallStateHangingUp:
|
||||
entry.Info("Call hanging up")
|
||||
m.endCall(jid, call.Id, signaling.ReasonHangup)
|
||||
|
||||
case client.TypeCallStateDiscarded:
|
||||
reason := "unknown"
|
||||
if disc, ok := call.State.(*client.CallStateDiscarded); ok && disc.Reason != nil {
|
||||
reason = disc.Reason.CallDiscardReasonType()
|
||||
}
|
||||
entry.WithField("reason", reason).Info("Call discarded")
|
||||
m.endCall(jid, call.Id, mapDiscardReason(reason))
|
||||
|
||||
case client.TypeCallStateError:
|
||||
errMsg := "unknown"
|
||||
if errState, ok := call.State.(*client.CallStateError); ok && errState.Error != nil {
|
||||
errMsg = errState.Error.Message
|
||||
}
|
||||
entry.WithField("error", errMsg).Error("Call error")
|
||||
m.endCall(jid, call.Id, signaling.ReasonMediaFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// forwards tdlib signaling data into ntgcalls
|
||||
func (m *Manager) OnNewSignalingData(jid string, u *client.UpdateNewCallSignalingData) {
|
||||
s := m.lookup(jid, u.CallId)
|
||||
if s == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"jid": jid,
|
||||
"call_id": u.CallId,
|
||||
}).Warn("Signaling data for unknown call")
|
||||
return
|
||||
}
|
||||
s.adapter.withCallContext(func() {
|
||||
if err := s.adapter.ntg.SendSignalingData(s.userID, u.Data); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"jid": jid,
|
||||
"call_id": u.CallId,
|
||||
"user_id": s.userID,
|
||||
}).WithError(err).Error("Failed to forward signaling data to ntgcalls")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) bridgeFor(jid string, callID int32) *signaling.Bridge {
|
||||
s := m.lookup(jid, callID)
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.getBridge()
|
||||
}
|
||||
|
||||
// unified teardown for HangingUp / Discarded / Error
|
||||
func (m *Manager) endCall(jid string, callID int32, reason signaling.TerminationReason) {
|
||||
s := m.lookup(jid, callID)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if b := s.getBridge(); b != nil {
|
||||
b.Terminate(reason)
|
||||
}
|
||||
s.cleanup()
|
||||
}
|
||||
|
||||
func mapDiscardReason(tdlibReason string) signaling.TerminationReason {
|
||||
switch tdlibReason {
|
||||
case "callDiscardReasonDeclined":
|
||||
return signaling.ReasonDecline
|
||||
case "callDiscardReasonMissed", "callDiscardReasonExpired":
|
||||
return signaling.ReasonRingTimeout
|
||||
case "callDiscardReasonDisconnected":
|
||||
return signaling.ReasonMediaFailed
|
||||
case "callDiscardReasonHungUp", "callDiscardReasonEmpty":
|
||||
return signaling.ReasonHangup
|
||||
default:
|
||||
return signaling.ReasonUnknown
|
||||
}
|
||||
}
|
||||
304
calls/signaling/tgsig/manager_test.go
Normal file
304
calls/signaling/tgsig/manager_test.go
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
package tgsig
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"gotgcalls/ntgcalls"
|
||||
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
)
|
||||
|
||||
type fakeTdlib struct {
|
||||
createCalls []*client.CreateCallRequest
|
||||
acceptCalls []*client.AcceptCallRequest
|
||||
discardCalls []*client.DiscardCallRequest
|
||||
sentSignal []*client.SendCallSignalingDataRequest
|
||||
nextCallID int32
|
||||
}
|
||||
|
||||
func (f *fakeTdlib) CreateCall(req *client.CreateCallRequest) (*client.CallId, error) {
|
||||
f.createCalls = append(f.createCalls, req)
|
||||
f.nextCallID++
|
||||
return &client.CallId{Id: f.nextCallID}, nil
|
||||
}
|
||||
func (f *fakeTdlib) AcceptCall(req *client.AcceptCallRequest) (*client.Ok, error) {
|
||||
f.acceptCalls = append(f.acceptCalls, req)
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
func (f *fakeTdlib) DiscardCall(req *client.DiscardCallRequest) (*client.Ok, error) {
|
||||
f.discardCalls = append(f.discardCalls, req)
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
func (f *fakeTdlib) SendCallSignalingData(req *client.SendCallSignalingDataRequest) (*client.Ok, error) {
|
||||
f.sentSignal = append(f.sentSignal, req)
|
||||
return &client.Ok{}, nil
|
||||
}
|
||||
|
||||
type fakeNtg struct {
|
||||
onSignal ntgcalls.SignalCallback
|
||||
onConnCh ntgcalls.ConnectionChangeCallback
|
||||
stopCalls []int64
|
||||
}
|
||||
|
||||
func (f *fakeNtg) OnSignal(cb ntgcalls.SignalCallback) { f.onSignal = cb }
|
||||
func (f *fakeNtg) OnConnectionChange(cb ntgcalls.ConnectionChangeCallback) { f.onConnCh = cb }
|
||||
func (f *fakeNtg) CreateP2PCall(int64) error { return nil }
|
||||
func (f *fakeNtg) SkipExchange(int64, []byte, bool) error { return nil }
|
||||
func (f *fakeNtg) ConnectP2P(int64, []ntgcalls.RTCServer, []string, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeNtg) SendSignalingData(int64, []byte) error { return nil }
|
||||
func (f *fakeNtg) Stop(chatID int64) error {
|
||||
f.stopCalls = append(f.stopCalls, chatID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// recorders sitting in place of the real per-side handlers, so tests can
|
||||
// observe which side the bridge fires Terminate on
|
||||
|
||||
type recCaller struct {
|
||||
started bool
|
||||
terminated bool
|
||||
terminateReason signaling.TerminationReason
|
||||
}
|
||||
|
||||
func (r *recCaller) Start() { r.started = true }
|
||||
func (r *recCaller) Terminate(reason signaling.TerminationReason) {
|
||||
r.terminated = true
|
||||
r.terminateReason = reason
|
||||
}
|
||||
|
||||
type recCallee struct {
|
||||
ringing bool
|
||||
accepted bool
|
||||
terminated bool
|
||||
terminateReason signaling.TerminationReason
|
||||
}
|
||||
|
||||
func (r *recCallee) Ringing() { r.ringing = true }
|
||||
func (r *recCallee) Accept() { r.accepted = true }
|
||||
func (r *recCallee) Terminate(reason signaling.TerminationReason) {
|
||||
r.terminated = true
|
||||
r.terminateReason = reason
|
||||
}
|
||||
|
||||
type noopTimers struct{}
|
||||
|
||||
func (noopTimers) Set(signaling.TimerKind, time.Duration) {}
|
||||
func (noopTimers) Cancel(signaling.TimerKind) {}
|
||||
|
||||
type tgFixture struct {
|
||||
mgr *Manager
|
||||
adapter *Adapter
|
||||
tdlib *fakeTdlib
|
||||
ntg *fakeNtg
|
||||
caller *recCaller
|
||||
callee *recCallee
|
||||
bridge *signaling.Bridge
|
||||
}
|
||||
|
||||
func newFixture() *tgFixture {
|
||||
tdlib := &fakeTdlib{}
|
||||
ntg := &fakeNtg{}
|
||||
mgr := NewManager(nil)
|
||||
a := New(tdlib, ntg, nil, "jid@gw", mgr)
|
||||
return &tgFixture{
|
||||
mgr: mgr, adapter: a, tdlib: tdlib, ntg: ntg,
|
||||
caller: &recCaller{}, callee: &recCallee{},
|
||||
}
|
||||
}
|
||||
|
||||
// TG-originated bridge: tg is the bridge's Callee, recCaller plays the xmpp
|
||||
// side. Returns *callBase so the test can drive its state.
|
||||
func (f *tgFixture) buildTGOriginated(callID int32, userID int64) *callBase {
|
||||
tgSide := f.adapter.NewCallee(callID, userID, false)
|
||||
f.bridge = signaling.New(signaling.Config{
|
||||
Caller: f.caller, // xmpp-side (recorder)
|
||||
Callee: f.callee, // tg-side (recorder; the real tg side is registered with the Manager for endCall lookup)
|
||||
Timers: noopTimers{},
|
||||
})
|
||||
tgSide.callBase.Bind(f.bridge)
|
||||
return &tgSide.callBase
|
||||
}
|
||||
|
||||
// buildXmppOriginated wires an XMPP-originated bridge: tg-side is the bridge's
|
||||
// Caller. Manually registers the callBase since NewCaller doesn't auto-register
|
||||
// (production code registers from Caller.Start after tdlib.CreateCall succeeds).
|
||||
func (f *tgFixture) buildXmppOriginated(callID int32, userID int64) *callBase {
|
||||
tgSide := f.adapter.NewCaller(userID, false)
|
||||
tgSide.callBase.setCallID(callID)
|
||||
f.adapter.registerSide(userID, &tgSide.callBase)
|
||||
f.mgr.Register(f.adapter.jid, callID, &tgSide.callBase)
|
||||
f.bridge = signaling.New(signaling.Config{
|
||||
Caller: f.caller, // tg-side here (recorder; real tg side is what the Manager has)
|
||||
Callee: f.callee, // xmpp-side
|
||||
Timers: noopTimers{},
|
||||
})
|
||||
tgSide.callBase.Bind(f.bridge)
|
||||
return &tgSide.callBase
|
||||
}
|
||||
|
||||
func discardUpdate(callID int32, userID int64, isOutgoing bool, reason client.CallDiscardReason) *client.UpdateCall {
|
||||
return &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: callID,
|
||||
UserId: userID,
|
||||
IsOutgoing: isOutgoing,
|
||||
IsVideo: false,
|
||||
State: &client.CallStateDiscarded{Reason: reason},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func hangingUpUpdate(callID int32, userID int64, isOutgoing bool) *client.UpdateCall {
|
||||
return &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false,
|
||||
State: &client.CallStateHangingUp{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pendingUpdate(callID int32, userID int64, isOutgoing, isReceived bool) *client.UpdateCall {
|
||||
return &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: callID, UserId: userID, IsOutgoing: isOutgoing, IsVideo: false,
|
||||
State: &client.CallStatePending{IsCreated: true, IsReceived: isReceived},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// every tdlib teardown event -> bridge.Terminate on both halves with the
|
||||
// mapped reason; catches new discard reasons leaking through as Unknown
|
||||
func TestTGTeardownMatrix_TGOriginated(t *testing.T) {
|
||||
errorUpdate := func(callID int32, userID int64) *client.UpdateCall {
|
||||
return &client.UpdateCall{Call: &client.Call{
|
||||
Id: callID, UserId: userID, IsOutgoing: false,
|
||||
State: &client.CallStateError{Error: &client.Error{Code: 500, Message: "boom"}},
|
||||
}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(callID int32, userID int64) *client.UpdateCall
|
||||
want signaling.TerminationReason
|
||||
}{
|
||||
{"discard/declined", func(c int32, u int64) *client.UpdateCall {
|
||||
return discardUpdate(c, u, false, &client.CallDiscardReasonDeclined{})
|
||||
}, signaling.ReasonDecline},
|
||||
{"discard/missed", func(c int32, u int64) *client.UpdateCall {
|
||||
return discardUpdate(c, u, false, &client.CallDiscardReasonMissed{})
|
||||
}, signaling.ReasonRingTimeout},
|
||||
{"discard/disconnected", func(c int32, u int64) *client.UpdateCall {
|
||||
return discardUpdate(c, u, false, &client.CallDiscardReasonDisconnected{})
|
||||
}, signaling.ReasonMediaFailed},
|
||||
{"discard/hungUp", func(c int32, u int64) *client.UpdateCall {
|
||||
return discardUpdate(c, u, false, &client.CallDiscardReasonHungUp{})
|
||||
}, signaling.ReasonHangup},
|
||||
{"discard/empty", func(c int32, u int64) *client.UpdateCall {
|
||||
return discardUpdate(c, u, false, &client.CallDiscardReasonEmpty{})
|
||||
}, signaling.ReasonHangup},
|
||||
{"hangingUp", func(c int32, u int64) *client.UpdateCall {
|
||||
return hangingUpUpdate(c, u, false)
|
||||
}, signaling.ReasonHangup},
|
||||
{"error", errorUpdate, signaling.ReasonMediaFailed},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newFixture()
|
||||
callID := int32(500 + i)
|
||||
userID := int64(8000 + i)
|
||||
f.buildTGOriginated(callID, userID)
|
||||
f.bridge.Start()
|
||||
f.bridge.Ringing()
|
||||
|
||||
f.mgr.OnUpdateCall("jid@gw", tc.build(callID, userID))
|
||||
|
||||
if !f.caller.terminated || !f.callee.terminated {
|
||||
t.Fatalf("both sides should tear down: caller=%v callee=%v", f.caller.terminated, f.callee.terminated)
|
||||
}
|
||||
if f.caller.terminateReason != tc.want || f.callee.terminateReason != tc.want {
|
||||
t.Errorf("reason: caller=%v callee=%v, want %v", f.caller.terminateReason, f.callee.terminateReason, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// XMPP-originated outbound: transition to Ringing only on Pending{IsReceived:true}.
|
||||
// Pending{IsReceived:false} (server-created, peer not notified) must not transition,
|
||||
// or <ringing> goes out before the peer is alerting.
|
||||
func TestTGPending_XMPPOriginated_DrivesBridgeToRinging(t *testing.T) {
|
||||
f := newFixture()
|
||||
f.buildXmppOriginated(303, 9303)
|
||||
f.bridge.Start()
|
||||
|
||||
if got := f.bridge.State(); got != signaling.StateOriginating {
|
||||
t.Fatalf("pre-Pending state = %s, want originating", got)
|
||||
}
|
||||
|
||||
// first Pending: server-created, must NOT ring
|
||||
f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, false))
|
||||
if got := f.bridge.State(); got != signaling.StateOriginating {
|
||||
t.Fatalf("post-Pending(IsReceived=false) state = %s, want originating", got)
|
||||
}
|
||||
if f.callee.ringing {
|
||||
t.Error("xmpp-Callee.Ringing fired on Pending(IsReceived=false); want only on IsReceived=true")
|
||||
}
|
||||
|
||||
// second Pending: peer device alerting, ring
|
||||
f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, true))
|
||||
if got := f.bridge.State(); got != signaling.StateRinging {
|
||||
t.Fatalf("post-Pending(IsReceived=true) state = %s, want ringing", got)
|
||||
}
|
||||
if !f.callee.ringing {
|
||||
t.Error("xmpp-Callee.Ringing did not fire on Pending(IsReceived=true)")
|
||||
}
|
||||
|
||||
// duplicate Pending(IsReceived=true) no-ops via Bridge.Ringing's state guard
|
||||
f.callee.ringing = false
|
||||
f.mgr.OnUpdateCall("jid@gw", pendingUpdate(303, 9303, true, true))
|
||||
if got := f.bridge.State(); got != signaling.StateRinging {
|
||||
t.Fatalf("after duplicate Pending state = %s, want ringing", got)
|
||||
}
|
||||
if f.callee.ringing {
|
||||
t.Error("xmpp-Callee.Ringing re-fired on duplicate Pending; want once")
|
||||
}
|
||||
|
||||
// ExchangingKeys drives Ringing -> Accepted. Accept is called without
|
||||
// re-emitting <ringing>: the ringback window between the two stanzas
|
||||
// is the time the user spent looking at their phone, not 30ms.
|
||||
f.mgr.OnUpdateCall("jid@gw", &client.UpdateCall{
|
||||
Call: &client.Call{
|
||||
Id: 303, UserId: 9303, IsOutgoing: true, IsVideo: false,
|
||||
State: &client.CallStateExchangingKeys{},
|
||||
},
|
||||
})
|
||||
if got := f.bridge.State(); got != signaling.StateAccepted {
|
||||
t.Fatalf("post-ExchangingKeys state = %s, want accepted", got)
|
||||
}
|
||||
if !f.callee.accepted {
|
||||
t.Error("xmpp-Callee.Accept did not fire on ExchangingKeys")
|
||||
}
|
||||
}
|
||||
|
||||
// endCall must drop registrations: subsequent OnUpdateCall for the same
|
||||
// (jid, callID) is a no-op (no side gets re-fired).
|
||||
func TestTGDiscard_DropsRegistrationFromManager(t *testing.T) {
|
||||
f := newFixture()
|
||||
f.buildTGOriginated(111, 9011)
|
||||
f.bridge.Start()
|
||||
|
||||
f.mgr.OnUpdateCall("jid@gw", discardUpdate(111, 9011, false, &client.CallDiscardReasonHungUp{}))
|
||||
if got := f.mgr.lookup("jid@gw", 111); got != nil {
|
||||
t.Errorf("callBase still registered after discard: %v", got)
|
||||
}
|
||||
// dropSide on the adapter
|
||||
if _, ok := f.adapter.lookupSideByUser(9011); ok {
|
||||
t.Errorf("adapter still has user 9011 after discard")
|
||||
}
|
||||
// ntgcalls Stop was called
|
||||
if len(f.ntg.stopCalls) != 1 || f.ntg.stopCalls[0] != 9011 {
|
||||
t.Errorf("ntg.Stop calls = %v, want [9011]", f.ntg.stopCalls)
|
||||
}
|
||||
}
|
||||
48
calls/signaling/timers.go
Normal file
48
calls/signaling/timers.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package signaling
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AfterFunc-backed Timers, one per kind, firing Bridge.Timeout.
|
||||
// Wiring is two-step because Bridge and timers want each other at
|
||||
// construction: NewStdTimers, New(...), SetBridge.
|
||||
type StdTimers struct {
|
||||
mu sync.Mutex
|
||||
bridge *Bridge
|
||||
t map[TimerKind]*time.Timer
|
||||
}
|
||||
|
||||
func NewStdTimers() *StdTimers {
|
||||
return &StdTimers{t: make(map[TimerKind]*time.Timer)}
|
||||
}
|
||||
|
||||
func (s *StdTimers) SetBridge(b *Bridge) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.bridge = b
|
||||
}
|
||||
|
||||
func (s *StdTimers) Set(kind TimerKind, dur time.Duration) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.t[kind]; ok {
|
||||
old.Stop()
|
||||
}
|
||||
b := s.bridge
|
||||
s.t[kind] = time.AfterFunc(dur, func() {
|
||||
if b != nil {
|
||||
b.Timeout(kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StdTimers) Cancel(kind TimerKind) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.t[kind]; ok {
|
||||
old.Stop()
|
||||
delete(s.t, kind)
|
||||
}
|
||||
}
|
||||
344
calls/signaling/xmppsig/adapter.go
Normal file
344
calls/signaling/xmppsig/adapter.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
// xmpp side of signaling.Bridge; one Adapter per session,
|
||||
// sharing a *jingle.Manager for stanza dispatch
|
||||
package xmppsig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type AdapterConfig struct {
|
||||
Sender jingle.Sender // *xmpp.Component satisfies this
|
||||
LocalJID string // gateway's bare JID
|
||||
Manager *jingle.Manager
|
||||
// fresh PC per call, with the transceivers/tracks SDP negotiation needs
|
||||
PCFactory func() (*webrtc.PeerConnection, error)
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
cfg AdapterConfig
|
||||
}
|
||||
|
||||
func NewAdapter(cfg AdapterConfig) *Adapter { return &Adapter{cfg: cfg} }
|
||||
func (a *Adapter) Manager() *jingle.Manager { return a.cfg.Manager }
|
||||
func (a *Adapter) LocalJID() string { return a.cfg.LocalJID }
|
||||
|
||||
// xmpp side for a TG-originated call; PC and session built upfront so a
|
||||
// failure unwinds both. Bind must run before bridge.Start.
|
||||
func (a *Adapter) NewCaller(remoteJID string, callerUserID int64) (*Caller, error) {
|
||||
pc, err := a.cfg.PCFactory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PCFactory: %w", err)
|
||||
}
|
||||
// XEP-0353 requires a full JID; /telegabber matches the gateway's other
|
||||
// per-contact outbound stanzas
|
||||
localJID := fmt.Sprintf("%d@%s/telegabber", callerUserID, a.cfg.LocalJID)
|
||||
c := &Caller{xmppBase: xmppBase{
|
||||
a: a, remote: remoteJID, pc: pc, localJID: localJID,
|
||||
side: signaling.CallerSide,
|
||||
}}
|
||||
// must install OnTrack forwarder before SetRemoteDescription, otherwise
|
||||
// pion fires OnTrack during SDP processing and a late handler misses it
|
||||
c.hookTrack(pc)
|
||||
|
||||
sid := newSID()
|
||||
sess, err := jingle.New(jingle.SessionOpts{
|
||||
PC: pc, Sender: a.cfg.Sender,
|
||||
LocalJID: localJID, RemoteJID: remoteJID,
|
||||
SID: sid, Role: jingle.RoleInitiator, Media: []string{"audio"},
|
||||
Observer: c,
|
||||
})
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, fmt.Errorf("jingle.New: %w", err)
|
||||
}
|
||||
c.set(sid, sess)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// xmpp side for an XMPP-originated call; Bind must run before bridge.Start
|
||||
func (a *Adapter) NewCallee(p jingle.IncomingProposal) (*Callee, *jingle.Session, error) {
|
||||
pc, err := a.cfg.PCFactory()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("PCFactory: %w", err)
|
||||
}
|
||||
|
||||
c := &Callee{xmppBase: xmppBase{a: a, remote: p.From, pc: pc, side: signaling.CalleeSide}}
|
||||
c.hookTrack(pc)
|
||||
// LocalJID is the propose's `to`, not the bare component domain;
|
||||
// anotherim's libdino filters proceed with from.equals_bare(peer_state.jid)
|
||||
// so a proceed from the bare component domain silently fails to match
|
||||
// and anotherim never sends session-initiate
|
||||
localJID := p.To
|
||||
if localJID == "" {
|
||||
localJID = a.cfg.LocalJID
|
||||
}
|
||||
// Conversations' RtpSessionActivity crashes on bare-JID `with` once ICE
|
||||
// completes (IllegalStateException "No RTP connection found"); real
|
||||
// callees always send <proceed> from a full JID. Stick the SID on as
|
||||
// resource so concurrent calls don't collide.
|
||||
if !strings.ContainsRune(localJID, '/') && p.SID != "" {
|
||||
localJID = localJID + "/" + p.SID
|
||||
}
|
||||
sess, err := jingle.New(jingle.SessionOpts{
|
||||
PC: pc, Sender: a.cfg.Sender,
|
||||
LocalJID: localJID, RemoteJID: p.From,
|
||||
SID: p.SID, Role: jingle.RoleResponder, Media: p.Media,
|
||||
Observer: c,
|
||||
})
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
c.set(p.SID, sess)
|
||||
return c, sess, nil
|
||||
}
|
||||
|
||||
// wires pion's connection-state callback to bridge media transitions.
|
||||
// Disconnected can be transient (ICE restart) so only Failed reacts.
|
||||
// side dedups MediaConnected per source. Called from xmppBase.Bind.
|
||||
func hookPCState(pc *webrtc.PeerConnection, br *signaling.Bridge, side signaling.Side) {
|
||||
if pc == nil || br == nil {
|
||||
return
|
||||
}
|
||||
// DTLS logger lazily on first state change - the transceiver's
|
||||
// DTLSTransport doesn't exist until SetLocalDescription runs
|
||||
var dtlsHookOnce sync.Once
|
||||
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
||||
dtlsHookOnce.Do(func() { hookDTLSState(pc) })
|
||||
entry := log.WithField("state", s.String())
|
||||
// log the selected ICE pair on Connected - flaky calls have happened
|
||||
// when pion picked a docker-bridge or Tailscale pair whose STUN pings
|
||||
// succeed but real RTP doesn't transit. SCTP isn't negotiated for
|
||||
// audio-only, so reach the ICE transport via any RTP sender's
|
||||
// DTLSTransport (BUNDLE means they all share one).
|
||||
if s == webrtc.PeerConnectionStateConnected {
|
||||
var dtls *webrtc.DTLSTransport
|
||||
for _, snd := range pc.GetSenders() {
|
||||
if snd != nil && snd.Transport() != nil {
|
||||
dtls = snd.Transport()
|
||||
break
|
||||
}
|
||||
}
|
||||
if dtls == nil {
|
||||
for _, r := range pc.GetReceivers() {
|
||||
if r != nil && r.Transport() != nil {
|
||||
dtls = r.Transport()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case dtls == nil:
|
||||
entry = entry.WithField("ice_pair", "no-dtls-transport")
|
||||
case dtls.ICETransport() == nil:
|
||||
entry = entry.WithField("ice_pair", "no-ice-transport")
|
||||
default:
|
||||
pair, err := dtls.ICETransport().GetSelectedCandidatePair()
|
||||
switch {
|
||||
case err != nil:
|
||||
entry = entry.WithError(err).WithField("ice_pair", "get-selected-failed")
|
||||
case pair == nil || pair.Local == nil || pair.Remote == nil:
|
||||
entry = entry.WithField("ice_pair", "nil")
|
||||
default:
|
||||
entry = entry.WithFields(log.Fields{
|
||||
"local": fmt.Sprintf("%s:%d (%s)", pair.Local.Address, pair.Local.Port, pair.Local.Typ.String()),
|
||||
"remote": fmt.Sprintf("%s:%d (%s)", pair.Remote.Address, pair.Remote.Port, pair.Remote.Typ.String()),
|
||||
"local_related": pair.Local.RelatedAddress,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Debug("pion: PC connection state")
|
||||
switch s {
|
||||
case webrtc.PeerConnectionStateConnected:
|
||||
br.MediaConnected(side)
|
||||
case webrtc.PeerConnectionStateFailed:
|
||||
br.MediaFailed(signaling.ReasonMediaFailed)
|
||||
}
|
||||
})
|
||||
pc.OnICEConnectionStateChange(func(s webrtc.ICEConnectionState) {
|
||||
log.WithField("state", s.String()).Debug("pion: ICE connection state")
|
||||
})
|
||||
pc.OnICEGatheringStateChange(func(s webrtc.ICEGatheringState) {
|
||||
log.WithField("state", s.String()).Debug("pion: ICE gathering state")
|
||||
})
|
||||
// do NOT register pc.OnICECandidate here - jingle.New attaches the
|
||||
// session's trickle handler there, pion's API is replace-only, and
|
||||
// overwriting it strands srflx/relay candidates -> breaks TURN-only paths
|
||||
}
|
||||
|
||||
// logs DTLS state - separates "handshake never completed" from "completed
|
||||
// but no audio" (SRTP/SSRC/codec). Called from the first state change so
|
||||
// the transport actually exists.
|
||||
func hookDTLSState(pc *webrtc.PeerConnection) {
|
||||
var dtls *webrtc.DTLSTransport
|
||||
for _, s := range pc.GetSenders() {
|
||||
if s != nil && s.Transport() != nil {
|
||||
dtls = s.Transport()
|
||||
break
|
||||
}
|
||||
}
|
||||
if dtls == nil {
|
||||
for _, r := range pc.GetReceivers() {
|
||||
if r != nil && r.Transport() != nil {
|
||||
dtls = r.Transport()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if dtls == nil {
|
||||
log.Debug("pion: no DTLSTransport available at hook time; DTLS state changes will go unlogged")
|
||||
return
|
||||
}
|
||||
dtls.OnStateChange(func(s webrtc.DTLSTransportState) {
|
||||
log.WithField("state", s.String()).Info("pion: DTLS transport state")
|
||||
})
|
||||
}
|
||||
|
||||
func mapReason(r signaling.TerminationReason) string {
|
||||
switch r {
|
||||
case signaling.ReasonHangup:
|
||||
return jingle.ReasonSuccess
|
||||
case signaling.ReasonDecline:
|
||||
return jingle.ReasonDecline
|
||||
case signaling.ReasonBusy:
|
||||
return jingle.ReasonBusy
|
||||
case signaling.ReasonRingTimeout:
|
||||
return jingle.ReasonTimeout
|
||||
case signaling.ReasonExchangeTimeout, signaling.ReasonConnectTimeout:
|
||||
return jingle.ReasonConnectivityError
|
||||
case signaling.ReasonMediaFailed:
|
||||
return jingle.ReasonFailedTransport
|
||||
case signaling.ReasonPeerGone:
|
||||
return jingle.ReasonGone
|
||||
default:
|
||||
return jingle.ReasonGeneralError
|
||||
}
|
||||
}
|
||||
|
||||
func reasonFromCondition(cond string) signaling.TerminationReason {
|
||||
switch cond {
|
||||
case "rejected", jingle.ReasonDecline:
|
||||
return signaling.ReasonDecline
|
||||
case "retracted", jingle.ReasonGone, jingle.ReasonCancel:
|
||||
return signaling.ReasonPeerGone
|
||||
case jingle.ReasonBusy:
|
||||
return signaling.ReasonBusy
|
||||
case jingle.ReasonTimeout:
|
||||
return signaling.ReasonRingTimeout
|
||||
case jingle.ReasonConnectivityError:
|
||||
return signaling.ReasonConnectTimeout
|
||||
case jingle.ReasonFailedTransport, jingle.ReasonFailedApplication:
|
||||
return signaling.ReasonMediaFailed
|
||||
default:
|
||||
return signaling.ReasonHangup
|
||||
}
|
||||
}
|
||||
|
||||
var sidCounter atomic.Uint64
|
||||
|
||||
func newSID() string {
|
||||
return fmt.Sprintf("tg-call-%d", sidCounter.Add(1))
|
||||
}
|
||||
|
||||
// telegram user-id from a JID localpart: "12345@gw.example" -> (12345, true)
|
||||
func ParseTargetJID(jid string) (int64, bool) {
|
||||
parts := strings.SplitN(jid, "@", 2)
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// PC with no ICE servers - for tests; production uses MakePCFactory
|
||||
func DefaultPCFactory() (*webrtc.PeerConnection, error) {
|
||||
return buildPC(nil)
|
||||
}
|
||||
|
||||
// PCFactory pulling ICE servers from provider per call, so short-lived TURN
|
||||
// creds refresh without restarting the component
|
||||
func MakePCFactory(provider func() []webrtc.ICEServer) func() (*webrtc.PeerConnection, error) {
|
||||
return func() (*webrtc.PeerConnection, error) {
|
||||
var servers []webrtc.ICEServer
|
||||
if provider != nil {
|
||||
servers = provider()
|
||||
}
|
||||
return buildPC(servers)
|
||||
}
|
||||
}
|
||||
|
||||
func buildPC(iceServers []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
|
||||
// SetHandleUndeclaredSSRCWithoutAnswer: anotherim's libnice starts
|
||||
// sending RTP the moment ICE is selected, which often beats the local
|
||||
// SetRemoteDescription(answer) - so pion drops the inbound track with
|
||||
// "Incoming unhandled RTP ssrc(N), OnTrack will not be fired" and the
|
||||
// peer's audio never reaches TG. With this flag pion buffers early RTP.
|
||||
s := webrtc.SettingEngine{}
|
||||
s.SetHandleUndeclaredSSRCWithoutAnswer(true)
|
||||
m := &webrtc.MediaEngine{}
|
||||
if err := m.RegisterDefaultCodecs(); err != nil {
|
||||
return nil, fmt.Errorf("RegisterDefaultCodecs: %w", err)
|
||||
}
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(s), webrtc.WithMediaEngine(m))
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// placeholder opus track BEFORE the transceiver (not just a kind).
|
||||
// Otherwise pion's RTPSender.Send leaves hasSent=false, the later
|
||||
// ReplaceTrack short-circuits without Bind, the real track has no
|
||||
// packetizer, and WriteSample silently drops every sample. Caller
|
||||
// path always hit this because setupAudio attaches the real track
|
||||
// after SetLocalDescription.
|
||||
placeholder, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeOpus,
|
||||
ClockRate: 48000,
|
||||
Channels: 2,
|
||||
SDPFmtpLine: "minptime=10;useinbandfec=1",
|
||||
},
|
||||
"audio-placeholder",
|
||||
"telegabber-placeholder",
|
||||
)
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, fmt.Errorf("placeholder track: %w", err)
|
||||
}
|
||||
tr, err := pc.AddTransceiverFromTrack(placeholder, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
})
|
||||
if err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
// drain RTCP off the outgoing sender, otherwise its queue can stall
|
||||
// under load. exits when pc.Close makes Read error out.
|
||||
go drainSenderRTCP(tr.Sender())
|
||||
return pc, nil
|
||||
}
|
||||
|
||||
func drainSenderRTCP(sender *webrtc.RTPSender) {
|
||||
if sender == nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, err := sender.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
128
calls/signaling/xmppsig/base.go
Normal file
128
calls/signaling/xmppsig/base.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// per-call state shared by Caller and Callee
|
||||
type xmppBase struct {
|
||||
a *Adapter
|
||||
remote string
|
||||
// full JID we act as on the wire; falls back to a.cfg.LocalJID
|
||||
localJID string
|
||||
// CallerSide/CalleeSide for Bind's MediaConnected wiring
|
||||
side signaling.Side
|
||||
|
||||
mu sync.Mutex
|
||||
sid string
|
||||
session *jingle.Session
|
||||
bridge *signaling.Bridge
|
||||
pc *webrtc.PeerConnection
|
||||
remoteSDPSeen bool
|
||||
onRemoteSDP func(sdp string)
|
||||
|
||||
// pion fires OnTrack during SetRemoteDescription, before any handler
|
||||
// the orchestrator might want to install - so hookTrack runs a permanent
|
||||
// forwarder at PC construction and buffers into pendingTracks until
|
||||
// SetTrackHandler arrives
|
||||
onTrackFn func(t *webrtc.TrackRemote)
|
||||
pendingTracks []*webrtc.TrackRemote
|
||||
}
|
||||
|
||||
func (b *xmppBase) set(sid string, sess *jingle.Session) {
|
||||
b.mu.Lock()
|
||||
b.sid, b.session = sid, sess
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// attaches the bridge and wires pion's connection-state callback
|
||||
func (b *xmppBase) Bind(br *signaling.Bridge) {
|
||||
b.mu.Lock()
|
||||
b.bridge = br
|
||||
pc := b.pc
|
||||
side := b.side
|
||||
b.mu.Unlock()
|
||||
hookPCState(pc, br, side)
|
||||
}
|
||||
|
||||
func (b *xmppBase) bridgeRef() *signaling.Bridge {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.bridge
|
||||
}
|
||||
|
||||
func (b *xmppBase) sessionRef() *jingle.Session {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.session
|
||||
}
|
||||
|
||||
func (b *xmppBase) cleanup() {
|
||||
b.mu.Lock()
|
||||
sid := b.sid
|
||||
b.mu.Unlock()
|
||||
if sid == "" {
|
||||
return
|
||||
}
|
||||
b.a.cfg.Manager.Unregister(sid)
|
||||
}
|
||||
|
||||
func (b *xmppBase) PeerConnection() *webrtc.PeerConnection {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.pc
|
||||
}
|
||||
|
||||
func (b *xmppBase) SetOnRemoteSDP(fn func(sdp string)) {
|
||||
b.mu.Lock()
|
||||
b.onRemoteSDP = fn
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// must run before the first SetRemoteDescription, or pion's first
|
||||
// OnTrack event is lost
|
||||
func (b *xmppBase) hookTrack(pc *webrtc.PeerConnection) {
|
||||
pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
b.mu.Lock()
|
||||
fn := b.onTrackFn
|
||||
if fn == nil {
|
||||
b.pendingTracks = append(b.pendingTracks, t)
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.mu.Unlock()
|
||||
fn(t)
|
||||
})
|
||||
}
|
||||
|
||||
// swaps in the real handler and drains buffered tracks
|
||||
func (b *xmppBase) SetTrackHandler(fn func(t *webrtc.TrackRemote)) {
|
||||
b.mu.Lock()
|
||||
b.onTrackFn = fn
|
||||
pending := b.pendingTracks
|
||||
b.pendingTracks = nil
|
||||
b.mu.Unlock()
|
||||
for _, t := range pending {
|
||||
fn(t)
|
||||
}
|
||||
}
|
||||
|
||||
// fires the orchestrator hook exactly once per call
|
||||
func (b *xmppBase) onRemoteDescription(sdp string) {
|
||||
b.mu.Lock()
|
||||
if b.remoteSDPSeen {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.remoteSDPSeen = true
|
||||
hook := b.onRemoteSDP
|
||||
b.mu.Unlock()
|
||||
if hook != nil {
|
||||
hook(sdp)
|
||||
}
|
||||
}
|
||||
75
calls/signaling/xmppsig/callee.go
Normal file
75
calls/signaling/xmppsig/callee.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// xmpp-side Callee for XMPP-originated calls; the gateway is the JMI responder
|
||||
type Callee struct {
|
||||
xmppBase
|
||||
}
|
||||
|
||||
func (c *Callee) Ringing() {
|
||||
sess := c.sessionRef()
|
||||
if sess == nil || sess.State() != jingle.StateRinging {
|
||||
return
|
||||
}
|
||||
if err := sess.Ringing(context.Background()); err != nil {
|
||||
log.WithField("remote", c.remote).WithError(err).Debug("xmppsig.Callee.Ringing: send failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Callee) Accept() {
|
||||
sess := c.sessionRef()
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
if err := sess.AcceptProposal(context.Background()); err != nil {
|
||||
log.WithField("remote", c.remote).WithError(err).Error("xmppsig.Callee.Accept: AcceptProposal failed")
|
||||
if b := c.bridgeRef(); b != nil {
|
||||
go b.Terminate(signaling.ReasonUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pick the right verb for the current session state: Close, Decline, or
|
||||
// session-terminate
|
||||
func (c *Callee) Terminate(reason signaling.TerminationReason) {
|
||||
sess := c.sessionRef()
|
||||
cond := mapReason(reason)
|
||||
var err error
|
||||
switch sess.State() {
|
||||
case jingle.StateNew:
|
||||
err = sess.Close()
|
||||
case jingle.StateRinging:
|
||||
err = sess.Decline(context.Background(), cond)
|
||||
case jingle.StateTerminated:
|
||||
// already torn down
|
||||
default:
|
||||
err = sess.Terminate(context.Background(), cond)
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("xmppsig.Callee.Terminate: tear-down send failed")
|
||||
}
|
||||
c.cleanup()
|
||||
}
|
||||
|
||||
// never fire on the responder side
|
||||
func (c *Callee) OnRinging(_ string) {}
|
||||
func (c *Callee) OnProceeded(_ string) {}
|
||||
|
||||
func (c *Callee) OnRemoteDescriptionApplied(sdp string) {
|
||||
c.onRemoteDescription(sdp)
|
||||
}
|
||||
|
||||
func (c *Callee) OnTerminated(reason string) {
|
||||
if b := c.bridgeRef(); b != nil {
|
||||
go b.Terminate(reasonFromCondition(reason))
|
||||
}
|
||||
c.cleanup()
|
||||
}
|
||||
224
calls/signaling/xmppsig/callee_test.go
Normal file
224
calls/signaling/xmppsig/callee_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// cross-side teardown for XMPP-originated calls:
|
||||
// inbound terminate -> Session observer -> bridge.Terminate -> both halves Terminate
|
||||
|
||||
func TestCalleeOnTerminated_SessionTerminate_TearsDownTGCaller(t *testing.T) {
|
||||
callee, _, tgSide := newBoundCallee(t)
|
||||
callee.OnTerminated(jingle.ReasonSuccess)
|
||||
waitUntil(t, "tg-Caller.Terminate", time.Second, tgSide.isTerminated)
|
||||
if tgSide.reason() != signaling.ReasonHangup {
|
||||
t.Errorf("tg-side reason = %v, want ReasonHangup", tgSide.reason())
|
||||
}
|
||||
}
|
||||
|
||||
// inbound JMI <retract> in StateRinging drives the same teardown
|
||||
func TestCalleeInboundRetract_TearsDownTGCaller(t *testing.T) {
|
||||
callee, sess, tgSide := newBoundCallee(t)
|
||||
// simulate the post-propose StateRinging by hand
|
||||
sess.HandleJMI("alice@xmpp.example/desktop", &jingle.JMIPropose{
|
||||
ID: sess.SID(),
|
||||
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
|
||||
})
|
||||
waitUntil(t, "session in ringing", time.Second, func() bool {
|
||||
return sess.State() == jingle.StateRinging
|
||||
})
|
||||
|
||||
mgr := callee.a.cfg.Manager
|
||||
ret := stanza.NewMessage(stanza.Attrs{
|
||||
Type: stanza.MessageTypeChat,
|
||||
From: "alice@xmpp.example/desktop",
|
||||
To: "gw.example",
|
||||
Id: "ret-1",
|
||||
})
|
||||
ret.Extensions = []stanza.MsgExtension{&jingle.JMIRetract{ID: sess.SID()}}
|
||||
mgr.HandleMessage(&ret)
|
||||
|
||||
waitUntil(t, "tg-Caller.Terminate", time.Second, tgSide.isTerminated)
|
||||
if tgSide.reason() != signaling.ReasonPeerGone {
|
||||
t.Errorf("tg-side reason = %v, want ReasonPeerGone", tgSide.reason())
|
||||
}
|
||||
}
|
||||
|
||||
// outgoing JMI <ringing>/<proceed> `from` must match propose's `to`,
|
||||
// otherwise libdino's from.equals_bare(peer_state.jid) silently drops
|
||||
// the proceed and no session-initiate follows
|
||||
func TestCallee_RingingProceedFromMatchesProposeTo(t *testing.T) {
|
||||
send := &recSender{}
|
||||
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
|
||||
a := NewAdapter(AdapterConfig{
|
||||
Sender: send, LocalJID: "gw.example", Manager: mgr,
|
||||
PCFactory: pcFactory(t),
|
||||
})
|
||||
|
||||
// realistic XMPP-originated propose
|
||||
prop := jingle.IncomingProposal{
|
||||
SID: "incoming-sid-1",
|
||||
From: "alice@xmpp.example/desktop",
|
||||
To: "12345@gw.example",
|
||||
Media: []string{"audio"},
|
||||
}
|
||||
_, sess, err := a.NewCallee(prop)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCallee: %v", err)
|
||||
}
|
||||
mgr.Register(sess)
|
||||
|
||||
// Bring the session into StateRinging via the same path production
|
||||
// uses, so Ringing/AcceptProposal pass their state guards.
|
||||
sess.HandleJMI(prop.From, &jingle.JMIPropose{
|
||||
ID: prop.SID,
|
||||
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
|
||||
})
|
||||
waitUntil(t, "session in ringing", time.Second, func() bool {
|
||||
return sess.State() == jingle.StateRinging
|
||||
})
|
||||
|
||||
if err := sess.Ringing(context.Background()); err != nil {
|
||||
t.Fatalf("Ringing: %v", err)
|
||||
}
|
||||
if err := sess.AcceptProposal(context.Background()); err != nil {
|
||||
t.Fatalf("AcceptProposal: %v", err)
|
||||
}
|
||||
|
||||
// every JMI <message>: from's bare must equal propose.To and from must
|
||||
// be a full JID (Conversations crashes on bare-JID `with`)
|
||||
wantBare := prop.To
|
||||
wantFrom := prop.To + "/" + prop.SID
|
||||
dontWantFrom := "gw.example"
|
||||
var sawRinging, sawProceed bool
|
||||
for _, pkt := range send.snapshot() {
|
||||
var msg *stanza.Message
|
||||
switch m := pkt.(type) {
|
||||
case *stanza.Message:
|
||||
msg = m
|
||||
case stanza.Message:
|
||||
msg = &m
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if msg.From == dontWantFrom {
|
||||
t.Errorf("outbound JMI message from=%q (the bare component domain); want bare=%q with a resource", msg.From, wantBare)
|
||||
}
|
||||
if msg.From != wantFrom {
|
||||
t.Errorf("outbound JMI message from=%q; want %q", msg.From, wantFrom)
|
||||
}
|
||||
if !strings.Contains(msg.From, "/") {
|
||||
t.Errorf("outbound JMI message from=%q is bare; Conversations routes bare-JID updates through a crash-prone proposal-state lambda", msg.From)
|
||||
}
|
||||
for _, ext := range msg.Extensions {
|
||||
switch ext.(type) {
|
||||
case *jingle.JMIRinging:
|
||||
sawRinging = true
|
||||
case *jingle.JMIProceed:
|
||||
sawProceed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawRinging {
|
||||
t.Error("never observed an outbound <ringing>")
|
||||
}
|
||||
if !sawProceed {
|
||||
t.Error("never observed an outbound <proceed>")
|
||||
}
|
||||
}
|
||||
|
||||
// <ringing> must come from Callee.Ringing (driven by tdlib Pending), not
|
||||
// from Callee.Accept; otherwise back-to-back ringing+proceed produces a
|
||||
// millisecond ringback window in Dino/anotherim and "discovering devices"
|
||||
// in Conversations
|
||||
func TestCalleeAccept_DoesNotEmitRinging(t *testing.T) {
|
||||
send := &recSender{}
|
||||
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
|
||||
a := NewAdapter(AdapterConfig{
|
||||
Sender: send, LocalJID: "gw.example", Manager: mgr,
|
||||
PCFactory: pcFactory(t),
|
||||
})
|
||||
prop := jingle.IncomingProposal{
|
||||
SID: "sid-accept-no-ring",
|
||||
From: "alice@xmpp.example/desktop",
|
||||
To: "12345@gw.example",
|
||||
Media: []string{"audio"},
|
||||
}
|
||||
callee, sess, err := a.NewCallee(prop)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCallee: %v", err)
|
||||
}
|
||||
mgr.Register(sess)
|
||||
|
||||
sess.HandleJMI(prop.From, &jingle.JMIPropose{
|
||||
ID: prop.SID,
|
||||
Descriptions: []jingle.JMIDescription{{Media: "audio"}},
|
||||
})
|
||||
waitUntil(t, "session in ringing", time.Second, func() bool {
|
||||
return sess.State() == jingle.StateRinging
|
||||
})
|
||||
|
||||
// Accept without Ringing: <proceed> must appear, <ringing> must not
|
||||
callee.Accept()
|
||||
waitUntil(t, "proceed emitted", time.Second, func() bool {
|
||||
for _, pkt := range send.snapshot() {
|
||||
msg, ok := pkt.(*stanza.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, ext := range msg.Extensions {
|
||||
if _, isProceed := ext.(*jingle.JMIProceed); isProceed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
for _, pkt := range send.snapshot() {
|
||||
msg, ok := pkt.(*stanza.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, ext := range msg.Extensions {
|
||||
if _, isRinging := ext.(*jingle.JMIRinging); isRinging {
|
||||
t.Fatalf("Accept emitted <ringing>; <ringing> must come exclusively from Callee.Ringing (split was deliberate, see callee.go)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newBoundCallee(t *testing.T) (*Callee, *jingle.Session, *recBridgeSide) {
|
||||
t.Helper()
|
||||
send := &recSender{}
|
||||
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
|
||||
a := NewAdapter(AdapterConfig{
|
||||
Sender: send, LocalJID: "gw.example", Manager: mgr,
|
||||
PCFactory: pcFactory(t),
|
||||
})
|
||||
callee, sess, err := a.NewCallee(jingle.IncomingProposal{
|
||||
SID: "incoming-sid-1", From: "alice@xmpp.example/desktop", To: "gw.example", Media: []string{"audio"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCallee: %v", err)
|
||||
}
|
||||
// register so inbound stanzas in integration tests find the session
|
||||
mgr.Register(sess)
|
||||
|
||||
tgSide := &recBridgeSide{}
|
||||
br := signaling.New(signaling.Config{
|
||||
Caller: tgSide, Callee: callee, Timers: noopTimers{},
|
||||
})
|
||||
callee.Bind(br)
|
||||
// bridge.Start moves out of StateIdle so terminate fires for real
|
||||
br.Start()
|
||||
return callee, sess, tgSide
|
||||
}
|
||||
86
calls/signaling/xmppsig/caller.go
Normal file
86
calls/signaling/xmppsig/caller.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// xmpp-side Caller for TG-originated calls; the gateway is the JMI initiator
|
||||
type Caller struct {
|
||||
xmppBase
|
||||
}
|
||||
|
||||
// invoked by Bridge under its mutex; bridge re-entry must be deferred
|
||||
func (c *Caller) Start() {
|
||||
sess := c.sessionRef()
|
||||
entry := log.WithFields(log.Fields{
|
||||
"sid": sess.SID(),
|
||||
"remote": c.remote,
|
||||
"local_jid": c.localJID,
|
||||
})
|
||||
|
||||
c.a.cfg.Manager.Register(sess)
|
||||
|
||||
if err := sess.Propose(context.Background()); err != nil {
|
||||
entry.WithError(err).Error("xmppsig.Caller.Start: Session.Propose failed")
|
||||
if b := c.bridgeRef(); b != nil {
|
||||
go b.Terminate(signaling.ReasonUnknown)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// pick the right verb for the current session state: Close, Retract, or
|
||||
// session-terminate
|
||||
func (c *Caller) Terminate(reason signaling.TerminationReason) {
|
||||
sess := c.sessionRef()
|
||||
cond := mapReason(reason)
|
||||
var err error
|
||||
switch sess.State() {
|
||||
case jingle.StateNew:
|
||||
err = sess.Close()
|
||||
case jingle.StateProposed:
|
||||
err = sess.Retract(context.Background())
|
||||
case jingle.StateTerminated:
|
||||
// already torn down
|
||||
default:
|
||||
err = sess.Terminate(context.Background(), cond)
|
||||
}
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{"reason": reason, "remote": c.remote}).
|
||||
WithError(err).Debug("xmppsig.Caller.Terminate: tear-down send failed")
|
||||
}
|
||||
c.cleanup()
|
||||
}
|
||||
|
||||
// jingle.SessionObserver
|
||||
|
||||
func (c *Caller) OnRinging(_ string) {
|
||||
if b := c.bridgeRef(); b != nil {
|
||||
go b.Ringing()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Caller) OnProceeded(_ string) {
|
||||
if b := c.bridgeRef(); b != nil {
|
||||
go b.CalleeAccepted()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Caller) OnRemoteDescriptionApplied(sdp string) {
|
||||
c.onRemoteDescription(sdp)
|
||||
}
|
||||
|
||||
// the local Terminate then no-ops on the wire because the session is
|
||||
// already StateTerminated; cleanup runs early to shorten the window
|
||||
// the session stays registered
|
||||
func (c *Caller) OnTerminated(reason string) {
|
||||
if b := c.bridgeRef(); b != nil {
|
||||
go b.Terminate(reasonFromCondition(reason))
|
||||
}
|
||||
c.cleanup()
|
||||
}
|
||||
217
calls/signaling/xmppsig/caller_test.go
Normal file
217
calls/signaling/xmppsig/caller_test.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// goroutine-safe; Session.Send fires from background goroutines after
|
||||
// JMI <proceed>
|
||||
type recSender struct {
|
||||
mu sync.Mutex
|
||||
sent []stanza.Packet
|
||||
iqSent []*stanza.IQ
|
||||
}
|
||||
|
||||
func (r *recSender) Send(p stanza.Packet) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.sent = append(r.sent, p)
|
||||
if iq, ok := p.(*stanza.IQ); ok {
|
||||
r.iqSent = append(r.iqSent, iq)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
|
||||
ch := make(chan stanza.IQ, 1)
|
||||
return ch, r.Send(iq)
|
||||
}
|
||||
|
||||
func (r *recSender) SendRaw(string) error { return nil }
|
||||
|
||||
func (r *recSender) snapshot() []stanza.Packet {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]stanza.Packet, len(r.sent))
|
||||
copy(out, r.sent)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *recSender) firstMessage() *stanza.Message {
|
||||
for _, p := range r.snapshot() {
|
||||
switch m := p.(type) {
|
||||
case *stanza.Message:
|
||||
return m
|
||||
case stanza.Message:
|
||||
return &m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// records bridge-side Terminate firings; serves as the other-side stand-in
|
||||
// for tests of either xmppsig.Caller or xmppsig.Callee
|
||||
type recBridgeSide struct {
|
||||
mu sync.Mutex
|
||||
startedOrAccept bool
|
||||
terminated bool
|
||||
terminateReason signaling.TerminationReason
|
||||
}
|
||||
|
||||
func (r *recBridgeSide) Start() { r.mu.Lock(); r.startedOrAccept = true; r.mu.Unlock() }
|
||||
func (r *recBridgeSide) Ringing() {}
|
||||
func (r *recBridgeSide) Accept() { r.mu.Lock(); r.startedOrAccept = true; r.mu.Unlock() }
|
||||
func (r *recBridgeSide) Terminate(reason signaling.TerminationReason) {
|
||||
r.mu.Lock()
|
||||
r.terminated = true
|
||||
r.terminateReason = reason
|
||||
r.mu.Unlock()
|
||||
}
|
||||
func (r *recBridgeSide) isTerminated() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.terminated
|
||||
}
|
||||
func (r *recBridgeSide) reason() signaling.TerminationReason {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.terminateReason
|
||||
}
|
||||
|
||||
type noopTimers struct{}
|
||||
|
||||
func (noopTimers) Set(signaling.TimerKind, time.Duration) {}
|
||||
func (noopTimers) Cancel(signaling.TimerKind) {}
|
||||
|
||||
// real pion PC; faking webrtc is more invasive than running on loopback
|
||||
func pcFactory(t *testing.T) func() (*webrtc.PeerConnection, error) {
|
||||
t.Helper()
|
||||
return func() (*webrtc.PeerConnection, error) {
|
||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Cleanup(func() { _ = pc.Close() })
|
||||
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
}); err != nil {
|
||||
_ = pc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pc, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnTerminated fires bridge.Terminate on a goroutine (deferred re-entry),
|
||||
// so callers poll
|
||||
func waitUntil(t *testing.T, desc string, d time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waitUntil(%s): timed out after %v", desc, d)
|
||||
}
|
||||
|
||||
// JMI <propose> must use a full JID (userID@gw/telegabber). XEP-0353
|
||||
// requires it so the recipient can route <proceed>/<reject> back.
|
||||
|
||||
func TestCallerStart_SendsProposeWithFullLocalJID(t *testing.T) {
|
||||
send := &recSender{}
|
||||
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
|
||||
a := NewAdapter(AdapterConfig{
|
||||
Sender: send, LocalJID: "gw.example", Manager: mgr,
|
||||
PCFactory: pcFactory(t),
|
||||
})
|
||||
caller, err := a.NewCaller("alice@xmpp.example", 12345)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCaller: %v", err)
|
||||
}
|
||||
br := signaling.New(signaling.Config{
|
||||
Caller: caller, Callee: &recBridgeSide{}, Timers: noopTimers{},
|
||||
})
|
||||
caller.Bind(br)
|
||||
|
||||
br.Start() // triggers caller.Start synchronously
|
||||
|
||||
msg := send.firstMessage()
|
||||
if msg == nil {
|
||||
t.Fatal("no message sent - Caller.Start did not produce a JMI <propose>")
|
||||
}
|
||||
if msg.From != "12345@gw.example/telegabber" {
|
||||
t.Errorf("propose from = %q, want %q (full JID with resource per XEP-0353)", msg.From, "12345@gw.example/telegabber")
|
||||
}
|
||||
if msg.To != "alice@xmpp.example" {
|
||||
t.Errorf("propose to = %q, want alice@xmpp.example", msg.To)
|
||||
}
|
||||
if string(msg.Type) != "chat" {
|
||||
t.Errorf("propose type = %q, want chat", msg.Type)
|
||||
}
|
||||
|
||||
// payload: one <propose> in JMI ns with a <description media="audio">
|
||||
xmlBytes, err := xml.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
s := string(xmlBytes)
|
||||
if !strings.Contains(s, `<propose xmlns="urn:xmpp:jingle-message:0"`) {
|
||||
t.Errorf("propose missing JMI namespace: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio"`) {
|
||||
t.Errorf("propose missing rtp description with media=audio: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// cross-side teardown for TG-originated calls:
|
||||
// inbound <reject>/<retract>/session-terminate -> observer -> bridge.Terminate
|
||||
|
||||
func TestCallerOnTerminated_RejectedReason_TearsDownTGCalleeWithDecline(t *testing.T) {
|
||||
caller, tgSide := newStartedCaller(t)
|
||||
caller.OnTerminated("rejected")
|
||||
|
||||
waitUntil(t, "tg-Callee.Terminate", time.Second, tgSide.isTerminated)
|
||||
if tgSide.reason() != signaling.ReasonDecline {
|
||||
t.Errorf("tg-side reason = %v, want ReasonDecline", tgSide.reason())
|
||||
}
|
||||
}
|
||||
|
||||
// Caller bound to a TG-originated bridge, started so the session is in
|
||||
// StateProposed; returns the Caller and the tg-side stand-in
|
||||
func newStartedCaller(t *testing.T) (*Caller, *recBridgeSide) {
|
||||
t.Helper()
|
||||
send := &recSender{}
|
||||
mgr := &jingle.Manager{LocalJID: "gw.example", Sender: send}
|
||||
a := NewAdapter(AdapterConfig{
|
||||
Sender: send, LocalJID: "gw.example", Manager: mgr,
|
||||
PCFactory: pcFactory(t),
|
||||
})
|
||||
caller, err := a.NewCaller("alice@xmpp.example", 12345)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCaller: %v", err)
|
||||
}
|
||||
tgSide := &recBridgeSide{}
|
||||
br := signaling.New(signaling.Config{
|
||||
Caller: caller, Callee: tgSide, Timers: noopTimers{},
|
||||
})
|
||||
caller.Bind(br)
|
||||
br.Start()
|
||||
waitUntil(t, "session proposed", time.Second, func() bool {
|
||||
s := caller.sessionRef()
|
||||
return s != nil && s.State() == jingle.StateProposed
|
||||
})
|
||||
return caller, tgSide
|
||||
}
|
||||
167
calls/signaling/xmppsig/extdisco.go
Normal file
167
calls/signaling/xmppsig/extdisco.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"gosrc.io/xmpp"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// XEP-0215 v2; older v1 is dead on modern prosody/ejabberd
|
||||
const NSExtDisco = "urn:xmpp:extdisco:2"
|
||||
|
||||
// one resolved STUN/TURN/TURNS entry; for TURN the user/pass are
|
||||
// short-lived HMAC creds the server issues per-query
|
||||
type Service struct {
|
||||
Type string // "stun" | "turn" | "turns" | "stuns"
|
||||
Host string
|
||||
Port int
|
||||
Transport string // "udp" | "tcp"
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// XEP-0215 wire encoding. Port is decoded as string because some servers
|
||||
// have emitted non-numeric values; we surface the parse error.
|
||||
type servicesQuery struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:extdisco:2 services"`
|
||||
Services []serviceElem `xml:"service"`
|
||||
}
|
||||
|
||||
type serviceElem struct {
|
||||
XMLName xml.Name `xml:"service"`
|
||||
Type string `xml:"type,attr"`
|
||||
Host string `xml:"host,attr"`
|
||||
Port string `xml:"port,attr"`
|
||||
Transport string `xml:"transport,attr"`
|
||||
Username string `xml:"username,attr,omitempty"`
|
||||
Password string `xml:"password,attr,omitempty"`
|
||||
}
|
||||
|
||||
// gosrc.io/xmpp IQ payload interface
|
||||
func (servicesQuery) Namespace() string { return NSExtDisco }
|
||||
func (servicesQuery) Name() string { return "services" }
|
||||
func (servicesQuery) GetSet() *stanza.ResultSet { return nil }
|
||||
|
||||
// XEP-0215 services request; toJID is the c2s/parent server domain.
|
||||
// Empty slice is a legitimate result.
|
||||
func QueryServices(sender xmpp.Sender, fromJID, toJID string, timeout time.Duration) ([]Service, error) {
|
||||
iq, err := stanza.NewIQ(stanza.Attrs{
|
||||
Type: stanza.IQTypeGet,
|
||||
From: fromJID,
|
||||
To: toJID,
|
||||
Id: "extdisco-" + uuid.New().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IQ: %w", err)
|
||||
}
|
||||
iq.Payload = &servicesQuery{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
ch, err := sender.SendIQ(ctx, iq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send IQ: %w", err)
|
||||
}
|
||||
|
||||
var resp stanza.IQ
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("extdisco timeout after %s", timeout)
|
||||
case resp = <-ch:
|
||||
}
|
||||
|
||||
if resp.Type == stanza.IQTypeError {
|
||||
// dump the marshalled IQ so the <error> condition is visible
|
||||
raw, _ := xml.Marshal(resp)
|
||||
return nil, fmt.Errorf("extdisco error from %s: %s", toJID, string(raw))
|
||||
}
|
||||
|
||||
// gosrc.io decodes unrecognised payloads opaquely - re-marshal and
|
||||
// parse <services> ourselves rather than fight the stanza registry
|
||||
raw, err := xml.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("re-marshal response: %w", err)
|
||||
}
|
||||
return extractServices(raw)
|
||||
}
|
||||
|
||||
// unit-testable without gosrc.io; walks raw IQ XML
|
||||
func extractServices(raw []byte) ([]Service, error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(raw))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no <services> element in IQ")
|
||||
}
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if se.Name.Space != NSExtDisco || se.Name.Local != "services" {
|
||||
continue
|
||||
}
|
||||
var q servicesQuery
|
||||
if err := dec.DecodeElement(&q, &se); err != nil {
|
||||
return nil, fmt.Errorf("decode services: %w", err)
|
||||
}
|
||||
out := make([]Service, 0, len(q.Services))
|
||||
for _, s := range q.Services {
|
||||
port, err := strconv.Atoi(s.Port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service %s: bad port %q: %w", s.Type, s.Port, err)
|
||||
}
|
||||
out = append(out, Service{
|
||||
Type: s.Type,
|
||||
Host: s.Host,
|
||||
Port: port,
|
||||
Transport: s.Transport,
|
||||
Username: s.Username,
|
||||
Password: s.Password,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// XEP-0215 services -> pion webrtc.ICEServer list
|
||||
func ToICEServers(services []Service) []webrtc.ICEServer {
|
||||
out := make([]webrtc.ICEServer, 0, len(services))
|
||||
for _, s := range services {
|
||||
switch s.Type {
|
||||
case "stun", "stuns":
|
||||
out = append(out, webrtc.ICEServer{
|
||||
URLs: []string{fmt.Sprintf("%s:%s:%d", s.Type, s.Host, s.Port)},
|
||||
})
|
||||
case "turn", "turns":
|
||||
// skip turn entries without creds - pion rejects the whole PC
|
||||
// (InvalidAccessError) on any one missing-creds entry. Common
|
||||
// cause: prosody's mod_external_services only auto-maps
|
||||
// algorithms["turn"], so a `turns` entry needs an explicit
|
||||
// algorithm = "turn" to get creds generated.
|
||||
if s.Username == "" || s.Password == "" {
|
||||
continue
|
||||
}
|
||||
url := fmt.Sprintf("%s:%s:%d", s.Type, s.Host, s.Port)
|
||||
if s.Transport != "" {
|
||||
url += "?transport=" + s.Transport
|
||||
}
|
||||
out = append(out, webrtc.ICEServer{
|
||||
URLs: []string{url},
|
||||
Username: s.Username,
|
||||
Credential: s.Password,
|
||||
CredentialType: webrtc.ICECredentialTypePassword,
|
||||
})
|
||||
default:
|
||||
// skip unknown service types (e.g. ftp from older XEPs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
181
calls/signaling/xmppsig/extdisco_test.go
Normal file
181
calls/signaling/xmppsig/extdisco_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
func TestExtractServices_ProsodyResponse(t *testing.T) {
|
||||
// verbatim shape from prosody 0.12 mod_external_services
|
||||
raw := []byte(`<iq xmlns="jabber:client" type="result" from="localhost" to="probe@localhost/abc" id="x">` +
|
||||
`<services xmlns="urn:xmpp:extdisco:2">` +
|
||||
`<service transport="udp" port="3478" host="172.18.0.1" type="stun"/>` +
|
||||
`<service restricted="1" port="3478" transport="udp" username="1779481470" password="F5aXytaIwbEJWt6FJ66OqeEQtvw=" host="172.18.0.1" type="turn"/>` +
|
||||
`</services>` +
|
||||
`</iq>`)
|
||||
got, err := extractServices(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("extractServices: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 services, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0] != (Service{Type: "stun", Host: "172.18.0.1", Port: 3478, Transport: "udp"}) {
|
||||
t.Errorf("STUN entry mismatch: %+v", got[0])
|
||||
}
|
||||
if got[1] != (Service{
|
||||
Type: "turn", Host: "172.18.0.1", Port: 3478, Transport: "udp",
|
||||
Username: "1779481470", Password: "F5aXytaIwbEJWt6FJ66OqeEQtvw=",
|
||||
}) {
|
||||
t.Errorf("TURN entry mismatch: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToICEServers_StunAndTurn(t *testing.T) {
|
||||
in := []Service{
|
||||
{Type: "stun", Host: "stun.example.com", Port: 3478, Transport: "udp"},
|
||||
{Type: "turn", Host: "turn.example.com", Port: 3478, Transport: "udp", Username: "u", Password: "p"},
|
||||
{Type: "turns", Host: "turn.example.com", Port: 5349, Transport: "tcp", Username: "u", Password: "p"},
|
||||
}
|
||||
out := ToICEServers(in)
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("want 3 servers, got %d", len(out))
|
||||
}
|
||||
if out[0].URLs[0] != "stun:stun.example.com:3478" {
|
||||
t.Errorf("STUN URL mismatch: %q", out[0].URLs[0])
|
||||
}
|
||||
if out[1].URLs[0] != "turn:turn.example.com:3478?transport=udp" {
|
||||
t.Errorf("TURN URL mismatch: %q", out[1].URLs[0])
|
||||
}
|
||||
if out[1].Username != "u" || out[1].Credential != "p" {
|
||||
t.Errorf("TURN creds mismatch: user=%q cred=%v", out[1].Username, out[1].Credential)
|
||||
}
|
||||
if out[1].CredentialType != webrtc.ICECredentialTypePassword {
|
||||
t.Errorf("TURN cred type: want Password, got %v", out[1].CredentialType)
|
||||
}
|
||||
if out[2].URLs[0] != "turns:turn.example.com:5349?transport=tcp" {
|
||||
t.Errorf("TURNS URL mismatch: %q", out[2].URLs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// pion rejects the whole PC on any creds-less turn URL, so a single
|
||||
// misconfigured prosody entry must not poison every call
|
||||
func TestToICEServers_SkipsBrokenAndUnknown(t *testing.T) {
|
||||
in := []Service{
|
||||
{Type: "stun", Host: "h", Port: 3478, Transport: "udp"},
|
||||
{Type: "turn", Host: "h", Port: 3478, Transport: "udp", Username: "u", Password: "p"},
|
||||
// turns with creds: kept
|
||||
{Type: "turns", Host: "h", Port: 5349, Transport: "tcp", Username: "u", Password: "p"},
|
||||
// turn with no creds: dropped
|
||||
{Type: "turn", Host: "broken", Port: 3478, Transport: "udp"},
|
||||
// turns with no creds (prosody algorithm-omission bug): dropped
|
||||
{Type: "turns", Host: "broken", Port: 5349, Transport: "tcp"},
|
||||
// turn with username but no password: dropped (defensive)
|
||||
{Type: "turn", Host: "half", Port: 3478, Transport: "udp", Username: "u"},
|
||||
// unknown type: dropped
|
||||
{Type: "ftp", Host: "h", Port: 21, Transport: "tcp"},
|
||||
}
|
||||
out := ToICEServers(in)
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("want 3 servers (stun + turn-with-creds + turns-with-creds), got %d: %+v", len(out), out)
|
||||
}
|
||||
for _, srv := range out {
|
||||
for _, u := range srv.URLs {
|
||||
if (containsScheme(u, "turn:") || containsScheme(u, "turns:")) && (srv.Username == "" || srv.Credential == nil || srv.Credential == "") {
|
||||
t.Errorf("TURN entry kept without creds: %+v", srv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsScheme(url, scheme string) bool {
|
||||
return len(url) >= len(scheme) && url[:len(scheme)] == scheme
|
||||
}
|
||||
|
||||
// minimal Sender for QueryServices E2E against a hand-crafted response
|
||||
type fakeSender struct {
|
||||
sent []stanza.Packet
|
||||
reply func(*stanza.IQ) stanza.IQ
|
||||
failSend bool
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(p stanza.Packet) error {
|
||||
if f.failSend {
|
||||
return fmt.Errorf("simulated send failure")
|
||||
}
|
||||
f.sent = append(f.sent, p)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeSender) SendRaw(string) error { return nil }
|
||||
func (f *fakeSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
|
||||
ch := make(chan stanza.IQ, 1)
|
||||
if err := f.Send(iq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.reply != nil {
|
||||
ch <- f.reply(iq)
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestQueryServices_ParsesReply(t *testing.T) {
|
||||
fs := &fakeSender{
|
||||
reply: func(req *stanza.IQ) stanza.IQ {
|
||||
respPtr, _ := stanza.NewIQ(stanza.Attrs{
|
||||
Type: stanza.IQTypeResult,
|
||||
From: req.To,
|
||||
To: req.From,
|
||||
Id: req.Id,
|
||||
})
|
||||
respPtr.Payload = &servicesQuery{Services: []serviceElem{
|
||||
{Type: "stun", Host: "1.2.3.4", Port: "3478", Transport: "udp"},
|
||||
{Type: "turn", Host: "1.2.3.4", Port: "3478", Transport: "udp", Username: "u", Password: "p"},
|
||||
}}
|
||||
return *respPtr
|
||||
},
|
||||
}
|
||||
got, err := QueryServices(fs, "tlgrm.example.com", "example.com", time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryServices: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Type != "stun" || got[1].Type != "turn" {
|
||||
t.Errorf("unexpected service types: %+v", got)
|
||||
}
|
||||
if got[1].Username != "u" || got[1].Password != "p" {
|
||||
t.Errorf("TURN creds lost: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryServices_Timeout(t *testing.T) {
|
||||
// no reply hook -> never delivers, must hit context timeout
|
||||
fs := &fakeSender{}
|
||||
_, err := QueryServices(fs, "tlgrm.example.com", "example.com", 50*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Error("expected timeout error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryServices_ErrorIQ(t *testing.T) {
|
||||
fs := &fakeSender{
|
||||
reply: func(req *stanza.IQ) stanza.IQ {
|
||||
respPtr, _ := stanza.NewIQ(stanza.Attrs{
|
||||
Type: stanza.IQTypeError,
|
||||
From: req.To,
|
||||
To: req.From,
|
||||
Id: req.Id,
|
||||
})
|
||||
return *respPtr
|
||||
},
|
||||
}
|
||||
if _, err := QueryServices(fs, "tlgrm.example.com", "example.com", time.Second); err == nil {
|
||||
t.Error("expected error on <iq type='error'>")
|
||||
}
|
||||
}
|
||||
181
calls/signaling/xmppsig/pion_loop_test.go
Normal file
181
calls/signaling/xmppsig/pion_loop_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// every Send dispatches into the other Manager's HandlePacket on a fresh
|
||||
// goroutine, surfacing trickle/ordering bugs naturally
|
||||
type linkedSender struct{ target *jingle.Manager }
|
||||
|
||||
func (ls *linkedSender) Send(p stanza.Packet) error {
|
||||
go ls.target.HandlePacket(nil, p)
|
||||
return nil
|
||||
}
|
||||
func (ls *linkedSender) SendIQ(_ context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
|
||||
return make(chan stanza.IQ, 1), ls.Send(iq)
|
||||
}
|
||||
|
||||
type calleeBundle struct {
|
||||
callee *Callee
|
||||
pcConnected <-chan struct{}
|
||||
}
|
||||
|
||||
// full xmppsig path against itself: Caller on A, Callee on B, paired via
|
||||
// linkedSenders. Both PCs must reach Connected and both ends must see the
|
||||
// peer's track. Track-attachment timing matches setupAudio: attach after
|
||||
// SetRemoteDescription (pre-negotiation ReplaceTrack doesn't wire pion's encoder).
|
||||
func TestXmppsigPionLoop_TwoSidesConnectAndDeliverTrack(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e ICE handshake")
|
||||
}
|
||||
|
||||
mA := &jingle.Manager{LocalJID: "a@gw/telegabber"}
|
||||
mB := &jingle.Manager{LocalJID: "b@gw/telegabber"}
|
||||
mA.Sender = &linkedSender{target: mB}
|
||||
mB.Sender = &linkedSender{target: mA}
|
||||
|
||||
adapterA := NewAdapter(AdapterConfig{
|
||||
Sender: mA.Sender, LocalJID: mA.LocalJID, Manager: mA,
|
||||
PCFactory: DefaultPCFactory,
|
||||
})
|
||||
adapterB := NewAdapter(AdapterConfig{
|
||||
Sender: mB.Sender, LocalJID: mB.LocalJID, Manager: mB,
|
||||
PCFactory: DefaultPCFactory,
|
||||
})
|
||||
|
||||
calleeReady := make(chan calleeBundle, 1)
|
||||
mB.OnProposal = func(p jingle.IncomingProposal) (*jingle.Session, func(), error) {
|
||||
callee, sess, err := adapterB.NewCallee(p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pc := callee.PeerConnection()
|
||||
// production pattern: on OnRemoteSDP, slot a track into the
|
||||
// sender DefaultPCFactory pre-created
|
||||
callee.SetOnRemoteSDP(func(string) {
|
||||
tr := newSilenceTrack(t, "b-stream")
|
||||
if err := replaceFirstEmptySender(pc, tr); err != nil {
|
||||
t.Errorf("B replaceFirstEmptySender: %v", err)
|
||||
return
|
||||
}
|
||||
startSilencePump(t, tr)
|
||||
})
|
||||
calleeReady <- calleeBundle{callee: callee, pcConnected: connectedSignal(pc)}
|
||||
go func() {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && sess.State() != jingle.StateRinging {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
callee.Accept()
|
||||
}()
|
||||
return sess, nil, nil
|
||||
}
|
||||
|
||||
caller, err := adapterA.NewCaller("b@gw/telegabber", 42)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCaller: %v", err)
|
||||
}
|
||||
pcA := caller.PeerConnection()
|
||||
caller.SetOnRemoteSDP(func(string) {
|
||||
tr := newSilenceTrack(t, "a-stream")
|
||||
if err := replaceFirstEmptySender(pcA, tr); err != nil {
|
||||
t.Errorf("A replaceFirstEmptySender: %v", err)
|
||||
return
|
||||
}
|
||||
startSilencePump(t, tr)
|
||||
})
|
||||
connA := connectedSignal(pcA)
|
||||
|
||||
caller.Start()
|
||||
|
||||
var bundle calleeBundle
|
||||
select {
|
||||
case bundle = <-calleeReady:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("OnProposal never fired on B side")
|
||||
}
|
||||
|
||||
// install handlers AFTER Start so tracks landing during
|
||||
// SetRemoteDescription go through hookTrack's buffered path
|
||||
var aGot, bGot atomic.Int64
|
||||
caller.SetTrackHandler(func(*webrtc.TrackRemote) { aGot.Add(1) })
|
||||
bundle.callee.SetTrackHandler(func(*webrtc.TrackRemote) { bGot.Add(1) })
|
||||
|
||||
const deadline = 15 * time.Second
|
||||
select {
|
||||
case <-connA:
|
||||
case <-time.After(deadline):
|
||||
t.Fatalf("Caller PC never Connected (state=%s)", pcA.ConnectionState())
|
||||
}
|
||||
select {
|
||||
case <-bundle.pcConnected:
|
||||
case <-time.After(deadline):
|
||||
t.Fatalf("Callee PC never Connected (state=%s)",
|
||||
bundle.callee.PeerConnection().ConnectionState())
|
||||
}
|
||||
|
||||
waitUntilTrue(t, "A received B's track", 10*time.Second, func() bool { return aGot.Load() > 0 })
|
||||
waitUntilTrue(t, "B received A's track", 10*time.Second, func() bool { return bGot.Load() > 0 })
|
||||
}
|
||||
|
||||
// fresh opus track; caller attaches and starts the pump at the right
|
||||
// point in the negotiation
|
||||
func newSilenceTrack(t *testing.T, streamID string) *webrtc.TrackLocalStaticSample {
|
||||
t.Helper()
|
||||
tr, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2},
|
||||
"audio", streamID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTrackLocalStaticSample: %v", err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// mirrors orchestrator.attachOutgoingTrack; falls back to AddTrack for
|
||||
// bare PCFactory tests
|
||||
func replaceFirstEmptySender(pc *webrtc.PeerConnection, tr webrtc.TrackLocal) error {
|
||||
for _, s := range pc.GetSenders() {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.ReplaceTrack(tr); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, err := pc.AddTrack(tr)
|
||||
return err
|
||||
}
|
||||
|
||||
// pushes 20ms opus silence into tr; stops at test cleanup
|
||||
func startSilencePump(t *testing.T, tr *webrtc.TrackLocalStaticSample) {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
defer func() { _ = recover() }() // tolerate double-close in failure paths
|
||||
close(stop)
|
||||
})
|
||||
go pumpSilenceOpus(tr, stop)
|
||||
}
|
||||
|
||||
// closes once pc reaches Connected; must be installed before pc could reach
|
||||
// Connected because pion fires on state change, not on register
|
||||
func connectedSignal(pc *webrtc.PeerConnection) <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
var once atomic.Bool
|
||||
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
||||
if s == webrtc.PeerConnectionStateConnected && once.CompareAndSwap(false, true) {
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return ch
|
||||
}
|
||||
175
calls/signaling/xmppsig/track_buffer_test.go
Normal file
175
calls/signaling/xmppsig/track_buffer_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package xmppsig
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
)
|
||||
|
||||
// pion OnTrack fires inside SetRemoteDescription, but setupAudio runs
|
||||
// later (from OnRemoteDescriptionApplied) - so hookTrack must install
|
||||
// a permanent forwarder at PC construction and buffer until
|
||||
// SetTrackHandler. This drives a real pion<->pion roundtrip with the
|
||||
// handler installed after the track has already landed.
|
||||
func TestHookTrack_BuffersTrackThatArrivesBeforeSetHandler(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e ICE handshake")
|
||||
}
|
||||
|
||||
// gateway-side PC, same shape as xmppsig.NewCaller
|
||||
pcGateway, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatalf("gateway PC: %v", err)
|
||||
}
|
||||
defer pcGateway.Close()
|
||||
if _, err := pcGateway.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||
}); err != nil {
|
||||
t.Fatalf("gateway AddTransceiver: %v", err)
|
||||
}
|
||||
|
||||
// the forwarder under test
|
||||
var base xmppBase
|
||||
base.hookTrack(pcGateway)
|
||||
|
||||
// remote pion PC with an audio track to send
|
||||
pcPeer, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatalf("peer PC: %v", err)
|
||||
}
|
||||
defer pcPeer.Close()
|
||||
peerTrack, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2},
|
||||
"audio", "peer-stream",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("peer track: %v", err)
|
||||
}
|
||||
if _, err := pcPeer.AddTrack(peerTrack); err != nil {
|
||||
t.Fatalf("peer AddTrack: %v", err)
|
||||
}
|
||||
|
||||
// pion offer/answer + ICE-complete handshake
|
||||
gatewayConnected := make(chan struct{})
|
||||
pcGateway.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
||||
if s == webrtc.PeerConnectionStateConnected {
|
||||
select {
|
||||
case <-gatewayConnected:
|
||||
default:
|
||||
close(gatewayConnected)
|
||||
}
|
||||
}
|
||||
})
|
||||
completePionHandshake(t, pcGateway, pcPeer)
|
||||
select {
|
||||
case <-gatewayConnected:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatalf("gateway PC never reached Connected (state=%s)", pcGateway.ConnectionState())
|
||||
}
|
||||
|
||||
// real RTP so OnTrack fires (pion needs the first packet to dispatch)
|
||||
stopRTP := make(chan struct{})
|
||||
go pumpSilenceOpus(peerTrack, stopRTP)
|
||||
defer close(stopRTP)
|
||||
|
||||
// wait for pion to dispatch the first packet through hookTrack
|
||||
waitUntilTrue(t, "track buffered by hookTrack", 5*time.Second, func() bool {
|
||||
base.mu.Lock()
|
||||
defer base.mu.Unlock()
|
||||
return len(base.pendingTracks) > 0
|
||||
})
|
||||
|
||||
// install handler late, like setupAudio from OnRemoteDescriptionApplied
|
||||
var delivered atomic.Int64
|
||||
base.SetTrackHandler(func(t *webrtc.TrackRemote) {
|
||||
delivered.Add(1)
|
||||
})
|
||||
if delivered.Load() != 1 {
|
||||
t.Errorf("SetTrackHandler did not drain pendingTracks: delivered=%d, want 1", delivered.Load())
|
||||
}
|
||||
|
||||
// subsequent track events should go direct to the handler
|
||||
base.mu.Lock()
|
||||
if len(base.pendingTracks) != 0 {
|
||||
t.Errorf("pendingTracks not cleared after drain: %d entries", len(base.pendingTracks))
|
||||
}
|
||||
base.mu.Unlock()
|
||||
}
|
||||
|
||||
// completePionHandshake drives a pion offer/answer + trickle ICE between
|
||||
// two PeerConnections, with the gateway as the answerer (matching the
|
||||
// xmpp-originated incoming call shape - Dino's session-initiate carries
|
||||
// the offer, the gateway sends the answer).
|
||||
func completePionHandshake(t *testing.T, pcAnswerer, pcOfferer *webrtc.PeerConnection) {
|
||||
t.Helper()
|
||||
// trickle ICE: forward each side's candidates to the other
|
||||
pcOfferer.OnICECandidate(func(c *webrtc.ICECandidate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if err := pcAnswerer.AddICECandidate(c.ToJSON()); err != nil {
|
||||
t.Logf("answerer AddICECandidate: %v", err)
|
||||
}
|
||||
})
|
||||
pcAnswerer.OnICECandidate(func(c *webrtc.ICECandidate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if err := pcOfferer.AddICECandidate(c.ToJSON()); err != nil {
|
||||
t.Logf("offerer AddICECandidate: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
offer, err := pcOfferer.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pcOfferer.SetLocalDescription(offer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pcAnswerer.SetRemoteDescription(*pcOfferer.LocalDescription()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, err := pcAnswerer.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pcAnswerer.SetLocalDescription(answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pcOfferer.SetRemoteDescription(*pcAnswerer.LocalDescription()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// writes 20ms opus silence every 20ms until stop closes
|
||||
func pumpSilenceOpus(track *webrtc.TrackLocalStaticSample, stop <-chan struct{}) {
|
||||
// minimal opus silence frame: TOC byte + 1-byte SILK
|
||||
silence := []byte{0xF8, 0xFF, 0xFE}
|
||||
tick := time.NewTicker(20 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-tick.C:
|
||||
_ = track.WriteSample(media.Sample{Data: silence, Duration: 20 * time.Millisecond})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// local poller; duplicates the jingle one to avoid test-file ordering deps
|
||||
func waitUntilTrue(t *testing.T, what string, d time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waitUntilTrue(%s): timed out after %v", what, d)
|
||||
}
|
||||
34
docker/isolate_ntgcalls_crypto.sh
Executable file
34
docker/isolate_ntgcalls_crypto.sh
Executable file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
# Rename every symbol that libntgcalls.a defines AND that the system OpenSSL
|
||||
# (libssl/libcrypto) also exports, so the two crypto libraries don't fight at
|
||||
# static link time. After this, TDLib's calls (compiled against system OpenSSL
|
||||
# headers) resolve to libcrypto.so.3, while ntgcalls/webrtc's internal calls
|
||||
# stay wired to its bundled BoringSSL via the renamed names.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LIB=/usr/local/lib/libntgcalls.a
|
||||
SYS_CRYPTO=/usr/lib/x86_64-linux-gnu/libcrypto.so.3
|
||||
SYS_SSL=/usr/lib/x86_64-linux-gnu/libssl.so.3
|
||||
PREFIX=ntgssl_
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
nm --defined-only --extern-only "$LIB" 2>/dev/null \
|
||||
| awk 'NF>=3 {print $NF}' | sort -u > "$work/ntg_defined.txt"
|
||||
|
||||
nm -D --defined-only "$SYS_CRYPTO" "$SYS_SSL" \
|
||||
| awk 'NF>=3 {n=$NF; sub(/@@.*/, "", n); print n}' | sort -u > "$work/sys_crypto.txt"
|
||||
|
||||
comm -12 "$work/ntg_defined.txt" "$work/sys_crypto.txt" > "$work/clash.txt"
|
||||
test -s "$work/clash.txt"
|
||||
awk -v p="$PREFIX" '{print $1, p $1}' "$work/clash.txt" > "$work/redefine.txt"
|
||||
|
||||
# objcopy operates on archives natively, transforming each member in place.
|
||||
# This avoids ar x / ar rcs roundtripping, which would flatten the archive's
|
||||
# directory structure and silently drop members with duplicate basenames.
|
||||
objcopy --redefine-syms="$work/redefine.txt" "$LIB"
|
||||
ranlib "$LIB"
|
||||
|
||||
printf 'Isolated %d crypto symbols in libntgcalls.a\n' "$(wc -l < "$work/clash.txt")"
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
28
go.mod
28
go.mod
|
|
@ -4,12 +4,16 @@ go 1.24.2
|
|||
|
||||
require (
|
||||
github.com/dgraph-io/badger/v4 v4.1.0
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pion/interceptor v0.1.44
|
||||
github.com/pion/rtp v1.10.1
|
||||
github.com/pion/webrtc/v4 v4.2.12
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/santhosh-tekuri/jsonschema v1.2.4
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e
|
||||
github.com/zelenin/go-tdlib v0.5.2
|
||||
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302
|
||||
gopkg.in/yaml.v2 v2.2.4
|
||||
gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1
|
||||
gotgcalls v0.0.0-00010101000000-000000000000
|
||||
|
|
@ -39,12 +43,28 @@ require (
|
|||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.2 // indirect
|
||||
github.com/pion/ice/v4 v4.2.5 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/sctp v1.9.5 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||
github.com/pion/stun/v3 v3.1.2 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v5 v5.0.3 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
nhooyr.io/websocket v1.6.5 // indirect
|
||||
)
|
||||
|
|
|
|||
68
go.sum
68
go.sum
|
|
@ -73,8 +73,9 @@ github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
|
|||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
|
|
@ -88,8 +89,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxv
|
|||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
|
|
@ -111,6 +113,42 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
|
|||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||
github.com/pion/ice/v4 v4.2.5 h1:5umUQy4hX6HwMsCnJ0SX337YYCeTWDgC9JWyvUqHIHs=
|
||||
github.com/pion/ice/v4 v4.2.5/go.mod h1:aaABRaykEYnNjccjbiimuYxViaASeuv5mk9BpplUxK0=
|
||||
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
|
||||
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag=
|
||||
github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
|
||||
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
|
||||
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||
github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak=
|
||||
github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w=
|
||||
github.com/pion/webrtc/v4 v4.2.12 h1:ux8i+aJxu0OdhcAcVO39JEeodWugD0wdVJoRDtXk1CY=
|
||||
github.com/pion/webrtc/v4 v4.2.12/go.mod h1:M/DeGZkhdWZVmVgGr34HOD9yUDekVJtz9c9PGO18urQ=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
|
|
@ -132,9 +170,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
|
@ -150,6 +191,8 @@ golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnf
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
|
|
@ -169,8 +212,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -193,14 +236,15 @@ golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
|
|
@ -227,11 +271,15 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
|
|||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
|
||||
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
|
||||
gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/calls/audio/ntgadapter"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
|
||||
"dev.narayana.im/narayana/telegabber/config"
|
||||
"dev.narayana.im/narayana/telegabber/persistence"
|
||||
"dev.narayana.im/narayana/telegabber/telegram/cache"
|
||||
"gotgcalls/ntgcalls"
|
||||
|
||||
"github.com/zelenin/go-tdlib/client"
|
||||
"gosrc.io/xmpp"
|
||||
|
|
@ -102,10 +106,57 @@ type Client struct {
|
|||
MessageIdChanges map[int64]map[int64]*newId
|
||||
MessageIdChangesLock sync.Mutex
|
||||
|
||||
// guards the call trio. Connect() builds it after c.client is live;
|
||||
// close() tears it down before c.client is closed. Accessors return
|
||||
// nil when no live session.
|
||||
ntgCallsLock sync.Mutex
|
||||
ntgCalls *ntgcalls.Client
|
||||
tgCallAdapter *tgsig.Adapter
|
||||
xmppCallAdapter *xmppsig.Adapter
|
||||
|
||||
// captured at adapter construction time
|
||||
callDeps CallDeps
|
||||
|
||||
locks clientLocks
|
||||
SendMessageLock sync.Mutex
|
||||
}
|
||||
|
||||
// per-process call wiring needed to build per-session adapters
|
||||
type CallDeps struct {
|
||||
TgManager *tgsig.Manager
|
||||
XmppConfig xmppsig.AdapterConfig
|
||||
}
|
||||
|
||||
// must be called before Connect()
|
||||
func (c *Client) SetCallDeps(deps CallDeps) {
|
||||
c.callDeps = deps
|
||||
}
|
||||
|
||||
// nil if no live session
|
||||
func (c *Client) CallAdapter() *tgsig.Adapter {
|
||||
c.ntgCallsLock.Lock()
|
||||
defer c.ntgCallsLock.Unlock()
|
||||
return c.tgCallAdapter
|
||||
}
|
||||
|
||||
// nil if no live session
|
||||
func (c *Client) XmppCallAdapter() *xmppsig.Adapter {
|
||||
c.ntgCallsLock.Lock()
|
||||
defer c.ntgCallsLock.Unlock()
|
||||
return c.xmppCallAdapter
|
||||
}
|
||||
|
||||
// pure construction; caller assigns and locks
|
||||
func buildCallAdapters(tdClient *client.Client, jid string, deps CallDeps) (
|
||||
*ntgcalls.Client, *tgsig.Adapter, *xmppsig.Adapter,
|
||||
) {
|
||||
ntg := ntgcalls.NTgCalls()
|
||||
audioNtg := ntgadapter.New(ntg)
|
||||
tgA := tgsig.New(tdClient, ntg, audioNtg, jid, deps.TgManager)
|
||||
xmppA := xmppsig.NewAdapter(deps.XmppConfig)
|
||||
return ntg, tgA, xmppA
|
||||
}
|
||||
|
||||
type clientLocks struct {
|
||||
authorizationReady sync.Mutex
|
||||
chatMessageLocks map[int64]*sync.Mutex
|
||||
|
|
|
|||
|
|
@ -135,6 +135,14 @@ func (c *Client) Connect(resource string) error {
|
|||
|
||||
c.client = tdlibClient
|
||||
|
||||
// build the call trio after c.client is live; close() tears it down
|
||||
// before c.client is closed
|
||||
ntg, tgA, xmppA := buildCallAdapters(c.client, c.jid, c.callDeps)
|
||||
c.ntgCallsLock.Lock()
|
||||
c.ntgCalls, c.tgCallAdapter, c.xmppCallAdapter = ntg, tgA, xmppA
|
||||
c.ntgCallsLock.Unlock()
|
||||
log.Info("ntgcalls client initialized")
|
||||
|
||||
// stage 3: if a client is succesfully created, AuthorizationStateReady is already reached
|
||||
log.Warn("Authorization successful!")
|
||||
|
||||
|
|
@ -306,6 +314,24 @@ func (c *Client) close() {
|
|||
}
|
||||
c.locks.authorizerWriteLock.Unlock()
|
||||
|
||||
// tear the call trio down before c.client so accessors see the
|
||||
// adapter's closed lifecycle (withCallContext bails) instead of a
|
||||
// stale c.client; the lock serializes with the accessors
|
||||
c.ntgCallsLock.Lock()
|
||||
if c.ntgCalls != nil {
|
||||
if c.tgCallAdapter != nil {
|
||||
// terminates active calls (closes audio halves synchronously)
|
||||
// and marks the adapter closed; after this returns, ntgcalls.Free is safe
|
||||
c.tgCallAdapter.Close()
|
||||
c.tgCallAdapter = nil
|
||||
}
|
||||
c.xmppCallAdapter = nil
|
||||
|
||||
c.ntgCalls.Free()
|
||||
c.ntgCalls = nil
|
||||
}
|
||||
c.ntgCallsLock.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
_, err := c.client.Close()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ func (c *Client) updateHandler() {
|
|||
|
||||
for update := range listener.Updates {
|
||||
if update.GetClass() == client.ClassUpdate {
|
||||
// debug probe: did UpdateCall reach the listener at all?
|
||||
if t := update.GetType(); t == client.TypeUpdateCall || t == client.TypeUpdateNewCallSignalingData {
|
||||
log.WithField("type", t).Debug("updateHandler: call-related update received")
|
||||
}
|
||||
switch update.GetType() {
|
||||
case client.TypeUpdateUser:
|
||||
typedUpdate, ok := update.(*client.UpdateUser)
|
||||
|
|
@ -172,6 +176,18 @@ func (c *Client) updateHandler() {
|
|||
uhOh()
|
||||
}
|
||||
c.updateChatReadOutbox(typedUpdate)
|
||||
case client.TypeUpdateCall:
|
||||
typedUpdate, ok := update.(*client.UpdateCall)
|
||||
if !ok {
|
||||
uhOh()
|
||||
}
|
||||
c.callDeps.TgManager.OnUpdateCall(c.jid, typedUpdate)
|
||||
case client.TypeUpdateNewCallSignalingData:
|
||||
typedUpdate, ok := update.(*client.UpdateNewCallSignalingData)
|
||||
if !ok {
|
||||
uhOh()
|
||||
}
|
||||
c.callDeps.TgManager.OnNewSignalingData(c.jid, typedUpdate)
|
||||
default:
|
||||
// log only handled types
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1202,6 +1202,12 @@ func (c *Client) getPrefixSeparator(chatId int64) string {
|
|||
|
||||
// ProcessIncomingMessage transfers a message to XMPP side and marks it as read on Telegram side
|
||||
func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) {
|
||||
// drop tdlib's end-of-call audit message; the xmpp client already saw
|
||||
// the call lifecycle via Jingle
|
||||
if message.Content != nil && message.Content.MessageContentType() == client.TypeMessageCall {
|
||||
return
|
||||
}
|
||||
|
||||
isCarbon := c.isCarbonsEnabled() && message.IsOutgoing
|
||||
jids := c.getCarbonFullJids(isCarbon, "")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,22 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"dev.narayana.im/narayana/telegabber/badger"
|
||||
"dev.narayana.im/narayana/telegabber/calls"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/tgsig"
|
||||
"dev.narayana.im/narayana/telegabber/calls/signaling/xmppsig"
|
||||
"dev.narayana.im/narayana/telegabber/config"
|
||||
"dev.narayana.im/narayana/telegabber/persistence"
|
||||
"dev.narayana.im/narayana/telegabber/telegram"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gosrc.io/xmpp"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
|
|
@ -28,6 +34,15 @@ var db *persistence.SessionsYamlDB
|
|||
var componentEverConnected atomic.Bool
|
||||
var sessionLock sync.Mutex
|
||||
|
||||
// call infrastructure, built in NewComponent
|
||||
var jingleManager *jingle.Manager
|
||||
var tgManager *tgsig.Manager
|
||||
var orchestrator *calls.Orchestrator
|
||||
var callDeps telegram.CallDeps
|
||||
|
||||
// latest XEP-0215 result; nil means no extdisco yet, pion uses host candidates only
|
||||
var iceServers atomic.Pointer[[]webrtc.ICEServer]
|
||||
|
||||
const (
|
||||
B uint64 = 1
|
||||
KB = B << 10
|
||||
|
|
@ -93,6 +108,35 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
// call routers + orchestrator before loadSessions so per-session
|
||||
// telegram.Clients get wired with both at construction
|
||||
jingleManager = &jingle.Manager{
|
||||
LocalJID: gateway.Jid.Bare(),
|
||||
Sender: component,
|
||||
}
|
||||
tgManager = tgsig.NewManager(nil) // incoming hook set after orchestrator exists
|
||||
orchestrator = calls.New(calls.Config{
|
||||
JingleManager: jingleManager,
|
||||
TgManager: tgManager,
|
||||
Lookup: sessionLookup,
|
||||
})
|
||||
// close the cycle; jingleManager.OnProposal was wired by calls.New
|
||||
tgManager.SetIncomingHandler(orchestrator.NewFromTelegram)
|
||||
callDeps = telegram.CallDeps{
|
||||
TgManager: tgManager,
|
||||
XmppConfig: xmppsig.AdapterConfig{
|
||||
Sender: component,
|
||||
LocalJID: gateway.Jid.Bare(),
|
||||
Manager: jingleManager,
|
||||
PCFactory: xmppsig.MakePCFactory(func() []webrtc.ICEServer {
|
||||
if p := iceServers.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
// probe all known sessions
|
||||
err = loadSessions(conf.Db, component)
|
||||
if err != nil {
|
||||
|
|
@ -102,11 +146,43 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath stri
|
|||
sm := xmpp.NewStreamManager(component, func(s xmpp.Sender) {
|
||||
componentEverConnected.Store(true)
|
||||
go heartbeat(component)
|
||||
go refreshICEServers(s)
|
||||
})
|
||||
|
||||
return sm, component, nil
|
||||
}
|
||||
|
||||
// fetch XEP-0215 STUN/TURN list from the parent server (the c2s server, i.e.
|
||||
// the component domain with its leftmost label stripped) and store it for
|
||||
// PCFactory; runs on every (re)connect because TURN creds are short-lived
|
||||
func refreshICEServers(s xmpp.Sender) {
|
||||
parent := parentDomain(gateway.Jid.Domain)
|
||||
if parent == "" {
|
||||
log.Warnf("extdisco: cannot derive parent server from component JID %q; skipping", gateway.Jid.Domain)
|
||||
return
|
||||
}
|
||||
services, err := xmppsig.QueryServices(s, gateway.Jid.Bare(), parent, 10*time.Second)
|
||||
if err != nil {
|
||||
log.Warnf("extdisco: query %s -> %s failed: %v", gateway.Jid.Bare(), parent, err)
|
||||
return
|
||||
}
|
||||
servers := xmppsig.ToICEServers(services)
|
||||
iceServers.Store(&servers)
|
||||
log.Infof("extdisco: loaded %d ICE servers from %s (raw services: %d)", len(servers), parent, len(services))
|
||||
for i, srv := range servers {
|
||||
log.Debugf("extdisco[%d]: urls=%v hasUser=%t hasCred=%t", i, srv.URLs, srv.Username != "", srv.Credential != nil && srv.Credential != "")
|
||||
}
|
||||
}
|
||||
|
||||
// strips the leftmost label, e.g. tlgrm.example.com -> example.com;
|
||||
// "" if there's no dot
|
||||
func parentDomain(domain string) string {
|
||||
if i := strings.IndexByte(domain, '.'); i > 0 && i < len(domain)-1 {
|
||||
return domain[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func heartbeat(component *xmpp.Component) {
|
||||
var err error
|
||||
probeType := gateway.SPType("probe")
|
||||
|
|
@ -224,6 +300,7 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen
|
|||
log.Error(errors.Wrap(err, "TDlib initialization failure"))
|
||||
return session, false
|
||||
}
|
||||
session.SetCallDeps(callDeps)
|
||||
if savedSession.KeepOnline {
|
||||
if err = session.Connect(""); err != nil {
|
||||
log.Error(err)
|
||||
|
|
@ -238,6 +315,17 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen
|
|||
return session, true
|
||||
}
|
||||
|
||||
// orchestrator's SessionLookup callback
|
||||
func sessionLookup(bareJID string) (*tgsig.Adapter, *xmppsig.Adapter, bool) {
|
||||
sessionLock.Lock()
|
||||
cl, ok := sessions[bareJID]
|
||||
sessionLock.Unlock()
|
||||
if !ok || cl == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return cl.CallAdapter(), cl.XmppCallAdapter(), true
|
||||
}
|
||||
|
||||
// SaveSessions dumps current sessions to the file
|
||||
func SaveSessions() {
|
||||
sessionLock.Lock()
|
||||
|
|
@ -251,9 +339,10 @@ func SaveSessions() {
|
|||
}, persistence.SessionMarshaller)
|
||||
}
|
||||
|
||||
// Close gracefully terminates the component and saves active sessions.
|
||||
// If the component never reached steady state, SaveSessions is skipped
|
||||
// to avoid overwriting persisted state with the empty-at-startup map.
|
||||
// gracefully terminate the component and save active sessions.
|
||||
// SaveSessions is skipped when componentEverConnected is false to avoid
|
||||
// overwriting persisted YAML with the empty-at-startup map (seen during
|
||||
// a tunnel-down crash-loop)
|
||||
func Close(component *xmpp.Component) {
|
||||
log.Error("Disconnecting...")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import (
|
|||
"strconv"
|
||||
|
||||
"gosrc.io/xmpp/stanza"
|
||||
|
||||
// side-effect: register Jingle stanza types with stanza.TypeRegistry
|
||||
_ "dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
)
|
||||
|
||||
// PresenceNickExtension is from XEP-0172
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"dev.narayana.im/narayana/telegabber/telegram"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/extensions"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/gateway"
|
||||
"dev.narayana.im/narayana/telegabber/xmpp/jingle"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
|
@ -35,6 +36,19 @@ func logPacketType(p stanza.Packet) {
|
|||
log.Warnf("Ignoring packet: %T\n", p)
|
||||
}
|
||||
|
||||
// short-circuits JMI traffic into the call-signaling adapter before the
|
||||
// body/reply handler runs
|
||||
func hasJMIExtension(exts []stanza.MsgExtension) bool {
|
||||
for _, e := range exts {
|
||||
switch e.(type) {
|
||||
case *jingle.JMIPropose, *jingle.JMIProceed, *jingle.JMIRinging,
|
||||
*jingle.JMIReject, *jingle.JMIRetract, *jingle.JMIFinish:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleIq processes an incoming XMPP iq
|
||||
func HandleIq(s xmpp.Sender, p stanza.Packet) {
|
||||
iq, ok := p.(*stanza.IQ)
|
||||
|
|
@ -43,6 +57,14 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) {
|
|||
return
|
||||
}
|
||||
|
||||
// route Jingle IQ stanzas to the jingle manager
|
||||
if jingleManager != nil {
|
||||
if _, isJingle := iq.Payload.(*jingle.JingleIQ); isJingle {
|
||||
jingleManager.HandlePacket(s, p)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("%#v", iq)
|
||||
if iq.Type == stanza.IQTypeGet {
|
||||
_, ok := iq.Payload.(*extensions.IqVcardTemp)
|
||||
|
|
@ -114,6 +136,14 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
|
|||
return
|
||||
}
|
||||
|
||||
// Route JMI (XEP-0353) messages to the process-wide jingle manager first.
|
||||
// If any of the message's extensions are JMI, the manager consumes it
|
||||
// and the regular body/reply handling below would be irrelevant.
|
||||
if jingleManager != nil && hasJMIExtension(msg.Extensions) {
|
||||
jingleManager.HandlePacket(s, &msg)
|
||||
return
|
||||
}
|
||||
|
||||
component, ok := s.(*xmpp.Component)
|
||||
if !ok {
|
||||
log.Error("Not a component")
|
||||
|
|
@ -649,6 +679,19 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
|
|||
disco.AddIdentity("", "account", "registered")
|
||||
disco.AddFeatures(stanza.NSMsgChatMarkers)
|
||||
disco.AddFeatures(stanza.NSMsgReceipts)
|
||||
// Jingle / JMI features so clients (e.g. Dino) treat the contact
|
||||
// as call-capable. Without these, JMI <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")
|
||||
|
|
|
|||
66
xmpp/jingle/candidate_pion.go
Normal file
66
xmpp/jingle/candidate_pion.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// strips the "candidate:" prefix so candidateFromSDP gets the same shape
|
||||
// it does on SDP import
|
||||
func pionToCandidate(c *webrtc.ICECandidate) (*Candidate, error) {
|
||||
if c == nil {
|
||||
return nil, errors.New("nil ICECandidate")
|
||||
}
|
||||
init := c.ToJSON()
|
||||
body := init.Candidate
|
||||
body = strings.TrimPrefix(body, "candidate:")
|
||||
cand, err := candidateFromSDP(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cand == nil {
|
||||
return nil, fmt.Errorf("unrecognised pion candidate %q", init.Candidate)
|
||||
}
|
||||
return cand, nil
|
||||
}
|
||||
|
||||
// fills SDPMid and parsed SDPMLineIndex when mid is numeric
|
||||
func candidateToPionInit(c *Candidate, mid string) (webrtc.ICECandidateInit, error) {
|
||||
sdpBody, err := candidateToSDP(c)
|
||||
if err != nil {
|
||||
return webrtc.ICECandidateInit{}, err
|
||||
}
|
||||
init := webrtc.ICECandidateInit{
|
||||
Candidate: "candidate:" + sdpBody,
|
||||
}
|
||||
if mid != "" {
|
||||
m := mid
|
||||
init.SDPMid = &m
|
||||
}
|
||||
if n, err := strconv.Atoi(mid); err == nil && n >= 0 && n <= 0xffff {
|
||||
idx := uint16(n)
|
||||
init.SDPMLineIndex = &idx
|
||||
}
|
||||
return init, nil
|
||||
}
|
||||
|
||||
// initiator/responder stay unset; Session stamps them per role before send
|
||||
func buildTransportInfo(sid, mid, ufrag, pwd, creator string, cand *Candidate) *JingleIQ {
|
||||
return &JingleIQ{
|
||||
Action: ActionTransportInfo,
|
||||
SID: sid,
|
||||
Contents: []Content{{
|
||||
Creator: creator,
|
||||
Name: mid,
|
||||
Transport: &Transport{
|
||||
Ufrag: ufrag,
|
||||
Pwd: pwd,
|
||||
Candidates: []Candidate{*cand},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
225
xmpp/jingle/convert.go
Normal file
225
xmpp/jingle/convert.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
// XEP-0166 Jingle stanzas <-> SDP strings
|
||||
package jingle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
hardcodedMediaProtocol = "UDP/TLS/RTP/SAVPF"
|
||||
hardcodedMediaPort = "9"
|
||||
hardcodedConnection = "IN IP4 0.0.0.0"
|
||||
hardcodedOrigin = "- 8770656990916039506 2 IN IP4 127.0.0.1"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultICEOptions = []string{"trickle"}
|
||||
wellKnownICEOptions = map[string]bool{"trickle": true, "renomination": true}
|
||||
)
|
||||
|
||||
// direction-flag resolution and metadata for SDPToJingle
|
||||
type ConvertOpts struct {
|
||||
// governs how senders=initiator/responder maps to sendonly/recvonly
|
||||
Initiator bool
|
||||
// <content creator='...'/> value; defaults to "initiator"
|
||||
LocalCreator string
|
||||
// <jingle sid='...'/> value
|
||||
SessionID string
|
||||
}
|
||||
|
||||
func JingleToSDP(j *JingleIQ, opts ConvertOpts) (string, error) {
|
||||
if j == nil {
|
||||
return "", errors.New("nil JingleIQ")
|
||||
}
|
||||
b := &sdpBuilder{}
|
||||
b.line("v=0")
|
||||
b.line("o=" + hardcodedOrigin)
|
||||
b.line("s=-")
|
||||
b.line("t=0 0")
|
||||
|
||||
sessAttrs, err := buildSessionAttrs(j)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.appendAttrs(sessAttrs)
|
||||
|
||||
bundled := j.Group != nil && j.Group.Semantics != ""
|
||||
|
||||
for _, c := range j.Contents {
|
||||
if c.Description == nil || c.Transport == nil {
|
||||
continue
|
||||
}
|
||||
mediaAttrs, err := buildMediaAttrs(&c, bundled, opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
formats := make([]string, 0, len(c.Description.PayloadTypes))
|
||||
for _, pt := range c.Description.PayloadTypes {
|
||||
formats = append(formats, pt.ID)
|
||||
}
|
||||
mline := "m=" + c.Description.Media + " " + hardcodedMediaPort + " " + hardcodedMediaProtocol
|
||||
if len(formats) > 0 {
|
||||
mline += " " + strings.Join(formats, " ")
|
||||
}
|
||||
b.line(mline)
|
||||
b.line("c=" + hardcodedConnection)
|
||||
b.appendAttrs(mediaAttrs)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func SDPToJingle(sdp string, opts ConvertOpts) (*JingleIQ, error) {
|
||||
doc, err := parseSDP(sdp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creator := opts.LocalCreator
|
||||
if creator == "" {
|
||||
creator = "initiator"
|
||||
}
|
||||
j := &JingleIQ{SID: opts.SessionID}
|
||||
|
||||
if groupVal := mmFirst(doc.sessionAttrs, "group", ""); groupVal != "" {
|
||||
parts := strings.Fields(groupVal)
|
||||
if len(parts) >= 1 {
|
||||
sem := parts[0]
|
||||
if strings.ContainsAny(sem, " \t\r\n") {
|
||||
return nil, fmt.Errorf("group semantics contains whitespace: %q", sem)
|
||||
}
|
||||
g := &Group{Semantics: sem}
|
||||
for _, name := range parts[1:] {
|
||||
if strings.ContainsAny(name, " \t\r\n") {
|
||||
return nil, fmt.Errorf("group content name contains whitespace: %q", name)
|
||||
}
|
||||
g.Contents = append(g.Contents, GroupContent{Name: name})
|
||||
}
|
||||
j.Group = g
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range doc.media {
|
||||
content, err := buildJingleContent(&m, doc.sessionAttrs, creator, opts.Initiator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j.Contents = append(j.Contents, *content)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func buildSessionAttrs(j *JingleIQ) ([]kv, error) {
|
||||
var attrs []kv
|
||||
if j.Group != nil && j.Group.Semantics != "" {
|
||||
sem := j.Group.Semantics
|
||||
if strings.ContainsAny(sem, " \t\r\n") {
|
||||
return nil, fmt.Errorf("group semantics contains whitespace: %q", sem)
|
||||
}
|
||||
tags := make([]string, 0, len(j.Group.Contents))
|
||||
for _, gc := range j.Group.Contents {
|
||||
if strings.ContainsAny(gc.Name, " \t\r\n") {
|
||||
return nil, fmt.Errorf("group content name contains whitespace: %q", gc.Name)
|
||||
}
|
||||
tags = append(tags, gc.Name)
|
||||
}
|
||||
val := sem
|
||||
if len(tags) > 0 {
|
||||
val += " " + strings.Join(tags, " ")
|
||||
}
|
||||
attrs = append(attrs, kv{"group", val})
|
||||
}
|
||||
attrs = append(attrs, kv{"msid-semantic", " WMS my-media-stream"})
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
// attr order matches jinglesdp.tcl: transport, payload/feedback/ssrc,
|
||||
// mid/direction/rtcp/candidates
|
||||
func buildMediaAttrs(c *Content, bundled bool, opts ConvertOpts) ([]kv, error) {
|
||||
var attrs []kv
|
||||
if err := appendTransportAttrs(c.Transport, &attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := appendDescriptionAttrs(c.Description, &attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attrs = append(attrs, kv{"mid", c.Name})
|
||||
attrs = append(attrs, kv{sendersToSDP(c.Senders, opts.Initiator), ""})
|
||||
if c.Description.RtcpMux != nil || bundled {
|
||||
attrs = append(attrs, kv{"rtcp-mux", ""})
|
||||
}
|
||||
attrs = append(attrs, kv{"rtcp", "9 IN IP4 0.0.0.0"})
|
||||
for _, cand := range c.Transport.Candidates {
|
||||
s, err := candidateToSDP(&cand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attrs = append(attrs, kv{"candidate", s})
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func buildJingleContent(m *mediaSection, sessAttrs []kv, creator string, initiator bool) (*Content, error) {
|
||||
name := mmFirst(m.attrs, "mid", m.media)
|
||||
c := &Content{
|
||||
Creator: creator,
|
||||
Name: name,
|
||||
}
|
||||
if s := sendersFromMM(m.attrs, initiator); s != "both" {
|
||||
c.Senders = s
|
||||
}
|
||||
desc, err := buildJingleDescription(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Description = desc
|
||||
transport, err := buildJingleTransport(m, sessAttrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Transport = transport
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SDP direction attr name; value is empty because a=sendrecv is bare
|
||||
func sendersToSDP(senders string, initiator bool) string {
|
||||
switch senders {
|
||||
case "", "both":
|
||||
return "sendrecv"
|
||||
case "none":
|
||||
return "inactive"
|
||||
case "initiator":
|
||||
if initiator {
|
||||
return "sendonly"
|
||||
}
|
||||
return "recvonly"
|
||||
case "responder":
|
||||
if initiator {
|
||||
return "recvonly"
|
||||
}
|
||||
return "sendonly"
|
||||
}
|
||||
return "sendrecv"
|
||||
}
|
||||
|
||||
func sendersFromMM(mm []kv, initiator bool) string {
|
||||
if mmHas(mm, "sendrecv") {
|
||||
return "both"
|
||||
}
|
||||
if mmHas(mm, "inactive") {
|
||||
return "none"
|
||||
}
|
||||
if mmHas(mm, "sendonly") {
|
||||
if initiator {
|
||||
return "initiator"
|
||||
}
|
||||
return "responder"
|
||||
}
|
||||
if mmHas(mm, "recvonly") {
|
||||
if initiator {
|
||||
return "responder"
|
||||
}
|
||||
return "initiator"
|
||||
}
|
||||
return "both"
|
||||
}
|
||||
268
xmpp/jingle/convert_payload.go
Normal file
268
xmpp/jingle/convert_payload.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// attr order follows jinglesdp.tcl
|
||||
func appendDescriptionAttrs(d *Description, attrs *[]kv) error {
|
||||
if d == nil {
|
||||
return errors.New("content missing description")
|
||||
}
|
||||
if d.Media == "" {
|
||||
return errors.New("description missing media")
|
||||
}
|
||||
for _, pt := range d.PayloadTypes {
|
||||
rtpmap, err := payloadTypeToSDP(&pt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*attrs = append(*attrs, kv{"rtpmap", rtpmap})
|
||||
if len(pt.Parameters) >= 1 {
|
||||
fmtp, err := paramsToFmtp(pt.ID, pt.Parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*attrs = append(*attrs, kv{"fmtp", fmtp})
|
||||
}
|
||||
for _, fb := range pt.RTCPFbs {
|
||||
s, err := fbToSDP(pt.ID, &fb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*attrs = append(*attrs, kv{"rtcp-fb", s})
|
||||
}
|
||||
for _, fb := range pt.RTCPFbTrrInts {
|
||||
*attrs = append(*attrs, kv{"rtcp-fb", pt.ID + " trr-int " + fb.Value})
|
||||
}
|
||||
}
|
||||
for _, fb := range d.RTCPFbs {
|
||||
s, err := fbToSDP("*", &fb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*attrs = append(*attrs, kv{"rtcp-fb", s})
|
||||
}
|
||||
for _, fb := range d.RTCPFbTrrInts {
|
||||
*attrs = append(*attrs, kv{"rtcp-fb", "* trr-int " + fb.Value})
|
||||
}
|
||||
for _, ext := range d.RTPHdrexts {
|
||||
if ext.ID == "" || ext.URI == "" {
|
||||
return errors.New("rtp-hdrext missing id or uri")
|
||||
}
|
||||
*attrs = append(*attrs, kv{"extmap", ext.ID + " " + ext.URI})
|
||||
}
|
||||
if d.ExtmapAllowMixed != nil {
|
||||
*attrs = append(*attrs, kv{"extmap-allow-mixed", ""})
|
||||
}
|
||||
for _, sg := range d.SSRCGroups {
|
||||
if strings.ContainsAny(sg.Semantics, " \t\r\n") {
|
||||
return fmt.Errorf("ssrc-group semantics contains whitespace: %q", sg.Semantics)
|
||||
}
|
||||
ssrcs := make([]string, 0, len(sg.Sources))
|
||||
for _, s := range sg.Sources {
|
||||
ssrcs = append(ssrcs, s.SSRC)
|
||||
}
|
||||
*attrs = append(*attrs, kv{"ssrc-group", sg.Semantics + " " + strings.Join(ssrcs, " ")})
|
||||
}
|
||||
for _, src := range d.Sources {
|
||||
for _, p := range src.Parameters {
|
||||
if p.Name == "" {
|
||||
return errors.New("source parameter missing name")
|
||||
}
|
||||
if strings.ContainsAny(p.Value, "\r\n") {
|
||||
return errors.New("ssrc parameter value contains CR/LF")
|
||||
}
|
||||
if p.Value == "" {
|
||||
*attrs = append(*attrs, kv{"ssrc", src.SSRC + " " + p.Name})
|
||||
} else {
|
||||
*attrs = append(*attrs, kv{"ssrc", src.SSRC + " " + p.Name + ":" + p.Value})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildJingleDescription(m *mediaSection) (*Description, error) {
|
||||
desc := &Description{Media: m.media}
|
||||
|
||||
// "*" is the description-level key
|
||||
fbMap := map[string][]RTCPFb{}
|
||||
trrMap := map[string][]RTCPFbTrrInt{}
|
||||
for _, val := range mmGetAll(m.attrs, "rtcp-fb") {
|
||||
parts := strings.SplitN(val, " ", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
id := parts[0]
|
||||
typ := parts[1]
|
||||
var sub string
|
||||
if len(parts) >= 3 {
|
||||
sub = parts[2]
|
||||
}
|
||||
if typ == "trr-int" {
|
||||
trrMap[id] = append(trrMap[id], RTCPFbTrrInt{Value: sub})
|
||||
} else {
|
||||
fbMap[id] = append(fbMap[id], RTCPFb{Type: typ, Subtype: sub})
|
||||
}
|
||||
}
|
||||
|
||||
fmtpMap := map[string][]Parameter{}
|
||||
for _, val := range mmGetAll(m.attrs, "fmtp") {
|
||||
sp := strings.SplitN(val, " ", 2)
|
||||
if len(sp) < 2 {
|
||||
continue
|
||||
}
|
||||
id := sp[0]
|
||||
rest := sp[1]
|
||||
var params []Parameter
|
||||
for _, pair := range strings.Split(rest, ";") {
|
||||
kvSplit := strings.SplitN(pair, "=", 2)
|
||||
if len(kvSplit) == 2 {
|
||||
params = append(params, Parameter{Name: kvSplit[0], Value: kvSplit[1]})
|
||||
}
|
||||
}
|
||||
fmtpMap[id] = params
|
||||
}
|
||||
|
||||
// description-level rtcp-fb (id="*") goes first per Tcl/Java
|
||||
for _, fb := range fbMap["*"] {
|
||||
desc.RTCPFbs = append(desc.RTCPFbs, fb)
|
||||
}
|
||||
delete(fbMap, "*")
|
||||
for _, fb := range trrMap["*"] {
|
||||
desc.RTCPFbTrrInts = append(desc.RTCPFbTrrInts, fb)
|
||||
}
|
||||
delete(trrMap, "*")
|
||||
|
||||
for _, rtpmap := range mmGetAll(m.attrs, "rtpmap") {
|
||||
pair := strings.SplitN(rtpmap, " ", 2)
|
||||
if len(pair) < 2 {
|
||||
continue
|
||||
}
|
||||
id := pair[0]
|
||||
sp := strings.Split(pair[1], "/")
|
||||
if len(sp) < 2 {
|
||||
continue
|
||||
}
|
||||
pt := PayloadType{ID: id, Name: sp[0], Clockrate: sp[1]}
|
||||
if len(sp) >= 3 && sp[2] != "1" {
|
||||
pt.Channels = sp[2]
|
||||
}
|
||||
if params, ok := fmtpMap[id]; ok {
|
||||
pt.Parameters = params
|
||||
}
|
||||
if fbs, ok := fbMap[id]; ok {
|
||||
pt.RTCPFbs = fbs
|
||||
}
|
||||
if trrs, ok := trrMap[id]; ok {
|
||||
pt.RTCPFbTrrInts = trrs
|
||||
}
|
||||
desc.PayloadTypes = append(desc.PayloadTypes, pt)
|
||||
}
|
||||
|
||||
for _, extval := range mmGetAll(m.attrs, "extmap") {
|
||||
sp := strings.SplitN(extval, " ", 2)
|
||||
if len(sp) < 2 {
|
||||
continue
|
||||
}
|
||||
desc.RTPHdrexts = append(desc.RTPHdrexts, RTPHdrext{ID: sp[0], URI: sp[1]})
|
||||
}
|
||||
if mmHas(m.attrs, "extmap-allow-mixed") {
|
||||
desc.ExtmapAllowMixed = &ExtmapAllowMixed{}
|
||||
}
|
||||
|
||||
for _, val := range mmGetAll(m.attrs, "ssrc-group") {
|
||||
sp := strings.Fields(val)
|
||||
if len(sp) < 2 {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(sp[0], " \t\r\n") {
|
||||
return nil, fmt.Errorf("ssrc-group semantics contains whitespace: %q", sp[0])
|
||||
}
|
||||
sg := SSRCGroup{Semantics: sp[0]}
|
||||
for _, ssrc := range sp[1:] {
|
||||
sg.Sources = append(sg.Sources, SSRCGroupSource{SSRC: ssrc})
|
||||
}
|
||||
desc.SSRCGroups = append(desc.SSRCGroups, sg)
|
||||
}
|
||||
|
||||
// aggregate ssrc attrs by ssrc id, preserving first-seen order
|
||||
sourceMap := map[string][]Parameter{}
|
||||
var sourceOrder []string
|
||||
for _, val := range mmGetAll(m.attrs, "ssrc") {
|
||||
sp := strings.SplitN(val, " ", 2)
|
||||
if len(sp) < 2 {
|
||||
continue
|
||||
}
|
||||
sid := sp[0]
|
||||
rest := sp[1]
|
||||
kvParts := strings.SplitN(rest, ":", 2)
|
||||
pn := kvParts[0]
|
||||
var pv string
|
||||
if len(kvParts) >= 2 {
|
||||
pv = kvParts[1]
|
||||
}
|
||||
if pn == "" {
|
||||
return nil, errors.New("ssrc attribute missing parameter name")
|
||||
}
|
||||
if strings.ContainsAny(pv, "\r\n") {
|
||||
return nil, errors.New("ssrc parameter value contains CR/LF")
|
||||
}
|
||||
if _, ok := sourceMap[sid]; !ok {
|
||||
sourceOrder = append(sourceOrder, sid)
|
||||
}
|
||||
sourceMap[sid] = append(sourceMap[sid], Parameter{Name: pn, Value: pv})
|
||||
}
|
||||
for _, sid := range sourceOrder {
|
||||
desc.Sources = append(desc.Sources, Source{SSRC: sid, Parameters: sourceMap[sid]})
|
||||
}
|
||||
|
||||
if mmHas(m.attrs, "rtcp-mux") {
|
||||
desc.RtcpMux = &RtcpMux{}
|
||||
}
|
||||
return desc, nil
|
||||
}
|
||||
|
||||
func payloadTypeToSDP(pt *PayloadType) (string, error) {
|
||||
if pt.ID == "" {
|
||||
return "", errors.New("payload-type missing id")
|
||||
}
|
||||
if pt.Name == "" {
|
||||
return "", errors.New("payload-type missing name")
|
||||
}
|
||||
if pt.Channels == "" || pt.Channels == "1" {
|
||||
return pt.ID + " " + pt.Name + "/" + pt.Clockrate, nil
|
||||
}
|
||||
return pt.ID + " " + pt.Name + "/" + pt.Clockrate + "/" + pt.Channels, nil
|
||||
}
|
||||
|
||||
func fbToSDP(id string, fb *RTCPFb) (string, error) {
|
||||
if fb.Type == "" {
|
||||
return "", errors.New("rtcp-fb missing type")
|
||||
}
|
||||
if fb.Subtype == "" {
|
||||
return id + " " + fb.Type, nil
|
||||
}
|
||||
return id + " " + fb.Type + " " + fb.Subtype, nil
|
||||
}
|
||||
|
||||
func paramsToFmtp(id string, params []Parameter) (string, error) {
|
||||
if len(params) == 1 {
|
||||
p := params[0]
|
||||
if p.Name == "" {
|
||||
return id + " " + p.Value, nil
|
||||
}
|
||||
return id + " " + p.Name + "=" + p.Value, nil
|
||||
}
|
||||
parts := make([]string, 0, len(params))
|
||||
for _, p := range params {
|
||||
if p.Name == "" || p.Value == "" {
|
||||
return "", errors.New("fmtp parameter missing name or value")
|
||||
}
|
||||
parts = append(parts, p.Name+"="+p.Value)
|
||||
}
|
||||
return id + " " + strings.Join(parts, ";"), nil
|
||||
}
|
||||
231
xmpp/jingle/convert_test.go
Normal file
231
xmpp/jingle/convert_test.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// exercises BUNDLE + opus + fmtp + extmap + ssrc + DTLS-SRTP + ICE-UDP
|
||||
const sampleSDP = "v=0\r\n" +
|
||||
"o=- 0 0 IN IP4 0.0.0.0\r\n" +
|
||||
"s=-\r\n" +
|
||||
"t=0 0\r\n" +
|
||||
"a=group:BUNDLE 0\r\n" +
|
||||
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
||||
"c=IN IP4 0.0.0.0\r\n" +
|
||||
"a=mid:0\r\n" +
|
||||
"a=sendrecv\r\n" +
|
||||
"a=rtcp-mux\r\n" +
|
||||
"a=ice-ufrag:abc\r\n" +
|
||||
"a=ice-pwd:thispasswordisatleast22chars\r\n" +
|
||||
"a=ice-options:trickle\r\n" +
|
||||
"a=fingerprint:sha-256 AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89\r\n" +
|
||||
"a=setup:actpass\r\n" +
|
||||
"a=rtpmap:111 opus/48000/2\r\n" +
|
||||
"a=fmtp:111 minptime=10;useinbandfec=1\r\n" +
|
||||
"a=rtcp-fb:111 transport-cc\r\n" +
|
||||
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n" +
|
||||
"a=ssrc:12345 cname:abcdef\r\n" +
|
||||
"a=ssrc:12345 msid:stream track\r\n" +
|
||||
"a=candidate:1 1 udp 2113937151 192.0.2.1 10000 typ host generation 0\r\n"
|
||||
|
||||
func sampleJingle() *JingleIQ {
|
||||
j, err := SDPToJingle(sampleSDP, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: "abc123"})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
j.Action = "session-initiate"
|
||||
j.Initiator = "alice@example.com/foo"
|
||||
return j
|
||||
}
|
||||
|
||||
func TestRoundtripJingleStructViaSDP(t *testing.T) {
|
||||
in := sampleJingle()
|
||||
opts := ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: in.SID}
|
||||
sdp, err := JingleToSDP(in, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("JingleToSDP: %v", err)
|
||||
}
|
||||
out, err := SDPToJingle(sdp, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("SDPToJingle: %v", err)
|
||||
}
|
||||
// Action / Initiator / Responder live on the IQ envelope and are not in
|
||||
// SDP, so the roundtrip can only restore Contents, Group, and SID.
|
||||
if out.SID != in.SID {
|
||||
t.Errorf("SID lost: %q -> %q", in.SID, out.SID)
|
||||
}
|
||||
if !reflect.DeepEqual(in.Group, out.Group) {
|
||||
t.Errorf("Group mismatch:\nin: %+v\nout: %+v", in.Group, out.Group)
|
||||
}
|
||||
if len(in.Contents) != len(out.Contents) {
|
||||
t.Fatalf("content count: in=%d out=%d", len(in.Contents), len(out.Contents))
|
||||
}
|
||||
for i := range in.Contents {
|
||||
assertContentEqual(t, &in.Contents[i], &out.Contents[i])
|
||||
}
|
||||
}
|
||||
|
||||
// only fields that survive a round trip; RtcpMux gets set unconditionally
|
||||
// when BUNDLE is in play, candidate id gets dropped, etc.
|
||||
func assertContentEqual(t *testing.T, a, b *Content) {
|
||||
t.Helper()
|
||||
if a.Name != b.Name || a.Creator != b.Creator {
|
||||
t.Errorf("content header: %+v vs %+v", a, b)
|
||||
}
|
||||
// empty input means "both", which sendersFromMM omits
|
||||
if want, got := normalizeSenders(a.Senders), normalizeSenders(b.Senders); want != got {
|
||||
t.Errorf("senders: %q vs %q", want, got)
|
||||
}
|
||||
if !reflect.DeepEqual(a.Description, b.Description) {
|
||||
t.Errorf("description differs:\nin: %+v\nout: %+v", a.Description, b.Description)
|
||||
}
|
||||
assertTransportEqual(t, a.Transport, b.Transport)
|
||||
}
|
||||
|
||||
func normalizeSenders(s string) string {
|
||||
if s == "" {
|
||||
return "both"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func assertTransportEqual(t *testing.T, a, b *Transport) {
|
||||
t.Helper()
|
||||
if a.Ufrag != b.Ufrag || a.Pwd != b.Pwd {
|
||||
t.Errorf("ufrag/pwd: in=(%q,%q) out=(%q,%q)", a.Ufrag, a.Pwd, b.Ufrag, b.Pwd)
|
||||
}
|
||||
if !reflect.DeepEqual(a.Fingerprint, b.Fingerprint) {
|
||||
t.Errorf("fingerprint differs:\nin: %+v\nout: %+v", a.Fingerprint, b.Fingerprint)
|
||||
}
|
||||
if (a.Trickle == nil) != (b.Trickle == nil) {
|
||||
t.Errorf("trickle presence differs: in=%v out=%v", a.Trickle != nil, b.Trickle != nil)
|
||||
}
|
||||
if (a.Renomination == nil) != (b.Renomination == nil) {
|
||||
t.Errorf("renomination presence differs: in=%v out=%v", a.Renomination != nil, b.Renomination != nil)
|
||||
}
|
||||
if len(a.Candidates) != len(b.Candidates) {
|
||||
t.Fatalf("candidate count: in=%d out=%d", len(a.Candidates), len(b.Candidates))
|
||||
}
|
||||
for i := range a.Candidates {
|
||||
ac, bc := a.Candidates[i], b.Candidates[i]
|
||||
// id and network are not present in SDP candidate form; ignore.
|
||||
ac.ID, bc.ID = "", ""
|
||||
ac.Network, bc.Network = "", ""
|
||||
if !reflect.DeepEqual(ac, bc) {
|
||||
t.Errorf("candidate %d differs:\nin: %+v\nout: %+v", i, ac, bc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJingleXMLRoundtrip(t *testing.T) {
|
||||
in := sampleJingle()
|
||||
raw1, err := xml.MarshalIndent(in, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal in: %v", err)
|
||||
}
|
||||
var mid JingleIQ
|
||||
if err := xml.Unmarshal(raw1, &mid); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw:\n%s", err, raw1)
|
||||
}
|
||||
raw2, err := xml.MarshalIndent(&mid, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal mid: %v", err)
|
||||
}
|
||||
if !bytes.Equal(raw1, raw2) {
|
||||
t.Errorf("XML not idempotent under marshal/unmarshal\n---first---\n%s\n---second---\n%s", raw1, raw2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSDP_AttrsAndMedia(t *testing.T) {
|
||||
sdp := "v=0\r\n" +
|
||||
"o=- 0 0 IN IP4 0.0.0.0\r\n" +
|
||||
"s=-\r\n" +
|
||||
"t=0 0\r\n" +
|
||||
"a=group:BUNDLE 0\r\n" +
|
||||
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
||||
"c=IN IP4 0.0.0.0\r\n" +
|
||||
"a=mid:0\r\n" +
|
||||
"a=rtpmap:111 opus/48000/2\r\n"
|
||||
doc, err := parseSDP(sdp)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSDP: %v", err)
|
||||
}
|
||||
if mmFirst(doc.sessionAttrs, "group", "") != "BUNDLE 0" {
|
||||
t.Errorf("session group: %q", mmFirst(doc.sessionAttrs, "group", ""))
|
||||
}
|
||||
if len(doc.media) != 1 {
|
||||
t.Fatalf("expected 1 m=block, got %d", len(doc.media))
|
||||
}
|
||||
m := doc.media[0]
|
||||
if m.media != "audio" || m.port != "9" || m.protocol != "UDP/TLS/RTP/SAVPF" {
|
||||
t.Errorf("m= header: %+v", m)
|
||||
}
|
||||
if !reflect.DeepEqual(m.formats, []string{"111"}) {
|
||||
t.Errorf("formats: %v", m.formats)
|
||||
}
|
||||
if m.connection != "IN IP4 0.0.0.0" {
|
||||
t.Errorf("connection: %q", m.connection)
|
||||
}
|
||||
if mmFirst(m.attrs, "mid", "") != "0" {
|
||||
t.Errorf("mid: %q", mmFirst(m.attrs, "mid", ""))
|
||||
}
|
||||
if mmFirst(m.attrs, "rtpmap", "") != "111 opus/48000/2" {
|
||||
t.Errorf("rtpmap: %q", mmFirst(m.attrs, "rtpmap", ""))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJingleToSDP_RejectsMalformed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*JingleIQ)
|
||||
}{
|
||||
{"missing fingerprint", func(j *JingleIQ) { j.Contents[0].Transport.Fingerprint = nil }},
|
||||
{"non-UDP candidate", func(j *JingleIQ) { j.Contents[0].Transport.Candidates[0].Protocol = "tcp" }},
|
||||
{"CR/LF in ssrc param value", func(j *JingleIQ) {
|
||||
j.Contents[0].Description.Sources[0].Parameters[0].Value = "bad\nvalue"
|
||||
}},
|
||||
{"whitespace in group semantics", func(j *JingleIQ) { j.Group.Semantics = "BUNDLE BAD" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
j := sampleJingle()
|
||||
tc.mutate(j)
|
||||
if _, err := JingleToSDP(j, ConvertOpts{Initiator: true}); err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDPToJingle_FingerprintSessionFallback(t *testing.T) {
|
||||
// Media block omits fingerprint+setup; session block provides them.
|
||||
sdp := "v=0\r\n" +
|
||||
"o=- 0 0 IN IP4 0.0.0.0\r\n" +
|
||||
"s=-\r\n" +
|
||||
"t=0 0\r\n" +
|
||||
"a=fingerprint:sha-256 AA:BB:CC\r\n" +
|
||||
"a=setup:actpass\r\n" +
|
||||
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
||||
"c=IN IP4 0.0.0.0\r\n" +
|
||||
"a=ice-ufrag:u\r\n" +
|
||||
"a=ice-pwd:p\r\n" +
|
||||
"a=mid:0\r\n" +
|
||||
"a=rtpmap:111 opus/48000/2\r\n"
|
||||
j, err := SDPToJingle(sdp, ConvertOpts{Initiator: true})
|
||||
if err != nil {
|
||||
t.Fatalf("SDPToJingle: %v", err)
|
||||
}
|
||||
if len(j.Contents) != 1 {
|
||||
t.Fatalf("contents: %d", len(j.Contents))
|
||||
}
|
||||
fp := j.Contents[0].Transport.Fingerprint
|
||||
if fp == nil {
|
||||
t.Fatal("fingerprint not pulled up from session level")
|
||||
}
|
||||
if fp.Hash != "sha-256" || fp.Text != "AA:BB:CC" || fp.Setup != "actpass" {
|
||||
t.Errorf("fingerprint: %+v", fp)
|
||||
}
|
||||
}
|
||||
167
xmpp/jingle/convert_transport.go
Normal file
167
xmpp/jingle/convert_transport.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func appendTransportAttrs(t *Transport, attrs *[]kv) error {
|
||||
if t == nil {
|
||||
return errors.New("content missing transport")
|
||||
}
|
||||
if t.Ufrag == "" {
|
||||
return errors.New("transport missing ufrag")
|
||||
}
|
||||
if t.Pwd == "" {
|
||||
return errors.New("transport missing pwd")
|
||||
}
|
||||
*attrs = append(*attrs, kv{"ice-ufrag", t.Ufrag})
|
||||
*attrs = append(*attrs, kv{"ice-pwd", t.Pwd})
|
||||
|
||||
var iceOptions []string
|
||||
if t.Trickle != nil {
|
||||
iceOptions = append(iceOptions, "trickle")
|
||||
}
|
||||
if t.Renomination != nil {
|
||||
iceOptions = append(iceOptions, "renomination")
|
||||
}
|
||||
if len(iceOptions) == 0 {
|
||||
iceOptions = defaultICEOptions
|
||||
}
|
||||
*attrs = append(*attrs, kv{"ice-options", strings.Join(iceOptions, " ")})
|
||||
|
||||
fp := t.Fingerprint
|
||||
if fp == nil {
|
||||
return errors.New("transport missing DTLS fingerprint")
|
||||
}
|
||||
if fp.Hash == "" || fp.Text == "" {
|
||||
return errors.New("fingerprint missing hash or body")
|
||||
}
|
||||
if strings.ContainsAny(fp.Hash, " \t\r\n") {
|
||||
return fmt.Errorf("fingerprint hash contains whitespace: %q", fp.Hash)
|
||||
}
|
||||
*attrs = append(*attrs, kv{"fingerprint", fp.Hash + " " + fp.Text})
|
||||
if fp.Setup != "" {
|
||||
*attrs = append(*attrs, kv{"setup", fp.Setup})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// falls back to session-level fingerprint/setup when the media block omits
|
||||
// them (matches Conversations SessionDescription)
|
||||
func buildJingleTransport(m *mediaSection, sessAttrs []kv) (*Transport, error) {
|
||||
t := &Transport{
|
||||
Ufrag: mmFirst(m.attrs, "ice-ufrag", ""),
|
||||
Pwd: mmFirst(m.attrs, "ice-pwd", ""),
|
||||
}
|
||||
|
||||
fpRaw := mmFirst(m.attrs, "fingerprint", mmFirst(sessAttrs, "fingerprint", ""))
|
||||
setupVal := mmFirst(m.attrs, "setup", mmFirst(sessAttrs, "setup", ""))
|
||||
if fpRaw != "" && setupVal != "" {
|
||||
sp := strings.SplitN(fpRaw, " ", 2)
|
||||
if len(sp) >= 2 {
|
||||
t.Fingerprint = &Fingerprint{
|
||||
Hash: sp[0],
|
||||
Setup: setupVal,
|
||||
Text: sp[1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if iceOptions := mmFirst(m.attrs, "ice-options", ""); iceOptions != "" {
|
||||
for _, opt := range strings.Fields(iceOptions) {
|
||||
if !wellKnownICEOptions[opt] {
|
||||
continue
|
||||
}
|
||||
switch opt {
|
||||
case "trickle":
|
||||
t.Trickle = &ICEOptionTrickle{}
|
||||
case "renomination":
|
||||
t.Renomination = &ICEOptionRenomination{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, val := range mmGetAll(m.attrs, "candidate") {
|
||||
cand, err := candidateFromSDP(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cand != nil {
|
||||
t.Candidates = append(t.Candidates, *cand)
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func candidateToSDP(c *Candidate) (string, error) {
|
||||
for _, kv := range []struct{ name, val string }{
|
||||
{"foundation", c.Foundation},
|
||||
{"component", c.Component},
|
||||
{"protocol", c.Protocol},
|
||||
{"priority", c.Priority},
|
||||
{"ip", c.IP},
|
||||
{"port", c.Port},
|
||||
} {
|
||||
if kv.val == "" {
|
||||
return "", fmt.Errorf("candidate missing %s", kv.name)
|
||||
}
|
||||
}
|
||||
proto := strings.ToLower(c.Protocol)
|
||||
if proto != "udp" {
|
||||
return "", fmt.Errorf("'%s' is not a supported protocol", proto)
|
||||
}
|
||||
var extra []string
|
||||
if c.Type != "" {
|
||||
extra = append(extra, "typ", c.Type)
|
||||
}
|
||||
if c.RelAddr != "" {
|
||||
extra = append(extra, "raddr", c.RelAddr)
|
||||
}
|
||||
if c.RelPort != "" {
|
||||
extra = append(extra, "rport", c.RelPort)
|
||||
}
|
||||
if c.Generation != "" {
|
||||
extra = append(extra, "generation", c.Generation)
|
||||
}
|
||||
line := c.Foundation + " " + c.Component + " " + proto + " " + c.Priority + " " + c.IP + " " + c.Port
|
||||
if len(extra) > 0 {
|
||||
line += " " + strings.Join(extra, " ")
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
func candidateFromSDP(value string) (*Candidate, error) {
|
||||
parts := strings.Fields(value)
|
||||
if len(parts) < 6 {
|
||||
return nil, nil
|
||||
}
|
||||
cand := &Candidate{
|
||||
Foundation: parts[0],
|
||||
Component: parts[1],
|
||||
Protocol: strings.ToLower(parts[2]),
|
||||
Priority: parts[3],
|
||||
IP: parts[4],
|
||||
Port: parts[5],
|
||||
}
|
||||
if cand.Protocol != "udp" {
|
||||
return nil, fmt.Errorf("'%s' is not a supported protocol", cand.Protocol)
|
||||
}
|
||||
extra := parts[6:]
|
||||
for i := 0; i+1 < len(extra); i += 2 {
|
||||
k := extra[i]
|
||||
v := extra[i+1]
|
||||
switch k {
|
||||
case "typ":
|
||||
cand.Type = v
|
||||
case "raddr":
|
||||
cand.RelAddr = v
|
||||
case "rport":
|
||||
cand.RelPort = v
|
||||
case "generation":
|
||||
cand.Generation = v
|
||||
}
|
||||
}
|
||||
return cand, nil
|
||||
}
|
||||
81
xmpp/jingle/jmi.go
Normal file
81
xmpp/jingle/jmi.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// Jingle Message Initiation, XEP-0353
|
||||
const NSJMI = "urn:xmpp:jingle-message:0"
|
||||
|
||||
// <propose/> - advertises media the caller wants
|
||||
type JMIPropose struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 propose"`
|
||||
ID string `xml:"id,attr"`
|
||||
Descriptions []JMIDescription `xml:"urn:xmpp:jingle:apps:rtp:1 description"`
|
||||
}
|
||||
|
||||
// slim XEP-0167 <description> used inside JMI; only media is meaningful here
|
||||
type JMIDescription struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 description"`
|
||||
Media string `xml:"media,attr"`
|
||||
}
|
||||
|
||||
// <proceed/> - callee picked up, caller may send session-initiate
|
||||
type JMIProceed struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 proceed"`
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
|
||||
// <ringing/> - callee alerting
|
||||
type JMIRinging struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 ringing"`
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
|
||||
// <reject/> - callee declined before media setup
|
||||
type JMIReject struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 reject"`
|
||||
ID string `xml:"id,attr"`
|
||||
Reason *Reason `xml:"urn:xmpp:jingle:1 reason,omitempty"`
|
||||
}
|
||||
|
||||
// <retract/> - caller cancelled before proceed
|
||||
type JMIRetract struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 retract"`
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
|
||||
// <finish/> - terminal marker, parsed only so the parser doesn't fall back to Node
|
||||
type JMIFinish struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle-message:0 finish"`
|
||||
ID string `xml:"id,attr"`
|
||||
}
|
||||
|
||||
// gosrc.io/xmpp MsgExtension boilerplate
|
||||
func (JMIPropose) Namespace() string { return NSJMI }
|
||||
func (JMIProceed) Namespace() string { return NSJMI }
|
||||
func (JMIRinging) Namespace() string { return NSJMI }
|
||||
func (JMIReject) Namespace() string { return NSJMI }
|
||||
func (JMIRetract) Namespace() string { return NSJMI }
|
||||
func (JMIFinish) Namespace() string { return NSJMI }
|
||||
|
||||
func init() {
|
||||
for _, m := range []struct {
|
||||
local string
|
||||
empty stanza.MsgExtension
|
||||
}{
|
||||
{"propose", JMIPropose{}},
|
||||
{"proceed", JMIProceed{}},
|
||||
{"ringing", JMIRinging{}},
|
||||
{"reject", JMIReject{}},
|
||||
{"retract", JMIRetract{}},
|
||||
{"finish", JMIFinish{}},
|
||||
} {
|
||||
stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{
|
||||
Space: NSJMI,
|
||||
Local: m.local,
|
||||
}, m.empty)
|
||||
}
|
||||
}
|
||||
197
xmpp/jingle/manager.go
Normal file
197
xmpp/jingle/manager.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
type IncomingProposal struct {
|
||||
SID string
|
||||
From string
|
||||
To string
|
||||
Media []string
|
||||
}
|
||||
|
||||
// runs when JMI <propose> arrives for an unknown SID.
|
||||
// - (s, postReady, nil): accept; Manager registers s and calls postReady
|
||||
// (if non-nil) AFTER HandleJMI(propose) has reached StateRinging. Useful
|
||||
// for tg-side goroutines that could race back in via the bridge.
|
||||
// - (nil, _, nil): silently ignore.
|
||||
// - (nil, _, err): logged, propose dropped.
|
||||
type ProposalHandler func(IncomingProposal) (*Session, func(), error)
|
||||
|
||||
// dispatches inbound Jingle IQ and JMI message stanzas to matching Sessions;
|
||||
// one Manager per gateway-side endpoint
|
||||
type Manager struct {
|
||||
LocalJID string // `from=` on IQ result/error replies
|
||||
Sender Sender // IQ replies only; Sessions own outbound traffic
|
||||
OnProposal ProposalHandler
|
||||
|
||||
sessions sync.Map // sid -> *Session
|
||||
// stops two concurrent <propose>s for the same sid from both
|
||||
// invoking OnProposal
|
||||
pendingPropose sync.Map
|
||||
}
|
||||
|
||||
func (m *Manager) Register(s *Session) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
m.sessions.Store(s.SID(), s)
|
||||
}
|
||||
|
||||
func (m *Manager) Unregister(sid string) {
|
||||
m.sessions.Delete(sid)
|
||||
}
|
||||
|
||||
func (m *Manager) session(sid string) *Session {
|
||||
v, ok := m.sessions.Load(sid)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return v.(*Session)
|
||||
}
|
||||
|
||||
// entrypoint for gosrc.io/xmpp's router (message and iq routes)
|
||||
func (m *Manager) HandlePacket(_ interface{}, p stanza.Packet) {
|
||||
// gosrc.io delivers *stanza.IQ but stanza.Message as value
|
||||
switch pkt := p.(type) {
|
||||
case *stanza.IQ:
|
||||
m.handleIQ(pkt)
|
||||
case stanza.Message:
|
||||
m.handleMessage(&pkt)
|
||||
case *stanza.Message:
|
||||
m.handleMessage(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) HandleIQ(iq *stanza.IQ) { m.handleIQ(iq) }
|
||||
func (m *Manager) HandleMessage(msg *stanza.Message) { m.handleMessage(msg) }
|
||||
|
||||
func (m *Manager) handleIQ(iq *stanza.IQ) {
|
||||
j, ok := iq.Payload.(*JingleIQ)
|
||||
if !ok || j == nil {
|
||||
return
|
||||
}
|
||||
sess := m.session(j.SID)
|
||||
if sess == nil {
|
||||
m.replyError(iq, "item-not-found")
|
||||
return
|
||||
}
|
||||
_, errCondition := sess.HandleJingleIQ(iq, j)
|
||||
if errCondition != "" {
|
||||
m.replyError(iq, errCondition)
|
||||
return
|
||||
}
|
||||
if iq.Type == stanza.IQTypeSet || iq.Type == stanza.IQTypeGet {
|
||||
m.replyResult(iq)
|
||||
}
|
||||
if j.Action == ActionSessionTerminate {
|
||||
m.Unregister(j.SID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleMessage(msg *stanza.Message) {
|
||||
for _, ext := range msg.Extensions {
|
||||
switch x := ext.(type) {
|
||||
case *JMIPropose:
|
||||
m.dispatchPropose(msg, x)
|
||||
case *JMIProceed:
|
||||
m.dispatchJMI(x.ID, msg.From, x)
|
||||
case *JMIRinging:
|
||||
m.dispatchJMI(x.ID, msg.From, x)
|
||||
case *JMIReject:
|
||||
m.dispatchJMI(x.ID, msg.From, x)
|
||||
m.Unregister(x.ID)
|
||||
case *JMIRetract:
|
||||
m.dispatchJMI(x.ID, msg.From, x)
|
||||
m.Unregister(x.ID)
|
||||
case *JMIFinish:
|
||||
// terminal, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dispatchPropose(msg *stanza.Message, p *JMIPropose) {
|
||||
if existing := m.session(p.ID); existing != nil {
|
||||
return
|
||||
}
|
||||
// concurrent <propose>s for the same sid: only one dispatcher proceeds
|
||||
if _, loaded := m.pendingPropose.LoadOrStore(p.ID, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
defer m.pendingPropose.Delete(p.ID)
|
||||
if m.OnProposal == nil {
|
||||
return
|
||||
}
|
||||
media := make([]string, 0, len(p.Descriptions))
|
||||
for _, d := range p.Descriptions {
|
||||
media = append(media, d.Media)
|
||||
}
|
||||
sess, postReady, err := m.OnProposal(IncomingProposal{SID: p.ID, From: msg.From, To: msg.To, Media: media})
|
||||
if err != nil || sess == nil {
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{"sid": p.ID, "from": msg.From}).WithError(err).
|
||||
Warn("jingle.Manager.dispatchPropose: OnProposal returned err")
|
||||
}
|
||||
return
|
||||
}
|
||||
m.Register(sess)
|
||||
sess.HandleJMI(msg.From, p)
|
||||
if postReady != nil {
|
||||
postReady()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dispatchJMI(sid, from string, ext stanza.MsgExtension) {
|
||||
sess := m.session(sid)
|
||||
if sess == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"sid": sid,
|
||||
"from": from,
|
||||
"ext_type": fmt.Sprintf("%T", ext),
|
||||
}).Warn("jingle.Manager.dispatchJMI: no session for sid; dropping (stray <proceed>/<reject> from SID mismatch?)")
|
||||
return
|
||||
}
|
||||
sess.HandleJMI(from, ext)
|
||||
}
|
||||
|
||||
func (m *Manager) replyResult(iq *stanza.IQ) {
|
||||
if m.Sender == nil {
|
||||
return
|
||||
}
|
||||
out, err := stanza.NewIQ(stanza.Attrs{
|
||||
Type: stanza.IQTypeResult,
|
||||
From: iq.To,
|
||||
To: iq.From,
|
||||
Id: iq.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = m.Sender.Send(out)
|
||||
}
|
||||
|
||||
// <iq type='error'>; condition from urn:ietf:params:xml:ns:xmpp-stanzas
|
||||
func (m *Manager) replyError(iq *stanza.IQ, condition string) {
|
||||
if m.Sender == nil {
|
||||
return
|
||||
}
|
||||
out, err := stanza.NewIQ(stanza.Attrs{
|
||||
Type: stanza.IQTypeError,
|
||||
From: iq.To,
|
||||
To: iq.From,
|
||||
Id: iq.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
out.Error = &stanza.Err{
|
||||
Type: "cancel",
|
||||
Reason: condition,
|
||||
}
|
||||
_ = m.Sender.Send(out)
|
||||
}
|
||||
13
xmpp/jingle/namespaces.go
Normal file
13
xmpp/jingle/namespaces.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package jingle
|
||||
|
||||
const (
|
||||
NSJingle = "urn:xmpp:jingle:1"
|
||||
NSRTP = "urn:xmpp:jingle:apps:rtp:1"
|
||||
NSRTPHdrext = "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"
|
||||
NSRTPFeedback = "urn:xmpp:jingle:apps:rtp:rtcp-fb:0"
|
||||
NSRTPSSMA = "urn:xmpp:jingle:apps:rtp:ssma:0"
|
||||
NSDTLS = "urn:xmpp:jingle:apps:dtls:0"
|
||||
NSGrouping = "urn:xmpp:jingle:apps:grouping:0"
|
||||
NSICEUDP = "urn:xmpp:jingle:transports:ice-udp:1"
|
||||
NSICEOption = "http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option"
|
||||
)
|
||||
141
xmpp/jingle/sdp.go
Normal file
141
xmpp/jingle/sdp.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const sdpLineDivider = "\r\n"
|
||||
|
||||
// one SDP "a=" attribute line
|
||||
type kv struct {
|
||||
k, v string
|
||||
}
|
||||
|
||||
// one m= block + its connection line and ordered attrs
|
||||
type mediaSection struct {
|
||||
media string
|
||||
port string
|
||||
protocol string
|
||||
formats []string
|
||||
connection string
|
||||
attrs []kv
|
||||
}
|
||||
|
||||
type sdpDoc struct {
|
||||
sessionAttrs []kv
|
||||
media []mediaSection
|
||||
}
|
||||
|
||||
// preserves attribute order (SDP repeats keys; order matters for
|
||||
// rtpmap/candidate)
|
||||
func parseSDP(s string) (*sdpDoc, error) {
|
||||
doc := &sdpDoc{}
|
||||
var cur *mediaSection
|
||||
|
||||
for _, raw := range strings.Split(s, "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
if len(line) < 2 || line[1] != '=' {
|
||||
continue
|
||||
}
|
||||
key := line[0]
|
||||
value := line[2:]
|
||||
switch key {
|
||||
case 'm':
|
||||
if cur != nil {
|
||||
doc.media = append(doc.media, *cur)
|
||||
}
|
||||
parts := strings.Split(value, " ")
|
||||
if len(parts) < 3 {
|
||||
return nil, fmt.Errorf("malformed m= line: %q", value)
|
||||
}
|
||||
ms := mediaSection{
|
||||
media: parts[0],
|
||||
port: parts[1],
|
||||
protocol: parts[2],
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
ms.formats = parts[3:]
|
||||
}
|
||||
cur = &ms
|
||||
case 'c':
|
||||
if cur != nil {
|
||||
cur.connection = value
|
||||
}
|
||||
case 'a':
|
||||
k, v := splitAttr(value)
|
||||
if cur == nil {
|
||||
doc.sessionAttrs = append(doc.sessionAttrs, kv{k, v})
|
||||
} else {
|
||||
cur.attrs = append(cur.attrs, kv{k, v})
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
doc.media = append(doc.media, *cur)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// value is everything after the first colon; later colons are preserved
|
||||
func splitAttr(body string) (string, string) {
|
||||
if i := strings.IndexByte(body, ':'); i >= 0 {
|
||||
return body[:i], body[i+1:]
|
||||
}
|
||||
return body, ""
|
||||
}
|
||||
|
||||
func mmGetAll(mm []kv, key string) []string {
|
||||
var out []string
|
||||
for _, p := range mm {
|
||||
if p.k == key {
|
||||
out = append(out, p.v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mmFirst(mm []kv, key, def string) string {
|
||||
for _, p := range mm {
|
||||
if p.k == key {
|
||||
return p.v
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func mmHas(mm []kv, key string) bool {
|
||||
for _, p := range mm {
|
||||
if p.k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type sdpBuilder struct {
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (b *sdpBuilder) line(s string) {
|
||||
b.lines = append(b.lines, s)
|
||||
}
|
||||
|
||||
// empty value emits "a=key" (no colon)
|
||||
func (b *sdpBuilder) attr(k, v string) {
|
||||
if v == "" {
|
||||
b.lines = append(b.lines, "a="+k)
|
||||
} else {
|
||||
b.lines = append(b.lines, "a="+k+":"+v)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *sdpBuilder) appendAttrs(mm []kv) {
|
||||
for _, p := range mm {
|
||||
b.attr(p.k, p.v)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *sdpBuilder) String() string {
|
||||
return strings.Join(b.lines, sdpLineDivider) + sdpLineDivider
|
||||
}
|
||||
736
xmpp/jingle/session.go
Normal file
736
xmpp/jingle/session.go
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
// state machine binding one pion PeerConnection to the XMPP signalling
|
||||
// channel (XEP-0166 + XEP-0353 JMI preamble)
|
||||
package jingle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
type Role uint8
|
||||
|
||||
const (
|
||||
RoleInitiator Role = iota // local side proposed the call
|
||||
RoleResponder // peer proposed the call
|
||||
)
|
||||
|
||||
// high-level call lifecycle; pion's ConnectionState owns media substates
|
||||
type State uint8
|
||||
|
||||
const (
|
||||
StateNew State = iota
|
||||
StateProposed // caller: sent <propose>, awaiting proceed/reject/retract
|
||||
StateRinging // callee: got <propose>, sent <ringing>, awaiting user accept/decline
|
||||
StateProceeded // proceed exchanged; media setup either pending or in flight
|
||||
StateActive // session-accept exchanged; pion drives ICE/DTLS from here
|
||||
StateTerminated
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateNew:
|
||||
return "new"
|
||||
case StateProposed:
|
||||
return "proposed"
|
||||
case StateRinging:
|
||||
return "ringing"
|
||||
case StateProceeded:
|
||||
return "proceeded"
|
||||
case StateActive:
|
||||
return "active"
|
||||
case StateTerminated:
|
||||
return "terminated"
|
||||
}
|
||||
return fmt.Sprintf("state(%d)", uint8(s))
|
||||
}
|
||||
|
||||
// slice of gosrc.io/xmpp.Sender that Session needs; *xmpp.Client and
|
||||
// *xmpp.Component both satisfy it
|
||||
type Sender interface {
|
||||
Send(packet stanza.Packet) error
|
||||
SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error)
|
||||
}
|
||||
|
||||
// JMI/Jingle lifecycle callbacks. Methods may fire under the session mutex,
|
||||
// so observers must not call back into the session synchronously - defer
|
||||
// to a goroutine. When set, Session does NOT auto-send <ringing/> on
|
||||
// incoming <propose>; the observer drives that.
|
||||
type SessionObserver interface {
|
||||
OnRinging(from string)
|
||||
OnProceeded(from string)
|
||||
// fires after SetRemoteDescription, before CreateAnswer on the responder,
|
||||
// so a same-stack ReplaceTrack lands in the answer SDP
|
||||
OnRemoteDescriptionApplied(sdp string)
|
||||
// reason: XEP-0166 condition local-name, or "rejected"/"retracted"
|
||||
OnTerminated(reason string)
|
||||
}
|
||||
|
||||
// PC and Sender are required
|
||||
type SessionOpts struct {
|
||||
PC *webrtc.PeerConnection
|
||||
Sender Sender
|
||||
LocalJID string
|
||||
RemoteJID string // bare is fine for initial <propose>; latched to full on <proceed>
|
||||
SID string
|
||||
Role Role
|
||||
Media []string // only audio supported
|
||||
Observer SessionObserver
|
||||
}
|
||||
|
||||
// one call. Public methods are safe from any goroutine; mu is held only
|
||||
// briefly and never across pion calls or Sender I/O.
|
||||
type Session struct {
|
||||
opts SessionOpts
|
||||
// latched from opts so it's readable without mu
|
||||
observer SessionObserver
|
||||
|
||||
mu sync.Mutex
|
||||
state State
|
||||
remoteCandQueue []remoteCandidate // candidates from before SetRemoteDescription
|
||||
remoteDescApplied bool
|
||||
localCandQueue []webrtc.ICECandidate // gathered before session-initiate/accept
|
||||
signaled bool
|
||||
// captured from the local SDP after SetLocalDescription
|
||||
ufrag string
|
||||
pwd string
|
||||
mids []string
|
||||
closed bool
|
||||
}
|
||||
|
||||
type remoteCandidate struct {
|
||||
mid string
|
||||
init webrtc.ICECandidateInit
|
||||
}
|
||||
|
||||
// human-readable so XML dumps are easy to grep
|
||||
var stanzaIDCounter atomic.Uint64
|
||||
|
||||
func newStanzaID() string {
|
||||
return fmt.Sprintf("jng-%d", stanzaIDCounter.Add(1))
|
||||
}
|
||||
|
||||
// caller adds transceivers/tracks to PC before Propose/AcceptProposal
|
||||
func New(opts SessionOpts) (*Session, error) {
|
||||
if opts.PC == nil {
|
||||
return nil, errors.New("jingle: PC required")
|
||||
}
|
||||
if opts.Sender == nil {
|
||||
return nil, errors.New("jingle: Sender required")
|
||||
}
|
||||
if opts.SID == "" {
|
||||
return nil, errors.New("jingle: SID required")
|
||||
}
|
||||
if opts.LocalJID == "" || opts.RemoteJID == "" {
|
||||
return nil, errors.New("jingle: LocalJID and RemoteJID required")
|
||||
}
|
||||
if len(opts.Media) == 0 {
|
||||
opts.Media = []string{"audio"}
|
||||
}
|
||||
s := &Session{opts: opts, observer: opts.Observer}
|
||||
opts.PC.OnICECandidate(s.onICECandidate)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) State() State {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
func (s *Session) SID() string { return s.opts.SID }
|
||||
|
||||
// after <proceed> arrives this is the full JID of the device that picked up
|
||||
func (s *Session) RemoteJID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.opts.RemoteJID
|
||||
}
|
||||
|
||||
// <message><propose/></message>
|
||||
func (s *Session) Propose(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateNew || s.opts.Role != RoleInitiator {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("propose: bad state %s", st)
|
||||
}
|
||||
to := s.opts.RemoteJID
|
||||
from := s.opts.LocalJID
|
||||
media := append([]string(nil), s.opts.Media...)
|
||||
s.mu.Unlock()
|
||||
|
||||
msg := newJMIMessage(from, to, &JMIPropose{
|
||||
ID: s.opts.SID,
|
||||
Descriptions: mediaDescs(media),
|
||||
})
|
||||
if err := s.sendStanza(msg); err != nil {
|
||||
return fmt.Errorf("propose: send: %w", err)
|
||||
}
|
||||
s.setStateIf(StateNew, StateProposed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// <message><ringing/></message> - callee tells caller the device is alerting
|
||||
func (s *Session) Ringing(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateRinging || s.opts.Role != RoleResponder {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("ringing: bad state %s", st)
|
||||
}
|
||||
to, from := s.opts.RemoteJID, s.opts.LocalJID
|
||||
s.mu.Unlock()
|
||||
|
||||
msg := newJMIMessage(from, to, &JMIRinging{ID: s.opts.SID})
|
||||
return s.sendStanza(msg)
|
||||
}
|
||||
|
||||
// <message><proceed/></message>; media setup waits for session-initiate
|
||||
func (s *Session) AcceptProposal(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateRinging || s.opts.Role != RoleResponder {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("accept: bad state %s", st)
|
||||
}
|
||||
to, from := s.opts.RemoteJID, s.opts.LocalJID
|
||||
s.mu.Unlock()
|
||||
|
||||
msg := newJMIMessage(from, to, &JMIProceed{ID: s.opts.SID})
|
||||
if err := s.sendStanza(msg); err != nil {
|
||||
return fmt.Errorf("accept: send: %w", err)
|
||||
}
|
||||
s.setStateIf(StateRinging, StateProceeded)
|
||||
return nil
|
||||
}
|
||||
|
||||
// <message><reject/></message> (callee, pre-media)
|
||||
func (s *Session) Decline(ctx context.Context, reason string) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateRinging || s.opts.Role != RoleResponder {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("decline: bad state %s", st)
|
||||
}
|
||||
to, from := s.opts.RemoteJID, s.opts.LocalJID
|
||||
s.mu.Unlock()
|
||||
|
||||
r := &JMIReject{ID: s.opts.SID}
|
||||
if reason != "" {
|
||||
r.Reason = &Reason{Condition: &ReasonCondition{XMLName: xml.Name{Space: NSJingle, Local: reason}}}
|
||||
}
|
||||
msg := newJMIMessage(from, to, r)
|
||||
err := s.sendStanza(msg)
|
||||
s.terminate()
|
||||
return err
|
||||
}
|
||||
|
||||
// <message><retract/></message> (caller, pre-<proceed>)
|
||||
func (s *Session) Retract(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateProposed || s.opts.Role != RoleInitiator {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("retract: bad state %s", st)
|
||||
}
|
||||
to, from := s.opts.RemoteJID, s.opts.LocalJID
|
||||
s.mu.Unlock()
|
||||
|
||||
msg := newJMIMessage(from, to, &JMIRetract{ID: s.opts.SID})
|
||||
err := s.sendStanza(msg)
|
||||
s.terminate()
|
||||
return err
|
||||
}
|
||||
|
||||
// <jingle action=session-terminate>; falls through to Retract/Decline if
|
||||
// invoked pre-media
|
||||
func (s *Session) Terminate(ctx context.Context, reason string) error {
|
||||
s.mu.Lock()
|
||||
switch s.state {
|
||||
case StateTerminated:
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
case StateProposed, StateRinging:
|
||||
role := s.opts.Role
|
||||
s.mu.Unlock()
|
||||
if role == RoleInitiator {
|
||||
return s.Retract(ctx)
|
||||
}
|
||||
return s.Decline(ctx, reason)
|
||||
}
|
||||
to, from := s.opts.RemoteJID, s.opts.LocalJID
|
||||
role := s.opts.Role
|
||||
s.mu.Unlock()
|
||||
|
||||
j := &JingleIQ{
|
||||
Action: ActionSessionTerminate,
|
||||
SID: s.opts.SID,
|
||||
}
|
||||
if role == RoleInitiator {
|
||||
j.Initiator = from
|
||||
} else {
|
||||
j.Responder = from
|
||||
}
|
||||
if reason != "" {
|
||||
j.Reason = &Reason{Condition: &ReasonCondition{XMLName: xml.Name{Space: NSJingle, Local: reason}}}
|
||||
}
|
||||
iq, err := newJingleIQ(from, to, stanza.IQTypeSet, j)
|
||||
if err != nil {
|
||||
s.terminate()
|
||||
return err
|
||||
}
|
||||
sendErr := s.sendStanza(iq)
|
||||
s.terminate()
|
||||
return sendErr
|
||||
}
|
||||
|
||||
// dispatches an incoming JMI payload; Manager has matched the SID
|
||||
func (s *Session) HandleJMI(from string, ext stanza.MsgExtension) {
|
||||
switch m := ext.(type) {
|
||||
case *JMIPropose:
|
||||
s.handleJMIPropose(from, *m)
|
||||
case *JMIProceed:
|
||||
s.handleJMIProceed(from)
|
||||
case *JMIRinging:
|
||||
s.handleJMIRinging(from)
|
||||
case *JMIReject:
|
||||
s.handleJMIReject(from)
|
||||
case *JMIRetract:
|
||||
s.handleJMIRetract(from)
|
||||
case *JMIFinish:
|
||||
// terminal informational marker; ignored
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleJMIPropose(from string, m JMIPropose) {
|
||||
s.mu.Lock()
|
||||
if s.state != StateNew || s.opts.Role != RoleResponder {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.opts.RemoteJID = from
|
||||
s.state = StateRinging
|
||||
s.mu.Unlock()
|
||||
// auto-<ringing/> when no observer is wired
|
||||
if s.observer == nil {
|
||||
_ = s.Ringing(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleJMIProceed(from string) {
|
||||
s.mu.Lock()
|
||||
if s.state != StateProposed || s.opts.Role != RoleInitiator {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.opts.RemoteJID = from // latch full JID
|
||||
s.state = StateProceeded
|
||||
s.mu.Unlock()
|
||||
if s.observer != nil {
|
||||
s.observer.OnProceeded(from)
|
||||
}
|
||||
// off the dispatcher so pion's gather doesn't block JMI routing
|
||||
go s.startOutgoingMedia()
|
||||
}
|
||||
|
||||
func (s *Session) handleJMIRinging(from string) {
|
||||
if s.observer != nil {
|
||||
s.observer.OnRinging(from)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleJMIReject(from string) {
|
||||
s.mu.Lock()
|
||||
if s.state == StateTerminated {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
// terminate before observer; observers re-entering via the bridge then
|
||||
// see StateTerminated and short-circuit their own teardown
|
||||
s.terminate()
|
||||
if s.observer != nil {
|
||||
s.observer.OnTerminated("rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleJMIRetract(from string) {
|
||||
// caller cancelled before responder proceeded
|
||||
s.mu.Lock()
|
||||
if s.state == StateTerminated {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.terminate()
|
||||
if s.observer != nil {
|
||||
s.observer.OnTerminated("retracted")
|
||||
}
|
||||
}
|
||||
|
||||
// processes an inbound Jingle IQ; Manager has matched the SID. Returns the
|
||||
// result/error condition for the IQ reply.
|
||||
func (s *Session) HandleJingleIQ(iq *stanza.IQ, j *JingleIQ) (resultPayload stanza.IQPayload, errCondition string) {
|
||||
switch j.Action {
|
||||
case ActionSessionInitiate:
|
||||
if err := s.handleSessionInitiate(iq, j); err != nil {
|
||||
return nil, "bad-request"
|
||||
}
|
||||
case ActionSessionAccept:
|
||||
if err := s.handleSessionAccept(iq, j); err != nil {
|
||||
return nil, "bad-request"
|
||||
}
|
||||
case ActionSessionTerminate:
|
||||
s.handleSessionTerminate(iq, j)
|
||||
case ActionTransportInfo:
|
||||
s.handleTransportInfo(iq, j)
|
||||
default:
|
||||
// unsupported action: ack but no-op
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (s *Session) handleSessionInitiate(iq *stanza.IQ, j *JingleIQ) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateProceeded || s.opts.Role != RoleResponder {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("out-of-order in state %s", st)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
sdp, err := JingleToSDP(j, ConvertOpts{Initiator: false, LocalCreator: "initiator", SessionID: s.opts.SID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert offer: %w", err)
|
||||
}
|
||||
if err := s.opts.PC.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: sdp,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("SetRemoteDescription(offer): %w", err)
|
||||
}
|
||||
s.drainRemoteCandidates()
|
||||
s.notifyRemoteDescriptionApplied(sdp)
|
||||
|
||||
answer, err := s.opts.PC.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateAnswer: %w", err)
|
||||
}
|
||||
if err := s.opts.PC.SetLocalDescription(answer); err != nil {
|
||||
return fmt.Errorf("SetLocalDescription(answer): %w", err)
|
||||
}
|
||||
s.captureLocalMeta()
|
||||
|
||||
ja, err := SDPToJingle(answer.SDP, ConvertOpts{Initiator: false, LocalCreator: "initiator", SessionID: s.opts.SID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert answer: %w", err)
|
||||
}
|
||||
ja.Action = ActionSessionAccept
|
||||
ja.Responder = s.opts.LocalJID
|
||||
|
||||
out, err := newJingleIQ(s.opts.LocalJID, s.opts.RemoteJID, stanza.IQTypeSet, ja)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sendStanza(out); err != nil {
|
||||
return fmt.Errorf("send session-accept: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.state = StateActive
|
||||
s.mu.Unlock()
|
||||
s.drainLocalCandidates()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) handleSessionAccept(iq *stanza.IQ, j *JingleIQ) error {
|
||||
s.mu.Lock()
|
||||
if s.state != StateProceeded || s.opts.Role != RoleInitiator {
|
||||
st := s.state
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("out-of-order in state %s", st)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
sdp, err := JingleToSDP(j, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: s.opts.SID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert answer: %w", err)
|
||||
}
|
||||
if err := s.opts.PC.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: sdp,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("SetRemoteDescription(answer): %w", err)
|
||||
}
|
||||
s.drainRemoteCandidates()
|
||||
s.notifyRemoteDescriptionApplied(sdp)
|
||||
s.mu.Lock()
|
||||
s.state = StateActive
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatched inline so an answerer-side ReplaceTrack from the observer
|
||||
// lands before CreateAnswer runs
|
||||
func (s *Session) notifyRemoteDescriptionApplied(sdp string) {
|
||||
if s.observer != nil {
|
||||
s.observer.OnRemoteDescriptionApplied(sdp)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleTransportInfo(iq *stanza.IQ, j *JingleIQ) {
|
||||
for _, c := range j.Contents {
|
||||
if c.Transport == nil {
|
||||
continue
|
||||
}
|
||||
for _, cand := range c.Transport.Candidates {
|
||||
candCopy := cand
|
||||
init, err := candidateToPionInit(&candCopy, c.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.applyRemoteCandidate(c.Name, init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleSessionTerminate(iq *stanza.IQ, j *JingleIQ) {
|
||||
reason := ""
|
||||
if j != nil && j.Reason != nil && j.Reason.Condition != nil {
|
||||
reason = j.Reason.Condition.XMLName.Local
|
||||
}
|
||||
// terminate before observer; observers re-entering via the bridge then
|
||||
// see StateTerminated and short-circuit their own teardown
|
||||
s.terminate()
|
||||
if s.observer != nil {
|
||||
s.observer.OnTerminated(reason)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) startOutgoingMedia() {
|
||||
offer, err := s.opts.PC.CreateOffer(nil)
|
||||
if err != nil {
|
||||
s.terminate()
|
||||
return
|
||||
}
|
||||
if err := s.opts.PC.SetLocalDescription(offer); err != nil {
|
||||
s.terminate()
|
||||
return
|
||||
}
|
||||
s.captureLocalMeta()
|
||||
|
||||
j, err := SDPToJingle(offer.SDP, ConvertOpts{Initiator: true, LocalCreator: "initiator", SessionID: s.opts.SID})
|
||||
if err != nil {
|
||||
s.terminate()
|
||||
return
|
||||
}
|
||||
j.Action = ActionSessionInitiate
|
||||
j.Initiator = s.opts.LocalJID
|
||||
|
||||
iq, err := newJingleIQ(s.opts.LocalJID, s.opts.RemoteJID, stanza.IQTypeSet, j)
|
||||
if err != nil {
|
||||
s.terminate()
|
||||
return
|
||||
}
|
||||
if err := s.sendStanza(iq); err != nil {
|
||||
s.terminate()
|
||||
return
|
||||
}
|
||||
s.drainLocalCandidates()
|
||||
}
|
||||
|
||||
// ICE candidates are buffered until SetLocalDescription/SetRemoteDescription
|
||||
// has run; otherwise pion drops them
|
||||
|
||||
func (s *Session) onICECandidate(c *webrtc.ICECandidate) {
|
||||
if c == nil {
|
||||
return // end-of-gathering; no wire marker
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !s.signaled {
|
||||
s.localCandQueue = append(s.localCandQueue, *c)
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = s.sendTransportInfo(c)
|
||||
}
|
||||
|
||||
func (s *Session) drainLocalCandidates() {
|
||||
s.mu.Lock()
|
||||
s.signaled = true
|
||||
queue := s.localCandQueue
|
||||
s.localCandQueue = nil
|
||||
s.mu.Unlock()
|
||||
for i := range queue {
|
||||
_ = s.sendTransportInfo(&queue[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) applyRemoteCandidate(mid string, init webrtc.ICECandidateInit) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !s.remoteDescApplied {
|
||||
s.remoteCandQueue = append(s.remoteCandQueue, remoteCandidate{mid: mid, init: init})
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = s.opts.PC.AddICECandidate(init)
|
||||
}
|
||||
|
||||
func (s *Session) drainRemoteCandidates() {
|
||||
s.mu.Lock()
|
||||
s.remoteDescApplied = true
|
||||
queue := s.remoteCandQueue
|
||||
s.remoteCandQueue = nil
|
||||
s.mu.Unlock()
|
||||
for _, rc := range queue {
|
||||
_ = s.opts.PC.AddICECandidate(rc.init)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) sendTransportInfo(c *webrtc.ICECandidate) error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
ufrag, pwd := s.ufrag, s.pwd
|
||||
mids := s.mids
|
||||
role := s.opts.Role
|
||||
localJID, remoteJID := s.opts.LocalJID, s.opts.RemoteJID
|
||||
s.mu.Unlock()
|
||||
|
||||
cand, err := pionToCandidate(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mid := c.SDPMid
|
||||
if mid == "" && len(mids) > 0 {
|
||||
mid = mids[0]
|
||||
}
|
||||
j := buildTransportInfo(s.opts.SID, mid, ufrag, pwd, "initiator", cand)
|
||||
if role == RoleInitiator {
|
||||
j.Initiator = localJID
|
||||
} else {
|
||||
j.Responder = localJID
|
||||
}
|
||||
iq, err := newJingleIQ(localJID, remoteJID, stanza.IQTypeSet, j)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendStanza(iq)
|
||||
}
|
||||
|
||||
func (s *Session) captureLocalMeta() {
|
||||
local := s.opts.PC.LocalDescription()
|
||||
if local == nil {
|
||||
return
|
||||
}
|
||||
ufrag, pwd, mids := extractSDPMeta(local.SDP)
|
||||
s.mu.Lock()
|
||||
s.ufrag, s.pwd, s.mids = ufrag, pwd, mids
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// with BUNDLE, ufrag/pwd are identical across m= blocks; take the first
|
||||
func extractSDPMeta(s string) (ufrag, pwd string, mids []string) {
|
||||
doc, err := parseSDP(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, m := range doc.media {
|
||||
if ufrag == "" {
|
||||
ufrag = mmFirst(m.attrs, "ice-ufrag", "")
|
||||
}
|
||||
if pwd == "" {
|
||||
pwd = mmFirst(m.attrs, "ice-pwd", "")
|
||||
}
|
||||
if mid := mmFirst(m.attrs, "mid", ""); mid != "" {
|
||||
mids = append(mids, mid)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) setStateIf(want, next State) {
|
||||
s.mu.Lock()
|
||||
if s.state == want {
|
||||
s.state = next
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Session) terminate() {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
s.state = StateTerminated
|
||||
s.remoteCandQueue = nil
|
||||
s.localCandQueue = nil
|
||||
pc := s.opts.PC
|
||||
s.mu.Unlock()
|
||||
if pc != nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// release pion + buffers without sending anything on the wire
|
||||
func (s *Session) Close() error {
|
||||
s.terminate()
|
||||
return nil
|
||||
}
|
||||
|
||||
func mediaDescs(media []string) []JMIDescription {
|
||||
out := make([]JMIDescription, 0, len(media))
|
||||
for _, m := range media {
|
||||
out = append(out, JMIDescription{Media: m})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newJMIMessage(from, to string, ext stanza.MsgExtension) *stanza.Message {
|
||||
msg := stanza.NewMessage(stanza.Attrs{
|
||||
Type: stanza.MessageTypeChat,
|
||||
From: from,
|
||||
To: to,
|
||||
Id: newStanzaID(),
|
||||
})
|
||||
msg.Extensions = []stanza.MsgExtension{ext}
|
||||
return &msg
|
||||
}
|
||||
|
||||
func (s *Session) sendStanza(p stanza.Packet) error {
|
||||
return s.opts.Sender.Send(p)
|
||||
}
|
||||
|
||||
func newJingleIQ(from, to string, t stanza.StanzaType, j *JingleIQ) (*stanza.IQ, error) {
|
||||
iq, err := stanza.NewIQ(stanza.Attrs{
|
||||
Type: t,
|
||||
From: from,
|
||||
To: to,
|
||||
Id: newStanzaID(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iq.Payload = j
|
||||
return iq, nil
|
||||
}
|
||||
260
xmpp/jingle/session_test.go
Normal file
260
xmpp/jingle/session_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// wires one Manager's outbound into the other's HandlePacket without
|
||||
// sockets; each Send fires on a fresh goroutine so trickle/ordering
|
||||
// bugs surface naturally
|
||||
type linkedSender struct {
|
||||
target *Manager
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (ls *linkedSender) Send(p stanza.Packet) error {
|
||||
ls.wg.Add(1)
|
||||
go func() {
|
||||
defer ls.wg.Done()
|
||||
ls.target.HandlePacket(nil, p)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ls *linkedSender) SendIQ(ctx context.Context, iq *stanza.IQ) (chan stanza.IQ, error) {
|
||||
ch := make(chan stanza.IQ, 1)
|
||||
return ch, ls.Send(iq)
|
||||
}
|
||||
|
||||
func (ls *linkedSender) SendRaw(string) error { return nil }
|
||||
|
||||
func (ls *linkedSender) wait() { ls.wg.Wait() }
|
||||
|
||||
// two Managers cross-linked through linkedSenders
|
||||
func pair() (*Manager, *Manager, *linkedSender, *linkedSender) {
|
||||
mA := &Manager{LocalJID: "a@x/r"}
|
||||
mB := &Manager{LocalJID: "b@x/r"}
|
||||
sA := &linkedSender{target: mB}
|
||||
sB := &linkedSender{target: mA}
|
||||
mA.Sender = sA
|
||||
mB.Sender = sB
|
||||
return mA, mB, sA, sB
|
||||
}
|
||||
|
||||
// pion PC with t.Cleanup
|
||||
func makePC(t *testing.T) *webrtc.PeerConnection {
|
||||
t.Helper()
|
||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerConnection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pc.Close() })
|
||||
return pc
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, d time.Duration, fn func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if fn() {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waitFor(%s): timeout after %v", what, d)
|
||||
}
|
||||
|
||||
// JMI preamble without media setup
|
||||
func TestJMIProposeRingProceed(t *testing.T) {
|
||||
mA, mB, sA, sB := pair()
|
||||
|
||||
var bSess *Session
|
||||
var bSessMu sync.Mutex
|
||||
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
|
||||
s, err := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sB,
|
||||
LocalJID: mB.LocalJID, RemoteJID: p.From,
|
||||
SID: p.SID, Role: RoleResponder, Media: p.Media,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bSessMu.Lock()
|
||||
bSess = s
|
||||
bSessMu.Unlock()
|
||||
return s, nil, nil
|
||||
}
|
||||
|
||||
aSess, err := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sA,
|
||||
LocalJID: mA.LocalJID, RemoteJID: "b@x",
|
||||
SID: "sid-1", Role: RoleInitiator, Media: []string{"audio"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mA.Register(aSess)
|
||||
|
||||
if err := aSess.Propose(context.Background()); err != nil {
|
||||
t.Fatalf("Propose: %v", err)
|
||||
}
|
||||
|
||||
waitFor(t, "B reach ringing", 2*time.Second, func() bool {
|
||||
bSessMu.Lock()
|
||||
defer bSessMu.Unlock()
|
||||
return bSess != nil && bSess.State() == StateRinging
|
||||
})
|
||||
|
||||
if err := bSess.AcceptProposal(context.Background()); err != nil {
|
||||
t.Fatalf("AcceptProposal: %v", err)
|
||||
}
|
||||
|
||||
// A enters StateProceeded; media setup will then drive it forward
|
||||
// (CreateOffer, etc.) - that's tested elsewhere. Allow either
|
||||
// StateProceeded or StateActive since the goroutine may have raced
|
||||
// ahead, depending on how fast pion gathers.
|
||||
waitFor(t, "A leave Proposed", 2*time.Second, func() bool {
|
||||
s := aSess.State()
|
||||
return s == StateProceeded || s == StateActive || s == StateTerminated
|
||||
})
|
||||
|
||||
aSess.Close()
|
||||
bSess.Close()
|
||||
sA.wait()
|
||||
sB.wait()
|
||||
}
|
||||
|
||||
// <reject>: caller -> Terminated when callee declines while ringing
|
||||
func TestJMIRejectFromCallee(t *testing.T) {
|
||||
mA, mB, sA, sB := pair()
|
||||
|
||||
var bSess *Session
|
||||
var bSessMu sync.Mutex
|
||||
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
|
||||
s, _ := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sB,
|
||||
LocalJID: mB.LocalJID, RemoteJID: p.From,
|
||||
SID: p.SID, Role: RoleResponder, Media: p.Media,
|
||||
})
|
||||
bSessMu.Lock()
|
||||
bSess = s
|
||||
bSessMu.Unlock()
|
||||
return s, nil, nil
|
||||
}
|
||||
|
||||
aSess, _ := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sA,
|
||||
LocalJID: mA.LocalJID, RemoteJID: "b@x",
|
||||
SID: "sid-rej", Role: RoleInitiator, Media: []string{"audio"},
|
||||
})
|
||||
mA.Register(aSess)
|
||||
if err := aSess.Propose(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
waitFor(t, "B reach ringing", 2*time.Second, func() bool {
|
||||
bSessMu.Lock()
|
||||
defer bSessMu.Unlock()
|
||||
return bSess != nil && bSess.State() == StateRinging
|
||||
})
|
||||
|
||||
if err := bSess.Decline(context.Background(), ReasonDecline); err != nil {
|
||||
t.Fatalf("Decline: %v", err)
|
||||
}
|
||||
waitFor(t, "A terminated", 2*time.Second, func() bool {
|
||||
return aSess.State() == StateTerminated
|
||||
})
|
||||
}
|
||||
|
||||
// <retract>: callee -> Terminated when caller cancels while ringing
|
||||
func TestJMIRetractByCaller(t *testing.T) {
|
||||
mA, mB, sA, sB := pair()
|
||||
|
||||
var bSess *Session
|
||||
var bSessMu sync.Mutex
|
||||
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
|
||||
s, _ := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sB,
|
||||
LocalJID: mB.LocalJID, RemoteJID: p.From,
|
||||
SID: p.SID, Role: RoleResponder, Media: p.Media,
|
||||
})
|
||||
bSessMu.Lock()
|
||||
bSess = s
|
||||
bSessMu.Unlock()
|
||||
return s, nil, nil
|
||||
}
|
||||
|
||||
aSess, _ := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sA,
|
||||
LocalJID: mA.LocalJID, RemoteJID: "b@x",
|
||||
SID: "sid-ret", Role: RoleInitiator, Media: []string{"audio"},
|
||||
})
|
||||
mA.Register(aSess)
|
||||
if err := aSess.Propose(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "B reach ringing", 2*time.Second, func() bool {
|
||||
bSessMu.Lock()
|
||||
defer bSessMu.Unlock()
|
||||
return bSess != nil && bSess.State() == StateRinging
|
||||
})
|
||||
|
||||
if err := aSess.Retract(context.Background()); err != nil {
|
||||
t.Fatalf("Retract: %v", err)
|
||||
}
|
||||
waitFor(t, "B terminated", 2*time.Second, func() bool {
|
||||
return bSess.State() == StateTerminated
|
||||
})
|
||||
}
|
||||
|
||||
// duplicate-SID guard
|
||||
func TestJMIDuplicateProposeIgnored(t *testing.T) {
|
||||
mA, mB, sA, sB := pair()
|
||||
|
||||
calls := 0
|
||||
var mu sync.Mutex
|
||||
mB.OnProposal = func(p IncomingProposal) (*Session, func(), error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
s, _ := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sB,
|
||||
LocalJID: mB.LocalJID, RemoteJID: p.From,
|
||||
SID: p.SID, Role: RoleResponder, Media: p.Media,
|
||||
})
|
||||
return s, nil, nil
|
||||
}
|
||||
|
||||
aSess, _ := New(SessionOpts{
|
||||
PC: makePC(t), Sender: sA,
|
||||
LocalJID: mA.LocalJID, RemoteJID: "b@x",
|
||||
SID: "dup-sid", Role: RoleInitiator, Media: []string{"audio"},
|
||||
})
|
||||
mA.Register(aSess)
|
||||
_ = aSess.Propose(context.Background())
|
||||
_ = aSess.Propose(context.Background()) // second one rejected by state guard, but if forced through...
|
||||
// To make the test actually drive a duplicate, send a raw second propose:
|
||||
dup := newJMIMessage(mA.LocalJID, mB.LocalJID, &JMIPropose{
|
||||
ID: "dup-sid",
|
||||
Descriptions: []JMIDescription{{Media: "audio"}},
|
||||
})
|
||||
if err := sA.Send(dup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Allow dispatch to complete.
|
||||
sA.wait()
|
||||
sB.wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if calls != 1 {
|
||||
t.Errorf("OnProposal called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
210
xmpp/jingle/stanzas.go
Normal file
210
xmpp/jingle/stanzas.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package jingle
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
// supported Jingle actions; content-*, security-info, transport-*,
|
||||
// description-info are intentionally not supported
|
||||
const (
|
||||
ActionSessionInitiate = "session-initiate"
|
||||
ActionSessionAccept = "session-accept"
|
||||
ActionSessionTerminate = "session-terminate"
|
||||
ActionTransportInfo = "transport-info"
|
||||
)
|
||||
|
||||
const (
|
||||
ReasonSuccess = "success"
|
||||
ReasonBusy = "busy"
|
||||
ReasonDecline = "decline"
|
||||
ReasonCancel = "cancel"
|
||||
ReasonGone = "gone"
|
||||
ReasonFailedTransport = "failed-transport"
|
||||
ReasonFailedApplication = "failed-application"
|
||||
ReasonIncompatibleParameters = "incompatible-parameters"
|
||||
ReasonUnsupportedTransports = "unsupported-transports"
|
||||
ReasonUnsupportedApplications = "unsupported-applications"
|
||||
ReasonGeneralError = "general-error"
|
||||
ReasonTimeout = "timeout"
|
||||
ReasonConnectivityError = "connectivity-error"
|
||||
)
|
||||
|
||||
type JingleIQ struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:1 jingle"`
|
||||
Action string `xml:"action,attr"`
|
||||
Initiator string `xml:"initiator,attr,omitempty"`
|
||||
Responder string `xml:"responder,attr,omitempty"`
|
||||
SID string `xml:"sid,attr"`
|
||||
Contents []Content `xml:"urn:xmpp:jingle:1 content"`
|
||||
Group *Group `xml:"urn:xmpp:jingle:apps:grouping:0 group,omitempty"`
|
||||
Reason *Reason `xml:"urn:xmpp:jingle:1 reason,omitempty"`
|
||||
}
|
||||
|
||||
func (j JingleIQ) Namespace() string { return j.XMLName.Space }
|
||||
func (j JingleIQ) GetSet() *stanza.ResultSet { return nil }
|
||||
|
||||
type Content struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:1 content"`
|
||||
Creator string `xml:"creator,attr"`
|
||||
Senders string `xml:"senders,attr,omitempty"`
|
||||
Name string `xml:"name,attr"`
|
||||
Disposition string `xml:"disposition,attr,omitempty"`
|
||||
Description *Description `xml:"urn:xmpp:jingle:apps:rtp:1 description,omitempty"`
|
||||
Transport *Transport `xml:"urn:xmpp:jingle:transports:ice-udp:1 transport,omitempty"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:grouping:0 group"`
|
||||
Semantics string `xml:"semantics,attr"`
|
||||
Contents []GroupContent `xml:"urn:xmpp:jingle:apps:grouping:0 content"`
|
||||
}
|
||||
|
||||
type GroupContent struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:grouping:0 content"`
|
||||
Name string `xml:"name,attr"`
|
||||
}
|
||||
|
||||
// preserves the inner condition element name and optional text;
|
||||
// semantics handled in callers
|
||||
type Reason struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:1 reason"`
|
||||
Condition *ReasonCondition `xml:",any"`
|
||||
Text string `xml:"urn:xmpp:jingle:1 text,omitempty"`
|
||||
}
|
||||
|
||||
// whichever element appears under <reason>; local name is the condition
|
||||
type ReasonCondition struct {
|
||||
XMLName xml.Name
|
||||
}
|
||||
|
||||
type Description struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 description"`
|
||||
Media string `xml:"media,attr"`
|
||||
SSRC string `xml:"ssrc,attr,omitempty"`
|
||||
PayloadTypes []PayloadType `xml:"urn:xmpp:jingle:apps:rtp:1 payload-type"`
|
||||
RTPHdrexts []RTPHdrext `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 rtp-hdrext"`
|
||||
ExtmapAllowMixed *ExtmapAllowMixed `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 extmap-allow-mixed,omitempty"`
|
||||
RtcpMux *RtcpMux `xml:"urn:xmpp:jingle:apps:rtp:1 rtcp-mux,omitempty"`
|
||||
RTCPFbs []RTCPFb `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb"`
|
||||
RTCPFbTrrInts []RTCPFbTrrInt `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb-trr-int"`
|
||||
Sources []Source `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"`
|
||||
SSRCGroups []SSRCGroup `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 ssrc-group"`
|
||||
}
|
||||
|
||||
type PayloadType struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 payload-type"`
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr,omitempty"`
|
||||
Clockrate string `xml:"clockrate,attr,omitempty"`
|
||||
Channels string `xml:"channels,attr,omitempty"`
|
||||
Ptime string `xml:"ptime,attr,omitempty"`
|
||||
Maxptime string `xml:"maxptime,attr,omitempty"`
|
||||
Parameters []Parameter `xml:"urn:xmpp:jingle:apps:rtp:1 parameter"`
|
||||
RTCPFbs []RTCPFb `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb"`
|
||||
RTCPFbTrrInts []RTCPFbTrrInt `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb-trr-int"`
|
||||
}
|
||||
|
||||
// name/value pair used under <payload-type> (rtp:1) and <source> (ssma);
|
||||
// namespace is governed by the parent's tag, so XMLName has none
|
||||
type Parameter struct {
|
||||
XMLName xml.Name `xml:"parameter"`
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"value,attr,omitempty"`
|
||||
}
|
||||
|
||||
type RTCPFb struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb"`
|
||||
Type string `xml:"type,attr"`
|
||||
Subtype string `xml:"subtype,attr,omitempty"`
|
||||
}
|
||||
|
||||
type RTCPFbTrrInt struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtcp-fb:0 rtcp-fb-trr-int"`
|
||||
Value string `xml:"value,attr"`
|
||||
}
|
||||
|
||||
type RTPHdrext struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 rtp-hdrext"`
|
||||
ID string `xml:"id,attr"`
|
||||
URI string `xml:"uri,attr"`
|
||||
Senders string `xml:"senders,attr,omitempty"`
|
||||
}
|
||||
|
||||
// presence flag, RFC 8285 §6
|
||||
type ExtmapAllowMixed struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:rtp-hdrext:0 extmap-allow-mixed"`
|
||||
}
|
||||
|
||||
// presence flag for RTCP mux
|
||||
type RtcpMux struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:1 rtcp-mux"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"`
|
||||
SSRC string `xml:"ssrc,attr"`
|
||||
Parameters []Parameter `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 parameter"`
|
||||
}
|
||||
|
||||
type SSRCGroup struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 ssrc-group"`
|
||||
Semantics string `xml:"semantics,attr"`
|
||||
Sources []SSRCGroupSource `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"`
|
||||
}
|
||||
|
||||
type SSRCGroupSource struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:rtp:ssma:0 source"`
|
||||
SSRC string `xml:"ssrc,attr"`
|
||||
}
|
||||
|
||||
type Transport struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:transports:ice-udp:1 transport"`
|
||||
Ufrag string `xml:"ufrag,attr,omitempty"`
|
||||
Pwd string `xml:"pwd,attr,omitempty"`
|
||||
Candidates []Candidate `xml:"urn:xmpp:jingle:transports:ice-udp:1 candidate"`
|
||||
Fingerprint *Fingerprint `xml:"urn:xmpp:jingle:apps:dtls:0 fingerprint,omitempty"`
|
||||
|
||||
// well-known ICE options under gultsch.de namespace
|
||||
Trickle *ICEOptionTrickle `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option trickle,omitempty"`
|
||||
Renomination *ICEOptionRenomination `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option renomination,omitempty"`
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:transports:ice-udp:1 candidate"`
|
||||
Foundation string `xml:"foundation,attr"`
|
||||
Component string `xml:"component,attr"`
|
||||
Protocol string `xml:"protocol,attr"`
|
||||
Priority string `xml:"priority,attr"`
|
||||
IP string `xml:"ip,attr"`
|
||||
Port string `xml:"port,attr"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Network string `xml:"network,attr,omitempty"`
|
||||
Generation string `xml:"generation,attr,omitempty"`
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
RelAddr string `xml:"rel-addr,attr,omitempty"`
|
||||
RelPort string `xml:"rel-port,attr,omitempty"`
|
||||
}
|
||||
|
||||
type Fingerprint struct {
|
||||
XMLName xml.Name `xml:"urn:xmpp:jingle:apps:dtls:0 fingerprint"`
|
||||
Hash string `xml:"hash,attr"`
|
||||
Setup string `xml:"setup,attr,omitempty"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type ICEOptionTrickle struct {
|
||||
XMLName xml.Name `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option trickle"`
|
||||
}
|
||||
|
||||
type ICEOptionRenomination struct {
|
||||
XMLName xml.Name `xml:"http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option renomination"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{
|
||||
Space: NSJingle,
|
||||
Local: "jingle",
|
||||
}, JingleIQ{})
|
||||
}
|
||||
Loading…
Reference in a new issue