export interface ThreeCxConfig { domain: string; clientId: string; clientSecret: string; } export class ThreeCxClient { private accessToken: string | undefined; private readonly baseUrl: string; constructor(private readonly config: ThreeCxConfig) { this.baseUrl = `https://${config.domain.replace(/^https?:\/\//, "")}`; } async getAccessToken(): Promise { if (this.accessToken) { return this.accessToken; } const res = await fetch(`${this.baseUrl}/connect/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: this.config.clientId, client_secret: this.config.clientSecret, }), }); if (!res.ok) { throw new Error(`3CX token request failed: ${res.status}`); } const body = (await res.json()) as { access_token?: string }; if (!body.access_token) { throw new Error("3CX token response missing access_token"); } this.accessToken = body.access_token; return this.accessToken; } async getJson(path: string): Promise<{ status: number; body: T }> { const token = await this.getAccessToken(); const res = await fetch(`${this.baseUrl}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, }); const text = await res.text(); const body = (text ? JSON.parse(text) : {}) as T; return { status: res.status, body }; } async patchJson(path: string, payload: unknown): Promise<{ status: number; body: T }> { const token = await this.getAccessToken(); const res = await fetch(`${this.baseUrl}${path}`, { method: "PATCH", headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(payload), }); const text = await res.text(); const body = (text ? JSON.parse(text) : {}) as T; return { status: res.status, body }; } async listUsers(): Promise { const users: T[] = []; let path = "/xapi/v1/Users?$top=100&$select=Id,Number,FirstName,LastName,Blfs,PrimaryGroupId&$expand=Groups($select=Id,Name),Phones"; for (let i = 0; i < 20; i++) { const page = await this.getJson<{ value?: T[]; "@odata.nextLink"?: string }>(path); if (page.status !== 200) { throw new Error(`3CX Users list failed: ${page.status}`); } users.push(...(page.body.value ?? [])); const next = page.body["@odata.nextLink"]; if (!next) { break; } path = next.replace(this.baseUrl, ""); } return users; } async getPeerByNumber(number: string): Promise<{ Id: number; Number: string; Name?: string } | undefined> { const encoded = number.replace(/'/g, "''"); const page = await this.getJson<{ value?: Array<{ Id: number; Number: string; Name?: string }> }>( `/xapi/v1/Peers?$filter=Number eq '${encoded}'&$top=1` ); if (page.status !== 200) { return undefined; } return page.body.value?.[0]; } async patchUserBlfs(userId: number, blfs: string): Promise<{ status: number }> { const path = `/xapi/v1/Users(${userId})`; const current = await this.getJson>(path); if (current.status !== 200) { return { status: current.status }; } const payload = { ...current.body }; delete payload["@odata.context"]; payload.Blfs = blfs; const patched = await this.patchJson>(path, payload); return { status: patched.status }; } }