softphone: SIP.js engine, /api/me/webrtc, minimal dock
GET /api/me/webrtc returns the <ext>-web identity, WebSocket URL (WEBRTC_WS_URL overrides wss://PUBLIC_HOSTNAME/ws) and STUN/TURN servers. SoftphoneEngine wraps SIP.js 0.21.2: register with backoff reconnect, call, answer, decline, hang up, remote audio. A minimal dock on /me enables it (asks for the microphone on the click) and dials/answers; M5-T2 grows it into the full panel. Generator: ice_host_candidates keeps the LAN address (include_local_address), so LAN browsers don't need hairpin NAT, and -web endpoints set from_domain = selfphoneng.invalid because SIP.js drops INVITEs whose From host starts with a digit (Docker/VPS hostnames). e2e m5-t1 (Docker): browser registers as 170-web and has two-way audio with a baresip phone both ways. The LAN and hotspot check on the owner's server is still open (roadmap note). Task: M5-T1
This commit is contained in:
1 parent
f60dda0bb0
commit
ae3a723a3c
20 files changed
+703
-4
No files matched your search
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The browser softphone (05 §5.2): SIP.js over WebSocket to Asterisk, no React in here.
|
||||
// React reads it through subscribe()/state (useSyncExternalStore in useSoftphone.ts).
|
||||
import {
|
||||
Invitation,
|
||||
Inviter,
|
||||
Registerer,
|
||||
RegistererState,
|
||||
SessionState,
|
||||
UserAgent,
|
||||
Web,
|
||||
type Session,
|
||||
} from 'sip.js';
|
||||
import type { WebRtcCreds } from '@selfphoneng/shared';
|
||||
|
||||
export type RegState = 'off' | 'connecting' | 'registered' | 'error';
|
||||
|
||||
export type CallInfo = {
|
||||
id: string;
|
||||
direction: 'in' | 'out';
|
||||
/** The other side's number (SIP user part). */
|
||||
remote: string;
|
||||
remoteName: string | null;
|
||||
/** incoming: ringing here; calling: ringing there; active: talking. */
|
||||
state: 'incoming' | 'calling' | 'active';
|
||||
answeredAt: number | null;
|
||||
};
|
||||
|
||||
export type EngineState = { reg: RegState; error: string | null; calls: CallInfo[] };
|
||||
|
||||
const BACKOFF_S = [2, 4, 8, 16, 30];
|
||||
const MAX_CALLS = 2;
|
||||
|
||||
type Tracked = { session: Session; info: CallInfo; audio: HTMLAudioElement };
|
||||
|
||||
export class SoftphoneEngine {
|
||||
state: EngineState = { reg: 'off', error: null, calls: [] };
|
||||
private listeners = new Set<() => void>();
|
||||
private ua: UserAgent | null = null;
|
||||
private registerer: Registerer | null = null;
|
||||
private host = '';
|
||||
private sessions = new Map<string, Tracked>();
|
||||
private retry: ReturnType<typeof setTimeout> | undefined;
|
||||
private attempt = 0;
|
||||
private stopped = true;
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
return () => void this.listeners.delete(fn);
|
||||
};
|
||||
|
||||
private set(patch: Partial<EngineState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
private publishCalls() {
|
||||
this.set({ calls: [...this.sessions.values()].map((t) => ({ ...t.info })) });
|
||||
}
|
||||
|
||||
async start(creds: WebRtcCreds): Promise<void> {
|
||||
await this.stop();
|
||||
this.stopped = false;
|
||||
this.host = new URL(creds.sipUri.replace(/^sip:/, 'sip://')).hostname;
|
||||
this.set({ reg: 'connecting', error: null });
|
||||
const ua = new UserAgent({
|
||||
uri: UserAgent.makeURI(creds.sipUri),
|
||||
transportOptions: { server: creds.wsUrl },
|
||||
authorizationUsername: creds.authUser,
|
||||
authorizationPassword: creds.password,
|
||||
displayName: creds.displayName,
|
||||
contactParams: { transport: creds.wsUrl.startsWith('wss:') ? 'wss' : 'ws' },
|
||||
sessionDescriptionHandlerFactoryOptions: {
|
||||
peerConnectionConfiguration: { iceServers: creds.iceServers },
|
||||
},
|
||||
logLevel: 'warn',
|
||||
delegate: {
|
||||
onInvite: (inv) => this.incoming(inv),
|
||||
onDisconnect: (err) => {
|
||||
if (err && !this.stopped) this.reconnect();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.ua = ua;
|
||||
const registerer = new Registerer(ua, { expires: 300 });
|
||||
this.registerer = registerer;
|
||||
registerer.stateChange.addListener((s) => {
|
||||
if (this.stopped) return;
|
||||
if (s === RegistererState.Registered) {
|
||||
this.attempt = 0;
|
||||
this.set({ reg: 'registered', error: null });
|
||||
} else if (this.state.reg === 'registered') this.set({ reg: 'connecting' });
|
||||
});
|
||||
try {
|
||||
await ua.start();
|
||||
await this.register();
|
||||
} catch (e) {
|
||||
this.set({ reg: 'error', error: (e as Error).message });
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private register() {
|
||||
return this.registerer!.register({
|
||||
requestDelegate: {
|
||||
onReject: (res) =>
|
||||
this.set({
|
||||
reg: 'error',
|
||||
error: `Registration refused (${res.message.statusCode} ${res.message.reasonPhrase})`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Transport lost: reconnect with backoff 2 → 30 s, then register again. */
|
||||
private reconnect() {
|
||||
clearTimeout(this.retry);
|
||||
if (this.stopped || !this.ua) return;
|
||||
if (this.state.reg !== 'error') this.set({ reg: 'connecting' });
|
||||
const s = BACKOFF_S[Math.min(this.attempt++, BACKOFF_S.length - 1)]!;
|
||||
this.retry = setTimeout(() => {
|
||||
if (this.stopped || !this.ua) return;
|
||||
this.ua
|
||||
.reconnect()
|
||||
.then(() => this.register())
|
||||
.catch(() => this.reconnect());
|
||||
}, s * 1000);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
clearTimeout(this.retry);
|
||||
for (const id of [...this.sessions.keys()]) await this.hangup(id).catch(() => undefined);
|
||||
await this.registerer?.unregister().catch(() => undefined);
|
||||
await this.ua?.stop().catch(() => undefined);
|
||||
this.ua = null;
|
||||
this.registerer = null;
|
||||
this.set({ reg: 'off', error: null, calls: [] });
|
||||
}
|
||||
|
||||
async call(number: string): Promise<void> {
|
||||
const target = UserAgent.makeURI(`sip:${encodeURIComponent(number)}@${this.host}`);
|
||||
if (!this.ua || !target) throw new Error('Softphone is not started');
|
||||
if (this.sessions.size >= MAX_CALLS) throw new Error('Two calls already');
|
||||
const inviter = new Inviter(this.ua, target, {
|
||||
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
|
||||
});
|
||||
this.track(inviter, 'out', number, null);
|
||||
await inviter.invite();
|
||||
}
|
||||
|
||||
private incoming(inv: Invitation) {
|
||||
if (this.sessions.size >= MAX_CALLS) {
|
||||
void inv.reject({ statusCode: 486 });
|
||||
return;
|
||||
}
|
||||
this.track(
|
||||
inv,
|
||||
'in',
|
||||
inv.remoteIdentity.uri.user ?? '',
|
||||
inv.remoteIdentity.displayName || null,
|
||||
);
|
||||
}
|
||||
|
||||
async answer(id: string): Promise<void> {
|
||||
const t = this.sessions.get(id);
|
||||
if (!(t?.session instanceof Invitation)) return;
|
||||
await t.session.accept({
|
||||
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
|
||||
});
|
||||
}
|
||||
|
||||
async decline(id: string): Promise<void> {
|
||||
const t = this.sessions.get(id);
|
||||
if (t?.session instanceof Invitation) await t.session.reject({ statusCode: 486 });
|
||||
}
|
||||
|
||||
/** bye() once talking; before that, cancel (outgoing) or reject (incoming). */
|
||||
async hangup(id: string): Promise<void> {
|
||||
const s = this.sessions.get(id)?.session;
|
||||
if (!s) return;
|
||||
if (s.state === SessionState.Established) await s.bye();
|
||||
else if (s instanceof Inviter) await s.cancel();
|
||||
else if (s instanceof Invitation) await s.reject();
|
||||
}
|
||||
|
||||
private track(session: Session, direction: 'in' | 'out', remote: string, name: string | null) {
|
||||
const id = session.id;
|
||||
const audio = new Audio();
|
||||
audio.autoplay = true;
|
||||
const info: CallInfo = {
|
||||
id,
|
||||
direction,
|
||||
remote,
|
||||
remoteName: name,
|
||||
state: direction === 'in' ? 'incoming' : 'calling',
|
||||
answeredAt: null,
|
||||
};
|
||||
this.sessions.set(id, { session, info, audio });
|
||||
session.stateChange.addListener((s) => {
|
||||
if (s === SessionState.Established) {
|
||||
const sdh = session.sessionDescriptionHandler;
|
||||
if (sdh instanceof Web.SessionDescriptionHandler) {
|
||||
audio.srcObject = sdh.remoteMediaStream;
|
||||
void audio.play().catch(() => undefined);
|
||||
}
|
||||
info.state = 'active';
|
||||
info.answeredAt = Date.now();
|
||||
}
|
||||
if (s === SessionState.Terminated) {
|
||||
audio.srcObject = null;
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
this.publishCalls();
|
||||
});
|
||||
this.publishCalls();
|
||||
}
|
||||
}
|
||||
|
||||
/** One engine per tab. */
|
||||
export const softphone = new SoftphoneEngine();
|
||||
Reference in new issue
Block a user