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
@@ -17,6 +17,7 @@
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-router": "^8.4.0",
|
||||
"sip.js": "0.21.2",
|
||||
"zod": "^4.6.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
UserDto,
|
||||
UserUpdate,
|
||||
VoicemailDto,
|
||||
WebRtcCreds,
|
||||
Warning,
|
||||
} from '@selfphoneng/shared';
|
||||
|
||||
@@ -145,6 +146,7 @@ export const systemApi = {
|
||||
};
|
||||
|
||||
export const meApi = {
|
||||
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
|
||||
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
|
||||
audioUrl: (id: string, download = false) =>
|
||||
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { authApi, meApi } from '../../api/client.ts';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { subscribeLive, useLive } from '../../live/store.ts';
|
||||
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
|
||||
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
|
||||
|
||||
const link = 'text-sky-700 underline dark:text-sky-400';
|
||||
@@ -62,6 +63,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<Route index element={<Home user={user} />} />
|
||||
<Route path="voicemail" element={<VoicemailPage />} />
|
||||
</Routes>
|
||||
<SoftphoneDock />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Softphone bar on every /me page (05 §5.3). M5-T1: enable, status, dial, answer, hang up.
|
||||
// M5-T2 adds the expanding panel, hold/mute/DTMF, transfer, devices and notifications.
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { meApi } from '../api/client.ts';
|
||||
import { Button, inputClass } from '../components/ui.tsx';
|
||||
import { softphone, type CallInfo } from './SoftphoneEngine.ts';
|
||||
|
||||
const KEY = 'softphone.enabled';
|
||||
const remembered = () => {
|
||||
try {
|
||||
return localStorage.getItem(KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const remember = (on: boolean) => {
|
||||
try {
|
||||
if (on) localStorage.setItem(KEY, '1');
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* private mode: just don't remember */
|
||||
}
|
||||
};
|
||||
|
||||
export const useSoftphone = () => useSyncExternalStore(softphone.subscribe, () => softphone.state);
|
||||
|
||||
const STATUS = { off: 'Off', connecting: 'Connecting…', registered: 'Ready', error: 'Error' };
|
||||
|
||||
async function startSoftphone() {
|
||||
const creds = await meApi.webrtc();
|
||||
await softphone.start(creds);
|
||||
}
|
||||
|
||||
export function SoftphoneDock() {
|
||||
const { reg, error, calls } = useSoftphone();
|
||||
const [number, setNumber] = useState('');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (remembered() && softphone.state.reg === 'off')
|
||||
startSoftphone().catch((e: Error) => setProblem(e.message));
|
||||
}, []);
|
||||
|
||||
async function enable() {
|
||||
setProblem(null);
|
||||
try {
|
||||
// Ask for the microphone now, on a click, not in the middle of the first call.
|
||||
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mic.getTracks().forEach((t) => t.stop());
|
||||
await startSoftphone();
|
||||
remember(true);
|
||||
} catch (e) {
|
||||
setProblem(
|
||||
(e as Error).name === 'NotAllowedError'
|
||||
? 'Microphone access was blocked.'
|
||||
: (e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function dial() {
|
||||
const n = number.replace(/[^0-9*#+]/g, '');
|
||||
if (!n) return;
|
||||
setProblem(null);
|
||||
await softphone.call(n).catch((e: Error) => setProblem(e.message));
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Softphone"
|
||||
className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-zinc-200 bg-white py-3 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Softphone: <span role="status">{STATUS[reg]}</span>
|
||||
</span>
|
||||
{reg === 'off' ? (
|
||||
<Button variant="primary" onClick={enable}>
|
||||
Enable softphone
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
remember(false);
|
||||
void softphone.stop();
|
||||
}}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(problem ?? error) && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{problem ?? error}</p>
|
||||
)}
|
||||
{reg === 'registered' && (
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void dial();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="sp-number" className="sr-only">
|
||||
Number to call
|
||||
</label>
|
||||
<input
|
||||
id="sp-number"
|
||||
className={inputClass}
|
||||
inputMode="tel"
|
||||
autoComplete="off"
|
||||
placeholder="Number or extension"
|
||||
value={number}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="primary">
|
||||
Call
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{calls.map((c) => (
|
||||
<CallRow key={c.id} call={c} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CallRow({ call }: { call: CallInfo }) {
|
||||
const who = call.remoteName ?? (call.remote.length > 6 ? formatNumber(call.remote) : call.remote);
|
||||
const label = { incoming: 'Incoming call', calling: 'Calling…', active: 'On call' }[call.state];
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${label}: ${who}`}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
|
||||
>
|
||||
<span className="flex-1 text-sm">
|
||||
<span className="font-medium">{who}</span> · {label}
|
||||
</span>
|
||||
{call.state === 'incoming' && (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => void softphone.answer(call.id)}>
|
||||
Answer
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void softphone.decline(call.id)}>
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{call.state !== 'incoming' && (
|
||||
<Button variant="danger" onClick={() => void softphone.hangup(call.id)}>
|
||||
Hang up
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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