/** * PBX extension directory for the dial popover's extension chooser. * * Devices come from AMI `DeviceStateList` (needs the `reporting` write class — * granted to the [logship] manager user): every `PJSIP/` device with * its current state. The extension's display name (FreePBX "Display Name" / * CallerID name) lives in the astdb as `AMPUSER//cidname` and is read * with AMI `DBGet` — also gated by `reporting`, so no extra manager class is * needed (`PJSIPShowEndpoints` would need `system`). Names are cached 10 min * per extension. Extensions without a PBX name fall back to the ERP: active * system users whose SIP_Extension matches (cached 5 min). Devices are cached * 30 s. Fail-soft: an AMI or iDempiere error yields an empty / name-less list. */ import { connectAmi, amiValue, type AmiClient } from './amiClient' import { string } from 'alga-js' export interface PbxExtension { ext: string; state: string; pbxName: string } export interface DirectoryExtension extends PbxExtension { name: string; userId: number | null } const g = globalThis as any const devCache: { list: PbxExtension[]; expires: number } = g.__dialDevCache ?? (g.__dialDevCache = { list: [], expires: 0 }) const nameCache: { map: Map; expires: number } = g.__dialNameCache ?? (g.__dialNameCache = { map: new Map(), expires: 0 }) /** astdb `AMPUSER//cidname` per extension (`''` = no entry), each with its own expiry. */ const pbxNameCache: Map = g.__dialPbxNameCache ?? (g.__dialPbxNameCache = new Map()) const PBX_NAME_TTL = 10 * 60 * 1000 const STATE_LABEL: Record = { NOT_INUSE: 'frei', INUSE: 'im Gespräch', BUSY: 'besetzt', RINGING: 'klingelt', RINGINUSE: 'klingelt', ONHOLD: 'gehalten', UNAVAILABLE: 'nicht erreichbar', INVALID: 'ungültig', UNKNOWN: 'unbekannt' } export const stateLabel = (s: string) => STATE_LABEL[String(s || '').toUpperCase()] || String(s || '').toLowerCase() /** * AMI `DBGet`: the response only says "Result will follow"; the value arrives * as a `DBGetResponse` event and the list ends with `DBGetComplete` (both * carrying our ActionID). Resolves `null` when the key does not exist. */ const dbGet = (ami: AmiClient, family: string, key: string, timeoutMs = 3000): Promise => { return new Promise((resolve) => { const actionId = `dbget-${family}-${key}-${Math.random().toString(36).slice(2, 8)}` let value: string | null = null let done = false const finish = (v: string | null) => { if (done) return; done = true; clearTimeout(timer); off(); resolve(v) } const off = ami.onEvent((evt) => { if (amiValue(evt, 'ActionID') !== actionId) return const name = amiValue(evt, 'Event') if (name === 'DBGetResponse') value = amiValue(evt, 'Val') else if (name === 'DBGetComplete') finish(value) }) const timer = setTimeout(() => finish(value), timeoutMs) timer.unref?.() ami.send('DBGet', { Family: family, Key: key }, timeoutMs, actionId).then((res) => { if (amiValue(res, 'Response') !== 'Success') finish(null) // "Database entry not found" }).catch(() => finish(null)) }) } /** Fills `pbxNameCache` for the given extensions (only expired / unknown ones are queried). */ const loadPbxNames = async (ami: AmiClient, exts: string[]) => { const now = Date.now() const missing = exts.filter((ext) => (pbxNameCache.get(ext)?.expires ?? 0) <= now) if (missing.length === 0) return await Promise.all(missing.map(async (ext) => { try { const v = await dbGet(ami, 'AMPUSER', `${ext}/cidname`) pbxNameCache.set(ext, { name: String(v ?? '').trim(), expires: Date.now() + PBX_NAME_TTL }) } catch { // keep whatever was cached; retry on the next refresh } })) } export const listPbxExtensions = async (): Promise => { if (devCache.expires > Date.now()) return devCache.list const config = useRuntimeConfig() const cfg = (config as any).asterisk || {} if (!cfg.enabled || !cfg.secret) return [] const ami = await connectAmi({ host: cfg.host, port: cfg.port, username: cfg.username, secret: cfg.secret }) try { const found = new Map() const off = ami.onEvent((evt) => { if (amiValue(evt, 'Event') !== 'DeviceStateChange') return const m = /^(?:PJSIP|SIP)\/(\d{2,6})$/.exec(amiValue(evt, 'Device')) if (m) found.set(m[1], amiValue(evt, 'State')) }) const res = await ami.send('DeviceStateList', {}, 8000) if (amiValue(res, 'Response') !== 'Success') throw new Error(amiValue(res, 'Message') || 'DeviceStateList failed') // The list is streamed as events after the response; give it a moment. await new Promise((r) => setTimeout(r, 400)) off() const exts = [...found.keys()] try { await loadPbxNames(ami, exts) } catch (err: any) { console.warn('[dial] extension names (astdb) lookup failed:', err?.message || err) } const list = exts .map((ext) => ({ ext, state: found.get(ext) || '', pbxName: pbxNameCache.get(ext)?.name || '' })) .sort((a, b) => a.ext.localeCompare(b.ext, undefined, { numeric: true })) devCache.list = list devCache.expires = Date.now() + 30 * 1000 return list } finally { ami.close() } } const loadNames = async (): Promise> => { if (nameCache.expires > Date.now()) return nameCache.map const config = useRuntimeConfig() const token = (config.api as any)?.idempieretoken const map = new Map() if (token) { try { const res: any = await $fetch(`${config.api.url}/models/ad_user?$filter=${string.urlEncode('IsSystemUser eq true AND IsActive eq true')}&$top=200`, { headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, retry: 0, timeout: 15000 }) for (const u of (res?.records || [])) { const ext = String(u?.SIP_Extension ?? u?.sip_Extension ?? '').replace(/\D+/g, '') if (ext) map.set(ext, { name: String(u?.Name || ''), userId: Number(u?.id) }) } } catch (err: any) { console.warn('[dial] extension names lookup failed:', err?.data?.message || err?.message || err) } } nameCache.map = map nameCache.expires = Date.now() + 5 * 60 * 1000 return map } export const extensionDirectory = async (): Promise => { const [devices, names] = await Promise.all([listPbxExtensions().catch(() => [] as PbxExtension[]), loadNames()]) // Display name: the PBX's own extension name first, the matching ERP user as fallback. return devices.map((d) => ({ ...d, name: d.pbxName || names.get(d.ext)?.name || '', userId: names.get(d.ext)?.userId ?? null })) }