Merge pull request 'Feat/contacts' (#2) from feat/contacts into main
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 5s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
iamdoubz committed 2026-10-23 16:14:19 -05:00
commit 53aefaa5d7
103 files changed
+18470 -72

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet
# --- optional --- # --- optional ---
CONTRACK_HOSTNAME= # Contrack contacts (spec 11 §4): its own DNS name, e.g. contacts.example.com
TZ=America/Chicago TZ=America/Chicago
API_PORT=8080 API_PORT=8080
POSTGRES_PORT=5433 POSTGRES_PORT=5433
+1
View File
@@ -27,6 +27,7 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning | | `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning |
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking | | `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task | | `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
## Hard rules ## Hard rules
+2
View File
@@ -39,6 +39,7 @@ import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts'; import { integrationRoutes } from './routes/integrations.ts';
import { reportRoutes } from './routes/reports.ts'; import { reportRoutes } from './routes/reports.ts';
import { aboutRoutes } from './routes/about.ts'; import { aboutRoutes } from './routes/about.ts';
import { contactRoutes } from './routes/contacts.ts';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -146,6 +147,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(settingsRoutes); app.register(settingsRoutes);
app.register(liveRoutes); app.register(liveRoutes);
app.register(meRoutes); app.register(meRoutes);
app.register(contactRoutes);
app.register(soundRoutes); app.register(soundRoutes);
app.register(ringGroupRoutes); app.register(ringGroupRoutes);
app.register(queueRoutes); app.register(queueRoutes);
+236
View File
@@ -0,0 +1,236 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Address books (11 §2): a user's (owner = their id) and the company's (owner null), kept in the
// `contacts` table (builtin) or in Contrack (contacts.provider = 'contrack', 11 §4). Every caller
// goes through these functions and doesn't care which.
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import {
normalizeNumber,
type ContactBookDto,
type ContactCopyResult,
type ContactData,
type ContactDto,
type ContactImportResult,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { contacts } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import {
ContrackError,
contrackBook,
contrackCreate,
contrackBulk,
contrackSettings,
needToken,
type Contrack,
} from './contrack.ts';
/** null = the company book. */
export type Owner = string | null;
const inBook = (owner: Owner) =>
owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId);
type Row = typeof contacts.$inferSelect;
export const toDto = (r: Row): ContactDto => ({
id: r.id,
book: r.ownerUserId ? 'mine' : 'company',
name: r.name,
company: r.company,
email: r.email,
numbers: r.numbers as ContactData['numbers'],
notes: r.notes,
url: null,
});
/** Contrack's errors as API answers (409 for "connect first", 502 for Contrack itself). */
const asHttp = (err: unknown): never => {
if (err instanceof ContrackError)
throw new HttpError(err.status === 409 ? 409 : 502, 'contrack', err.message);
throw err;
};
/** Editing, deleting and importing happen in Contrack itself in Contrack mode (11 §3.1, §4.3). */
export async function assertBuiltin(db: Db) {
if (await contrackSettings(db))
throw new HttpError(409, 'contrack', 'Contacts live in Contrack: change them there');
}
/** Normalized numbers (00 §8); `bad` gets the ones that aren't phone numbers. */
export function cleanNumbers(c: ContactData, bad?: (n: string) => void): ContactData['numbers'] {
const out: ContactData['numbers'] = [];
for (const n of c.numbers)
try {
const number = normalizeNumber(n.number);
if (!out.some((o) => o.number === number)) out.push({ label: n.label, number });
} catch {
if (!bad) throw badRequest(`"${n.number}" isn't a phone number`);
bad(n.number);
}
return out;
}
const byName = (a: ContactDto, b: ContactDto) => a.name.localeCompare(b.name);
async function contrackList(db: Db, c: Contrack, owner: Owner) {
const b = await contrackBook(db, c, owner);
return { items: (b?.contacts ?? []).toSorted(byName), book: b };
}
/**
* The books someone sees: my contacts, then the company's (userId), or only the company's (null,
* Admin → Contacts). `contrack` says whether that person's (or the company's) token works.
*/
export async function readBooks(db: Db, userId: string | null): Promise<ContactBookDto> {
const c = await contrackSettings(db);
if (!c) {
const rows = await db
.select()
.from(contacts)
.where(
userId
? or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId))
: isNull(contacts.ownerUserId),
)
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
return { items: rows.map(toDto), provider: 'builtin', contrack: null };
}
const company = await contrackList(db, c, null);
const mine = userId ? await contrackList(db, c, userId) : null;
const own = mine ?? company; // whose token the page is about
return {
items: [...(mine?.items ?? []), ...company.items],
provider: 'contrack',
contrack: {
publicUrl: c.publicUrl,
connected: !!own.book && !own.book.error,
error: own.book?.error ?? null,
toCopy:
own.book && !own.book.error
? (await notInContrack(db, userId, own.book.contacts)).length
: 0,
},
};
}
/** This book's builtin contacts that its Contrack account doesn't have yet. */
async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise<ContactData[]> {
const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
}
/**
* "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
* in one Contrack import. The builtin ones stay, for switching back.
*/
export async function copyToContrack(db: Db, owner: Owner): Promise<ContactCopyResult> {
const c = await contrackSettings(db);
if (!c)
throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
const b = await contrackBook(db, c, owner);
if (!b) asHttp(needToken(owner));
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
const all = (await db.select().from(contacts).where(inBook(owner))).length;
const fresh = await notInContrack(db, owner, b!.contacts);
if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
}
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise<ContactDto> {
const clean = { ...d, numbers: cleanNumbers(d) };
const c = await contrackSettings(db);
if (c) return contrackCreate(db, c, owner, clean).catch(asHttp);
const [row] = await db
.insert(contacts)
.values({ ...clean, ownerUserId: owner })
.returning();
return toDto(row!);
}
/** Same contact: same name (any case) and a number in common, or neither has a number. */
const sameKey = (c: ContactData) => c.name.trim().toLowerCase();
const isDuplicate = (a: ContactData, b: ContactData) =>
sameKey(a) === sameKey(b) &&
((!a.numbers.length && !b.numbers.length) ||
a.numbers.some((n) => b.numbers.some((m) => m.number === n.number)));
/**
* Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares.
* With `commit` false it only counts. Bad numbers are dropped and reported.
*/
export async function addToBook(
db: Db,
owner: Owner,
list: ContactData[],
commit: boolean,
errors: string[] = [],
): Promise<ContactImportResult> {
const c = await contrackSettings(db);
let have: ContactDto[];
if (c) {
const b = await contrackBook(db, c, owner);
if (!b) asHttp(needToken(owner));
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
have = b!.contacts;
} else have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
const fresh: ContactData[] = [];
let duplicates = 0;
for (const d of list) {
const numbers = cleanNumbers(d, (n) =>
errors.push(`${d.name}: "${n}" isn't a phone number, left out`),
);
const clean = { ...d, numbers };
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
else fresh.push(clean);
}
if (commit && fresh.length) {
// ponytail: one Contrack request per contact; imports go to Contrack's own importer, so
// this is only accepted shares.
if (c) for (const d of fresh) await contrackCreate(db, c, owner, d).catch(asHttp);
// ponytail: one insert of up to 5,000 rows (the import cap).
else await db.insert(contacts).values(fresh.map((d) => ({ ...d, ownerUserId: owner })));
}
return {
committed: commit,
added: fresh.length,
duplicates,
noNumber: fresh.filter((d) => !d.numbers.length).length,
errors: errors.slice(0, 20),
};
}
/**
* Names for numbers, from my book first, then the company's (11 §1). Numbers as calls store them
* (normalized). userId null: the company book only.
*/
export async function contactNames(
db: Db,
userId: string | null,
numbers: (string | null)[],
opts: { wait?: boolean } = {},
): Promise<Map<string, string>> {
const want = [...new Set(numbers.filter((n): n is string => !!n))];
const out = new Map<string, string>();
if (!want.length) return out;
const c = await contrackSettings(db);
if (c) {
const books = [
userId ? await contrackBook(db, c, userId, opts) : null,
await contrackBook(db, c, null, opts),
];
for (const b of books)
for (const ct of b?.contacts ?? [])
for (const n of ct.numbers)
if (want.includes(n.number) && !out.has(n.number)) out.set(n.number, ct.name);
return out;
}
// ponytail: a scan of the two books' numbers; a GIN index when books reach tens of thousands.
const { rows } = await db.execute<{ name: string; number: string; mine: boolean }>(sql`
select c.name, n->>'number' as number, c.owner_user_id is not null as mine
from ${contacts} c, jsonb_array_elements(c.numbers) n
where ${userId ? sql`(c.owner_user_id = ${userId} or c.owner_user_id is null)` : sql`c.owner_user_id is null`}
and n->>'number' in ${want}`);
for (const r of rows.sort((a, b) => Number(b.mine) - Number(a.mine)))
if (!out.has(r.number)) out.set(r.number, r.name);
return out;
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the
// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for
// the caller, and the phones show it.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */
export const callerNameToken = (appSecret: string) =>
createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex');
export const sameToken = (a: string, b: string) =>
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
/**
* What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at
* most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could
* read as syntax.
*/
export const phoneSafeName = (name: string) =>
name
.normalize('NFC')
.replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 40)
.trim();
+265
View File
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST
// routes with a person's (or the company account's) personal token, and keep each book in memory
// for 5 minutes, so name lookups don't wait on it.
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
import { AiError, assertLocalUrl } from '../ai/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
export class ContrackError extends Error {
status: number | undefined;
constructor(message: string, status?: number) {
super(message);
this.status = status;
}
}
/** One call to Contrack's API (base: the local address in Settings → Contacts). */
async function ctk<T>(
base: string,
token: string,
path: string,
init: RequestInit = {},
): Promise<T> {
try {
assertLocalUrl(base);
} catch (err) {
throw new ContrackError((err as AiError).message);
}
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
let r: Response;
try {
r = await fetch(url, {
...init,
headers: {
...(init.headers as Record<string, string> | undefined),
Authorization: `Bearer ${token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
redirect: 'error',
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
const e = err as Error & { cause?: { code?: string } };
throw new ContrackError(
e.name === 'TimeoutError'
? `Contrack (${url.host}) didn't answer within 10 s`
: `Can't reach Contrack at ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`,
);
}
if (r.status === 401) throw new ContrackError('Contrack refused the token', 401);
if (!r.ok) {
const body = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
throw new ContrackError(`Contrack: ${body?.error?.message ?? `HTTP ${r.status}`}`, r.status);
}
return (await r.json()) as T;
}
type Slim = {
id: string;
name: string;
company: string | null;
emails: { email: string }[];
phones: { phone: string }[];
};
export type Contrack = { url: string; publicUrl: string | null; companyToken: string | null };
/** Settings → Contacts, or null in builtin mode. */
export async function contrackSettings(db: Db): Promise<Contrack | null> {
if ((await getSetting(db, 'contacts.provider')) !== 'contrack') return null;
const c = await getSetting(db, 'contacts.contrack');
if (!c) return null;
return {
url: c.url,
publicUrl: c.publicUrl,
companyToken: c.companyTokenEnc ? decrypt(c.companyTokenEnc) : null,
};
}
/** The token for a book: a user's own, or the company account's (owner null). */
export async function tokenFor(db: Db, c: Contrack, owner: string | null): Promise<string | null> {
if (owner === null) return c.companyToken;
const [u] = await db
.select({ enc: users.contrackTokenEnc })
.from(users)
.where(eq(users.id, owner));
return u?.enc ? decrypt(u.enc) : null;
}
const toDto = (c: Contrack, s: Slim, book: ContactDto['book']): ContactDto => {
const numbers: ContactData['numbers'] = [];
for (const p of s.phones)
try {
const number = normalizeNumber(p.phone);
if (!numbers.some((n) => n.number === number)) numbers.push({ label: '', number });
} catch {
// Not a number a phone can dial ("ask reception"): left out.
}
return {
id: s.id,
book,
name: s.name,
company: s.company,
email: s.emails[0]?.email ?? null,
numbers,
notes: null,
url: c.publicUrl ? `${c.publicUrl}/contact/${encodeURIComponent(s.id)}` : null,
};
};
type Book = { contacts: ContactDto[]; at: number; error: string | null };
/** By owner: a user id, or '' for the company account. Keyed by token too, so a new one refetches. */
const cache = new Map<string, Book & { token: string }>();
const TTL = 5 * 60_000;
/** Forget a book (after we add to it, or a token changes). */
export const forgetBook = (owner: string | null) => cache.delete(owner ?? '');
const inflight = new Map<string, Promise<Book>>();
/**
* A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing
* Contrack keeps the last good list and says why. `wait: false` (a phone ringing, 11 §6 C4)
* never waits on Contrack: it answers what the cache has and refreshes in the background.
*/
export async function contrackBook(
db: Db,
c: Contrack,
owner: string | null,
{ now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {},
): Promise<Book | null> {
const token = await tokenFor(db, c, owner);
if (!token) return null;
const key = owner ?? '';
const hit = cache.get(key);
const usable = hit && hit.token === token ? hit : undefined;
if (usable && now - usable.at < TTL) return usable;
if (!inflight.has(key))
inflight.set(
key,
fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)),
);
if (!wait) return usable ?? null;
return inflight.get(key)!;
}
async function fetchBook(
c: Contrack,
owner: string | null,
token: string,
hit: Book | undefined,
now: number,
): Promise<Book> {
const key = owner ?? '';
const book = owner === null ? 'company' : 'mine';
try {
const rows = await ctk<Slim[]>(c.url, token, 'api/contacts?view=slim');
const fresh = { contacts: rows.map((r) => toDto(c, r, book)), at: now, error: null, token };
cache.set(key, fresh);
return fresh;
} catch (err) {
const stale = {
contacts: hit?.contacts ?? [],
at: now, // tried now: the next try is in 5 minutes, not on every lookup
error: (err as Error).message,
token,
};
cache.set(key, stale);
return stale;
}
}
/** Writing to a book whose owner hasn't connected Contrack. */
export const needToken = (owner: string | null) =>
new ContrackError(
owner
? 'Connect your Contrack account first (My settings → Contacts)'
: 'The company Contrack account has no token (Settings → Contacts)',
409,
);
/** Adds a contact to a Contrack book (11 §4.3: "Add to contacts", an accepted share). */
export async function contrackCreate(
db: Db,
c: Contrack,
owner: string | null,
d: ContactData,
): Promise<ContactDto> {
const token = await tokenFor(db, c, owner);
if (!token) throw needToken(owner);
const row = await ctk<Slim>(c.url, token, 'api/contacts', {
method: 'POST',
body: JSON.stringify({
name: d.name,
...(d.company ? { company: d.company } : {}),
...(d.email ? { emails: [{ email: d.email }] } : {}),
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
}),
});
forgetBook(owner);
return toDto(c, row, owner === null ? 'company' : 'mine');
}
/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
export async function contrackBulk(
db: Db,
c: Contrack,
owner: string | null,
list: ContactData[],
): Promise<{ count: number; failed: number }> {
const token = await tokenFor(db, c, owner);
if (!token) throw needToken(owner);
const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
method: 'POST',
headers: { 'X-Import-Id': randomUUID() },
body: JSON.stringify(
list.slice(0, 5000).map((d) => ({
name: d.name,
...(d.company ? { company: d.company } : {}),
...(d.email ? { emails: [{ email: d.email }] } : {}),
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
})),
),
});
forgetBook(owner);
return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
}
/** A timeline entry on a contact (11 §4.4); answers its id. */
export async function contrackInteraction(
c: Contrack,
token: string,
contactId: string,
body: Record<string, unknown>,
): Promise<string> {
const r = await ctk<{ id: string }>(
c.url,
token,
`api/contacts/${encodeURIComponent(contactId)}/interactions`,
{ method: 'POST', body: JSON.stringify(body) },
);
return r.id;
}
/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */
export async function contrackCheck(url: string, token: string | null): Promise<string> {
try {
assertLocalUrl(url);
} catch (err) {
throw new ContrackError((err as AiError).message);
}
const health = await fetch(new URL('healthz', url.endsWith('/') ? url : `${url}/`), {
redirect: 'error',
signal: AbortSignal.timeout(10_000),
}).catch(() => null);
if (!health?.ok) throw new ContrackError(`Can't reach Contrack at ${url}`);
if (!token) return 'Contrack answers.';
const rows = await ctk<Slim[]>(url, token, 'api/contacts?view=slim');
return `Contrack answers; the token reads ${rows.length} contact${rows.length === 1 ? '' : 's'}.`;
}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// CSV to contacts (04 §7.4): name,company,email,label1,number1,label2,number2,… with a header row
// (any order, any case; notes too).
import type { ContactData } from '@selfphoneng/shared';
/** RFC 4180 rows (blank ones too): quoted cells may hold commas, quotes ("") and line breaks. */
export function csvRows(input: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
const s = input.replace(/^\uFEFF/, '');
for (let i = 0; i < s.length; i++) {
const c = s[i]!;
if (quoted) {
if (c === '"' && s[i + 1] === '"') {
cell += '"';
i++;
} else if (c === '"') quoted = false;
else cell += c;
} else if (c === '"') quoted = true;
else if (c === ',') {
row.push(cell);
cell = '';
} else if (c === '\n' || c === '\r') {
if (c === '\r' && s[i + 1] === '\n') i++;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else cell += c;
}
if (cell || row.length) rows.push([...row, cell]);
return rows;
}
export function parseCsv(input: string): { contacts: ContactData[]; errors: string[] } {
const [header, ...rows] = csvRows(input);
if (!header) return { contacts: [], errors: [] };
const col = new Map(header.map((h, i) => [h.trim().toLowerCase(), i]));
if (!col.has('name'))
return { contacts: [], errors: ['The first row must name the columns, with a "name" column'] };
const contacts: ContactData[] = [];
const errors: string[] = [];
rows.forEach((r, i) => {
if (!r.some((c) => c.trim())) return;
const get = (k: string) => {
const at = col.get(k);
return at === undefined ? '' : (r[at] ?? '').trim();
};
const numbers: ContactData['numbers'] = [];
for (let n = 1; col.has(`number${n}`); n++)
if (get(`number${n}`)) numbers.push({ label: get(`label${n}`), number: get(`number${n}`) });
const name = get('name');
if (!name) {
errors.push(`Row ${i + 2}: no name, skipped`);
return;
}
contacts.push({
name,
company: get('company') || null,
email: get('email') || null,
numbers,
notes: get('notes') || null,
});
});
return { contacts, errors };
}
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C5: what a call looks like on a Contrack timeline (11 §4.4).
import { describe, expect, it } from 'vitest';
import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts';
const call = (c: Partial<TimelineCall>): TimelineCall => ({
out: false,
status: 'answered',
fromNumber: '13125551234',
toNumber: '13125550000',
talkSeconds: 192,
answeredBy: null,
recorded: false,
...c,
});
describe('timelineEntry', () => {
it('titles calls from where the person sat', () => {
const t = (c: Partial<TimelineCall>) => timelineEntry(call(c), null).title;
expect(t({})).toBe('Incoming call');
expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith');
expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call');
expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left');
expect(t({ out: true })).toBe('Outgoing call');
expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy');
expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer');
});
it('writes the facts, the summary and its action items as escaped HTML', () => {
const e = timelineEntry(
call({ recorded: true }),
'Acme <wants> a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit',
);
expect(e).toEqual({
title: 'Incoming call',
content:
'<p>From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded</p>' +
'<p>Acme &lt;wants&gt; a quote &amp; a call back.</p>' +
'<p>Action items:</p><ul><li>Dan sends the quote</li><li>Sue books a visit</li></ul>',
duration: 4,
firstItem: 'Dan sends the quote',
});
const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null);
expect([missed.content, missed.duration, missed.firstItem]).toEqual([
'<p>From (312) 555-1234 to (312) 555-0000</p>',
null,
null,
]);
expect(timelineEntry(call({ out: true }), null).content).toBe(
'<p>To (312) 555-0000 · 3:12</p>',
);
});
it('reads "Action items: none" as none', () => {
expect(splitSummary('Wrong number.\nAction items: none')).toEqual({
text: ['Wrong number.'],
items: [],
});
expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] });
expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] });
});
});
+218
View File
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their
// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's
// summary if they asked for it. Unknown numbers are not added to Contrack.
import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { formatNumber } from '@selfphoneng/shared';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import {
callExtensions,
calls,
contrackCallLog,
extensions,
recordings,
users,
} from '../db/schema.ts';
import { todayIn } from '../generator/load.ts';
import { getSetting } from '../services/settings.ts';
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
/** A recorded call waits this long for its summary before it's logged without one. */
const SUMMARY_WAIT_MS = 30 * 60_000;
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number');
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
export type TimelineCall = {
out: boolean;
status: string;
fromNumber: string | null;
toNumber: string | null;
talkSeconds: number;
/** Someone else answered (a ring group or queue colleague). */
answeredBy: string | null;
recorded: boolean;
};
/** "Action items:" lines of a summary (10 §3), and the text before them. */
export function splitSummary(summary: string): { text: string[]; items: string[] } {
const lines = summary
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
const at = lines.findIndex((l) => /^action items:/i.test(l));
if (at < 0) return { text: lines, items: [] };
return {
text: lines.slice(0, at),
items: lines
.slice(at + 1)
.filter((l) => /^[-*•]\s+/.test(l))
.map((l) => l.replace(/^[-*•]\s+/, ''))
.filter((l) => !/^none\.?$/i.test(l)),
};
}
/** The interaction for one call: title, HTML content, minutes, the first action item. */
export function timelineEntry(c: TimelineCall, summary: string | null) {
const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status);
const title = c.out
? c.status === 'answered'
? 'Outgoing call'
: `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}`
: c.status === 'voicemail'
? 'Missed call, voicemail left'
: missed || c.status !== 'answered'
? 'Missed call'
: c.answeredBy
? `Incoming call, answered by ${c.answeredBy}`
: 'Incoming call';
const facts = [
c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`,
...(c.talkSeconds ? [mmss(c.talkSeconds)] : []),
...(c.recorded ? ['recorded'] : []),
];
const parts = [`<p>${esc(facts.join(' · '))}</p>`];
const s = summary ? splitSummary(summary) : null;
if (s?.text.length) parts.push(...s.text.map((l) => `<p>${esc(l)}</p>`));
if (s?.items.length)
parts.push(`<p>Action items:</p><ul>${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>`);
return {
title,
content: parts.join(''),
duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null,
firstItem: s?.items[0] ?? null,
};
}
const answerer = alias(extensions, 'answerer');
/**
* One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of
* people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on
* the next run.
*/
export async function logCallsToContrack(d: {
db: Db;
log: { error(o: object, m: string): void };
now?: Date;
}): Promise<number> {
const c = await contrackSettings(d.db);
if (!c) return 0;
const now = d.now ?? new Date();
const tz = await getSetting(d.db, 'general.timezone');
const summariesOn =
(await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries'));
const rows = await d.db
.select({
call: calls,
role: callExtensions.role,
userId: users.id,
withSummary: users.contrackLogSummaries,
tokenEnc: users.contrackTokenEnc,
extId: extensions.id,
answeredById: answerer.id,
answeredBy: answerer.name,
summary: recordings.summary,
aiDoneAt: recordings.aiDoneAt,
})
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.innerJoin(users, eq(users.id, extensions.userId))
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
.where(
and(
isNotNull(users.contrackTokenEnc),
eq(users.contrackLogCalls, true),
eq(users.isActive, true),
ne(calls.direction, 'internal'),
gt(calls.endedAt, users.contrackLogSince),
lt(calls.endedAt, new Date(now.getTime() - 60_000)),
gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)),
sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`,
),
)
.orderBy(asc(calls.endedAt))
.limit(500);
// One entry per call and person (a person can be in a call as caller, rang and answered).
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
for (const r of rows) {
const k = `${r.call.id}|${r.userId}`;
const out = r.role === 'caller' || todo.get(k)?.out === true;
todo.set(k, { ...r, out });
}
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
let logged = 0;
for (const r of todo.values()) {
if (skipUser.has(r.userId)) continue;
const record = (interactionId: string | null, error: string | null = null) =>
d.db
.insert(contrackCallLog)
.values({ callId: r.call.id, userId: r.userId, interactionId, error })
.onConflictDoNothing();
const book = await contrackBook(d.db, c, r.userId);
if (!book || book.error) {
skipUser.add(r.userId);
continue;
}
const other = r.out ? r.call.toNumber : r.call.fromNumber;
const contact = other
? book.contacts.find((x) => x.numbers.some((n) => n.number === other))
: undefined;
if (!contact) {
await record(null);
continue;
}
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
continue; // the transcript job hasn't summarized it yet
const e = timelineEntry(
{
out: r.out,
status: r.call.status,
fromNumber: r.call.fromNumber,
toNumber: r.call.toNumber,
talkSeconds: r.call.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
recorded: r.call.recordingId !== null,
},
wantSummary ? r.summary : null,
);
try {
const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, {
type: 'call',
title: e.title,
content: e.content,
date: r.call.startedAt.toISOString(),
duration: e.duration,
source: 'selfphoneng',
...(e.firstItem
? {
actionItem: {
title: e.firstItem.slice(0, 200),
dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)),
},
}
: {}),
});
await record(id);
logged++;
} catch (err) {
const status = err instanceof ContrackError ? err.status : undefined;
// Refused (bad request, a read-only token, a deleted contact): noted, not retried.
if (status && status >= 400 && status < 500 && status !== 401 && status !== 429)
await record(null, (err as Error).message);
else {
skipUser.add(r.userId);
d.log.error({ err, user: r.userId }, 'contrack: call not logged yet');
}
}
}
return logged;
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C1: vCard and CSV imports read what phones and address books export (11 §3.1).
import { describe, expect, it } from 'vitest';
import { parseCsv } from './csv.ts';
import { parseVcards } from './vcard.ts';
const IPHONE = [
'BEGIN:VCARD',
'VERSION:3.0',
'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
'N:Doe;Jane;;;',
'FN:Jane Doe',
'ORG:Acme Corp;',
'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com',
'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234',
'item1.TEL:+1 312 555 0000',
'item1.X-ABLabel:_$!<Main>!$_',
'NOTE:Met at the fair\\, 2026\\nCall after 5',
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ',
'END:VCARD',
].join('\r\n');
const GOOGLE = `BEGIN:VCARD
VERSION:3.0
FN:Bob Smith
N:Smith;Bob;;;
TEL;TYPE=WORK:+13125559999
TEL;TYPE=HOME:3125558888
EMAIL;TYPE=INTERNET:bob@example.com
ORG:Widgets Inc.
item1.URL:http\\://example.com
END:VCARD
BEGIN:VCARD
VERSION:3.0
N:;;;;
TEL;TYPE=CELL:3125550101
END:VCARD
`;
const V21 = `BEGIN:VCARD
VERSION:2.1
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;
TEL;CELL:+49301234567
TEL;WORK;FAX:+49301234500
NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw=
o
END:VCARD`;
const V40 = `BEGIN:VCARD
VERSION:4.0
FN:Carol Lee
TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777
END:VCARD
BEGIN:VCARD
VERSION:4.0
NOTE:nothing to call
END:VCARD`;
describe('parseVcards', () => {
it('reads an iPhone export: names, company, labels, note; skips the photo', () => {
expect(parseVcards(IPHONE)).toEqual({
contacts: [
{
name: 'Jane Doe',
company: 'Acme Corp',
email: 'jane@example.com',
numbers: [
{ label: 'mobile', number: '(312) 555-1234' },
{ label: 'main', number: '+1 312 555 0000' },
],
notes: 'Met at the fair, 2026\nCall after 5',
},
],
errors: [],
});
});
it('reads a Google export, and names a card with only a number after it', () => {
const { contacts } = parseVcards(GOOGLE);
expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([
[
'Bob Smith',
'Widgets Inc.',
[
{ label: 'work', number: '+13125559999' },
{ label: 'home', number: '3125558888' },
],
],
['3125550101', null, [{ label: 'mobile', number: '3125550101' }]],
]);
});
it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => {
const [c] = parseVcards(V21).contacts;
expect(c).toMatchObject({
name: 'Dr. Jürgen Müller',
numbers: [
{ label: 'mobile', number: '+49301234567' },
{ label: 'fax', number: '+49301234500' },
],
notes: 'Line one\r\nLine two',
});
});
it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => {
expect(parseVcards(V40)).toEqual({
contacts: [
{
name: 'Carol Lee',
company: null,
email: null,
numbers: [{ label: 'mobile', number: '+1-312-555-7777' }],
notes: null,
},
],
errors: ['Card 2: no name or number, skipped'],
});
});
});
describe('parseCsv', () => {
it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => {
const csv =
'Number1,Name,label1,company,notes,number2,label2\r\n' +
'312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' +
',,,,,,\r\n' +
'3125550000,,work,,,,\r\n' +
'+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n';
expect(parseCsv(csv)).toEqual({
contacts: [
{
name: 'Doe, Jane',
company: 'Acme',
email: null,
numbers: [{ label: 'mobile', number: '312-555-1234' }],
notes: 'two\nlines',
},
{
name: 'Bob "B" Smith',
company: null,
email: null,
numbers: [
{ label: '', number: '+44 20 7946 0958' },
{ label: 'desk', number: '101' },
],
notes: null,
},
],
errors: ['Row 4: no name, skipped'],
});
});
it('needs a header with a name column', () => {
expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/);
});
});
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
// the note. Everything else (photos, addresses, birthdays) is ignored.
import type { ContactData } from '@selfphoneng/shared';
type Prop = { group: string; name: string; params: Map<string, string[]>; value: string };
/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
function parseParams(parts: string[]): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const p of parts) {
const eq = p.indexOf('=');
const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
const raw = eq < 0 ? p : p.slice(eq + 1);
const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
// 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
}
return out;
}
/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
function parseLine(line: string): Prop | null {
let quoted = false;
let colon = -1;
for (let i = 0; i < line.length; i++) {
if (line[i] === '"') quoted = !quoted;
else if (line[i] === ':' && !quoted) {
colon = i;
break;
}
}
if (colon < 0) return null;
const [head = '', ...params] = line.slice(0, colon).split(';');
const dot = head.lastIndexOf('.');
return {
group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
name: head.slice(dot + 1).toUpperCase(),
params: parseParams(params),
value: line.slice(colon + 1),
};
}
function decodeQp(v: string, charset: string): string {
const bytes: number[] = [];
for (let i = 0; i < v.length; i++) {
const hex = v.slice(i + 1, i + 3);
if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
bytes.push(parseInt(hex, 16));
i += 2;
} else bytes.push(v.charCodeAt(i) & 0xff);
}
try {
return new TextDecoder(charset).decode(new Uint8Array(bytes));
} catch {
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
}
}
/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
function components(v: string, sep: ';' | ','): string[] {
const out = [''];
for (let i = 0; i < v.length; i++) {
const c = v[i]!;
if (c === '\\' && i + 1 < v.length) {
const n = v[++i]!;
out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
} else if (c === sep) out.push('');
else out[out.length - 1] += c;
}
return out.map((s) => s.trim());
}
const text = (v: string) => components(v, ';').join(';').trim();
/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
function telLabel(types: string[], abLabel: string | undefined): string {
if (abLabel) {
const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
}
if (types.includes('FAX')) return 'fax';
if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
if (types.includes('WORK')) return 'work';
if (types.includes('HOME')) return 'home';
if (types.includes('MAIN')) return 'main';
return 'other';
}
/** Every card in the text, in order. A card with no name, company or number is an error. */
export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
// Unfold: a line starting with a space or tab continues the one before (and 2.1
// quoted-printable continues a line that ends with "=").
const lines: string[] = [];
for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
const prev = lines.at(-1);
if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
else lines.push(l);
}
const contacts: ContactData[] = [];
const errors: string[] = [];
let card: Prop[] | null = null;
for (const line of lines) {
const p = parseLine(line);
if (!p) continue;
if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
const c = toContact(card);
if (c) contacts.push(c);
else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
card = null;
} else if (card) {
const enc = p.params.get('ENCODING') ?? [];
if (enc.includes('QUOTED-PRINTABLE'))
p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
card.push(p);
}
}
return { contacts, errors };
}
function toContact(props: Prop[]): ContactData | null {
const first = (n: string) => props.find((p) => p.name === n)?.value;
const fn = first('FN') ? text(first('FN')!) : '';
const n = first('N');
const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
? components(n, ';')
: [];
const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
const abLabels = new Map(
props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
);
const numbers = props
.filter((p) => p.name === 'TEL')
.map((p) => ({
label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
}))
.filter((t) => t.number);
const note = first('NOTE') ? text(first('NOTE')!) || null : null;
const name = fn || fromN || org || numbers[0]?.number;
if (!name) return null;
return { name, company: org, email, numbers, notes: note };
}
@@ -0,0 +1,16 @@
CREATE TABLE "contact_shares" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"from_user_id" uuid,
"to_user_id" uuid NOT NULL,
"source_id" text NOT NULL,
"contact" jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"decided_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
);
--> statement-breakpoint
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "contrack_token_enc" text;
@@ -0,0 +1,16 @@
CREATE TABLE "contrack_call_log" (
"call_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"interaction_id" text,
"error" text,
"logged_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id")
);
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- People already connected (C3) start logging now, not with the last 2 days of calls.
UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -64,6 +64,27 @@
"when": 1792781321577, "when": 1792781321577,
"tag": "0008_call_reports", "tag": "0008_call_reports",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1792784242837,
"tag": "0009_contact_shares",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1792785318384,
"tag": "0010_contrack_token",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
} }
] ]
} }
+50
View File
@@ -63,6 +63,13 @@ export const users = pgTable('users', {
totpLastStep: bigint({ mode: 'number' }), totpLastStep: bigint({ mode: 'number' }),
mustChangePassword: boolean().notNull().default(false), mustChangePassword: boolean().notNull().default(false),
lastLoginAt: tstz(), lastLoginAt: tstz(),
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
contrackTokenEnc: text(),
/** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
contrackLogCalls: boolean().notNull().default(true),
/** …with each recorded call's summary (10 §3). */
contrackLogSummaries: boolean().notNull().default(false),
contrackLogSince: tstz(),
...timestamps, ...timestamps,
}); });
@@ -620,6 +627,49 @@ export const contacts = pgTable('contacts', {
...timestamps, ...timestamps,
}); });
/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
export const contrackCallLog = pgTable(
'contrack_call_log',
{
callId: uuid()
.notNull()
.references(() => calls.id, { onDelete: 'cascade' }),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
interactionId: text(),
error: text(),
loggedAt: tstz().notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.callId, t.userId] })],
);
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
export const contactShares = pgTable(
'contact_shares',
{
id: id(),
fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
toUserId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The shared contact's id in the sender's book: offering it again replaces a pending offer. */
sourceId: text().notNull(),
/** A copy taken when shared: { name, company, email, numbers, notes }. */
contact: jsonb().notNull(),
status: text().notNull().default('pending'),
decidedAt: tstz(),
...timestamps,
},
(t) => [
index()
.on(t.toUserId)
.where(sql`${t.status} = 'pending'`),
check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`),
],
);
export const smsMessages = pgTable( export const smsMessages = pgTable(
'sms_messages', 'sms_messages',
{ {
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
SP_TCADMIN_101=1 SP_TCADMIN_101=1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_103=2 SP_CLASS_103=2
SP_CLASS_104=2 SP_CLASS_104=2
SP_CLASS_105=2 SP_CLASS_105=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
SP_CLASS_102=2 SP_CLASS_102=2
SP_CLASS_103=2 SP_CLASS_103=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
SP_CLASS_102=2 SP_CLASS_102=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234 SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
SP_TCADMIN_101=1 SP_TCADMIN_101=1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_99=2 SP_CLASS_99=2
SP_CLASS_101=2 SP_CLASS_101=2
SP_CLASS_102=2 SP_CLASS_102=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
SP_CLASS_102=2 SP_CLASS_102=2
SP_CLASS_103=2 SP_CLASS_103=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -260,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
exten => s,1,Set(__SP_TARGET=550) exten => s,1,Set(__SP_TARGET=550)
same => n,Answer() same => n,Answer()
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)}) same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX})
same => n,Gosub(sp-sub-record,s,1) same => n,Gosub(sp-sub-record,s,1)
same => n,Queue(550,t,,,300) same => n,Queue(550,t,,,300)
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail) same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_101=2 SP_CLASS_101=2
SP_CLASS_102=2 SP_CLASS_102=2
SP_CLASS_103=2 SP_CLASS_103=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -260,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
[sp-rg-22222222-2222-4222-8222-222222222500] [sp-rg-22222222-2222-4222-8222-222222222500]
exten => s,1,Set(__SP_TARGET=500) exten => s,1,Set(__SP_TARGET=500)
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)}) same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX})
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm) same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done) same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,Goto(sp-vm-direct,101,1) same => n,Goto(sp-vm-direct,101,1)
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_1=2 SP_CLASS_1=2
SP_CLASS_12=2 SP_CLASS_12=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234 SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448 SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=http://127.0.0.1:8080/api/internal/caller-name
SP_CNAM_TOKEN=0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
SP_CLASS_101=2 SP_CLASS_101=2
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -284,6 +306,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
exten => 13125559876,1,Set(__SP_DID=13125559876) exten => 13125559876,1,Set(__SP_DID=13125559876)
same => n,Set(CDR(SP_DID)=13125559876) same => n,Set(CDR(SP_DID)=13125559876)
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)}) same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
same => n,Set(__SP_CNAM_PREFIX=SALES: ${SP_CNAM_PREFIX})
same => n,Goto(sp-ext-forward,13125550000,1) same => n,Goto(sp-ext-forward,13125550000,1)
; anything else ; anything else
exten => _[0-9a-z].,1,Hangup() exten => _[0-9a-z].,1,Hangup()
@@ -117,5 +117,9 @@
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"] "trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
} }
], ],
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448" "recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448",
"callerNames": {
"url": "http://127.0.0.1:8080/api/internal/caller-name",
"token": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"
}
} }
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID= SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE= SP_REC_ANNOUNCE=
SP_REC_SIDES= SP_REC_SIDES=
SP_CNAM_URL=
SP_CNAM_TOKEN=
SP_CLASS_99=0 SP_CLASS_99=0
SP_CLASS_102=1 SP_CLASS_102=1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
+7 -1
View File
@@ -95,7 +95,13 @@ export function renderDids(snap: Snapshot): string {
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`, `; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
`exten => ${num},1,Set(__SP_DID=${num})`, `exten => ${num},1,Set(__SP_DID=${num})`,
` same => n,Set(CDR(SP_DID)=${num})`, ` same => n,Set(CDR(SP_DID)=${num})`,
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), ...(prefix
? [
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
]
: []),
// Recorded number: the caller hears the notice (if set) before the recording starts. // Recorded number: the caller hears the notice (if set) before the recording starts.
...(d.recordInbound && snap.recordingAnnouncement ...(d.recordInbound && snap.recordingAnnouncement
? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`] ? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`]
+3
View File
@@ -75,6 +75,9 @@ export function renderGlobals(snap: Snapshot): string {
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`, `SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
// Spec 10 §3: also record each side on its own, for the transcript job. // Spec 10 §3: also record each side on its own, for the transcript job.
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`, `SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
// 11 §6 C4: where sp-sub-cnam asks for a caller's name in the ringing user's contacts.
`SP_CNAM_URL=${snap.callerNames ? s.localUrl(snap.callerNames.url) : ''}`,
`SP_CNAM_TOKEN=${snap.callerNames ? s.secret(snap.callerNames.token) : ''}`,
]; ];
const exts = [...snap.extensions].sort(byNumber); const exts = [...snap.extensions].sort(byNumber);
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`); for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
+7 -1
View File
@@ -18,7 +18,13 @@ export function renderQueueDialplan(snap: Snapshot): string {
`[sp-queue-${s.ident(q.id)}]`, `[sp-queue-${s.ident(q.id)}]`,
`exten => s,1,Set(__SP_TARGET=${num})`, `exten => s,1,Set(__SP_TARGET=${num})`,
' same => n,Answer()', ' same => n,Answer()',
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), ...(prefix
? [
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
]
: []),
...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []), ...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []),
` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`, ` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`,
` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`, ` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`,
@@ -22,7 +22,13 @@ export function renderRingGroups(snap: Snapshot): string {
`; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`, `; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`,
`[sp-rg-${s.ident(g.id)}]`, `[sp-rg-${s.ident(g.id)}]`,
`exten => s,1,Set(__SP_TARGET=${num})`, `exten => s,1,Set(__SP_TARGET=${num})`,
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), ...(prefix
? [
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
]
: []),
...(g.members.length === 0 ...(g.members.length === 0
? [] ? []
: g.strategy === 'ringall' : g.strategy === 'ringall'
+23 -1
View File
@@ -12,16 +12,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web))
same => n,GotoIf($["\${DS}" = ""]?unavail) same => n,GotoIf($["\${DS}" = ""]?unavail)
same => n,Dial(\${DS},\${ARG2},tx) same => n,GotoIf($["\${SP_DIRECTION}" != "inbound" | "\${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(\${ARG1}))
same => n,GotoIf($["\${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=\${SP_CNAM_PREFIX}\${GOSUB_RETVAL})
same => n,Dial(\${DS},\${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(\${DS},\${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(\${CURL(\${SP_CNAM_URL}?ext=\${FILTER(0-9,\${ARG1})}&number=\${FILTER(0-9,\${CALLERID(num)})}&token=\${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=\${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
+2
View File
@@ -26,6 +26,7 @@ export function loadCase(name: string): Snapshot {
holidays?: Snapshot['holidays']; holidays?: Snapshot['holidays'];
announcements?: Snapshot['announcements']; announcements?: Snapshot['announcements'];
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[]; mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
callerNames?: Snapshot['callerNames'];
}; };
const settings = Object.fromEntries( const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[]) (Object.keys(SettingsSchemas) as SettingKey[])
@@ -34,6 +35,7 @@ export function loadCase(name: string): Snapshot {
) as Snapshot['settings']; ) as Snapshot['settings'];
return { return {
today: raw.today ?? '2026-10-17', today: raw.today ?? '2026-10-17',
...(raw.callerNames ? { callerNames: raw.callerNames } : {}),
settings, settings,
dids: (raw.dids ?? []).map((d) => ({ dids: (raw.dids ?? []).map((d) => ({
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37', trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
+7 -1
View File
@@ -103,6 +103,8 @@ export type GeneratorDeps = {
debounceMs?: number; debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */ /** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number }; rtpPorts?: { start: number; end: number };
/** The caller-name lookup (11 §6 C4). */
callerNames?: { url: string; token: string };
}; };
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -167,7 +169,11 @@ export function createGenerator(deps: GeneratorDeps) {
restartRequired: false, restartRequired: false,
}; };
try { try {
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts }; const snap = {
...(await loadSnapshot(db)),
rtpPorts: deps.rtpPorts,
callerNames: deps.callerNames,
};
const files = render(snap); const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true }); await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map<GeneratedFileName, string | null>(); const current = new Map<GeneratedFileName, string | null>();
+2
View File
@@ -205,6 +205,8 @@ export type Snapshot = {
recordingAnnouncement: string | null; recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */ /** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
rtpPorts?: { start: number; end: number }; rtpPorts?: { start: number; end: number };
/** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */
callerNames?: { url: string; token: string };
}; };
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 }; export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
+6
View File
@@ -29,6 +29,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
/** Context-name parts (uuids), MOH class names. */ /** Context-name parts (uuids), MOH class names. */
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64); export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
/** Generated SIP passwords. */ /** Generated SIP passwords. */
/** The api on this machine, e.g. http://127.0.0.1:8080/api/internal/caller-name. */
export const localUrl = check(
/^http:\/\/(?:127\.0\.0\.1|localhost):[0-9]{1,5}\/[a-z0-9/_-]+$/,
'local url',
100,
);
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64); export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
/** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */ /** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */
export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64); export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64);
+5
View File
@@ -21,6 +21,7 @@ import { watchPinChanges } from './services/vmpin.ts';
import { startJobs } from './jobs.ts'; import { startJobs } from './jobs.ts';
import { watchWebhooks } from './services/webhooks.ts'; import { watchWebhooks } from './services/webhooks.ts';
import { watchEvents } from './services/events.ts'; import { watchEvents } from './services/events.ts';
import { callerNameToken } from './contacts/callername.ts';
let config; let config;
try { try {
@@ -47,6 +48,10 @@ const generator = createGenerator({
ami, ami,
dir: resolve(config.ASTERISK_CONFIG_DIR), dir: resolve(config.ASTERISK_CONFIG_DIR),
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END }, rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
callerNames: {
url: `http://127.0.0.1:${config.API_PORT}/api/internal/caller-name`,
token: callerNameToken(config.APP_SECRET),
},
log: app.log, log: app.log,
onApplied: (r) => { onApplied: (r) => {
live.emit('message', { live.emit('message', {
+32 -2
View File
@@ -3,7 +3,7 @@
// ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes. // ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes.
import { statfs } from 'node:fs/promises'; import { statfs } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { and, eq, isNull, lt, sql } from 'drizzle-orm'; import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import type { Config } from './config.ts'; import type { Config } from './config.ts';
import type { Db } from './db/client.ts'; import type { Db } from './db/client.ts';
@@ -19,11 +19,14 @@ import {
import type { Generator } from './generator/index.ts'; import type { Generator } from './generator/index.ts';
import { todayIn } from './generator/load.ts'; import { todayIn } from './generator/load.ts';
import type { LiveState } from './live/state.ts'; import type { LiveState } from './live/state.ts';
import { cleanupContactShares } from './routes/contacts.ts';
import { cleanupRecordings } from './routes/recordings.ts'; import { cleanupRecordings } from './routes/recordings.ts';
import { transcribeCalls } from './ai/calls.ts'; import { transcribeCalls } from './ai/calls.ts';
import { audit } from './services/audit.ts'; import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts'; import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts'; import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
import { logEvent } from './services/events.ts'; import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts'; import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts'; import type { SendMail } from './services/mail.ts';
@@ -108,6 +111,7 @@ export async function retentionCleanup(d: JobDeps) {
await d.db await d.db
.delete(systemEvents) .delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`)); .where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
await cleanupContactShares(d.db);
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted'); if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
} }
@@ -238,11 +242,35 @@ export function createJobs(d: JobDeps) {
); );
}; };
/** Contrack books warmed every 5 minutes, so a ringing phone's lookup finds them (11 §6 C4). */
const contactsWarm = async () => {
const c = await contrackSettings(d.db);
if (!c) return;
const owners = await d.db
.select({ id: users.id })
.from(users)
.where(and(eq(users.isActive, true), isNotNull(users.contrackTokenEnc)));
for (const owner of [null, ...owners.map((o) => o.id)]) await contrackBook(d.db, c, owner);
};
const callReports = async () => { const callReports = async () => {
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() }); await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
}; };
return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports }; const contrackLog = async () => {
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
};
return {
daily,
backup,
publicIpWatch,
trunkWatch,
diskWatch,
callReports,
contactsWarm,
contrackLog,
};
} }
/** Starts the jobs; returns a stop function. */ /** Starts the jobs; returns a stop function. */
@@ -265,6 +293,8 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch); if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch); if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callReports', j.callReports); run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
run('callTranscripts', async () => { run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log }); await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
}); });
+13 -9
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// What a vendor template gets (07 §1). Templates are pure functions of this; no I/O there. // What a vendor template gets (07 §1). Templates are pure functions of this; no I/O there.
import { asc, eq, inArray, isNull } from 'drizzle-orm'; import { asc, eq, inArray } from 'drizzle-orm';
import { phoneTimezone, type PhoneTimezone, type PhoneVendor } from '@selfphoneng/shared'; import { phoneTimezone, type PhoneTimezone, type PhoneVendor } from '@selfphoneng/shared';
import type { Config } from '../config.ts'; import type { Config } from '../config.ts';
import { decrypt } from '../crypto/secrets.ts'; import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { contacts, devices, extensions } from '../db/schema.ts'; import { devices, extensions } from '../db/schema.ts';
import { readBooks } from '../contacts/book.ts';
import { getSetting } from '../services/settings.ts'; import { getSetting } from '../services/settings.ts';
import { loadDialRules, type DialRule } from './dialplan.ts'; import { loadDialRules, type DialRule } from './dialplan.ts';
@@ -26,6 +27,8 @@ export type ProvisionContext = {
provPassword: string; provPassword: string;
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */ /** The phonebook with this phone's credentials in it (phones fetch it like the config). */
phonebookUrl: string; phonebookUrl: string;
/** Whose phone: their own contacts go in its directory (11 §6 C6). */
ownerUserId: string | null;
ntpServer: string; ntpServer: string;
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */ /** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
dialRules: DialRule[]; dialRules: DialRule[];
@@ -79,6 +82,7 @@ export async function loadContext(
provUser: d.mac, provUser: d.mac,
provPassword, provPassword,
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`, phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
ownerUserId: e.userId,
ntpServer: NTP_SERVER, ntpServer: NTP_SERVER,
dialRules: await loadDialRules(db), dialRules: await loadDialRules(db),
}, },
@@ -87,17 +91,17 @@ export async function loadContext(
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] }; export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
/** The company phonebook (07 §2): every extension, then the company contacts, by name. */ /** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then
export async function loadPhonebook(db: Db): Promise<PhonebookEntry[]> { * the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */
export async function loadPhonebook(
db: Db,
ownerUserId: string | null = null,
): Promise<PhonebookEntry[]> {
const exts = await db const exts = await db
.select({ name: extensions.name, number: extensions.number }) .select({ name: extensions.name, number: extensions.number })
.from(extensions) .from(extensions)
.orderBy(asc(extensions.name)); .orderBy(asc(extensions.name));
const company = await db const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's
.select({ name: contacts.name, numbers: contacts.numbers })
.from(contacts)
.where(isNull(contacts.ownerUserId))
.orderBy(asc(contacts.name));
return [ return [
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })), ...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
...company.map((c) => ({ ...company.map((c) => ({
@@ -31,6 +31,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
provPassword: 'Pr0vPassw0rdXyZ12345', provPassword: 'Pr0vPassw0rdXyZ12345',
phonebookUrl: phonebookUrl:
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml', 'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
ownerUserId: null,
ntpServer: 'pool.ntp.org', ntpServer: 'pool.ntp.org',
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]), dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
...over, ...over,
+352
View File
@@ -0,0 +1,352 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/me/contacts (my book, shares) and /api/contacts (the company book, admin): spec 11.
import { and, desc, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
ContactImportSchema,
ContactInputSchema,
ContactShareDecisionSchema,
ContactShareSchema,
type ContactBookDto,
type ContactCopyResult,
type ContactData,
type ContactDto,
type ContactImportResult,
type ContactShareDto,
type ListResponse,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
assertBuiltin,
cleanNumbers,
contactNames,
copyToContrack,
createContact,
readBooks,
toDto,
type Owner,
} from '../contacts/book.ts';
import { parseCsv } from '../contacts/csv.ts';
import { parseVcards } from '../contacts/vcard.ts';
import { contactShares, contacts, extensions, users } from '../db/schema.ts';
import { badRequest, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decrypt } from '../crypto/secrets.ts';
import { contrackCheck } from '../contacts/contrack.ts';
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
const IdParams = z.object({ id: z.uuid() });
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
/** 5,000 contacts or 5 MB of files per import (11 §3.1). */
const MAX_IMPORT = 5000;
const IMPORT_BODY = 6 * 1024 * 1024;
/** Every contact in the files, and what couldn't be read. */
function readFiles(files: { name: string; text: string }[]) {
const list: ContactData[] = [];
const errors: string[] = [];
for (const f of files) {
const vcf = /\.vcf$/i.test(f.name) || /^\s*BEGIN:VCARD/i.test(f.text);
const r = vcf ? parseVcards(f.text) : parseCsv(f.text);
list.push(...r.contacts);
errors.push(...r.errors.map((e) => `${f.name}: ${e}`));
}
if (list.length > MAX_IMPORT) throw badRequest(`At most ${MAX_IMPORT} contacts per import`);
return { list, errors };
}
export async function contactRoutes(app: FastifyInstance) {
const { db } = app;
// ───── the routes both books have; `owner` says whose ─────
const book = (
prefix: string,
guard: typeof requireAuth,
ownerOf: (u: { id: string }) => Owner,
) => {
const mine = (u: { id: string }, id: string) =>
db
.select()
.from(contacts)
.where(
and(
eq(contacts.id, id),
ownerOf(u) ? eq(contacts.ownerUserId, ownerOf(u)!) : isNull(contacts.ownerUserId),
),
)
.then(([r]) => {
if (!r) throw notFound('Contact not found');
return r;
});
const company = (u: { id: string }) => ownerOf(u) === null;
const log = (
req: { user: { id: string } | null; ip: string },
action: 'create' | 'update' | 'delete',
id: string | undefined,
summary: string,
) =>
company(req.user!)
? audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action,
entityType: 'contact',
entityId: id,
summary,
})
: undefined;
app.post(prefix, { preHandler: guard }, async (req, reply) => {
const c = ContactInputSchema.parse(req.body);
const dto = await createContact(db, ownerOf(req.user!), c);
await log(req, 'create', dto.id, `Added ${c.name} to the company contacts`);
return reply.code(201).send(dto);
});
app.patch(`${prefix}/:id`, { preHandler: guard }, async (req): Promise<ContactDto> => {
const { id } = IdParams.parse(req.params);
await assertBuiltin(db);
await mine(req.user!, id);
const c = ContactInputSchema.parse(req.body);
const [row] = await db
.update(contacts)
.set({ ...c, numbers: cleanNumbers(c), updatedAt: new Date() })
.where(eq(contacts.id, id))
.returning();
await log(req, 'update', id, `Changed the company contact ${c.name}`);
return toDto(row!);
});
app.delete(`${prefix}/:id`, { preHandler: guard }, async (req) => {
const { id } = IdParams.parse(req.params);
await assertBuiltin(db);
const r = await mine(req.user!, id);
await db.delete(contacts).where(eq(contacts.id, id));
await log(req, 'delete', id, `Deleted the company contact ${r.name}`);
return { ok: true };
});
app.post(
`${prefix}/import`,
{ preHandler: guard, bodyLimit: IMPORT_BODY },
async (req): Promise<ContactImportResult> => {
await assertBuiltin(db); // Contrack has its own importer (11 §3.1)
const { files } = ContactImportSchema.parse(req.body);
const commit = CommitQuery.parse(req.query).commit === '1';
const { list, errors } = readFiles(files);
const r = await addToBook(db, ownerOf(req.user!), list, commit, errors);
if (commit && r.added)
await log(req, 'create', undefined, `Imported ${r.added} company contacts`);
return r;
},
);
};
// ───── my book ─────
/** Mine and the company's: the page filters, and the browser looks up names from it. */
app.get('/api/me/contacts', { preHandler: requireAuth }, async (req): Promise<ContactBookDto> =>
readBooks(db, req.user!.id),
);
book('/api/me/contacts', requireAuth, (u) => u.id);
// ───── the company book ─────
app.get('/api/contacts', { preHandler: requireAdmin }, async (): Promise<ContactBookDto> =>
readBooks(db, null),
);
book('/api/contacts', requireAdmin, () => null);
/**
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
*/
app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
const q = z
.object({
ext: z.string().regex(/^\d{1,10}$/),
number: z.string().regex(/^\d{3,20}$/),
token: z.string().max(100),
})
.safeParse(req.query);
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
if (
!local ||
req.headers['x-forwarded-for'] ||
!q.success ||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
)
return reply.code(404).send('');
const [e] = await db
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
() => new Map<string, string>(),
);
return reply
.type('text/plain; charset=utf-8')
.send(phoneSafeName(names.get(q.data.number) ?? ''));
});
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
app.post(
'/api/me/contacts/copy-to-contrack',
{ preHandler: requireAuth },
async (req): Promise<ContactCopyResult> => copyToContrack(db, req.user!.id),
);
app.post(
'/api/contacts/copy-to-contrack',
{ preHandler: requireAdmin },
async (req): Promise<ContactCopyResult> => {
const r = await copyToContrack(db, null);
if (r.added)
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'contact',
summary: `Copied ${r.added} company contacts to Contrack`,
});
return r;
},
);
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
const b = z
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
const s = ContactShareSchema.parse(req.body);
const me = req.user!.id;
const picked = (await readBooks(db, me)).items.filter(
(c) => c.book === 'mine' && s.contactIds.includes(c.id),
);
if (picked.length !== new Set(s.contactIds).size) throw notFound('Contact not found');
const to = s.everyone
? await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.isActive, true), ne(users.id, me)))
: await db
.select({ id: users.id })
.from(extensions)
.innerJoin(users, eq(users.id, extensions.userId))
.where(
and(
inArray(extensions.number, s.extensions),
eq(users.isActive, true),
ne(users.id, me),
),
);
if (!to.length) throw badRequest('Nobody to share with');
await db.transaction(async (tx) => {
// The same contact offered again replaces the pending offer.
await tx.delete(contactShares).where(
and(
eq(contactShares.status, 'pending'),
inArray(
contactShares.toUserId,
to.map((u) => u.id),
),
inArray(
contactShares.sourceId,
picked.map((c) => c.id),
),
),
);
await tx.insert(contactShares).values(
to.flatMap((u) =>
picked.map(({ id, name, company, email, numbers, notes }) => ({
fromUserId: me,
toUserId: u.id,
sourceId: id,
contact: { name, company, email, numbers, notes },
})),
),
);
});
return { offers: picked.length * to.length, people: to.length };
});
app.get(
'/api/me/contacts/shares',
{ preHandler: requireAuth },
async (req): Promise<ListResponse<ContactShareDto>> => {
const rows = await db
.select({ s: contactShares, fromName: users.displayName })
.from(contactShares)
.leftJoin(users, eq(users.id, contactShares.fromUserId))
.where(and(eq(contactShares.toUserId, req.user!.id), eq(contactShares.status, 'pending')))
.orderBy(desc(contactShares.createdAt));
return {
items: rows.map(({ s, fromName }) => ({
id: s.id,
from: s.fromUserId && fromName ? { userId: s.fromUserId, name: fromName } : null,
contact: s.contact as ContactData,
createdAt: s.createdAt.toISOString(),
})),
nextCursor: null,
};
},
);
for (const decision of ['accept', 'deny'] as const)
app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => {
const { ids } = ContactShareDecisionSchema.parse(req.body);
const me = req.user!.id;
const mineAndPending = and(
eq(contactShares.toUserId, me),
eq(contactShares.status, 'pending'),
inArray(contactShares.id, ids),
);
const offers = await db.select().from(contactShares).where(mineAndPending);
// Added first, then marked: Contrack being down (or not connected) leaves the offers waiting.
const r =
decision === 'accept'
? await addToBook(
db,
me,
offers.map((o) => o.contact as ContactData),
true,
)
: null;
await db
.update(contactShares)
.set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
.where(
and(
mineAndPending,
inArray(
contactShares.id,
offers.map((o) => o.id),
),
),
);
return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length };
});
}
/** Decided offers go after 30 days (11 §2.1); retentionCleanup calls it. */
export const cleanupContactShares = (db: FastifyInstance['db']) =>
db
.delete(contactShares)
.where(
and(
ne(contactShares.status, 'pending'),
lt(contactShares.decidedAt, sql`now() - interval '30 days'`),
),
);
+85 -1
View File
@@ -3,10 +3,11 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises'; import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { and, desc, eq, isNull } from 'drizzle-orm'; import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { import {
ContrackOptionsSchema,
GREETINGS, GREETINGS,
MeUpdateSchema, MeUpdateSchema,
type ListResponse, type ListResponse,
@@ -27,8 +28,16 @@ import {
queueMembers, queueMembers,
queues, queues,
userRuntimeState, userRuntimeState,
users,
voicemailMessages, voicemailMessages,
} from '../db/schema.ts'; } from '../db/schema.ts';
import {
contrackBook,
contrackCheck,
contrackSettings,
forgetBook,
tokenFor,
} from '../contacts/contrack.ts';
import { setPaused } from '../services/queues.ts'; import { setPaused } from '../services/queues.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts'; import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts'; import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
@@ -115,9 +124,84 @@ export async function meRoutes(app: FastifyInstance) {
callReportTime: e.callReportTime, callReportTime: e.callReportTime,
callReportWeekday: e.callReportWeekday, callReportWeekday: e.callReportWeekday,
callReportSummaries: e.callReportSummaries, callReportSummaries: e.callReportSummaries,
contrack: await myContrack(userId),
}; };
} }
/** Contrack mode: is my token set, and does it work (11 §4.3). Null in builtin mode. */
async function myContrack(userId: string): Promise<MeDto['contrack']> {
const c = await contrackSettings(db);
if (!c) return null;
const token = await tokenFor(db, c, userId);
const book = token ? await contrackBook(db, c, userId) : null;
const [u] = await db
.select({ logCalls: users.contrackLogCalls, logSummaries: users.contrackLogSummaries })
.from(users)
.where(eq(users.id, userId));
return {
connected: !!book && !book.error,
tokenPrefix: token ? `${token.slice(0, 12)}…` : null,
publicUrl: c.publicUrl,
logCalls: u?.logCalls ?? true,
logSummaries: u?.logSummaries ?? false,
summariesAvailable:
(await getSetting(db, 'ai.callTranscripts')) && (await getSetting(db, 'ai.callSummaries')),
};
}
/** Connect my Contrack account: the token must read my contacts before it's kept. */
app.put('/api/me/contrack', async (req) => {
const { token } = z.object({ token: z.string().trim().min(8).max(200) }).parse(req.body);
const c = await contrackSettings(db);
if (!c) throw conflict('Contacts are not in Contrack (Settings → Contacts)');
await contrackCheck(c.url, token).catch((err: Error) => {
throw badRequest(err.message);
});
// Calls are logged from now on, not the ones before (11 §4.4).
await db
.update(users)
.set({
contrackTokenEnc: encrypt(token),
contrackLogSince: sql`coalesce(${users.contrackLogSince}, now())`,
})
.where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'user',
entityId: req.user!.id,
summary: 'Connected their Contrack account',
});
return myState(req.user!.id);
});
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
app.patch('/api/me/contrack', async (req) => {
const o = ContrackOptionsSchema.parse(req.body);
const [u] = await db
.select({ logCalls: users.contrackLogCalls })
.from(users)
.where(eq(users.id, req.user!.id));
await db
.update(users)
.set({
...(o.logCalls !== undefined ? { contrackLogCalls: o.logCalls } : {}),
...(o.logSummaries !== undefined ? { contrackLogSummaries: o.logSummaries } : {}),
// Turned back on: from now, not the calls while it was off.
...(o.logCalls && !u?.logCalls ? { contrackLogSince: new Date() } : {}),
})
.where(eq(users.id, req.user!.id));
return myState(req.user!.id);
});
app.delete('/api/me/contrack', async (req) => {
await db.update(users).set({ contrackTokenEnc: null }).where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
return myState(req.user!.id);
});
app.get('/api/me', async (req) => myState(req.user!.id)); app.get('/api/me', async (req) => myState(req.user!.id));
// Queue login from Home (05 §4, 04 §7.3), the same as *45<queue> on a phone. // Queue login from Home (05 §4, 04 §7.3), the same as *45<queue> on a phone.
+1 -1
View File
@@ -66,7 +66,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
/** The config, or (phonebook) the directory in the phone's vendor's format. */ /** The config, or (phonebook) the directory in the phone's vendor's format. */
async function render(c: ProvisionContext, phonebook = false): Promise<string> { async function render(c: ProvisionContext, phonebook = false): Promise<string> {
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c); if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
const entries = await loadPhonebook(db); const entries = await loadPhonebook(db, c.ownerUserId);
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries); return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
} }
+28 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { import {
AiEndpointSchema, AiEndpointSchema,
AiTtsEndpointSchema, AiTtsEndpointSchema,
ContrackInputSchema,
SettingsSchemas, SettingsSchemas,
SmtpInputSchema, SmtpInputSchema,
type AiTestResult, type AiTestResult,
@@ -28,6 +29,7 @@ import { backupMissed } from '../services/backup.ts';
import { detectPublicIp } from '../jobs.ts'; import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts'; import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts'; import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -45,9 +47,14 @@ const READ_ONLY = new Set<string>([
]); ]);
async function toDto(db: Db): Promise<SettingsDto> { async function toDto(db: Db): Promise<SettingsDto> {
const { smtp, ...rest } = await getAllSettings(db); const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
return { return {
...rest, ...rest,
'contacts.contrack': ctk && {
url: ctk.url,
publicUrl: ctk.publicUrl,
hasCompanyToken: ctk.companyTokenEnc !== '',
},
smtp: smtp && { smtp: smtp && {
host: smtp.host, host: smtp.host,
port: smtp.port, port: smtp.port,
@@ -115,6 +122,26 @@ export async function settingsRoutes(app: FastifyInstance) {
from: input.from, from: input.from,
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''), passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
}; };
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
const input = ContrackInputSchema.nullable().parse(raw);
const old = await getSetting(db, 'contacts.contrack');
if (input)
try {
assertLocalUrl(input.url);
} catch (err) {
throw badRequest(`Contrack address: ${(err as Error).message}`);
}
values[key] = input && {
url: input.url.replace(/\/+$/, ''),
publicUrl: input.publicUrl,
companyTokenEnc: input.companyToken
? encrypt(input.companyToken)
: input.clearCompanyToken
? ''
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw); } else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
} }
await checkRefs(db, values); await checkRefs(db, values);
+12 -2
View File
@@ -4,6 +4,7 @@
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm'; import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core'; import { alias } from 'drizzle-orm/pg-core';
import { formatNumber, type CallReport } from '@selfphoneng/shared'; import { formatNumber, type CallReport } from '@selfphoneng/shared';
import { contactNames } from '../contacts/book.ts';
import type { Db } from '../db/client.ts'; import type { Db } from '../db/client.ts';
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts'; import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
import type { SendMail } from './mail.ts'; import type { SendMail } from './mail.ts';
@@ -56,6 +57,8 @@ export type ReportCall = {
status: string; status: string;
fromNumber: string | null; fromNumber: string | null;
fromName: string | null; fromName: string | null;
/** The other side's name in my contacts or the company's (11 §1). */
contactName: string | null;
toNumber: string | null; toNumber: string | null;
talkSeconds: number; talkSeconds: number;
answeredBy: string | null; answeredBy: string | null;
@@ -80,9 +83,10 @@ export function reportText(
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`, `${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
]; ];
for (const c of list) { for (const c of list) {
const name = c.contactName ?? (c.out ? null : c.fromName);
const who = c.out const who = c.out
? `Outgoing to ${num(c.toNumber)}` ? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
: `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`; : `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
const facts = [ const facts = [
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status, c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
duration(c.talkSeconds), duration(c.talkSeconds),
@@ -158,12 +162,18 @@ export async function sendCallReports(d: {
.set({ callReportSentAt: period.to }) .set({ callReportSentAt: period.to })
.where(eq(extensions.id, e.id)); .where(eq(extensions.id, e.id));
if (!rows.length) continue; if (!rows.length) continue;
const names = await contactNames(
d.db,
e.userId,
rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
);
const list = rows.map((r): ReportCall => ({ const list = rows.map((r): ReportCall => ({
startedAt: r.c.startedAt, startedAt: r.c.startedAt,
out: r.out, out: r.out,
status: r.c.status, status: r.c.status,
fromNumber: r.c.fromNumber, fromNumber: r.c.fromNumber,
fromName: r.c.fromName, fromName: r.c.fromName,
contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
toNumber: r.c.toNumber, toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds, talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null, answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
+415
View File
@@ -0,0 +1,415 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 11 §6 C4 against real Asterisk: a call from outside rings 101's phone with the caller's name
// from 101's contacts. A second Asterisk plays the provider, a baresip phone is 101, and a tiny
// stand-in for the api's /api/internal/caller-name runs in the PBX's network namespace (so it is
// 127.0.0.1 to Asterisk, as in production). What the phone sees is read from the INVITE the PBX
// sends it (pjsip logger).
// Checks: a contact's name; no contact keeps the provider's name; a ring group's "Sales:" prefix
// stays in front; the api not answering still rings with the provider's name; 101's contact name
// does not follow a forward to 102.
// Usage: node --conditions=source apps/api/test/asterisk/cnam-test.ts
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { providerPjsip } from './provider.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const NET = 'sp-cnam';
const PBX = 'sp-cnam-pbx';
const PROVIDER = 'sp-cnam-provider';
const PHONES = 'sp-cnam-phones';
const API = 'sp-cnam-api';
const AMI_PORT = 15041;
const AMI_SECRET = 'cnam-test-secret';
const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
const TOKEN = 'ab'.repeat(32);
const EXT = {
'101': '00000000-0000-4000-8000-000000000101',
'102': '00000000-0000-4000-8000-000000000102',
};
const PASS = { '101': 'CnamTest101xxxxxxxxxxxxx', '102': 'CnamTest102xxxxxxxxxxxxx' };
const DID_USER = '13125551234'; // rings 101
const DID_GROUP = '13125551235'; // "Sales:" ring group 600 → 101
const KNOWN = '3125559999'; // Jane Doe in 101's contacts (10 digits, as voip.ms sends)
const UNKNOWN = '3125550001';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const docker = (...args: string[]) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const log = (...a: unknown[]) => console.log('[cnam-test]', ...a);
function snapshot(): Snapshot {
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
const ext = (number: '101' | '102', name: string) => ({
id: EXT[number],
number,
name,
sipPassword: PASS[number],
webrtcEnabled: false,
maxContacts: 5,
ringSeconds: 6, // the forward check waits for 101's no-answer
voicemailEnabled: false,
voicemailPin: '4826',
outboundClass: 'national' as const,
outboundCallerId: null,
emergencyCallerId: null,
recordMode: 'never' as const,
pickupGroup: null,
language: 'en',
mohClass: 'default',
isAdmin: false,
});
return {
today: '2026-10-23',
settings,
callerNames: { url: 'http://127.0.0.1:8080/api/internal/caller-name', token: TOKEN },
dids: [
{
id: '11111111-1111-4111-8111-111111111111',
number: DID_USER,
trunkId: TRUNK,
label: null,
destination: { type: 'user', id: EXT['101'] },
callerIdNamePrefix: null,
recordInbound: false,
},
{
id: '11111111-1111-4111-8111-111111111112',
number: DID_GROUP,
trunkId: TRUNK,
label: null,
destination: { type: 'ring_group', id: '22222222-2222-4222-8222-222222222222' },
callerIdNamePrefix: null,
recordInbound: false,
},
],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
trunks: [
{
id: TRUNK,
name: 'voip.ms',
enabled: true,
serverHost: PROVIDER,
serverPort: 5060,
transport: 'udp',
authMode: 'registration',
username: SUB.user,
password: SUB.pass,
fromUser: null,
fromDomain: null,
codecs: ['ulaw'],
srtp: false,
maxChannels: null,
},
],
ringGroups: [
{
id: '22222222-2222-4222-8222-222222222222',
number: '600',
name: 'Sales',
strategy: 'ringall',
ringSeconds: 20,
callerIdNamePrefix: 'Sales:',
musicInsteadOfRing: false,
members: ['101'],
noAnswerDestination: { type: 'hangup' },
},
],
queues: [],
conferences: [],
pagingGroups: [],
mohClasses: [],
ivrs: [],
timeConditions: [],
holidays: [],
announcements: [],
recordingAnnouncement: null,
routes: [],
};
}
/** The provider: "Mobile" <caller> calls one of our DIDs; ${CALLER} picks who calls. */
const providerDialplan = `[provider-out]
exten => _X.,1,Set(CALLERID(all)="Mobile" <\${CALLER}>)
same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)
[provider-wait]
exten => s,1,Answer()
same => n,Wait(30)
[provider-in]
exten => _X.,1,Answer()
same => n,Wait(30)
`;
/** baresip phones as 101 and 102: they ring, nobody answers (the INVITE is what we read). */
function phoneFiles(dir: string) {
for (const n of ['101', '102'] as const) {
const d = join(dir, `phone-${n}`);
mkdirSync(d, { recursive: true });
writeFileSync(
join(d, 'config'),
[
'poll_method epoll',
`sip_listen 0.0.0.0:${n === '101' ? 5070 : 5072}`,
'module_path /usr/lib/baresip/modules',
'module g711.so',
'module ausine.so',
'module aufile.so',
'module menu.so',
'module account.so',
'audio_source ausine,440',
'audio_player aufile,/dev/null',
'audio_alert aufile,/dev/null',
'',
].join('\n'),
);
writeFileSync(
join(d, 'accounts'),
`<sip:${n}@${PBX};transport=udp>;auth_pass=${PASS[n]};answermode=manual;audio_codecs=PCMU;regint=60\n`,
);
}
}
/** The api's caller-name route, faked: Jane Doe for KNOWN in 101's book, with the right key. */
const FAKE_API = `
const http = require('http');
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const ok = u.pathname === '/api/internal/caller-name' && u.searchParams.get('token') === '${TOKEN}';
const name = ok && u.searchParams.get('ext') === '101' && u.searchParams.get('number') === '1${KNOWN}' ? 'Jane Doe' : '';
console.log('lookup ' + u.searchParams.get('ext') + ' ' + u.searchParams.get('number') + ' ' + (ok ? 'key-ok' : 'key-bad') + ' -> ' + JSON.stringify(name));
if (process.env.SLOW) return setTimeout(() => res.end(name), 5000);
res.end(name);
}).listen(8080, '127.0.0.1');
`;
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
for (let i = 0; i < seconds; i++) {
if (await fn()) return;
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
async function main() {
const dir = mkdtempSync(join(tmpdir(), 'sp-cnam-'));
const pbxConf = join(dir, 'pbx');
const provConf = join(dir, 'provider');
mkdirSync(pbxConf);
mkdirSync(provConf);
const files = render(snapshot());
for (const f of GENERATED_FILES) {
writeFileSync(join(pbxConf, f), files[f]);
writeFileSync(
join(provConf, f),
f === 'pjsip_selfphoneng.conf'
? providerPjsip(SUB)
: f === 'extensions_selfphoneng.conf'
? providerDialplan
: '',
);
}
phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
const problems: string[] = [];
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
username: 'selfphoneng',
secret: AMI_SECRET,
logger: { info() {}, warn() {}, error() {}, debug() {} },
backoffMs: [500],
});
const asterisk = (name: string, conf: string, extra: string[]) =>
docker(
'run',
'-d',
'--rm',
'--name',
name,
'--network',
NET,
...extra,
'-e',
`AMI_SECRET=${AMI_SECRET}`,
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
);
const booted = (name: string) =>
waitFor(
`${name} to boot`,
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const startApi = (slow: boolean) => {
docker('rm', '-f', API);
const r = docker(
'run',
'-d',
'--rm',
'--name',
API,
'--network',
`container:${PBX}`,
...(slow ? ['-e', 'SLOW=1'] : []),
'node:22-alpine',
'node',
'-e',
FAKE_API,
);
if (!r.ok) throw new Error(`fake api: ${r.out}`);
};
/** The provider calls `did` as `caller`; returns the From display name of the INVITE to `ext`. */
async function ring(did: string, caller: string, ext: '101' | '102'): Promise<string | null> {
// channel originate can't set a variable: the provider's dialplan reads a global.
docker('exec', PROVIDER, 'asterisk', '-rx', `dialplan set global CALLER ${caller}`);
const since = new Date().toISOString();
await ami.command('pjsip set logger on');
const o = docker(
'exec',
PROVIDER,
'asterisk',
'-rx',
`channel originate Local/${did.slice(1)}@provider-out extension s@provider-wait`,
);
if (!o.ok) throw new Error(`originate: ${o.out}`);
let from: string | null = null;
await waitFor(
`the INVITE to ${ext}`,
async () => {
const logs = docker('logs', '--since', since, PBX).out;
const invites = logs
.split(/<--- Transmitting SIP request/)
.slice(1)
.filter((b) => new RegExp(`^INVITE sip:${ext}[-@]`, 'm').test(b));
const m = invites.map((b) => /^From: (?:"([^"]*)" )?</m.exec(b)).find(Boolean);
if (m) from = m[1] ?? '';
return m !== undefined;
},
20,
);
await ami.command('pjsip set logger off');
await ami.command('channel request hangup all');
docker('exec', PROVIDER, 'asterisk', '-rx', 'channel request hangup all');
await sleep(2000);
return from;
}
try {
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
docker('network', 'create', NET);
if (!asterisk(PROVIDER, provConf, []).ok) throw new Error('provider did not start');
await booted(PROVIDER);
const pbx = asterisk(PBX, pbxConf, [
'-p',
`127.0.0.1:${AMI_PORT}:5038`,
'-v',
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
]);
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
await booted(PBX);
startApi(false);
ami.start();
await new Promise((r) => ami.once('connected', r));
await waitFor('the trunk to register', async () =>
(await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' })).some(
(m) => m.Status === 'Registered',
),
);
log('starting baresip phones 101 and 102 (installs baresip on first run)…');
const phones = docker(
'run',
'-d',
'--rm',
'--name',
PHONES,
'--network',
NET,
'-v',
`${dir}:/phones`,
'debian:trixie-slim',
'sh',
'-c',
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' +
'(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && (baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 900',
);
if (!phones.ok) throw new Error(`phones: ${phones.out}`);
await waitFor(
'101 and 102 to register',
async () => {
const c = await ami.command('pjsip show contacts');
return /101\/sip:.*Avail/.test(c) && /102\/sip:.*Avail/.test(c);
},
240,
);
const expect = (what: string, got: string | null, want: string) => {
log(`${what}: the phone shows ${JSON.stringify(got)}`);
if (got !== want)
problems.push(
`${what}: phone showed ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
);
};
expect('a contact calls 101', await ring(DID_USER, KNOWN, '101'), 'Jane Doe');
expect('a stranger calls 101', await ring(DID_USER, UNKNOWN, '101'), 'Mobile');
expect(
'a contact calls the Sales group',
await ring(DID_GROUP, KNOWN, '101'),
'Sales: Jane Doe',
);
// 101 doesn't answer and forwards to 102 (AstDB SP/CFNA/101, as My settings writes it): 101's
// phone showed Jane Doe, and 102 must not see 101's private contact name.
await ami.command('database put SP CFNA/101 102');
expect('101 unanswered, forwarded to 102', await ring(DID_USER, KNOWN, '102'), 'Mobile');
await ami.command('database del SP CFNA/101');
const lookups = docker('logs', API).out;
if (!/lookup 101 13125559999 key-ok -> "Jane Doe"/.test(lookups))
problems.push(`no keyed lookup for 101:\n${lookups}`);
if (/key-bad/.test(lookups)) problems.push(`a lookup without the key:\n${lookups}`);
// The api too slow (5 s, the dialplan waits 2): the phone still rings, with the provider's name.
startApi(true);
await sleep(1000);
const t0 = Date.now();
expect('the api does not answer in time', await ring(DID_USER, KNOWN, '101'), 'Mobile');
log(`rang after ${Math.round((Date.now() - t0) / 100) / 10} s with the api hanging`);
} finally {
ami.stop();
if (process.env.KEEP) log(`KEEP: containers left running, config in ${dir}`);
else {
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
docker('network', 'rm', NET);
rmSync(dir, { recursive: true, force: true });
}
}
if (problems.length) {
console.error(`[cnam-test] FAILED:\n- ${problems.join('\n- ')}`);
process.exit(1);
}
log('PASS');
}
main().catch((e: unknown) => {
console.error('[cnam-test]', e);
process.exit(1);
});
+228
View File
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C1: my contacts, the company's, imports and shares (spec 11).
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { contactNames } from '../src/contacts/book.ts';
import { callerNameToken, phoneSafeName } from '../src/contacts/callername.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
describe.skipIf(!hasTestDb)('contacts', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const vcf = (name: string, tel: string) =>
`BEGIN:VCARD\r\nVERSION:3.0\r\nFN:${name}\r\nTEL;TYPE=CELL:${tel}\r\nEND:VCARD\r\n`;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
});
afterAll(() => t?.close());
it('my book and the company book: numbers normalized, users only edit their own', async () => {
const mine = await dan('POST', '/api/me/contacts', {
name: 'Jane Doe',
company: 'Acme',
email: '',
numbers: [{ label: 'mobile', number: '(312) 555-1234' }],
});
expect(mine.statusCode, mine.body).toBe(201);
expect(mine.json()).toMatchObject({
book: 'mine',
email: null,
numbers: [{ label: 'mobile', number: '13125551234' }],
});
expect(
(
await dan('POST', '/api/me/contacts', { name: 'X', numbers: [{ number: 'call me' }] })
).json().error.message,
).toMatch(/isn't a phone number/);
const co = await admin('POST', '/api/contacts', {
name: 'Pizza Place',
numbers: [{ label: 'main', number: '312-555-0000' }],
});
expect(co.statusCode, co.body).toBe(201);
expect((await dan('GET', '/api/contacts')).statusCode).toBe(403);
// Dan sees the company contact but can't change it.
const list = (await dan('GET', '/api/me/contacts')).json().items;
expect(list.map((c: { name: string; book: string }) => [c.name, c.book])).toEqual([
['Jane Doe', 'mine'],
['Pizza Place', 'company'],
]);
expect((await dan('DELETE', `/api/me/contacts/${co.json().id}`)).statusCode).toBe(404);
expect(
(await sue('PATCH', `/api/me/contacts/${mine.json().id}`, { name: 'Mine now' })).statusCode,
).toBe(404);
expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(1); // company only
});
it('import: preview first, then save; what is already there is skipped', async () => {
const files = [
{
name: 'phone.vcf',
text:
vcf('Jane Doe', '+1 312 555 1234') + // already in Dan's book
vcf('Bob Smith', '312.555.9999') +
'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nobody Home\r\nEMAIL:n@example.com\r\nEND:VCARD\r\n',
},
{ name: 'more.csv', text: 'name,number1\nBob Smith,3125559999\nCarl,12ab\n' },
];
const preview = await dan('POST', '/api/me/contacts/import', { files });
expect(preview.json()).toEqual({
committed: false,
added: 3,
duplicates: 2,
noNumber: 2,
errors: ['Carl: "12ab" isn\'t a phone number, left out'],
});
expect((await dan('GET', '/api/me/contacts')).json().items).toHaveLength(2);
const saved = await dan('POST', '/api/me/contacts/import?commit=1', { files });
expect(saved.json()).toMatchObject({ committed: true, added: 3, duplicates: 2 });
expect((await dan('POST', '/api/me/contacts/import?commit=1', { files })).json()).toMatchObject(
{
added: 0,
duplicates: 5,
},
);
// An admin imports into the company book.
const co = await admin('POST', '/api/contacts/import?commit=1', {
files: [{ name: 'co.vcf', text: vcf('Plumber', '3125557777') }],
});
expect(co.json()).toMatchObject({ added: 1 });
expect(
(await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toEqual(['Pizza Place', 'Plumber']);
});
it('sharing offers a copy; nothing lands until Accept, Deny adds nothing', async () => {
const book = (await dan('GET', '/api/me/contacts')).json().items as {
id: string;
name: string;
book: string;
}[];
const jane = book.find((c) => c.name === 'Jane Doe')!;
const bob = book.find((c) => c.name === 'Bob Smith')!;
expect(
(
await dan('POST', '/api/me/contacts/share', {
contactIds: [book.find((c) => c.book === 'company')!.id],
everyone: true,
})
).statusCode,
).toBe(404); // not Dan's to share
const r = await dan('POST', '/api/me/contacts/share', {
contactIds: [jane.id, bob.id],
extensions: ['102'],
});
expect(r.json()).toEqual({ offers: 2, people: 1 });
// Shared again: still one offer per contact.
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id, bob.id], everyone: true });
const before = (await sue('GET', '/api/me/contacts')).json().items.length;
const offers = (await sue('GET', '/api/me/contacts/shares')).json().items as {
id: string;
from: { name: string };
contact: { name: string };
}[];
expect(offers.map((o) => [o.from.name, o.contact.name]).sort()).toEqual([
['dan@example.com', 'Bob Smith'],
['dan@example.com', 'Jane Doe'],
]);
expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(before);
const janeOffer = offers.find((o) => o.contact.name === 'Jane Doe')!;
const bobOffer = offers.find((o) => o.contact.name === 'Bob Smith')!;
expect(
(await sue('POST', '/api/me/contacts/shares/deny', { ids: [bobOffer.id] })).json(),
).toEqual({ denied: 1 });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [janeOffer.id] })).json(),
).toEqual({
added: 1,
duplicates: 0,
});
// Decided offers can't be decided again, or by someone else.
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [bobOffer.id] })).json(),
).toEqual({
added: 0,
duplicates: 0,
});
const names = (await sue('GET', '/api/me/contacts'))
.json()
.items.map((c: { name: string }) => c.name);
expect(names).toContain('Jane Doe');
expect(names).not.toContain('Bob Smith');
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
// The copy is Sue's own: Dan editing his doesn't change it.
await dan('PATCH', `/api/me/contacts/${jane.id}`, { name: 'Jane D.', numbers: [] });
expect(
(await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Jane Doe');
});
it('looks up names: my book first, then the company', async () => {
await admin('POST', '/api/contacts', {
name: 'Front desk',
numbers: [{ number: '3125551234' }],
});
await dan('POST', '/api/me/contacts', { name: 'My Jane', numbers: [{ number: '3125551234' }] });
const names = await contactNames(t.db, danId, [
'13125551234',
'13125550000',
'19995550000',
null,
]);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'My Jane',
'13125550000': 'Pizza Place',
});
const company = await contactNames(t.db, null, ['13125551234']);
expect(company.get('13125551234')).toBe('Front desk');
});
it('the dialplan lookup (C4): only with the key, never through the proxy, phone-safe names', async () => {
const token = callerNameToken(t.app.config.APP_SECRET);
const look = (q: string, headers: Record<string, string> = {}) =>
t.app.inject({ method: 'GET', url: `/api/internal/caller-name?${q}`, headers });
const mine = await look(`ext=101&number=13125551234&token=${token}`);
expect([mine.statusCode, mine.body]).toEqual([200, 'My Jane']);
expect((await look(`ext=102&number=13125551234&token=${token}`)).body).toBe('Jane Doe'); // Sue's own copy, from the accepted share
expect((await look(`ext=999&number=13125551234&token=${token}`)).body).toBe('Front desk'); // no user
expect((await look(`ext=101&number=19995550000&token=${token}`)).body).toBe('');
expect((await look('ext=101&number=13125551234&token=wrong')).statusCode).toBe(404);
expect(
(
await look(`ext=101&number=13125551234&token=${token}`, {
'x-forwarded-for': '203.0.113.9',
})
).statusCode,
).toBe(404);
expect(phoneSafeName(String.raw`Jane "J" <Doe>, Inc.; (x)^|\ Ünïcode-Smith`)).toBe(
'Jane J Doe Inc. x Ünïcode-Smith',
);
expect(phoneSafeName('A'.repeat(60))).toHaveLength(40);
});
});
+361
View File
@@ -0,0 +1,361 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C3 against a real Contrack (decision 0003): the routes we use, as the pinned version answers
// them. Skipped unless CONTRACK_LIVE_URL is set; it needs a fresh Contrack with sign-in on and no
// account yet (it makes the accounts and tokens itself):
//
// docker run -d --rm --name contrack-live -p 127.0.0.1:3210:3210 -e AUTH_REQUIRED=true \
// ghcr.io/arvarik/contrack:2.0.2
// CONTRACK_LIVE_URL=http://127.0.0.1:3210 pnpm vitest run apps/api/test/contrack-live.test.ts
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { contactNames } from '../src/contacts/book.ts';
import { logCallsToContrack } from '../src/contacts/timeline.ts';
import {
callExtensions,
calls,
contacts,
extensions,
recordings,
users,
} from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const LIVE = process.env.CONTRACK_LIVE_URL;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
/** Contrack's own API, signed in with a session cookie (accounts, tokens) or a token. */
async function ctk(
path: string,
init: { method?: string; body?: unknown; cookie?: string; token?: string } = {},
) {
const r = await fetch(`${LIVE}${path}`, {
method: init.method ?? 'GET',
headers: {
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.cookie ? { Cookie: init.cookie } : {}),
...(init.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init.body ? JSON.stringify(init.body) : undefined,
});
const cookie = r.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
return {
status: r.status,
body: (await r.json().catch(() => null)) as Record<string, unknown>,
cookie,
};
}
const PASSWORD = 'Correct-horse-battery-9';
/** A Contrack account's read-write personal token that never expires. */
async function tokenOf(cookie: string, name: string) {
const t = await ctk('/api/auth/tokens', { method: 'POST', cookie, body: { name } });
expect(t.status, JSON.stringify(t.body)).toBe(201);
return t.body.token as string;
}
/** An admin makes a member, who signs in, sets their own password, and makes a token. */
async function member(adminCookie: string, username: string) {
const u = await ctk('/api/admin/users', {
method: 'POST',
cookie: adminCookie,
body: {
email: `${username}@example.com`,
username,
displayName: username,
temporaryPassword: 'Temporary-pass-123',
},
});
expect(u.status, JSON.stringify(u.body)).toBe(201);
const login = await ctk('/api/auth/login', {
method: 'POST',
body: { identifier: username, password: 'Temporary-pass-123' },
});
expect(login.status, JSON.stringify(login.body)).toBe(200);
const ch = await ctk('/api/auth/change-password', {
method: 'POST',
cookie: login.cookie,
body: { currentPassword: 'Temporary-pass-123', newPassword: PASSWORD },
});
expect(ch.status, JSON.stringify(ch.body)).toBeLessThan(300);
const again = await ctk('/api/auth/login', {
method: 'POST',
body: { identifier: username, password: PASSWORD },
});
return tokenOf(again.cookie, 'SelfPhoneNG');
}
describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const tok = { company: '', dan: '', sue: '' };
beforeAll(async () => {
// Contrack: the first account is the company's (and Contrack's admin); Dan and Sue are members.
const setup = await ctk('/api/auth/setup', {
method: 'POST',
body: {
email: 'company@example.com',
username: 'company',
password: PASSWORD,
displayName: 'Company',
},
});
expect(setup.status, `needs a fresh Contrack: ${JSON.stringify(setup.body)}`).toBe(201);
tok.company = await tokenOf(setup.cookie, 'SelfPhoneNG company');
tok.dan = await member(setup.cookie, 'dan');
tok.sue = await member(setup.cookie, 'sue');
for (const [token, body] of [
[tok.company, { name: 'Plumber', phones: ['+1 312 555 7777'] }],
[
tok.dan,
{
name: 'Jane Doe',
company: 'Acme',
emails: [{ email: 'jane@acme.example' }],
phones: [{ phone: '(312) 555-1234', label: 'mobile' }, 'front desk ext 123'],
},
],
] as const) {
const c = await ctk('/api/contacts', { method: 'POST', token, body });
expect(c.status, JSON.stringify(c.body)).toBe(201);
}
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
}, 60_000);
afterAll(() => t?.close());
it('Test, then save: Contrack answers and reads the company account', async () => {
expect(
(
await admin('POST', '/api/contacts/contrack/test', { url: LIVE, companyToken: tok.company })
).json(),
).toEqual({
ok: true,
detail: 'Contrack answers; the token reads 1 contact.',
});
expect(
(
await admin('POST', '/api/contacts/contrack/test', {
url: LIVE,
companyToken: 'ctk_wrong_token',
})
).json(),
).toEqual({
ok: false,
error: 'Contrack refused the token',
});
const r = await admin('PATCH', '/api/settings', {
'contacts.provider': 'contrack',
'contacts.contrack': {
url: LIVE,
publicUrl: 'https://contacts.example.com',
companyToken: tok.company,
},
});
expect(r.statusCode, r.body).toBe(200);
});
it('a person connects; their book and the company book come from Contrack', async () => {
expect((await dan('PUT', '/api/me/contrack', { token: 'ctk_wrong_token' })).statusCode).toBe(
400,
);
expect(
(await dan('PUT', '/api/me/contrack', { token: tok.dan })).json().contrack.connected,
).toBe(true);
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book.contrack).toEqual({
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
toCopy: 0,
});
expect(book.items).toEqual([
expect.objectContaining({
book: 'mine',
name: 'Jane Doe',
company: 'Acme',
email: 'jane@acme.example',
numbers: [{ label: '', number: '13125551234' }],
url: expect.stringMatching(
/^https:\/\/contacts\.example\.com\/contact\/jane-doe-[0-9a-f]{8}$/,
),
}),
expect.objectContaining({
book: 'company',
name: 'Plumber',
numbers: [{ label: '', number: '13125557777' }],
}),
]);
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'Jane Doe',
'13125557777': 'Plumber',
});
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
'Dan Doe',
'Sue Smith',
'Plumber',
]);
});
it('Add to contacts lands in Contrack, with its label', async () => {
const r = await dan('POST', '/api/me/contacts', {
name: 'Bob Smith',
company: 'Widgets',
email: 'bob@widgets.example',
numbers: [{ label: 'work', number: '312-555-9999' }],
});
expect(r.statusCode, r.body).toBe(201);
const id = r.json().id as string;
const full = await ctk(`/api/contacts/${id}`, { token: tok.dan });
expect(full.body).toMatchObject({
name: 'Bob Smith',
company: 'Widgets',
emails: [expect.objectContaining({ email: 'bob@widgets.example' })],
phones: [expect.objectContaining({ phone: '13125559999', label: 'work' })],
});
expect(
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Bob Smith');
});
it('a call with Jane lands on her Contrack timeline, with the summary and a follow-up (C5)', async () => {
const at = (minAgo: number) => new Date(Date.now() - minAgo * 60_000);
await t.db
.update(users)
.set({ contrackLogSince: at(60) })
.where(eq(users.id, danId));
await setSetting(t.db, 'ai.callTranscripts', true);
await setSetting(t.db, 'ai.callSummaries', true);
expect((await dan('PATCH', '/api/me/contrack', { logSummaries: true })).statusCode).toBe(200);
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
const [rec] = await t.db
.insert(recordings)
.values({
linkedid: 'live-r1',
filePath: 'live-r1.wav',
startedAt: at(20),
summary:
'Jane asked for a <quote> & delivery dates.\nAction items:\n- Dan sends the quote by Friday',
aiDoneAt: at(15),
})
.returning();
const [c] = await t.db
.insert(calls)
.values({
linkedid: 'live-c1',
direction: 'inbound',
status: 'answered',
startedAt: at(20),
endedAt: at(17),
fromNumber: '13125551234',
toNumber: '13125550000',
talkSeconds: 185,
answeredByExtensionId: ext!.id,
recordingId: rec!.id,
})
.returning();
await t.db
.insert(callExtensions)
.values({ callId: c!.id, extensionId: ext!.id, role: 'answered' });
expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(1);
expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(0);
const jane = (
(await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
id: string;
name: string;
}[]
).find((x) => x.name === 'Jane Doe')!;
const timeline = (await ctk(`/api/contacts/${jane.id}/timeline`, { token: tok.dan }))
.body as unknown as Record<string, unknown>[];
expect(timeline).toHaveLength(1);
expect(timeline[0]).toMatchObject({
type: 'call',
title: 'Incoming call',
date: c!.startedAt.toISOString(),
duration: expect.anything(), // Contrack answers it as "4.0"
actionItems: [
expect.objectContaining({ title: 'Dan sends the quote by Friday', completedAt: null }),
],
});
expect(Number(timeline[0]!.duration)).toBe(4);
const content = String(timeline[0]!.content);
expect(content).toContain('From (312) 555-1234 to (312) 555-0000 · 3:05 · recorded');
expect(content).toContain('Jane asked for a &lt;quote&gt; &amp; delivery dates.');
expect(content).toContain('<li>Dan sends the quote by Friday</li>');
expect(content).not.toContain('<quote>');
});
it('contacts from before Contrack copy into the account in one import (C6)', async () => {
await t.db.insert(contacts).values([
{
name: 'Old Friend',
ownerUserId: danId,
numbers: [{ label: 'home', number: '13125550202' }],
},
{ name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] },
]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
const r = await dan('POST', '/api/me/contacts/copy-to-contrack');
expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 });
const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
phones: { phone: string }[];
}[];
expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
});
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
.items.find((c: { name: string }) => c.name === 'Jane Doe');
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id], extensions: ['102'] });
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).statusCode,
).toBe(409);
await sue('PUT', '/api/me/contrack', { token: tok.sue });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
).toEqual({ added: 1, duplicates: 0 });
const sues = (await ctk('/api/contacts?view=slim', { token: tok.sue })).body as unknown as {
name: string;
}[];
expect(sues.map((c) => c.name)).toEqual(['Jane Doe']);
// Dan's account is untouched: Sue got a copy.
const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
}[];
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe', 'Old Friend']);
});
});
+524
View File
@@ -0,0 +1,524 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C3: contacts in Contrack (11 §4), against a fake Contrack that answers the routes we use.
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { contactNames } from '../src/contacts/book.ts';
import { forgetBook } from '../src/contacts/contrack.ts';
import { logCallsToContrack } from '../src/contacts/timeline.ts';
import {
callExtensions,
calls,
contacts,
contrackCallLog,
extensions,
recordings,
users,
} from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
type Slim = {
id: string;
name: string;
company: string | null;
emails: { email: string }[];
phones: { phone: string }[];
};
/** Contrack's slim rows, one private book per token (11 §4.1). */
function fakeContrack() {
const books: Record<string, Slim[]> = {
ctk_company: [
{
id: 'plumber-1a2b3c4d',
name: 'Plumber',
company: null,
emails: [],
phones: [{ phone: '+1 312 555 7777' }],
},
],
ctk_dan_0001: [
{
id: 'jane-doe-00000001',
name: 'Jane Doe',
company: 'Acme',
emails: [{ email: 'jane@acme.example' }],
phones: [{ phone: '(312) 555-1234' }, { phone: 'ask reception' }],
},
],
ctk_sue_0001: [],
};
let n = 0;
/** Timeline entries by contact id; `down` makes every API call fail with 503. */
const timeline: Record<string, Record<string, unknown>[]> = {};
const state = { down: false, readOnly: new Set<string>() };
const server: Server = createServer((req, res) => {
const send = (status: number, body: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.url === '/healthz') return send(200, { ok: true });
if (state.down) return send(503, { error: { code: 'DB_BUSY', message: 'Busy' } });
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const book = books[token];
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
if (req.method === 'POST' && req.url === '/api/contacts/bulk') {
if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id'])))
return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } });
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[];
for (const r of rows)
book.push({
id: `bulk-${++n}`,
name: r.name,
company: null,
emails: [],
phones: r.phones,
});
send(201, {
success: true,
count: rows.length,
failed: 0,
importId: req.headers['x-import-id'],
});
});
return;
}
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
if (req.method === 'POST' && into) {
if (state.readOnly.has(token))
return send(403, { error: { code: 'TOKEN_READ_ONLY', message: 'Read-only token' } });
if (!book.some((c) => c.id === into[1]))
return send(404, { error: { code: 'NOT_FOUND', message: 'Contact not found' } });
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const id = `int-${++n}`;
(timeline[into[1]!] ??= []).push({ id, ...(JSON.parse(raw) as object) });
send(201, { id });
});
return;
}
if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book);
if (req.method === 'POST' && req.url === '/api/contacts') {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const b = JSON.parse(raw) as {
name: string;
company?: string;
emails?: { email: string }[];
phones: { phone: string }[];
};
const row = {
id: `new-${++n}`,
name: b.name,
company: b.company ?? null,
emails: b.emails ?? [],
phones: b.phones,
};
book.push(row);
send(201, row);
});
return;
}
send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } });
});
return { server, books, timeline, state };
}
describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const fake = fakeContrack();
let url: string;
beforeAll(async () => {
await new Promise<void>((ok) => fake.server.listen(0, '127.0.0.1', ok));
url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
});
afterAll(async () => {
await t?.close();
fake.server.close();
});
it('Settings → Contacts: a local address only, the company token write-only', async () => {
const far = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: 'https://contrack.example.com', companyToken: 'ctk_company' },
});
expect(far.statusCode).toBe(400);
expect(far.json().error.message).toMatch(/IP address/);
expect(
(
await admin('POST', '/api/contacts/contrack/test', { url, companyToken: 'ctk_company' })
).json(),
).toEqual({
ok: true,
detail: 'Contrack answers; the token reads 1 contact.',
});
expect(
(
await admin('POST', '/api/contacts/contrack/test', {
url: 'http://127.0.0.1:9',
companyToken: 'x',
})
).json().ok,
).toBe(false);
const r = await admin('PATCH', '/api/settings', {
'contacts.provider': 'contrack',
'contacts.contrack': {
url: `${url}/`,
publicUrl: 'https://contacts.example.com/',
companyToken: 'ctk_company',
},
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json()['contacts.contrack']).toEqual({
url,
publicUrl: 'https://contacts.example.com',
hasCompanyToken: true,
});
expect(r.body).not.toContain('ctk_company');
// Saving again without a token keeps it.
await admin('PATCH', '/api/settings', {
'contacts.contrack': { url, publicUrl: 'https://contacts.example.com' },
});
expect((await admin('GET', '/api/settings')).json()['contacts.contrack'].hasCompanyToken).toBe(
true,
);
});
it('each person connects their own token; until then they see the company book', async () => {
expect((await dan('GET', '/api/me')).json().contrack).toEqual({
connected: false,
tokenPrefix: null,
publicUrl: 'https://contacts.example.com',
logCalls: true,
logSummaries: false,
summariesAvailable: false,
});
const before = (await dan('GET', '/api/me/contacts')).json();
expect(before.provider).toBe('contrack');
expect(before.contrack.connected).toBe(false);
expect(before.items.map((c: { name: string }) => c.name)).toEqual(['Plumber']);
const bad = await dan('PUT', '/api/me/contrack', { token: 'ctk_not_mine' });
expect(bad.statusCode).toBe(400);
expect(bad.json().error.message).toMatch(/refused the token/);
const ok = await dan('PUT', '/api/me/contrack', { token: 'ctk_dan_0001' });
expect(ok.json().contrack).toMatchObject({ connected: true, tokenPrefix: 'ctk_dan_0001…' });
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book.contrack).toEqual({
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
toCopy: 0,
});
expect(book.items).toEqual([
{
id: 'jane-doe-00000001',
book: 'mine',
name: 'Jane Doe',
company: 'Acme',
email: 'jane@acme.example',
numbers: [{ label: '', number: '13125551234' }], // "ask reception" isn't a number
notes: null,
url: 'https://contacts.example.com/contact/jane-doe-00000001',
},
expect.objectContaining({ name: 'Plumber', book: 'company' }),
]);
});
it('adding works, editing and importing happen in Contrack', async () => {
const r = await dan('POST', '/api/me/contacts', {
name: 'Bob',
numbers: [{ label: 'mobile', number: '312-555-9999' }],
});
expect(r.statusCode, r.body).toBe(201);
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
name: 'Bob',
phones: [{ phone: '13125559999', label: 'mobile' }],
});
expect(
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Bob');
expect(
(
await dan('PATCH', `/api/me/contacts/${'00000000-0000-4000-8000-000000000000'}`, {
name: 'x',
})
).statusCode,
).toBe(409);
expect(
(
await dan('POST', '/api/me/contacts/import', {
files: [{ name: 'a.vcf', text: 'BEGIN:VCARD\nFN:A\nEND:VCARD' }],
})
).statusCode,
).toBe(409);
});
it('names come from my Contrack book, then the company account; desk phones get the company', async () => {
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'Jane Doe',
'13125557777': 'Plumber',
});
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
'Dan Doe',
'Sue Smith',
'Plumber',
]);
});
it('a share waits until the recipient has connected Contrack, then lands in their account', async () => {
const r = await dan('POST', '/api/me/contacts/share', {
contactIds: ['jane-doe-00000001'],
extensions: ['102'],
});
expect(r.json()).toEqual({ offers: 1, people: 1 });
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
expect(offer.contact).toMatchObject({
name: 'Jane Doe',
numbers: [{ label: '', number: '13125551234' }],
});
const early = await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] });
expect(early.statusCode).toBe(409);
expect(early.json().error.message).toMatch(/Connect your Contrack account/);
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toHaveLength(1);
await sue('PUT', '/api/me/contrack', { token: 'ctk_sue_0001' });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
).toEqual({ added: 1, duplicates: 0 });
expect(fake.books.ctk_sue_0001!.map((c) => c.name)).toEqual(['Jane Doe']);
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
});
it("calls go on the contact's timeline once, the summary when asked for (C5)", async () => {
const now = Date.now();
const at = (minAgo: number) => new Date(now - minAgo * 60_000);
// Dan connected a moment ago: his calls from an hour back count for this test.
await t.db
.update(users)
.set({ contrackLogSince: at(60) })
.where(eq(users.id, danId));
await setSetting(t.db, 'ai.callTranscripts', true);
await setSetting(t.db, 'ai.callSummaries', true);
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
const [done, pending] = await t.db
.insert(recordings)
.values([
{
linkedid: 'r1',
filePath: 'r1.wav',
startedAt: at(30),
summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
aiDoneAt: at(25),
},
{ linkedid: 'r2', filePath: 'r2.wav', startedAt: at(10) },
])
.returning();
const call = (linkedid: string, minAgo: number, v: Partial<typeof calls.$inferInsert>) => ({
linkedid,
direction: 'inbound' as const,
status: 'answered' as const,
startedAt: at(minAgo + 3),
endedAt: at(minAgo),
fromNumber: '13125551234', // Jane Doe
toNumber: '13125550000',
talkSeconds: 125,
...v,
});
const rows = await t.db
.insert(calls)
.values([
call('k1', 30, { recordingId: done!.id }), // answered, recorded, summarized
call('k2', 20, { status: 'missed', talkSeconds: 0 }),
call('k3', 15, { direction: 'outbound', fromNumber: '101', toNumber: '13125551234' }),
call('k4', 12, { fromNumber: '13125550101' }), // not a contact
call('k5', 10, { recordingId: pending!.id }), // not summarized yet
call('k6', 90, {}), // before Dan connected
call('k7', 0.2, {}), // still being written (under a minute ago)
])
.returning();
const role = (i: number) => (rows[i]!.direction === 'outbound' ? 'caller' : 'answered');
await t.db
.insert(callExtensions)
.values(rows.map((r, i) => ({ callId: r.id, extensionId: ext!.id, role: role(i) })));
const run = (min = 0) =>
logCallsToContrack({ db: t.db, log: t.app.log, now: new Date(now + min * 60_000) });
// Summaries off (the default): no summary, and nothing waits for one.
expect(await run()).toBe(4); // k1, k2, k3, k5 (k4 a stranger, k6 too early, k7 too fresh)
const jane = fake.timeline['jane-doe-00000001']!;
expect(jane.map((e) => e.title)).toEqual([
'Incoming call',
'Missed call',
'Outgoing call',
'Incoming call',
]);
expect(jane[0]).toMatchObject({
type: 'call',
source: 'selfphoneng',
date: rows[0]!.startedAt.toISOString(),
duration: 3,
content: '<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p>',
});
expect(jane[0]).not.toHaveProperty('actionItem');
expect(await run()).toBe(0); // once
// With summaries: the next recorded call waits for its summary, then carries it.
await dan('PATCH', '/api/me/contrack', { logSummaries: true });
const [k8] = await t.db
.insert(calls)
.values(call('k8', 5, { recordingId: done!.id }))
.returning();
const [k9] = await t.db
.insert(calls)
.values(call('k9', 4, { recordingId: pending!.id }))
.returning();
await t.db
.insert(callExtensions)
.values([k8!, k9!].map((r) => ({ callId: r.id, extensionId: ext!.id, role: 'answered' })));
expect(await run()).toBe(1); // k8; k9 waits
expect(jane.at(-1)).toMatchObject({
content:
'<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p><p>Acme wants a quote.</p><p>Action items:</p><ul><li>Dan sends the quote</li></ul>',
actionItem: {
title: 'Dan sends the quote',
dueAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
},
});
// 40 minutes on: k9 without a summary (it waited 30), and k7 is old enough now.
expect(await run(40)).toBe(2);
expect(jane.find((e) => e.date === k9!.startedAt.toISOString())!.content).toBe(
'<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p>',
);
expect(jane).toHaveLength(7);
// Contrack down: nothing is lost, the call goes in on a later run.
const [k10] = await t.db
.insert(calls)
.values(call('k10', 3, {}))
.returning();
await t.db
.insert(callExtensions)
.values({ callId: k10!.id, extensionId: ext!.id, role: 'answered' });
forgetBook(danId);
fake.state.down = true;
expect(await run()).toBe(0);
fake.state.down = false;
forgetBook(danId);
expect(await run()).toBe(1);
// A read-only token: noted once, not retried every minute.
fake.state.readOnly.add('ctk_dan_0001');
const [k11] = await t.db
.insert(calls)
.values(call('k11', 2, {}))
.returning();
await t.db
.insert(callExtensions)
.values({ callId: k11!.id, extensionId: ext!.id, role: 'answered' });
expect(await run()).toBe(0);
const [noted] = await t.db
.select()
.from(contrackCallLog)
.where(eq(contrackCallLog.callId, k11!.id));
expect(noted).toMatchObject({ interactionId: null, error: 'Contrack: Read-only token' });
fake.state.readOnly.clear();
expect(await run()).toBe(0);
expect(jane).toHaveLength(8);
});
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
await t.db.insert(contacts).values([
{
name: 'Jane Doe',
ownerUserId: danId,
numbers: [{ label: 'mobile', number: '13125551234' }],
}, // in Contrack
{
name: 'Old Friend',
ownerUserId: danId,
numbers: [{ label: 'home', number: '13125550202' }],
},
{ name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account
{ name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] },
]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
added: 1,
duplicates: 1,
failed: 0,
});
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
name: 'Old Friend',
phones: [{ phone: '13125550202', label: 'home' }],
});
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
added: 0,
duplicates: 2,
failed: 0,
});
// The company book, by an admin only.
expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403);
expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1);
expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({
added: 1,
duplicates: 1,
failed: 0,
});
expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']);
// Dan's desk phone: his Contrack contacts, then the company's.
const names = (await loadPhonebook(t.db, danId)).map((e) => e.name);
expect(names.indexOf('Old Friend')).toBeGreaterThan(-1);
expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith'));
expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend');
});
it('back to builtin: the table again, tokens kept for later', async () => {
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
const book = (await dan('GET', '/api/me/contacts')).json();
expect([book.provider, book.contrack]).toEqual(['builtin', null]);
expect(book.items.map((c: { name: string }) => c.name)).toEqual([
'Jane Doe',
'Old Friend',
'Locksmith',
'Plumber',
]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
});
+26
View File
@@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
await setSetting(t.db, 'general.timezone', 'America/Chicago'); await setSetting(t.db, 'general.timezone', 'America/Chicago');
}); });
it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
const owner = await t.addUser('dan@example.com', 'dan-password-123');
const other = await t.addUser('sue@example.com', 'sue-password-123');
await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
await t.db.insert(contacts).values([
{ name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
{
name: 'Sue Secret',
ownerUserId: other.id,
numbers: [{ label: '', number: '13125550102' }],
},
]);
const book = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(book).toContain('<Telephone label="mobile">13125550101</Telephone>');
expect(book).not.toContain('Sue Secret');
expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
await req('PATCH', `/api/extensions/${extId}`, { userId: null });
const unowned = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(unowned).not.toContain('Mom');
});
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => { it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`); const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
expect(r.statusCode, r.body).toBe(200); expect(r.statusCode, r.body).toBe(200);
+1
View File
@@ -66,6 +66,7 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
callReportTime: '08:00', callReportTime: '08:00',
callReportWeekday: 1, callReportWeekday: 1,
callReportSummaries: false, callReportSummaries: false,
contrack: null,
}); });
}); });
+6 -2
View File
@@ -23,6 +23,10 @@ const EXPECTED: Record<string, Record<string, string>> = {
totp_last_step: 'bigint|YES|', totp_last_step: 'bigint|YES|',
must_change_password: 'boolean|NO|false', must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|', last_login_at: 'timestamp with time zone|YES|',
contrack_token_enc: 'text|YES|',
contrack_log_calls: 'boolean|NO|true',
contrack_log_summaries: 'boolean|NO|false',
contrack_log_since: 'timestamp with time zone|YES|',
...ts, ...ts,
}, },
extensions: { extensions: {
@@ -111,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb())); beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.()); afterAll(() => drop?.());
it('creates all 41 tables', async () => { it('creates all 43 tables', async () => {
const r = await db.execute( const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`, sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
); );
expect(r.rows[0]).toEqual({ n: 41 }); expect(r.rows[0]).toEqual({ n: 43 });
}); });
it.each(Object.keys(EXPECTED))('%s columns', async (table) => { it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+41
View File
@@ -22,6 +22,14 @@ import type {
CallDetailDto, CallDetailDto,
CallDto, CallDto,
CallFilter, CallFilter,
ContactBookDto,
ContactCopyResult,
ContrackOptions,
ContactDto,
ContactImportResult,
ContactInput,
ContactShare,
ContactShareDto,
MyCallDto, MyCallDto,
RecordingDto, RecordingDto,
AuthUser, AuthUser,
@@ -265,6 +273,9 @@ export const meApi = {
get: () => api<MeDto>('GET', '/api/me'), get: () => api<MeDto>('GET', '/api/me'),
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'), softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m), update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
connectContrack: (token: string) => api<MeDto>('PUT', '/api/me/contrack', { token }),
disconnectContrack: () => api<MeDto>('DELETE', '/api/me/contrack'),
contrackOptions: (o: ContrackOptions) => api<MeDto>('PATCH', '/api/me/contrack', o),
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'), webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */ /** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
setQueuePaused: (queueId: string, paused: boolean) => setQueuePaused: (queueId: string, paused: boolean) =>
@@ -284,6 +295,36 @@ export const meApi = {
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`), deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
}; };
/** My book is /api/me/contacts (it lists the company's too); the company's is /api/contacts. */
export type ContactBase = '/api/me/contacts' | '/api/contacts';
export const contactsApi = {
list: (base: ContactBase) => api<ContactBookDto>('GET', base),
copyToContrack: (base: ContactBase) => api<ContactCopyResult>('POST', `${base}/copy-to-contrack`),
testContrack: (url: string, companyToken?: string) =>
api<{ ok: true; detail: string } | { ok: false; error: string }>(
'POST',
'/api/contacts/contrack/test',
{ url, companyToken },
),
create: (base: ContactBase, c: ContactInput) => api<ContactDto>('POST', base, c),
update: (base: ContactBase, id: string, c: ContactInput) =>
api<ContactDto>('PATCH', `${base}/${id}`, c),
remove: (base: ContactBase, id: string) => api<{ ok: true }>('DELETE', `${base}/${id}`),
import: (base: ContactBase, files: { name: string; text: string }[], commit: boolean) =>
api<ContactImportResult>('POST', `${base}/import?commit=${commit ? 1 : 0}`, { files }),
share: (s: ContactShare) =>
api<{ offers: number; people: number }>('POST', '/api/me/contacts/share', s),
shares: () =>
api<ListResponse<ContactShareDto>>('GET', '/api/me/contacts/shares').then((r) => r.items),
decide: (decision: 'accept' | 'deny', ids: string[]) =>
api<{ added?: number; duplicates?: number; denied?: number }>(
'POST',
`/api/me/contacts/shares/${decision}`,
{ ids },
),
};
export const soundsApi = { export const soundsApi = {
list: () => api<ListResponse<SoundDto>>('GET', '/api/sounds').then((r) => r.items), list: () => api<ListResponse<SoundDto>>('GET', '/api/sounds').then((r) => r.items),
audioUrl: (id: string) => `/api/sounds/${id}/audio`, audioUrl: (id: string) => `/api/sounds/${id}/audio`,
+3
View File
@@ -27,6 +27,7 @@ import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx'; import { BlockedPage } from './pages/admin/BlockedPage.tsx';
import { CallsPage } from './pages/admin/CallsPage.tsx'; import { CallsPage } from './pages/admin/CallsPage.tsx';
import { RecordingsPage } from './pages/admin/RecordingsPage.tsx'; import { RecordingsPage } from './pages/admin/RecordingsPage.tsx';
import { CompanyContactsPage } from './pages/user/ContactsPage.tsx';
import { ReportsPage } from './pages/admin/ReportsPage.tsx'; import { ReportsPage } from './pages/admin/ReportsPage.tsx';
import { SystemPage } from './pages/admin/SystemPage.tsx'; import { SystemPage } from './pages/admin/SystemPage.tsx';
import { MePage } from './pages/user/MePage.tsx'; import { MePage } from './pages/user/MePage.tsx';
@@ -75,6 +76,8 @@ const router = createBrowserRouter([
<OfficeHoursPage /> <OfficeHoursPage />
) : p.path === 'blocked' ? ( ) : p.path === 'blocked' ? (
<BlockedPage /> <BlockedPage />
) : p.path === 'contacts' ? (
<CompanyContactsPage />
) : p.path === 'calls' ? ( ) : p.path === 'calls' ? (
<CallsPage /> <CallsPage />
) : p.path === 'recordings' ? ( ) : p.path === 'recordings' ? (
+16 -8
View File
@@ -19,7 +19,7 @@ import { DestinationPicker } from '../components/DestinationPicker.tsx';
import { ThemeToggle } from '../components/ThemeToggle.tsx'; import { ThemeToggle } from '../components/ThemeToggle.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx'; import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
import { subscribeLive, useLive } from '../live/store.ts'; import { subscribeLive, useLive } from '../live/store.ts';
import { EmailTab, EmergencyTab } from './admin/SettingsPage.tsx'; import { ContactsTab, EmailTab, EmergencyTab } from './admin/SettingsPage.tsx';
import { ImportNumbers, TrunkForm, trunkStatus } from './admin/TrunksPage.tsx'; import { ImportNumbers, TrunkForm, trunkStatus } from './admin/TrunksPage.tsx';
const STEPS = [ const STEPS = [
@@ -30,6 +30,7 @@ const STEPS = [
'Trunk', 'Trunk',
'Phone numbers', 'Phone numbers',
'Users', 'Users',
'Contacts',
'Main number', 'Main number',
'Emergency', 'Emergency',
'Done', 'Done',
@@ -87,9 +88,10 @@ export function SetupPage() {
{step === 5 && <TrunkStep next={next} />} {step === 5 && <TrunkStep next={next} />}
{step === 6 && <NumbersStep next={next} />} {step === 6 && <NumbersStep next={next} />}
{step === 7 && <UsersStep next={next} />} {step === 7 && <UsersStep next={next} />}
{step === 8 && <RoutingStep next={next} />} {step === 8 && <SettingsStep tab="contacts" next={next} skippable />}
{step === 9 && <SettingsStep tab="emergency" next={next} />} {step === 9 && <RoutingStep next={next} />}
{step === 10 && <DoneStep onFinish={() => finish.mutate()} busy={finish.isPending} />} {step === 10 && <SettingsStep tab="emergency" next={next} />}
{step === 11 && <DoneStep onFinish={() => finish.mutate()} busy={finish.isPending} />}
</section> </section>
</div> </div>
); );
@@ -355,7 +357,7 @@ function SettingsStep({
next, next,
skippable, skippable,
}: { }: {
tab: 'email' | 'emergency'; tab: 'email' | 'emergency' | 'contacts';
next: () => void; next: () => void;
skippable?: boolean; skippable?: boolean;
}) { }) {
@@ -369,7 +371,13 @@ function SettingsStep({
!s.data['emergency.defaultCallerIdDidId']; !s.data['emergency.defaultCallerIdDidId'];
return ( return (
<> <>
{tab === 'email' ? <EmailTab s={s.data} /> : <EmergencyTab s={s.data} />} {tab === 'email' ? (
<EmailTab s={s.data} />
) : tab === 'contacts' ? (
<ContactsTab s={s.data} />
) : (
<EmergencyTab s={s.data} />
)}
<Nav <Nav
next={next} next={next}
canNext={!needCid} canNext={!needCid}
@@ -450,7 +458,7 @@ function NumbersStep({ next }: { next: () => void }) {
return ( return (
<> <>
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 8. Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
</p> </p>
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />} {trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
{dids.data.length > 0 && ( {dids.data.length > 0 && (
@@ -728,7 +736,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
Set up each desk phone or softphone app with the SIP details under Users &amp; Extensions Set up each desk phone or softphone app with the SIP details under Users &amp; Extensions
→ Credentials. → Credentials.
</li> </li>
<li>Call your main number from a mobile phone: the phone you chose in step 8 rings.</li> <li>Call your main number from a mobile phone: the phone you chose in step 9 rings.</li>
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li> <li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
<li>Everything here can be changed later in the admin pages.</li> <li>Everything here can be changed later in the admin pages.</li>
</ul> </ul>
+1 -1
View File
@@ -23,7 +23,7 @@ export const ADMIN_PAGES = [
{ path: 'paging', label: 'Paging', milestone: null }, { path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null }, { path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: null }, { path: 'devices', label: 'Phones', milestone: null },
{ path: 'contacts', label: 'Contacts', milestone: 'M10' }, { path: 'contacts', label: 'Contacts', milestone: null },
{ path: 'calls', label: 'Call history', milestone: null }, { path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null }, { path: 'recordings', label: 'Recordings', milestone: null },
{ path: 'reports', label: 'Reports', milestone: null }, { path: 'reports', label: 'Reports', milestone: null },
+146
View File
@@ -15,6 +15,7 @@ import {
import { import {
aiApi, aiApi,
apiTokensApi, apiTokensApi,
contactsApi,
didsApi, didsApi,
extensionsApi, extensionsApi,
settingsApi, settingsApi,
@@ -36,6 +37,7 @@ const TABS = [
{ id: 'webhooks', label: 'Webhooks' }, { id: 'webhooks', label: 'Webhooks' },
{ id: 'tokens', label: 'API tokens' }, { id: 'tokens', label: 'API tokens' },
{ id: 'ai', label: 'AI' }, { id: 'ai', label: 'AI' },
{ id: 'contacts', label: 'Contacts' },
] as const; ] as const;
export function SettingsPage() { export function SettingsPage() {
@@ -71,6 +73,7 @@ export function SettingsPage() {
{settings.data && tab === 'webhooks' && <WebhooksTab s={settings.data} />} {settings.data && tab === 'webhooks' && <WebhooksTab s={settings.data} />}
{tab === 'tokens' && <TokensTab />} {tab === 'tokens' && <TokensTab />}
{settings.data && tab === 'ai' && <AiTab s={settings.data} />} {settings.data && tab === 'ai' && <AiTab s={settings.data} />}
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
</section> </section>
); );
} }
@@ -1215,3 +1218,146 @@ function TokensTab() {
</div> </div>
); );
} }
/** Contacts (spec 11): the builtin books, or Contrack running beside the PBX. Also a setup step. */
export function ContactsTab({ s }: { s: SettingsDto }) {
const save = useSave();
const ctk = s['contacts.contrack'];
const [provider, setProvider] = useState(s['contacts.provider']);
const [v, setV] = useState({
url: ctk?.url ?? 'http://127.0.0.1:3210',
publicUrl: ctk?.publicUrl ?? '',
companyToken: '',
});
const test = useMutation({
mutationFn: () => contactsApi.testContrack(v.url, v.companyToken || undefined),
});
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
setV({ ...v, [k]: e.target.value });
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
'contacts.provider': provider,
...(provider === 'contrack'
? {
'contacts.contrack': {
url: v.url,
publicUrl: v.publicUrl,
companyToken: v.companyToken || undefined,
},
}
: {}),
});
setV({ ...v, companyToken: '' });
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Where contacts live</legend>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="provider"
checked={provider === 'builtin'}
onChange={() => setProvider('builtin')}
/>
<span>
<span className="font-medium">Simple</span>: a phonebook inside SelfPhoneNG. Names,
numbers, .vcf and CSV import, sharing. Nothing else to run.
</span>
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="provider"
checked={provider === 'contrack'}
onChange={() => setProvider('contrack')}
/>
<span>
<span className="font-medium">Contrack</span>: a full contact and relationship manager
next to the phone system (notes, reminders, relationship tracking, imports from Google,
Apple and LinkedIn, CardDAV sync). Each person has their own Contrack account, and the
company has one everyone sees. It runs as its own container:{' '}
<code>docker compose --env-file .env -f deploy/contrack/compose.yml up -d</code>.
</span>
</label>
</fieldset>
{provider === 'contrack' && (
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Contrack</legend>
<Field
label="Address the phone system uses"
hint="On this server or the local network: localhost or a local IP"
>
{(a) => <input {...a} className={inputClass} value={v.url} onChange={set('url')} />}
</Field>
<Field
label="Address people open"
hint="Its own host name, such as https://contacts.example.com (CONTRACK_HOSTNAME in .env)"
>
{(a) => (
<input
{...a}
className={inputClass}
placeholder="https://contacts.example.com"
value={v.publicUrl}
onChange={set('publicUrl')}
/>
)}
</Field>
<Field
label="Company account token"
hint={
ctk?.hasCompanyToken
? 'Saved. Paste a new one to replace it.'
: 'Make a Contrack account for the company (such as company@…), then in its Settings → Account → API tokens, create one that never expires.'
}
>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={ctk?.hasCompanyToken ? '••••••••' : 'ctk_…'}
className={inputClass}
value={v.companyToken}
onChange={set('companyToken')}
/>
)}
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending}>
Test
</Button>
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{test.data.ok ? test.data.detail : test.data.error}
</span>
)}
</div>
<p className="text-xs text-zinc-500">
Each person then connects their own Contrack account in My settings, Contacts. If the AI
inside Contrack uses a cloud provider, what is stored there reaches that provider: turn
it off in Contrack, or point it at your local AI.
</p>
</fieldset>
)}
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
</form>
);
}
+679
View File
@@ -0,0 +1,679 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
// denies), click to call. useContactName() gives every page names for numbers.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
formatNumber,
normalizeNumber,
type ContactDto,
type ContactImportResult,
type ContactInput,
} from '@selfphoneng/shared';
import { contactsApi, type ContactBase } from '../../api/client.ts';
import { Dialog, ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
import { useMe } from './MeSettingsPage.tsx';
import { useCallBack } from './MyCallsPage.tsx';
const MINE: ContactBase = '/api/me/contacts';
const COMPANY: ContactBase = '/api/contacts';
const key = (base: ContactBase) => ['contacts', base];
const show = (n: string) => (n.length > 6 ? formatNumber(n) : n);
/** Name for a number from my book, then the company's (11 §1); undefined when there's none. */
export function useContactName() {
const list = useQuery({ queryKey: key(MINE), queryFn: () => contactsApi.list(MINE) });
const names = useMemo(() => {
const m = new Map<string, string>();
for (const c of list.data?.items ?? [])
for (const n of c.numbers) if (!m.has(n.number)) m.set(n.number, c.name);
return m;
}, [list.data]);
return (number: string | null | undefined) => {
if (!number) return undefined;
try {
return names.get(normalizeNumber(number)) ?? names.get(number);
} catch {
return names.get(number);
}
};
}
/** "Add to contacts" from a call or a voicemail: the drawer, filled with the number. */
export function AddToContacts({ number, name }: { number: string; name?: string | null }) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setOpen(true)}>
Add to contacts
</Button>
{open && (
<ContactDrawer
base={MINE}
contact={{ name: name ?? '', numbers: [{ label: 'mobile', number: show(number) }] }}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
export function ContactsPage() {
return (
<div className="flex flex-col gap-8">
<SharedWithYou />
<ContactBook base={MINE} title="Contacts" />
</div>
);
}
export function CompanyContactsPage() {
return <ContactBook base={COMPANY} title="Company contacts" />;
}
function ContactBook({ base, title }: { base: ContactBase; title: string }) {
const qc = useQueryClient();
const list = useQuery({ queryKey: key(base), queryFn: () => contactsApi.list(base) });
const { call, note } = useCallBack();
const [q, setQ] = useState('');
const [editing, setEditing] = useState<(ContactInput & { id?: string }) | null>(null);
const [deleting, setDeleting] = useState<ContactDto | null>(null);
const [importing, setImporting] = useState(false);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [sharing, setSharing] = useState(false);
const remove = useMutation({
mutationFn: (id: string) => contactsApi.remove(base, id),
onSuccess: async () => {
setDeleting(null);
await qc.invalidateQueries({ queryKey: ['contacts'] });
},
});
const words = q.trim().toLowerCase();
const digits = q.replace(/\D/g, '');
// ponytail: the whole book is loaded and filtered here; page it on the server if books get huge.
const ctk = list.data?.contrack ?? null;
const shown = (list.data?.items ?? []).filter(
(c) =>
!words ||
`${c.name} ${c.company ?? ''} ${c.email ?? ''}`.toLowerCase().includes(words) ||
(digits.length >= 3 && c.numbers.some((n) => n.number.includes(digits))),
);
const canShare = base === MINE;
// Contrack mode: contacts are changed in Contrack, so rows link there (11 §4.3).
const mine = (c: ContactDto) => !c.url && (base === COMPANY || c.book === 'mine');
return (
<section aria-labelledby="contacts-h" className="flex flex-col gap-3">
<h2 id="contacts-h" className="text-lg font-semibold">
{title}
</h2>
<div className="flex flex-wrap items-center gap-2">
<input
type="search"
aria-label="Search contacts"
placeholder="Search names, companies, numbers"
className={`${inputClass} max-w-72`}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<Button
variant="primary"
onClick={() => setEditing({ name: '', numbers: [{ label: 'mobile', number: '' }] })}
>
Add contact
</Button>
{!ctk ? (
<Button onClick={() => setImporting(true)}>Import</Button>
) : (
ctk.publicUrl && (
<a
href={ctk.publicUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
Import in Contrack
</a>
)
)}
{canShare && (
<Button disabled={!picked.size} onClick={() => setSharing(true)}>
Share{picked.size ? ` (${picked.size})` : ''}
</Button>
)}
</div>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
{note}
</p>
)}
{list.error && <Alert>{list.error.message}</Alert>}
{ctk && !ctk.connected && (
<Alert>
{ctk.error ??
(base === MINE
? 'Your contacts are in Contrack: connect your account in Settings → Contacts to see and add them.'
: 'The company Contrack account has no token yet: Settings → Contacts.')}
</Alert>
)}
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
{list.data && shown.length === 0 && (
<p className="text-sm text-zinc-500">
{q ? 'No contact matches that.' : 'No contacts yet.'}
</p>
)}
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{shown.map((c) => (
<li key={c.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
{canShare && (
<input
type="checkbox"
aria-label={`Pick ${c.name} to share`}
disabled={c.book !== 'mine'}
checked={picked.has(c.id)}
onChange={(e) => {
const next = new Set(picked);
if (e.target.checked) next.add(c.id);
else next.delete(c.id);
setPicked(next);
}}
/>
)}
<span className="min-w-40 flex-1">
<span className="font-medium">{c.name}</span>
{c.company && <span className="text-zinc-500"> · {c.company}</span>}
{base === MINE && c.book === 'company' && (
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
Company
</span>
)}
</span>
<span className="flex flex-wrap gap-x-3">
{c.numbers.map((n) => (
<button
key={n.number}
type="button"
className="text-sky-700 underline dark:text-sky-400"
aria-label={`Call ${c.name} ${n.label} ${show(n.number)}`}
onClick={() => call(n.number)}
>
{n.label && <span className="text-zinc-500 no-underline">{n.label} </span>}
{show(n.number)}
</button>
))}
</span>
{c.url && (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className="text-sky-700 underline dark:text-sky-400"
>
Open in Contrack
</a>
)}
{mine(c) && (
<span className="flex gap-1">
<Button variant="ghost" className="px-2 py-1" onClick={() => setEditing(c)}>
Edit
</Button>
<Button variant="ghost" className="px-2 py-1" onClick={() => setDeleting(c)}>
Delete
</Button>
</span>
)}
</li>
))}
</ul>
{editing && (
<ContactDrawer
base={base}
contact={{
...editing,
numbers: editing.numbers?.map((n) => ({ ...n, number: show(n.number) })),
}}
onClose={() => setEditing(null)}
/>
)}
{importing && <ImportDialog base={base} onClose={() => setImporting(false)} />}
{sharing && (
<ShareDialog
ids={[...picked]}
onClose={(done) => {
setSharing(false);
if (done) setPicked(new Set());
}}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete contact"
message={<p>Delete {deleting?.name}? This can't be undone.</p>}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
/** Contacts from before Contrack (11 §5, C6): copied once, kept here for switching back. */
function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
const qc = useQueryClient();
const copy = useMutation({
mutationFn: () => contactsApi.copyToContrack(base),
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
});
return (
<div
role="status"
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
>
<span className="flex-1">
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
{count === 1 ? "isn't" : "aren't"} in {base === COMPANY ? "the company's" : 'your'} Contrack
yet.
</span>
<Button variant="primary" disabled={copy.isPending} onClick={() => copy.mutate()}>
Copy {count === 1 ? 'it' : 'them'} to Contrack
</Button>
{copy.error && <Alert>{copy.error.message}</Alert>}
</div>
);
}
const LABELS = ['mobile', 'work', 'home', 'main', 'fax', 'other'];
function ContactDrawer(props: {
base: ContactBase;
contact: ContactInput & { id?: string };
onClose: () => void;
}) {
const qc = useQueryClient();
const { id, ...start } = props.contact;
const [form, setForm] = useState({
name: start.name,
company: start.company ?? '',
email: start.email ?? '',
notes: start.notes ?? '',
numbers: start.numbers?.length
? start.numbers.map((n) => ({ label: n.label ?? '', number: n.number }))
: [{ label: 'mobile', number: '' }],
});
const save = useMutation({
mutationFn: () => {
const c = { ...form, numbers: form.numbers.filter((n) => n.number.trim()) };
return id ? contactsApi.update(props.base, id, c) : contactsApi.create(props.base, c);
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['contacts'] });
props.onClose();
},
});
const setNumber = (i: number, patch: Partial<{ label: string; number: string }>) =>
setForm({ ...form, numbers: form.numbers.map((n, j) => (j === i ? { ...n, ...patch } : n)) });
return (
<Dialog
open
drawer
title={id ? 'Edit contact' : 'Add contact'}
onClose={props.onClose}
footer={
<>
<Button onClick={props.onClose}>Cancel</Button>
<Button variant="primary" form="contact-form" type="submit" disabled={save.isPending}>
Save
</Button>
</>
}
>
<form
id="contact-form"
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
{(['name', 'company', 'email'] as const).map((k) => (
<Field key={k} label={k[0]!.toUpperCase() + k.slice(1)}>
{(p) => (
<input
{...p}
className={inputClass}
required={k === 'name'}
type={k === 'email' ? 'email' : 'text'}
value={form[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
/>
)}
</Field>
))}
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Numbers</legend>
{form.numbers.map((n, i) => (
<div key={i} className="flex gap-2">
<select
aria-label={`Number ${i + 1} label`}
className={`${inputClass} w-28`}
value={n.label || 'other'}
onChange={(e) => setNumber(i, { label: e.target.value })}
>
{[...new Set([...LABELS, n.label].filter(Boolean))].map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
<input
aria-label={`Number ${i + 1}`}
inputMode="tel"
className={inputClass}
placeholder="(312) 555-1234"
value={n.number}
onChange={(e) => setNumber(i, { number: e.target.value })}
/>
<Button
variant="ghost"
aria-label={`Remove number ${i + 1}`}
onClick={() =>
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
}
>
✕
</Button>
</div>
))}
<div>
<Button
variant="ghost"
onClick={() =>
setForm({ ...form, numbers: [...form.numbers, { label: 'work', number: '' }] })
}
>
Add a number
</Button>
</div>
</fieldset>
<Field label="Notes">
{(p) => (
<textarea
{...p}
rows={3}
className={inputClass}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
)}
</Field>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
</Dialog>
);
}
function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => void }) {
const qc = useQueryClient();
const [files, setFiles] = useState<{ name: string; text: string }[]>([]);
const [preview, setPreview] = useState<ContactImportResult | null>(null);
const run = useMutation({
mutationFn: (commit: boolean) => contactsApi.import(base, files, commit),
onSuccess: async (r) => {
setPreview(r);
if (r.committed) await qc.invalidateQueries({ queryKey: ['contacts'] });
},
});
const done = preview?.committed;
return (
<Dialog
open
title={base === COMPANY ? 'Import company contacts' : 'Import contacts'}
onClose={onClose}
footer={
done ? (
<Button variant="primary" onClick={onClose}>
Done
</Button>
) : (
<>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={!preview || !preview.added || run.isPending}
onClick={() => run.mutate(true)}
>
Import {preview?.added ?? ''}
</Button>
</>
)
}
>
<div className="flex flex-col gap-3 text-sm">
<p className="text-zinc-600 dark:text-zinc-400">
vCard files (.vcf) from a phone, Google Contacts, Outlook or Apple Contacts, or a CSV with
a header row: name, company, email, label1, number1, label2, number2…
</p>
<input
type="file"
multiple
accept=".vcf,.csv,text/vcard,text/csv"
aria-label="Contact files"
disabled={done}
onChange={async (e) => {
const picked = [...(e.target.files ?? [])];
const read = await Promise.all(
picked.map(async (f) => ({ name: f.name, text: await f.text() })),
);
setFiles(read);
setPreview(null);
if (read.length) run.mutate(false);
}}
/>
{run.isPending && <p className="text-zinc-500">Reading…</p>}
{preview && (
<div role="status" className="flex flex-col gap-1">
<p>
{done ? 'Imported' : 'New'}: <strong>{preview.added}</strong>
{preview.duplicates > 0 && <> · already in the book, skipped: {preview.duplicates}</>}
{preview.noNumber > 0 && <> · without a phone number: {preview.noNumber}</>}
</p>
{preview.errors.length > 0 && (
<ul className="list-disc pl-5 text-amber-800 dark:text-amber-300">
{preview.errors.map((e) => (
<li key={e}>{e}</li>
))}
</ul>
)}
</div>
)}
{run.error && <Alert>{run.error.message}</Alert>}
</div>
</Dialog>
);
}
function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean) => void }) {
const people = Object.values(useLive((s) => s.extensions)).sort((a, b) =>
a.name.localeCompare(b.name),
);
const myNumber = useMe().data?.extension.number;
const [everyone, setEveryone] = useState(false);
const [to, setTo] = useState<Set<string>>(new Set());
const share = useMutation({
mutationFn: () => contactsApi.share({ contactIds: ids, everyone, extensions: [...to] }),
});
return (
<Dialog
open
title={`Share ${ids.length} contact${ids.length === 1 ? '' : 's'}`}
onClose={() => onClose(share.isSuccess)}
footer={
share.isSuccess ? (
<Button variant="primary" onClick={() => onClose(true)}>
Done
</Button>
) : (
<>
<Button onClick={() => onClose(false)}>Cancel</Button>
<Button
variant="primary"
disabled={(!everyone && !to.size) || share.isPending}
onClick={() => share.mutate()}
>
Share
</Button>
</>
)
}
>
{share.isSuccess ? (
<p role="status" className="text-sm">
Sent to {share.data.people} {share.data.people === 1 ? 'person' : 'people'}. They'll see
it under "Shared with you" and choose to accept or deny it.
</p>
) : (
<div className="flex flex-col gap-2 text-sm">
<p className="text-zinc-600 dark:text-zinc-400">
Each person gets a copy to accept into their own contacts, or deny.
</p>
<label className="flex items-center gap-2 font-medium">
<input
type="checkbox"
checked={everyone}
onChange={(e) => setEveryone(e.target.checked)}
/>
Everyone in the company
</label>
{!everyone &&
people
.filter((p) => p.number !== myNumber)
.map((p) => (
<label key={p.number} className="flex items-center gap-2">
<input
type="checkbox"
checked={to.has(p.number)}
onChange={(e) => {
const next = new Set(to);
if (e.target.checked) next.add(p.number);
else next.delete(p.number);
setTo(next);
}}
/>
{p.name} <span className="text-zinc-500">({p.number})</span>
</label>
))}
{share.error && <Alert>{share.error.message}</Alert>}
</div>
)}
</Dialog>
);
}
/** Pending offers from colleagues: nothing is added until Accept (11 §2.1). */
export function useContactShares() {
return useQuery({ queryKey: ['contacts', 'shares'], queryFn: contactsApi.shares });
}
function SharedWithYou() {
const qc = useQueryClient();
const shares = useContactShares();
const [result, setResult] = useState<string | null>(null);
const decide = useMutation({
mutationFn: ({ decision, ids }: { decision: 'accept' | 'deny'; ids: string[] }) =>
contactsApi.decide(decision, ids),
onSuccess: async (r) => {
setResult(
r.added !== undefined
? `Added ${r.added}${r.duplicates ? `; ${r.duplicates} you already had` : ''}.`
: `Denied ${r.denied}.`,
);
await qc.invalidateQueries({ queryKey: ['contacts'] });
},
});
if (!shares.data?.length && !result) return null;
const bySender = new Map<string, typeof shares.data>();
for (const s of shares.data ?? []) {
const k = s.from?.name ?? 'Someone who left';
bySender.set(k, [...(bySender.get(k) ?? []), s]);
}
return (
<section
aria-labelledby="shared-h"
className="flex flex-col gap-3 rounded-lg border border-sky-200 bg-sky-50 p-3 dark:border-sky-900 dark:bg-sky-950"
>
<h2 id="shared-h" className="text-lg font-semibold">
Shared with you
</h2>
{result && (
<p role="status" className="text-sm">
{result}
</p>
)}
{[...bySender].map(([from, list]) => (
<div key={from} className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">From {from}</span>
{list!.length > 1 && (
<>
<Button
className="px-2 py-1"
disabled={decide.isPending}
onClick={() => decide.mutate({ decision: 'accept', ids: list!.map((s) => s.id) })}
>
Accept all {list!.length}
</Button>
<Button
variant="ghost"
className="px-2 py-1"
disabled={decide.isPending}
onClick={() => decide.mutate({ decision: 'deny', ids: list!.map((s) => s.id) })}
>
Deny all
</Button>
</>
)}
</div>
<ul className="flex flex-col gap-1">
{list!.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1">
{s.contact.name}
{s.contact.company && (
<span className="text-zinc-500"> · {s.contact.company}</span>
)}
{s.contact.numbers.map((n) => (
<span key={n.number} className="text-zinc-500">
{' '}
· {show(n.number)}
</span>
))}
</span>
<Button
variant="primary"
className="px-2 py-1"
disabled={decide.isPending}
onClick={() => decide.mutate({ decision: 'accept', ids: [s.id] })}
>
Accept
</Button>
<Button
className="px-2 py-1"
disabled={decide.isPending}
onClick={() => decide.mutate({ decision: 'deny', ids: [s.id] })}
>
Deny
</Button>
</li>
))}
</ul>
</div>
))}
{decide.error && <Alert>{decide.error.message}</Alert>}
</section>
);
}
+35 -4
View File
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow // My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
// with their milestones (M5–M7).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router'; import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
@@ -16,6 +15,7 @@ import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
import { fmtNumber } from '../admin/CallsPage.tsx'; import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx'; import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx'; import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400'; const link = 'text-sky-700 underline dark:text-sky-400';
@@ -34,6 +34,7 @@ export function MePage({ user }: { user: AuthUser }) {
void qc.invalidateQueries({ queryKey: ['me', 'settings'] }); void qc.invalidateQueries({ queryKey: ['me', 'settings'] });
}, [myDnd, qc]); }, [myDnd, qc]);
const toasts = useLive((s) => s.toasts); const toasts = useLive((s) => s.toasts);
const offers = useContactShares().data?.length ?? 0;
const tab = ({ isActive }: { isActive: boolean }) => const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`; `rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
@@ -78,6 +79,15 @@ export function MePage({ user }: { user: AuthUser }) {
<NavLink to="/me/voicemail" className={tab}> <NavLink to="/me/voicemail" className={tab}>
Voicemail Voicemail
</NavLink> </NavLink>
<NavLink to="/me/contacts" className={tab}>
Contacts
{offers > 0 && (
<span className="ml-1 rounded-full bg-sky-600 px-1.5 text-xs text-white">
<span className="sr-only">, shared with you: </span>
{offers}
</span>
)}
</NavLink>
<NavLink to="/me/settings" className={tab}> <NavLink to="/me/settings" className={tab}>
Settings Settings
</NavLink> </NavLink>
@@ -111,6 +121,7 @@ export function MePage({ user }: { user: AuthUser }) {
<Route index element={<Home user={user} />} /> <Route index element={<Home user={user} />} />
<Route path="calls" element={<MyCallsPage />} /> <Route path="calls" element={<MyCallsPage />} />
<Route path="voicemail" element={<VoicemailPage />} /> <Route path="voicemail" element={<VoicemailPage />} />
<Route path="contacts" element={<ContactsPage />} />
<Route path="settings" element={<MeSettingsPage />} /> <Route path="settings" element={<MeSettingsPage />} />
<Route path="people" element={<PresencePanel me={me} />} /> <Route path="people" element={<PresencePanel me={me} />} />
</Routes> </Routes>
@@ -127,9 +138,11 @@ export function MePage({ user }: { user: AuthUser }) {
function Home({ user }: { user: AuthUser }) { function Home({ user }: { user: AuthUser }) {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail }); const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? []; const fresh = list.data?.filter(isNew) ?? [];
const nameOf = useContactName();
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p> <p>Hi {user.displayName}.</p>
<SharedNotice />
{user.extension && <StatusCard ext={user.extension.number} />} {user.extension && <StatusCard ext={user.extension.number} />}
{user.extension && <MissedCalls />} {user.extension && <MissedCalls />}
{user.extension && <ParkedOnHome />} {user.extension && <ParkedOnHome />}
@@ -142,7 +155,7 @@ function Home({ user }: { user: AuthUser }) {
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{fresh.slice(0, 5).map((m) => ( {fresh.slice(0, 5).map((m) => (
<li key={m.id} className="text-sm"> <li key={m.id} className="text-sm">
<span className="font-semibold">{callerOf(m)}</span>{' '} <span className="font-semibold">{nameOf(m.callerNumber) ?? callerOf(m)}</span>{' '}
<span className="text-zinc-500"> <span className="text-zinc-500">
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)} {new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</span> </span>
@@ -164,6 +177,21 @@ function Home({ user }: { user: AuthUser }) {
); );
} }
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
function SharedNotice() {
const offers = useContactShares().data ?? [];
if (!offers.length) return null;
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
return (
<p role="status" className="rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950">
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
<Link className={link} to="/me/contacts">
Accept or deny
</Link>
</p>
);
}
/** Do not disturb and forwarding at a glance (05 §4 Home). */ /** Do not disturb and forwarding at a glance (05 §4 Home). */
function StatusCard({ ext }: { ext: string }) { function StatusCard({ ext }: { ext: string }) {
const me = useMe(); const me = useMe();
@@ -243,6 +271,7 @@ function MissedCalls() {
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() }); const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });
const { call, note } = useCallBack(); const { call, note } = useCallBack();
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? []; const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
const nameOf = useContactName();
return ( return (
<section aria-labelledby="missed-h" className="flex flex-col gap-2"> <section aria-labelledby="missed-h" className="flex flex-col gap-2">
<h2 id="missed-h" className="text-lg font-semibold"> <h2 id="missed-h" className="text-lg font-semibold">
@@ -259,7 +288,9 @@ function MissedCalls() {
const o = otherParty(c); const o = otherParty(c);
return ( return (
<li key={c.id}> <li key={c.id}>
<span className="font-semibold">{o.name ?? fmtNumber(o.number)}</span>{' '} <span className="font-semibold">
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
</span>{' '}
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '} <span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '}
{o.number && ( {o.number && (
<button <button
+115 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call // My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
// report email. // report email, my Contrack account (11 §4.3).
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5). // Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
@@ -63,6 +63,7 @@ function SettingsForms({ me }: { me: MeDto }) {
</section> </section>
{me.voicemailEnabled && <Voicemail me={me} />} {me.voicemailEnabled && <Voicemail me={me} />}
<CallReportForm me={me} /> <CallReportForm me={me} />
{me.contrack && <ContrackConnect contrack={me.contrack} />}
<section aria-labelledby="app-h" className="flex flex-col gap-3"> <section aria-labelledby="app-h" className="flex flex-col gap-3">
<h2 id="app-h" className="text-lg font-semibold"> <h2 id="app-h" className="text-lg font-semibold">
Set up a phone app Set up a phone app
@@ -302,3 +303,116 @@ function CallReportForm({ me }: { me: MeDto }) {
</section> </section>
); );
} }
/** Contrack mode (11 §4.3): my own Contrack account, through a personal token I make there. */
function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']> }) {
const qc = useQueryClient();
const [token, setToken] = useState('');
const done = (me: MeDto) => {
qc.setQueryData(['me', 'settings'], me);
setToken('');
void qc.invalidateQueries({ queryKey: ['contacts'] });
};
const connect = useMutation({ mutationFn: () => meApi.connectContrack(token), onSuccess: done });
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
const where = contrack.publicUrl ? (
<a
className="text-sky-700 underline dark:text-sky-400"
href={contrack.publicUrl}
target="_blank"
rel="noreferrer"
>
Contrack
</a>
) : (
'Contrack'
);
return (
<section aria-labelledby="ctk-h" className="flex flex-col gap-3">
<h2 id="ctk-h" className="text-lg font-semibold">
Contacts
</h2>
{contrack.tokenPrefix && (
<div className="flex flex-wrap items-center gap-3 text-sm">
<span>
{contrack.connected ? 'Connected to your' : 'Your token for'} {where} account (
{contrack.tokenPrefix})
{!contrack.connected && (
<span className="text-red-700 dark:text-red-400">: it doesn't work any more</span>
)}
</span>
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
Disconnect
</Button>
</div>
)}
{contrack.tokenPrefix && (
<div className="flex flex-col gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={contrack.logCalls}
disabled={options.isPending}
onChange={(e) => options.mutate({ logCalls: e.target.checked })}
/>
Put my calls on my contacts' timelines in Contrack
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1"
checked={contrack.logSummaries && contrack.summariesAvailable}
disabled={!contrack.logCalls || !contrack.summariesAvailable || options.isPending}
onChange={(e) => options.mutate({ logSummaries: e.target.checked })}
/>
<span>
With the summary and action items of each recorded call
{!contrack.summariesAvailable && (
<span className="text-zinc-500"> (when your admin turns on call summaries)</span>
)}
<span className="block text-xs text-zinc-500">
What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
reach that provider.
</span>
</span>
</label>
</div>
)}
{!contrack.tokenPrefix && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Your contacts live in your own {where} account. There, open Settings → Account → API
tokens → Create token, with Read and write (so contacts shared with you can be added) and
Expires: Never, and paste it here. Only you see your contacts unless you share them.
</p>
)}
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
connect.mutate();
}}
>
<Field label={contrack.tokenPrefix ? 'Replace the token' : 'Contrack token'}>
{(p) => (
<input
{...p}
type="password"
autoComplete="off"
placeholder="ctk_…"
className={`${inputClass} w-72`}
value={token}
onChange={(e) => setToken(e.target.value)}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={!token.trim() || connect.isPending}>
Connect
</Button>
</form>
{(connect.error ?? disconnect.error ?? options.error) && (
<Alert>{(connect.error ?? disconnect.error)!.message}</Alert>
)}
</section>
);
}
+8 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's // My calls (05 §4): my call history; click the other number to call back (the softphone when it's
// on, else my desk phone rings first). "Add to contacts" arrives with contacts (M10). // on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import type { MyCallDto } from '@selfphoneng/shared'; import type { MyCallDto } from '@selfphoneng/shared';
@@ -8,6 +8,7 @@ import { meApi, recordingsApi } from '../../api/client.ts';
import { Alert, Button } from '../../components/ui.tsx'; import { Alert, Button } from '../../components/ui.tsx';
import { softphone } from '../../softphone/SoftphoneEngine.ts'; import { softphone } from '../../softphone/SoftphoneEngine.ts';
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx'; import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx';
/** The other side of the call, from where I sit. */ /** The other side of the call, from where I sit. */
export const otherParty = (c: MyCallDto) => export const otherParty = (c: MyCallDto) =>
@@ -36,6 +37,7 @@ export function MyCallsPage() {
getNextPageParam: (last) => last.nextCursor ?? undefined, getNextPageParam: (last) => last.nextCursor ?? undefined,
}); });
const { call, note } = useCallBack(); const { call, note } = useCallBack();
const nameOf = useContactName();
const calls = list.data?.pages.flatMap((p) => p.items) ?? []; const calls = list.data?.pages.flatMap((p) => p.items) ?? [];
return ( return (
<section aria-labelledby="calls-h" className="flex flex-col gap-3"> <section aria-labelledby="calls-h" className="flex flex-col gap-3">
@@ -52,6 +54,7 @@ export function MyCallsPage() {
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"> <ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{calls.map((c) => { {calls.map((c) => {
const other = otherParty(c); const other = otherParty(c);
const known = nameOf(other.number);
return ( return (
<li <li
key={c.id} key={c.id}
@@ -62,7 +65,7 @@ export function MyCallsPage() {
<span <span
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`} className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
> >
{other.name && <span>{other.name} </span>} {(known ?? other.name) && <span>{known ?? other.name} </span>}
{other.number ? ( {other.number ? (
<button <button
type="button" type="button"
@@ -78,6 +81,9 @@ export function MyCallsPage() {
{c.direction !== 'internal' && ( {c.direction !== 'internal' && (
<span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span> <span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span>
)} )}
{!known && other.number && c.direction !== 'internal' && (
<AddToContacts number={other.number} name={other.name} />
)}
</span> </span>
{c.recordingId && ( {c.recordingId && (
<audio <audio
+15 -9
View File
@@ -5,15 +5,16 @@ import { useRef, useState } from 'react';
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared'; import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts'; import { meApi } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx'; import { ConfirmDialog } from '../../components/Dialog.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx'; import { Alert, Button, inputClass } from '../../components/ui.tsx';
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
export const callerOf = (m: VoicemailDto) => export const callerOf = (m: VoicemailDto) =>
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller'); m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
export const isNew = (m: VoicemailDto) => m.folder !== 'Old'; export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
/** Words in the transcript, caller name or number, ignoring case. */ /** Words in the transcript, caller name (or contact name) or number, ignoring case. */
export const matches = (m: VoicemailDto, q: string) => export const matches = (m: VoicemailDto, q: string, contact?: string) =>
[m.transcript, m.callerName, m.callerNumber].some((f) => [m.transcript, m.callerName, m.callerNumber, contact].some((f) =>
f?.toLowerCase().includes(q.trim().toLowerCase()), f?.toLowerCase().includes(q.trim().toLowerCase()),
); );
@@ -33,7 +34,11 @@ function Messages() {
const [q, setQ] = useState(''); const [q, setQ] = useState('');
// ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if // ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if
// mailboxes ever get paged. // mailboxes ever get paged.
const shown = q.trim() ? list.data?.filter((m) => matches(m, q)) : list.data; const nameOf = useContactName();
const who = (m: VoicemailDto) => nameOf(m.callerNumber) ?? callerOf(m);
const shown = q.trim()
? list.data?.filter((m) => matches(m, q, nameOf(m.callerNumber)))
: list.data;
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] }); const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
const markRead = useMutation({ const markRead = useMutation({
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'), mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
@@ -75,7 +80,7 @@ function Messages() {
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className={isNew(m) ? 'font-semibold' : ''}> <p className={isNew(m) ? 'font-semibold' : ''}>
{callerOf(m)} {who(m)}
{isNew(m) && ( {isNew(m) && (
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200"> <span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
New New
@@ -96,7 +101,7 @@ function Messages() {
controls controls
preload="none" preload="none"
src={meApi.audioUrl(m.id)} src={meApi.audioUrl(m.id)}
aria-label={`Voicemail from ${callerOf(m)}`} aria-label={`Voicemail from ${who(m)}`}
className="w-full sm:w-64" className="w-full sm:w-64"
onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)} onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)}
/> />
@@ -110,6 +115,9 @@ function Messages() {
<Button variant="ghost" onClick={() => setDeleting(m)}> <Button variant="ghost" onClick={() => setDeleting(m)}>
Delete Delete
</Button> </Button>
{m.callerNumber && !nameOf(m.callerNumber) && (
<AddToContacts number={m.callerNumber} name={m.callerName} />
)}
</div> </div>
</li> </li>
))} ))}
@@ -117,9 +125,7 @@ function Messages() {
<ConfirmDialog <ConfirmDialog
open={deleting !== null} open={deleting !== null}
title="Delete voicemail" title="Delete voicemail"
message={ message={<p>Delete the message from {deleting && who(deleting)}? This can't be undone.</p>}
<p>Delete the message from {deleting && callerOf(deleting)}? This can't be undone.</p>
}
confirmLabel="Delete" confirmLabel="Delete"
busy={remove.isPending} busy={remove.isPending}
error={remove.error?.message} error={remove.error?.message}
+5 -1
View File
@@ -12,6 +12,7 @@ import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts'; import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
import { useSoftphone } from './useSoftphone.ts'; import { useSoftphone } from './useSoftphone.ts';
import { startRinging, stopRinging } from './sounds.ts'; import { startRinging, stopRinging } from './sounds.ts';
import { useContactName } from '../pages/user/ContactsPage.tsx';
const KEY = 'softphone.enabled'; const KEY = 'softphone.enabled';
const remembered = () => { const remembered = () => {
@@ -52,11 +53,14 @@ const STATUS = {
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']; const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
const clean = (s: string) => s.replace(/[^0-9*#+]/g, ''); const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
/** Caller name: extensions (live presence) → the name the caller sent → the number. */ /** Caller name: extensions (live presence) → my contacts and the company's → the name the caller
* sent → the number (11 §1). */
function useNameOf() { function useNameOf() {
const exts = useLive((s) => s.extensions); const exts = useLive((s) => s.extensions);
const contact = useContactName();
return (c: { remote: string; remoteName: string | null }) => return (c: { remote: string; remoteName: string | null }) =>
exts[c.remote]?.name ?? exts[c.remote]?.name ??
contact(c.remote) ??
c.remoteName ?? c.remoteName ??
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown'); (c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
} }
+7
View File
@@ -8,3 +8,10 @@
reverse_proxy 127.0.0.1:8080 reverse_proxy 127.0.0.1:8080
} }
} }
# Contrack (spec 11 §4), when you run deploy/contrack: uncomment, and set CONTRACK_HOSTNAME in .env
# (its own DNS name; Contrack can't live under a path).
# {$CONTRACK_HOSTNAME} {
# encode zstd gzip
# reverse_proxy 127.0.0.1:3210
# }
+32
View File
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Contrack (spec 11 §4, admin guide: Contacts): the "Robust" contacts, its own stack beside the PBX
# on the same server. Contrack is a separate open source program (AGPL-3.0) we talk to over HTTP.
#
# docker compose --env-file .env -f deploy/contrack/compose.yml up -d
#
# It listens on 127.0.0.1 only: the PBX (host networking) reaches it there, and people reach it
# through the reverse proxy at CONTRACK_HOSTNAME (deploy/caddy/Caddyfile, deploy/nginx).
# Then Settings → Contacts → Contrack, address http://127.0.0.1:3210.
services:
contrack:
# Pinned: SelfPhoneNG is tested against this version's API (decision 0003).
image: ghcr.io/arvarik/contrack:2.0.2
restart: unless-stopped
ports: ['127.0.0.1:3210:3210']
environment:
AUTH_REQUIRED: 'true' # everyone signs in; tokens need it
TRUST_PROXY_HOPS: '1'
PUBLIC_URL: https://${CONTRACK_HOSTNAME:?set CONTRACK_HOSTNAME in .env}
# Contrack's own AI and address lookups are outside SelfPhoneNG's "nothing leaves the
# server" rule. Uncomment to keep everything here (or point its AI at your local Ollama in
# Contrack's Settings → AI):
# AI_DISABLED: 'true'
# GEOCODING_DISABLED: 'true'
volumes:
- contrack-data:/app/data # its database, uploads and its own backups: back this up too
logging:
driver: json-file
options: { max-size: 10m, max-file: '3' }
volumes:
contrack-data:
+16
View File
@@ -45,3 +45,19 @@ server {
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
} }
} }
# Contrack (spec 11 §4), when you run deploy/contrack: its own name (it can't live under a path).
# server {
# listen 443 ssl;
# http2 on;
# server_name contacts.example.com;
# ssl_certificate /etc/letsencrypt/live/contacts.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/contacts.example.com/privkey.pem;
# client_max_body_size 50m; # contact imports and attachments
# location / {
# proxy_pass http://127.0.0.1:3210;
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
+1
View File
@@ -87,6 +87,7 @@ services:
network_mode: host network_mode: host
environment: environment:
PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env} PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env}
CONTRACK_HOSTNAME: ${CONTRACK_HOSTNAME:-} # deploy/caddy/Caddyfile, when Contrack runs
volumes: volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy:/data - ./data/caddy:/data
+1
View File
@@ -11,6 +11,7 @@ keeping the system safe, legal and up to date.
6. [Updating, backups and monitoring](maintenance.md) 6. [Updating, backups and monitoring](maintenance.md)
7. [Analog phones (ATAs)](ata.md) 7. [Analog phones (ATAs)](ata.md)
8. [Local AI](ai.md): voicemail transcripts 8. [Local AI](ai.md): voicemail transcripts
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
--- ---
+102
View File
@@ -0,0 +1,102 @@
# Contacts
Contacts put names on numbers: in call history, voicemail, the browser phone and desk phones
while they ring, the call report email, and the directory on desk phones (each phone has its
owner's contacts and the company's). Pick where they live
in the setup wizard or in **Settings → Contacts**:
- **Simple**: a phonebook inside SelfPhoneNG. Nothing else to run.
- **Contrack**: [Contrack](https://github.com/arvarik/contrack), a full contact and relationship
manager, running next to the phone system as its own container.
Everyone has their own contacts, and there are **company contacts** that everyone sees (only
admins change them, in **Admin → Contacts**).
## Simple
- **My Phone → Contacts**: add, edit, search, click a number to call. Calls and voicemails from a
number that isn't a contact have **Add to contacts**.
- **Import**: one or more `.vcf` files (exported from an iPhone, Android, Google Contacts,
Outlook or Apple Contacts) or a CSV with a header row (`name, company, email, label1, number1,
label2, number2, …`). You see what will be added first; contacts already there (same name and
number) are skipped. Up to 5,000 contacts at a time.
- **Share**: tick contacts, **Share**, pick colleagues or everyone. They get "Shared with you" at
the top of their Contacts page and on Home, and choose **Accept** (a copy goes into their
contacts) or **Deny**. Nothing is added to anyone's contacts without their click.
## Contrack
Contrack keeps each person's contacts in their own private Contrack account, and the company's in
a Contrack account of its own. SelfPhoneNG reads them (names on calls, the directory on desk
phones), adds to them ("Add to contacts", accepted shares), and links to them. Editing, importing,
notes and the rest happen in Contrack.
### Set it up
1. Give it a DNS name of its own, such as `contacts.example.com` (Contrack can't live under a
path of the phone system's name), pointing at this server. Set it in `.env`:
```bash
CONTRACK_HOSTNAME=contacts.example.com
```
2. Start it:
```bash
docker compose --env-file .env -f deploy/contrack/compose.yml up -d
```
It listens on `127.0.0.1:3210` only.
3. Put it behind your reverse proxy: with the bundled Caddy, uncomment the Contrack block at the
end of `deploy/caddy/Caddyfile` and restart Caddy; with your own nginx, see the end of
`deploy/nginx/selfphoneng.conf.example`.
4. Open `https://contacts.example.com`. The first visit creates the first (admin) account. Then,
in Contrack's **Settings → Administration → Invitations**, invite each person, and make one
account for the company, such as `company@example.com`.
5. Signed in to Contrack as the company account: **Settings → Account → API tokens → Create
token**. Under **Expires** choose **Never** (or 1 year, and replace it before then): the
default is 90 days, and names stop showing when a token expires. **Read only** is enough;
**Read and write** lets admins add company contacts from SelfPhoneNG.
6. In SelfPhoneNG, **Settings → Contacts → Contrack**: address `http://127.0.0.1:3210`, the
address people open (`https://contacts.example.com`), the company token, **Test**, **Save**.
7. Each person: in Contrack, **Settings → Account → API tokens → Create token**, **Read and
write**, expiring **Never** (or 1 year), then in SelfPhoneNG **My settings → Contacts**, paste
it, **Connect**. My settings says so when a token stops working.
Names refresh from Contrack every 5 minutes (at once after SelfPhoneNG adds a contact). If
Contrack is down, the last names it gave keep working.
### Calls on the timeline
Once connected, each person's calls with their Contrack contacts go on that contact's timeline in
Contrack ("Incoming call", "Missed call, voicemail left", …, with the time on the phone and whether
it was recorded), a minute or so after the call. In **My settings → Contacts** they can turn this
off, or add each recorded call's summary and action items (when call summaries are on in
**Settings → AI**); the first action item becomes a Contrack follow-up, due the next day. Calls
with numbers that aren't in their Contrack aren't added; **Add to contacts** first. Only calls
from the moment they connected are logged.
### Privacy
SelfPhoneNG only talks to Contrack on this server or the local network, and keeps the tokens
encrypted. Contrack itself can use AI (cloud providers, or your local Ollama) and look addresses
up on OpenStreetMap: those are Contrack's settings, outside SelfPhoneNG's "nothing leaves the
server" rule. To keep everything here, set `AI_DISABLED` and `GEOCODING_DISABLED` in
`deploy/contrack/compose.yml`, or point Contrack's AI at your local AI in its **Settings →
Administration → AI**.
### Backups
SelfPhoneNG's backup doesn't include Contrack. Contrack makes its own snapshots in its
`contrack-data` volume (its **Settings → Backups**); back up that volume too.
### Moving to Contrack with contacts already in SelfPhoneNG
Switching from Simple keeps everyone's contacts. Each person (and an admin, for the company
contacts) sees **Copy them to Contrack** on their Contacts page until they're copied; contacts
Contrack already has are skipped.
### Going back to Simple
**Settings → Contacts → Simple**. The builtin contacts are still there, and the Contrack tokens
are kept in case you switch back.
+15
View File
@@ -0,0 +1,15 @@
# 0003 — Contrack for "Robust" contacts
Owner, 2026-10-23: contacts come in two kinds (spec 11), and the robust one is
[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager.
It already does what a phonebook would take years to grow into: notes, timelines, reminders,
relationship tracking, duplicate merging, search, imports from Google, Apple and LinkedIn, and
CardDAV sync. It's allowed because it is open source (AGPL-3.0-only) and runs as **its own
program** (`deploy/contrack/compose.yml`, image pinned to 2.0.2) that SelfPhoneNG talks to over
HTTP on 127.0.0.1: none of its code is in our dependency tree, and there is no license mixing.
The risk is a young project with one maintainer: we use seven of its routes (`/healthz`,
`/api/contacts` list and create, and in C5 the timeline routes), pin the version, and
`apps/api/test/contrack.test.ts` keeps a fake of exactly those routes, and
`apps/api/test/contrack-live.test.ts` runs the same flows against a real Contrack
(`CONTRACK_LIVE_URL`, a fresh container; passed against 2.0.2 on 2026-10-23). A new Contrack
version is a deliberate update: read its CHANGELOG, run the live test against it, bump the tag.
+25 -1
View File
@@ -76,7 +76,11 @@ create table users (
totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11) totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11)
totp_last_step bigint, -- last 30 s step signed in with: no replay (M11) totp_last_step bigint, -- last 30 s step signed in with: no replay (M11)
must_change_password boolean not null default false, must_change_password boolean not null default false,
last_login_at timestamptz last_login_at timestamptz,
contrack_token_enc text, -- their Contrack personal token (11 §4.3)
contrack_log_calls boolean not null default true, -- calls on their Contrack timelines (11 §4.4)
contrack_log_summaries boolean not null default false, -- …with recorded calls' summaries
contrack_log_since timestamptz -- calls that ended after this
); );
create table sessions ( create table sessions (
@@ -449,6 +453,26 @@ create table voicemail_messages ( -- index of files Asterisk
transcript text -- speech to text (10), when it's on transcript text -- speech to text (10), when it's on
); );
create table contrack_call_log ( -- a call put on a Contrack timeline (11 §4.4)
call_id uuid not null references calls(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
interaction_id text, -- null: no contact has the number, or refused
error text,
logged_at timestamptz not null default now(),
primary key (call_id, user_id)
);
create table contact_shares ( -- a contact offered to a colleague (11 §2.1)
id uuid primary key default gen_random_uuid(),
from_user_id uuid references users(id) on delete set null,
to_user_id uuid not null references users(id) on delete cascade,
source_id text not null, -- the contact's id in the sender's book
contact jsonb not null, -- a copy: { name, company, email, numbers, notes }
status text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
decided_at timestamptz
);
create index on contact_shares (to_user_id) where status = 'pending';
create table contacts ( create table contacts (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
owner_user_id uuid references users(id) on delete cascade, -- null = company phonebook owner_user_id uuid references users(id) on delete cascade, -- null = company phonebook
+23 -1
View File
@@ -389,16 +389,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
; never sees someone else's contact name.
[sp-sub-dial-user] [sp-sub-dial-user]
exten => s,1,Set(DS=) exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail) same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx) same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
same => n,Set(_SP_CNAM=)
same => n,Return()
same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return() same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return() same => n,Return()
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
[sp-sub-cnam]
exten => s,1,Set(CURLOPT(conntimeout)=1)
same => n,Set(CURLOPT(httptimeout)=2)
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
[sp-predial-cnam]
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
same => n,Return()
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
+4 -2
View File
@@ -377,7 +377,7 @@ Auth & self:
| GET | `/api/me/greetings/:kind/audio` | user | the wav | | GET | `/api/me/greetings/:kind/audio` | user | the wav |
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) | | PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment | | GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | personal contacts | | GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` | | POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out | | POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
@@ -401,7 +401,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
| `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) | | `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) |
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 | | `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 |
| `/api/blocked-numbers` | | | `/api/blocked-numbers` | |
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) | | `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) or vCard (11 §3.1) |
| `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` | | `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
| `/api/api-tokens` | POST returns the token once | | `/api/api-tokens` | POST returns the token once |
@@ -457,6 +457,8 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `webhookDelivery` | every 10 s | send pending deliveries (§9) | | `webhookDelivery` | every 10 s | send pending deliveries (§9) |
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage | | `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
| `diskWatch` | hourly | if free disk < 10% → email admins once a day | | `diskWatch` | hourly | if free disk < 10% → email admins once a day |
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's | | `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
Jobs must never overlap with themselves (use a running flag). Jobs must never overlap with themselves (use a running flag).
+1 -1
View File
@@ -37,7 +37,7 @@ type ProvisionContext = {
| `GET /prov/<mac>.cfg` | Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by `devices.vendor` | | `GET /prov/<mac>.cfg` | Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by `devices.vendor` |
| `GET /prov/y0000000000<xx>.cfg` | Yealink common file | Return an empty valid file `#!version:1.0.0.1\n` | | `GET /prov/y0000000000<xx>.cfg` | Yealink common file | Return an empty valid file `#!version:1.0.0.1\n` |
| `GET /prov/cfg<mac>.xml` | Grandstream | | | `GET /prov/cfg<mac>.xml` | Grandstream | |
| `GET /prov/phonebook/<mac>.xml` | both | company phonebook + extensions, vendor-specific XML | | `GET /prov/phonebook/<mac>.xml` | both | extensions + the phone owner's contacts + company phonebook (11 §6.2), vendor-specific XML |
| `GET /prov/linphone/<token>.xml` | Linphone (§5) | token auth instead of Basic | | `GET /prov/linphone/<token>.xml` | Linphone (§5) | token auth instead of Basic |
Rules: Rules:
+3
View File
@@ -9,6 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when
--- ---
## Backlog (owner's order, 2026-10-21) ## Backlog (owner's order, 2026-10-21)
0. **Contacts** (owner, 2026-10-23: needed next), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work):
Simple (builtin phonebook) or Robust (Contrack), picked in the setup wizard. The owner uses
Contrack.
1. **Grandstream HT802 V2 set up automatically** (step 3 of 1. **Grandstream HT802 V2 set up automatically** (step 3 of
[Analog phone adapters](#analog-phone-adapters-owner-2026-10-21-wanted-the-owner-is-buying-an-ht802-v2)): [Analog phone adapters](#analog-phone-adapters-owner-2026-10-21-wanted-the-owner-is-buying-an-ht802-v2)):
several lines on one device and the `grandstream-ata.ts` template with the dial plan. Starts several lines on one device and the `grandstream-ata.ts` template with the dial plan. Starts
+342
View File
@@ -0,0 +1,342 @@
# 11 — Contacts
Owner, 2026-10-23: contacts come in two kinds, picked in the setup wizard and changeable later in
Settings → Contacts (`contacts.provider`):
- **Simple** (`builtin`): the `contacts` table (02), a phonebook inside SelfPhoneNG.
- **Robust** (`contrack`): [Contrack](https://github.com/arvarik/contrack), a self-hosted contact
and relationship manager, running beside the PBX. SelfPhoneNG reads names from it and writes each
call to the contact's timeline. The owner's own install uses this.
Both kinds feed the same features, so the rest of SelfPhoneNG asks one question, "who is
13125551234 for this user?", and doesn't care which one answers.
## 1. What contacts are used for (both kinds)
| Feature | Where | Today |
|---|---|---|
| My contacts: list, search, click to call, add/edit | `/me/contacts` (05 §4) | not built |
| Company phonebook, CSV and vCard (.vcf) import | `/admin/contacts` (05 §3) | not built |
| My contacts: CSV and vCard (.vcf) import | `/me/contacts` | not built |
| Caller name: softphone ringing, call history, voicemail list, call report email, transcripts | 05 §5, 04 §7.3, 10 §3 | extensions → caller ID name → number |
| "Add to contacts" from a call or voicemail | `/me/calls`, `/me/voicemail` | not built |
| Transfer search (contacts + extensions) | softphone (05 §5) | extensions only |
| Desk phone phonebook | provisioning (07) | extensions only |
Lookup order everywhere: **extensions → my contacts → company phonebook → caller ID name → number**.
## 2. The provider seam (`apps/api/src/contacts/`)
```ts
type ContactHit = {
name: string;
company: string | null;
url: string | null; // Contrack: the contact's page
book: 'mine' | 'company';
};
interface ContactsProvider {
lookup(userId: string, number: string): Promise<ContactHit | null>; // normalized E.164-ish digits
search(userId: string, q: string, limit: number): Promise<ContactDto[]>;
list(userId: string, cursor?: string): Promise<ListResponse<ContactDto>>;
create(userId: string, c: ContactInput): Promise<ContactDto>;
update?(userId: string, id: string, c: ContactInput): Promise<ContactDto>; // builtin only
remove?(userId: string, id: string): Promise<void>; // builtin only
logCall?(userId: string, call: CallForLog): Promise<void>; // contrack only (§4.4)
}
```
- `builtin.ts` and `contrack.ts` implement it; `index.ts` picks one from `contacts.provider`.
- `lookup` is **never a network call in the call path**. Contrack answers from a per-user cache
(§4.3); builtin answers from one indexed query.
- Numbers are compared after `normalizeNumber` (00 §8) on both sides, so "+1 (312) 555-1234",
"312-555-1234" and "13125551234" match.
- Owner, 2026-10-23: **every address book belongs to one owner**, in both modes, and the company is
one more owner. Simple: the builtin table, `owner_user_id` = the user, or null for the company.
Contrack: every Contrack account is private, so the company gets **its own Contrack account**
(the admin sets its token, §4.3) and each user connects **their own** account. SelfPhoneNG does
the sharing:
- the company book is shown read-only to everyone (lookup, search, desk phones);
- a personal book is private; its owner can share contacts from it (§2.1).
### 2.1 Sharing contacts (owner, 2026-10-23)
Sharing **offers a copy**: the recipient **accepts or denies** each shared contact. Accepted, it is
imported into their own book, and from then on the two copies are separate (editing one doesn't
change the other). Denied, nothing is added. Nothing lands in anyone's book without their click.
Both modes.
- `/me/contacts`: select one or more contacts → **Share** → pick colleagues, or **Everyone in the
company**. `POST /api/me/contacts/share { contactIds, userIds | everyone: true }` makes one
pending offer per contact per recipient. The answer says how many offers went to how many people.
- The offer holds a **copy taken when it was shared** (name, company, email, numbers; not notes or
a Contrack timeline), so the sender editing or deleting the contact later doesn't change it:
```sql
create table contact_shares (
id uuid primary key default gen_random_uuid(),
from_user_id uuid references users(id) on delete set null,
to_user_id uuid not null references users(id) on delete cascade,
contact jsonb not null, -- { name, company, email, numbers: [{ label, number }] }
status text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
decided_at timestamptz,
created_at timestamptz not null default now()
);
create index on contact_shares (to_user_id) where status = 'pending';
```
- The recipient sees **Shared with you** at the top of `/me/contacts` (a count on the Contacts
link and a line on Home): "Dan Doe shared Acme Corp (312) 555-1234", with **Accept** and
**Deny** on each, and **Accept all** / **Deny all** for one sender's batch.
`GET /api/me/contacts/shares`, `POST /api/me/contacts/shares/accept|deny { ids }` (one id or
a sender's batch).
- Accept imports with the duplicate rule (§3.1: same name and same normalized number already in
the book → skipped, and the answer says so); the offer is `accepted` either way. Deny marks it
`denied`. Decided offers are deleted after 30 days; pending ones stay until decided.
- The same contact offered again to someone with a pending offer of it replaces that offer, so a
recipient never sees two of one contact.
- Contrack mode: the copy is read with the sender's token when sharing; Accept creates it with the
recipient's token. A recipient without a connected Contrack account, or with a read-only token,
can't accept yet: the offer says "Connect Contrack to accept" and waits.
- Only users, not the company book: an admin adds to the company book directly.
## 3. Simple (builtin)
The `contacts` table already in 02. `numbers[].number` is stored normalized (as forwards are,
04 §5.5) and shown with `formatNumber`. Lookup (`contacts/book.ts` `contactNames`) scans the two
books' numbers with `jsonb_array_elements`; a GIN index when books reach tens of thousands.
As built (C1): `GET /api/me/contacts` answers my whole book and the company's in one list (no
paging); the page searches it in the browser, and the browser looks up names from it (call
history, voicemail, Home, the ringing softphone). The call report email looks up on the server.
API (04 §7.3, §7.4, as already listed there):
| Route | Who | Does |
|---|---|---|
| `GET/POST/PATCH/DELETE /api/me/contacts[/:id]` | user | my contacts; `GET ?q=` searches name, company, number digits |
| `GET/POST/PATCH/DELETE /api/contacts[/:id]` | admin | company phonebook |
| `POST /api/contacts/import` | admin | CSV `name,company,email,label1,number1,...` (04 §7.4); preview, then commit |
UI: `/me/contacts` (mine + company read-only, search, click to call, add/edit drawer, import,
share), `/admin/contacts` (table, drawer, import), "Add to contacts" on calls and voicemail.
### 3.1 Import: CSV and vCard (owner, 2026-10-23)
Admins import into the company book, users into their own; same code, same screen.
- `POST /api/contacts/import` (admin) and `POST /api/me/contacts/import` (user): `{ files: [{ name,
text }] }`, up to 20 files read as text in the browser (no multipart), `.csv` (04 §7.4 columns)
or `.vcf` (any number of cards per file). 6 MB and 5,000 contacts per import. `?commit=0`
answers a preview, `?commit=1` saves.
- vCard 2.1, 3.0 and 4.0, read by our own parser (`contacts/vcard.ts`, no new dependency): line
unfolding, `QUOTED-PRINTABLE` and `CHARSET` (2.1), escaped `\,` `\;` `\n`, `item1.` groups.
Fields: `FN` (or `N` when `FN` is empty), `ORG`, first `EMAIL`, every `TEL` with its `TYPE` as
the label (cell → mobile, work, home, fax, other), `NOTE`. Everything else (photos, addresses,
birthdays) is ignored.
- Preview: how many are new, how many are already in that book (same name and same normalized
number: skipped), how many have no phone number (imported anyway: they still have a name and
email), and the first rows that failed and why. Nothing is saved until **Import**.
- Contrack mode: SelfPhoneNG's import buttons open Contrack's own import (it reads Apple, Google,
LinkedIn and vCard exports), for the company account too.
## 4. Robust (Contrack)
### 4.1 What Contrack is (checked 2026-10-23, v2.0.2 of 2026-10-18)
- TypeScript (Node ≥ 26.10), React, SQLite with sqlite-vec, **AGPL-3.0-only**. One Docker image,
`ghcr.io/arvarik/contrack`, port 3210, data in `/app/data`.
- REST API under `/api` (OpenAPI 3.1 in its `docs/openapi.json`), plus MCP.
- Multi-user: every account has its own private address book. Credentials: the session cookie,
or a **personal token** `Authorization: Bearer ctk_…` (read-only or read-write, made in
Settings → Account). Tokens need sign-in on (`AUTH_REQUIRED=true`).
- Contacts carry `phones: [{ phone, label, isPrimary }]` (free text such as "+49 30 1234567").
- **Timeline**: `POST /api/contacts/:id/interactions` `{ type: 'call', title, content (HTML), date,
duration (minutes), actionItem? }`; `PATCH /api/interactions/:id` changes `title`/`content`.
A future `date` is refused.
- Its own AI (Gemini/OpenAI/Anthropic, or an OpenAI-compatible server such as our Ollama), web
research (SearXNG), connectors (CardDAV, IMAP, calendar, Google), backups, CSV/vCard-style
imports.
Endpoints SelfPhoneNG uses, and nothing else: `GET /healthz`, `GET /api/contacts?view=slim`,
`GET /api/contacts/:id`, `POST /api/contacts`, `GET /api/search?q=`,
`POST /api/contacts/:id/interactions`, `PATCH /api/interactions/:id`. A contract test checks them
against the pinned version's `openapi.json`.
### 4.2 Running it
- `deploy/contrack/compose.yml`, its own stack like `deploy/ai/compose.yml`: the image **pinned to
a version tag** (not `latest`, decision 0001), `127.0.0.1:3210:3210`, a `contrack-data`
volume, and `AUTH_REQUIRED=true`, `TRUST_PROXY_HOPS=1`, `PUBLIC_URL=https://${CONTRACK_HOSTNAME}`.
- People open Contrack on **its own host name** (`PUBLIC_URL` takes no path), e.g.
`contacts.example.com`. The Caddyfile gets a second site block when `CONTRACK_HOSTNAME` is set;
the nginx example gets a second server block. As built: both are commented blocks to uncomment
(a Caddy site with an empty name would fail), and `docker compose --env-file .env -f
deploy/contrack/compose.yml up -d` (a compose file in a subfolder reads no `.env` by itself).
- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrack.url` must pass the same
local-address check as the AI endpoints (10 §1), so contact data and tokens stay on the network.
- Backups: Contrack keeps its own snapshots in its volume. The SelfPhoneNG backup (pg_dump) does
not include it; the admin guide says to back up the volume too.
- `docs/decisions/0003-contrack.md`: why an outside service (it already does relationships,
timelines, imports, CardDAV sync, dedupe and search far beyond a phonebook), why it's allowed
(AGPL, open source, a separate program over HTTP, so no license mixing and no code in our
dependency tree), and the risk (one maintainer, young project: pinned version + contract test).
### 4.3 Connecting users
- Admin, Settings → Contacts (`contacts.provider`, `contacts.contrack` { url, publicUrl,
companyTokenEnc }): **Test** (`POST /api/contacts/contrack/test`: `/healthz`, then the token
against `/api/contacts?view=slim`; Contrack's `/api/auth/me` refuses tokens, so a token is
checked by reading contacts).
- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
"Add to company contacts" work), write-only like the SMTP password (`hasCompanyToken` in GET).
The admin makes that account in Contrack (e.g. `company@…`) and fills it like any other.
- Contrack tokens expire in 90 days unless made with **Never**: the guide and the forms say to pick
Never (or a year).
- Each user, My settings → Contacts: **paste a Contrack personal token** (read-write; read-only
allowed, then nothing is written to the timeline). `PUT /api/me/contrack { token }` checks it
reads contacts, then stores it with `encrypt()` in `users.contrack_token_enc` (contacts are a
person's, not an extension's); `DELETE` disconnects. Never logged, never returned: `GET /api/me`
has `contrack: { connected, tokenPrefix: "ctk_AbCdEfGh…", publicUrl }`.
- Why not have the PBX create Contrack accounts: Contrack's admin routes refuse tokens
(`SESSION_REQUIRED`), so the PBX would have to keep an admin password. A pasted token is
explicit, revocable from Contrack, and scoped to one person.
- **Lookup cache** (`contacts/contrack.ts`): per connected user and for the company account,
`GET /api/contacts?view=slim`, every phone through `normalizeNumber` (text that isn't a number
is left out), held in memory. As built: fetched on use when older than 5 minutes (no timer),
forgotten when SelfPhoneNG adds a contact or the token changes. A failing Contrack keeps the
last good list and the error, and is tried again 5 minutes later; a refused token (401) shows as
"doesn't work any more" in My settings. No error email.
- `/me/contacts` in this mode: search and list from Contrack (read-only rows, "Open in Contrack"
link to `${publicUrl}/contact/:id`), plus the company book. "Add to contacts" does
`POST /api/contacts { name, company, emails, phones: [{ phone, label }] }`. Editing, deleting
and importing answer 409 in this mode: they happen in Contrack. Desk phone directories (07) get
the company account's contacts. Accepting a share creates the copy with the recipient's token;
without one it answers 409 and the offer waits.
### 4.4 Calls on the Contrack timeline
New per-user options (My settings → Contacts, shown when connected):
- **Log my calls in Contrack** (default on): each of my calls with a number that matches one of my
Contrack contacts becomes a `call` interaction on that contact:
- title: "Incoming call (answered)", "Missed call", "Outgoing call", "Voicemail left" …
- date: `calls.started_at`; duration: talk minutes, rounded up; content: number, who answered,
"recorded" (escaped HTML).
- **Add the call summary** (default off, needs `ai.callSummaries`): the summary and action items
(10 §3) go in the content; the first action item also becomes a Contrack follow-up
(`actionItem`). Warning next to it: if Contrack's own AI uses a cloud provider, what was said on
the call reaches it. The admin guide recommends `AI_DISABLED=true` or our Ollama for Contrack.
- Job `contrackLog` (every 60 s, 04 §8): calls ended more than 1 min ago and not logged yet. A
recorded call waits for its summary (`recordings.ai_done_at`) up to 30 minutes when the summary
option is on. One row per logged call:
```sql
create table contrack_call_log (
call_id uuid not null references calls(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
interaction_id text, -- null: no matching contact, or not logged
logged_at timestamptz not null default now(),
primary key (call_id, user_id)
);
```
Contrack unreachable: try again next run, give up after 2 days (like call transcripts).
- Unknown numbers are not added to Contrack automatically; "Add to contacts" does it, and calls
from then on are logged.
As built (C5, `contacts/timeline.ts`):
- Options on `users` (`contrack_log_calls`, `contrack_log_summaries`, `contrack_log_since`),
`PATCH /api/me/contrack { logCalls?, logSummaries? }`, shown in `GET /api/me` `contrack` with
`summariesAvailable`. Calls count from `contrack_log_since`: set when the token is first
connected, and when logging is turned back on, so connecting doesn't fill the timeline with the
last 2 days. Migration 0011 sets it to the migration time for people connected before.
- Calls between colleagues (`internal`) aren't logged. Every person on the call (caller, rang,
answered) gets it on their own timeline, as in My calls, with their own contact.
- Titles: "Incoming call", "Incoming call, answered by Sue", "Missed call", "Missed call, voicemail
left", "Outgoing call", "Outgoing call, busy" / "no answer". Content: `<p>From … to … · 3:12 ·
recorded</p>`, then the summary's sentences and an `<ul>` of its action items, all escaped.
`source: "selfphoneng"`. Contrack requires a due date on a follow-up: the day after the call,
in the company time zone.
- `contrack_call_log` also has `error`: a refused write (4xx other than 401/429: a read-only
token, a deleted contact, a bad request) is noted once and not retried. Contrack down (5xx,
unreachable), or a token it refuses (401): tried again on the next run.
- Proven against Contrack 2.0.2 (`contrack-live.test.ts`): the entry, its follow-up, and the
escaped content arrive. Contrack answers `duration` as a string ("4.0").
### 4.5 Not in Contrack mode
- Editing a Contrack contact from SelfPhoneNG.
- A contact in several books is shown once per book; lookup takes the first in §1's order.
## 5. Setup wizard and switching
- New step **Contacts**, after Users (05 §2.1): two cards.
- **Simple**: "A phonebook inside SelfPhoneNG: names, numbers, CSV import. Nothing else to run."
- **Contrack**: "A full contact and relationship manager next to the phone system: notes,
reminders, relationship tracking, imports from Google/Apple/LinkedIn, CardDAV sync. Every call
lands on the contact's timeline. Runs as its own container." Then the URL field, Test, and the
two commands to start `deploy/contrack/compose.yml`. Skippable: "Set it up later" keeps Simple.
- Settings → Contacts can switch later. Builtin personal contacts are kept, not deleted; switching
to Contrack offers **Copy my contacts to Contrack** per user (`POST /api/contacts/bulk`,
≤ 5,000), switching back just shows the builtin table again.
## 6. Order of work
| Task | What | Done when |
|---|---|---|
| C1 | Seam + Simple: migration, builtin provider, routes, `/me/contacts`, `/admin/contacts`, CSV and vCard import (§3.1), sharing (§2.1), "Add to contacts", lookup everywhere in §1 except desk phones | a contact's name shows in call history, voicemail, call report and the ringing softphone; an iPhone and a Google .vcf export import |
| C2 | Company book on desk phones (07) | a Yealink shows company contacts in its directory. Already built in M10-T2 (`loadPhonebook`, the builtin company book); C3 adds the Contrack company account to it |
| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book. Done, see §6.1 |
| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary. Done, see §4.4 as built |
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional. Done, see §6.2 |
### 6.1 As built: C4
- `sp-sub-dial-user` (03 §6.2), for calls from outside only: `sp-sub-cnam` asks
`GET /api/internal/caller-name?ext=&number=&token=` with `func_curl` (connect 1 s, answer 2 s).
A name goes on each ringing leg with a pre-dial handler, `CONNECTEDLINE(name,i)` (what a
PJSIP phone shows in the INVITE's From), not on the call: the next ring group member, a queue
agent or a forward does its own lookup in its own user's book, and never sees another's.
- The prefix of a number, ring group or queue ("Sales:") is kept in front:
`__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does.
- Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of
APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this
machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't
logged (the key is in the query). Names come back as letters, digits, spaces and `. ' & @ -`,
at most 40 characters, so nothing can be read as dialplan or SIP syntax.
- It never waits on Contrack: a stale book answers what it has and refreshes in the background,
and the `contactsWarm` job refreshes every connected book every 5 minutes.
- `apps/api/test/asterisk/cnam-test.ts` proves it on real Asterisk with a baresip phone: a
contact's name, a stranger's provider name, "Sales: Jane Doe" through a ring group, 101's
contact name not reaching 102 after a no-answer forward, and the phone still ringing (with the
provider's name) when the api hangs.
### 6.2 As built: C6
- A phone's directory (`/prov/phonebook/<mac>.xml`, 07) is the extensions, then **its owner's own
contacts** (accepted shares are among them), then the company's, each by name, from the
builtin books or Contrack. A phone whose extension has no user gets the extensions and the
company only.
- Contrack mode: `GET /api/me/contacts` (and `/api/contacts`) says in `contrack.toCopy` how many
builtin contacts that book's Contrack account doesn't have yet (same name and number), and the
page offers **Copy them to Contrack**: `POST /api/me/contacts/copy-to-contrack` (admin:
`/api/contacts/copy-to-contrack`, audited) sends them in one `POST /api/contacts/bulk` (with an
`X-Import-Id`). The builtin rows stay, for switching back; once copied the offer is gone.
Contrack's own duplicate check then runs on the import as usual.
- Proven against Contrack 2.0.2 (`contrack-live.test.ts`).
Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP
server built from the pinned `openapi.json`; one e2e script against a real Contrack container.
## 7. Decided
- Owner, 2026-10-23: caller name on desk phones while ringing is its own task, C4, after C3.
- Owner, 2026-10-23: in Contrack mode the company book is a company Contrack account shared with
everyone; each user has their own private Contrack account.
- Owner, 2026-10-23: sharing offers a copy to picked colleagues or everyone, in both modes; each
recipient accepts (it goes into their own book) or denies (nothing is added). An import or an
accepted share skips contacts already in the book (§2.1, §3.1).
- Owner, 2026-10-23: admins and users can import .vcf files (§3.1).
+105
View File
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Contacts (spec 11): my book, the company book, imports and shares.
import { z } from 'zod';
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((s) => s || null);
export const ContactInputSchema = z.object({
name: z.string().trim().min(1, 'Enter a name').max(200),
company: optionalText(200),
email: optionalText(200).refine((s) => !s || z.email().safeParse(s).success, 'Not an email'),
/** Any way of writing a number; stored normalized (00 §8). */
numbers: z
.array(
z.object({
label: z.string().trim().max(30).default(''),
number: z.string().trim().min(1).max(40),
}),
)
.max(20)
.default([]),
notes: optionalText(5000),
});
export type ContactInput = z.input<typeof ContactInputSchema>;
export type ContactNumber = { label: string; number: string };
/** A contact as stored, and as a share offer carries it. */
export type ContactData = {
name: string;
company: string | null;
email: string | null;
numbers: ContactNumber[];
notes: string | null;
};
export type ContactDto = ContactData & {
/** A uuid, or a Contrack contact id such as jane-smith-f60e8536. */
id: string;
/** mine: I edit it; company: read-only for users (admins edit it in Admin → Contacts). */
book: 'mine' | 'company';
/** Contrack: the contact's page there (editing happens in Contrack). */
url: string | null;
};
/** GET /api/me/contacts and /api/contacts: the book(s), and where they live. */
export type ContactBookDto = {
items: ContactDto[];
provider: 'builtin' | 'contrack';
/** Contrack mode: the address people open, and whether this book's token works. */
contrack: {
publicUrl: string | null;
connected: boolean;
error: string | null;
/** Contacts still only in the builtin book (from before Contrack): "Copy them" (11 §5). */
toCopy: number;
} | null;
};
/** POST …/contacts/copy-to-contrack (11 §5, C6). */
export type ContactCopyResult = { added: number; duplicates: number; failed: number };
/** POST …/contacts/import: the files' text, read in the browser. */
export const ContactImportSchema = z.object({
files: z
.array(z.object({ name: z.string().max(200), text: z.string() }))
.min(1)
.max(20),
});
export type ContactImportResult = {
committed: boolean;
added: number;
/** Same name and number already in the book (or earlier in the files): skipped. */
duplicates: number;
/** Added without a phone number. */
noNumber: number;
/** The first problems: a card or row that was skipped, or a number that was dropped. */
errors: string[];
};
export const ContactShareSchema = z
.object({
contactIds: z.array(z.string().min(1).max(100)).min(1).max(500),
/** Colleagues by extension number. */
extensions: z.array(z.string().max(20)).max(500).default([]),
everyone: z.boolean().default(false),
})
.refine((s) => s.everyone || s.extensions.length, 'Pick who to share with');
export type ContactShare = z.input<typeof ContactShareSchema>;
export const ContactShareDecisionSchema = z.object({ ids: z.array(z.uuid()).min(1).max(1000) });
/** A contact someone shared with me, waiting for Accept or Deny (11 §2.1). */
export type ContactShareDto = {
id: string;
from: { userId: string; name: string } | null;
contact: ContactData;
createdAt: string;
};
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more