wizards 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/apps/runtime/dist/docs/dev/plugins/reloading.md +3 -3
  2. package/apps/runtime/dist/docs/dev/plugins/runners.md +9 -12
  3. package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
  4. package/apps/web/dist/assets/{AboutDialog-CXEpsI6L.js → AboutDialog-CH1rQdia.js} +1 -1
  5. package/apps/web/dist/assets/{ChangelogDialog-Cc2APUGI.js → ChangelogDialog-78MQLHfA.js} +1 -1
  6. package/apps/web/dist/assets/{ChatView-B2r4dW_m.js → ChatView-DLJfy69U.js} +1 -1
  7. package/apps/web/dist/assets/{EditorPage-CNAYlSms.js → EditorPage-BYrZokVk.js} +1 -1
  8. package/apps/web/dist/assets/{FlowPreview-DzU1nGDb.js → FlowPreview-xIWPdOCV.js} +1 -1
  9. package/apps/web/dist/assets/{Harness-ALQ9Csm6.js → Harness-CsDhNLSW.js} +1 -1
  10. package/apps/web/dist/assets/{NewWizardChannels-DMPqHkCF.js → NewWizardChannels-JB3V6DCL.js} +1 -1
  11. package/apps/web/dist/assets/{NewWizardPage-pi053e8N.js → NewWizardPage-Bkh-hjSe.js} +1 -1
  12. package/apps/web/dist/assets/{OnboardingPage-TYNX6Umd.js → OnboardingPage-Diw1ILjC.js} +1 -1
  13. package/apps/web/dist/assets/{ProjectPage-CvEkx_Ux.js → ProjectPage-FgoZDzPI.js} +1 -1
  14. package/apps/web/dist/assets/{PublicRunner-BrivKNHo.js → PublicRunner-ClT6B7t5.js} +1 -1
  15. package/apps/web/dist/assets/{RunDrawer-3osugizk.js → RunDrawer-DAwTEyjM.js} +1 -1
  16. package/apps/web/dist/assets/{RunnerView-Ci4_WzVv.js → RunnerView-B_tJroFM.js} +1 -1
  17. package/apps/web/dist/assets/{SettingsPage-DKntf7-9.js → SettingsPage-BsMN-XQs.js} +1 -1
  18. package/apps/web/dist/assets/{SetupPage-DZQxEQyk.js → SetupPage-C7FuIpSt.js} +1 -1
  19. package/apps/web/dist/assets/{SharePage-B9l4KthU.js → SharePage-CbFe83L9.js} +1 -1
  20. package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
  21. package/apps/web/dist/assets/{esm-CuqXcPS3.js → esm-BhIeKBJY.js} +1 -1
  22. package/apps/web/dist/assets/{index-j09nLtEs.js → index-fflYi8P1.js} +3 -3
  23. package/apps/web/dist/assets/{settings-menu-C7VsRpe9.js → settings-menu-C5Y8SOhG.js} +1 -1
  24. package/apps/web/dist/index.html +1 -1
  25. package/package.json +1 -1
  26. package/apps/web/dist/assets/changelog-CxdXZo3V.js +0 -1
  27. package/modules/calls/dist/client.css +0 -3
  28. package/modules/calls/dist/client.js +0 -1
  29. package/modules/calls/engenty.plugin.json +0 -8
  30. package/modules/calls/migrations/0001_init.sql +0 -26
  31. package/modules/calls/package.json +0 -25
  32. package/modules/calls/src/openai.ts +0 -155
  33. package/modules/calls/src/plugin.ts +0 -407
  34. package/modules/calls/src/rules.ts +0 -226
  35. package/modules/calls/src/schema.ts +0 -39
  36. package/modules/calls/src/session.ts +0 -353
  37. package/modules/calls/src/store.ts +0 -124
  38. package/modules/calls/src/twilio.ts +0 -90
  39. package/modules/calls/tsconfig.json +0 -10
  40. package/modules/calls/ui/Settings.tsx +0 -137
  41. package/modules/calls/ui/app.ts +0 -50
  42. package/modules/calls/ui/messages.ts +0 -81
  43. package/modules/calls/ui/parts.tsx +0 -36
  44. package/modules/calls/ui/plugin.tsx +0 -15
  45. package/modules/sms/dist/client.css +0 -3
  46. package/modules/sms/dist/client.js +0 -1
  47. package/modules/sms/engenty.plugin.json +0 -8
  48. package/modules/sms/migrations/0001_init.sql +0 -26
  49. package/modules/sms/package.json +0 -24
  50. package/modules/sms/src/plugin.ts +0 -329
  51. package/modules/sms/src/schema.ts +0 -26
  52. package/modules/sms/src/store.ts +0 -140
  53. package/modules/sms/src/surface.ts +0 -70
  54. package/modules/sms/src/twilio.ts +0 -130
  55. package/modules/sms/tsconfig.json +0 -10
  56. package/modules/sms/ui/Settings.tsx +0 -113
  57. package/modules/sms/ui/Share.tsx +0 -60
  58. package/modules/sms/ui/app.ts +0 -51
  59. package/modules/sms/ui/messages.ts +0 -70
  60. package/modules/sms/ui/parts.tsx +0 -36
  61. package/modules/sms/ui/plugin.tsx +0 -20
  62. package/modules/whatsapp/dist/client.css +0 -3
  63. package/modules/whatsapp/dist/client.js +0 -1
  64. package/modules/whatsapp/engenty.plugin.json +0 -8
  65. package/modules/whatsapp/migrations/0001_init.sql +0 -30
  66. package/modules/whatsapp/package.json +0 -24
  67. package/modules/whatsapp/src/graph.ts +0 -271
  68. package/modules/whatsapp/src/inbound.ts +0 -143
  69. package/modules/whatsapp/src/plugin.ts +0 -374
  70. package/modules/whatsapp/src/schema.ts +0 -32
  71. package/modules/whatsapp/src/store.ts +0 -156
  72. package/modules/whatsapp/src/surface.ts +0 -127
  73. package/modules/whatsapp/tsconfig.json +0 -10
  74. package/modules/whatsapp/ui/Settings.tsx +0 -141
  75. package/modules/whatsapp/ui/Share.tsx +0 -60
  76. package/modules/whatsapp/ui/app.ts +0 -54
  77. package/modules/whatsapp/ui/messages.ts +0 -80
  78. package/modules/whatsapp/ui/parts.tsx +0 -36
  79. package/modules/whatsapp/ui/plugin.tsx +0 -20
@@ -1,155 +0,0 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
-
3
- /**
4
- * OpenAI speaks: a call reaches its SIP endpoint, it posts `realtime.call.incoming`, this
5
- * plugin accepts the call with the wizard's rules and tools, and drives the conversation over
6
- * a WebSocket to the same session. The audio never touches the runtime.
7
- */
8
-
9
- export const OPENAI = "https://api.openai.com/v1";
10
-
11
- /** Where Twilio sends the call: the project at OpenAI's SIP endpoint, in the EU or the US. */
12
- export function sipUri(project: string, region: "eu" | "us"): string {
13
- const host = region === "eu" ? "sip-eu.api.openai.com" : "sip.api.openai.com";
14
- return `sip:${project}@${host};transport=tls`;
15
- }
16
-
17
- /** Only a signature of five minutes or so counts; a replay from a kept body does not. */
18
- const WEBHOOK_TOLERANCE_SECONDS = 300;
19
-
20
- /**
21
- * Standard Webhooks, as OpenAI signs: `webhook-signature` holds `v1,<base64 HMAC-SHA256>` of
22
- * `<webhook-id>.<webhook-timestamp>.<body>`, the key being the secret after `whsec_`, base64.
23
- */
24
- export function webhookOk(
25
- secret: string,
26
- headers: { id: string | null; timestamp: string | null; signature: string | null },
27
- raw: string,
28
- now = Date.now(),
29
- ): boolean {
30
- if (!headers.id || !headers.timestamp || !headers.signature) {
31
- return false;
32
- }
33
- const at = Number(headers.timestamp);
34
- if (!Number.isFinite(at) || Math.abs(now / 1000 - at) > WEBHOOK_TOLERANCE_SECONDS) {
35
- return false;
36
- }
37
- const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
38
- const expected = Buffer.from(
39
- createHmac("sha256", key)
40
- .update(`${headers.id}.${headers.timestamp}.${raw}`, "utf8")
41
- .digest("base64"),
42
- );
43
- return headers.signature.split(" ").some((part) => {
44
- const [version, signature] = part.split(",");
45
- const given = Buffer.from(signature ?? "");
46
- return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
47
- });
48
- }
49
-
50
- export interface IncomingCall {
51
- callId: string;
52
- headers: Map<string, string>;
53
- }
54
-
55
- /** The call OpenAI announces, with its SIP headers by lower-case name; null for any other event. */
56
- export function incomingCall(body: unknown): IncomingCall | null {
57
- const event = body as {
58
- type?: string;
59
- data?: { call_id?: string; sip_headers?: { name?: string; value?: string }[] };
60
- } | null;
61
- if (event?.type !== "realtime.call.incoming" || !event.data?.call_id) {
62
- return null;
63
- }
64
- const headers = new Map<string, string>();
65
- for (const h of event.data.sip_headers ?? []) {
66
- if (h.name && typeof h.value === "string") {
67
- headers.set(h.name.toLowerCase(), h.value);
68
- }
69
- }
70
- return { callId: event.data.call_id, headers };
71
- }
72
-
73
- export class OpenAIError extends Error {
74
- constructor(
75
- message: string,
76
- readonly status: number,
77
- ) {
78
- super(message);
79
- this.name = "OpenAIError";
80
- }
81
- }
82
-
83
- async function post(
84
- apiKey: string,
85
- path: string,
86
- body: unknown,
87
- fetchImpl: typeof fetch,
88
- ): Promise<void> {
89
- const res = await fetchImpl(`${OPENAI}${path}`, {
90
- method: "POST",
91
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
92
- body: body === undefined ? undefined : JSON.stringify(body),
93
- signal: AbortSignal.timeout(15_000),
94
- });
95
- if (!res.ok) {
96
- const detail = (await res.text()).slice(0, 300);
97
- throw new OpenAIError(`OpenAI answered ${res.status}: ${detail}`, res.status);
98
- }
99
- }
100
-
101
- export interface CallSessionConfig {
102
- model: string;
103
- instructions: string;
104
- tools: unknown[];
105
- voice: string;
106
- }
107
-
108
- /** Picks the call up with the wizard's rules; the person hears the model from here on. */
109
- export function acceptCall(
110
- apiKey: string,
111
- callId: string,
112
- config: CallSessionConfig,
113
- fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args),
114
- ): Promise<void> {
115
- return post(
116
- apiKey,
117
- `/realtime/calls/${encodeURIComponent(callId)}/accept`,
118
- {
119
- type: "realtime",
120
- model: config.model,
121
- instructions: config.instructions,
122
- tools: config.tools,
123
- tool_choice: "auto",
124
- audio: { output: { voice: config.voice } },
125
- },
126
- fetchImpl,
127
- );
128
- }
129
-
130
- export function rejectCall(
131
- apiKey: string,
132
- callId: string,
133
- fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args),
134
- ): Promise<void> {
135
- return post(apiKey, `/realtime/calls/${encodeURIComponent(callId)}/reject`, {}, fetchImpl);
136
- }
137
-
138
- export function hangUp(
139
- apiKey: string,
140
- callId: string,
141
- fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args),
142
- ): Promise<void> {
143
- return post(apiKey, `/realtime/calls/${encodeURIComponent(callId)}/hangup`, undefined, fetchImpl);
144
- }
145
-
146
- /**
147
- * The WebSocket to the accepted call. Node's own client takes no headers, so the key goes the
148
- * way a browser's does: as a sub-protocol.
149
- */
150
- export function openSocket(apiKey: string, callId: string): WebSocket {
151
- return new WebSocket(`wss://api.openai.com/v1/realtime?call_id=${encodeURIComponent(callId)}`, [
152
- "realtime",
153
- `openai-insecure-api-key.${apiKey}`,
154
- ]);
155
- }
@@ -1,407 +0,0 @@
1
- import { AsyncResource } from "node:async_hooks";
2
- import { definePlugin, type PluginRequest } from "@engenty-wizards/plugin-sdk";
3
- import { z } from "zod";
4
- import {
5
- acceptCall,
6
- hangUp,
7
- incomingCall,
8
- openSocket,
9
- rejectCall,
10
- sipUri,
11
- webhookOk,
12
- } from "./openai";
13
- import { ASKS, CALL_TOOLS, instructions, RUNNER, VOICES } from "./rules";
14
- import { CallSession, type Offered } from "./session";
15
- import { callByToken, dropAccount, getAccount, newCall, putAccount, updateCall } from "./store";
16
- import { dialTwiml, e164, emptyTwiml, sendText, signatureOk } from "./twilio";
17
-
18
- /**
19
- * Calls as a door. A WhatsApp call reaches the tenant's business number, Meta sends it as SIP
20
- * to the tenant's Twilio SIP domain; a phone call reaches the tenant's Twilio number. Either
21
- * way Twilio asks this plugin for TwiML, which hands the call to the tenant's OpenAI project
22
- * over SIP with a token in a header. OpenAI posts the incoming call here; the plugin picks it
23
- * up with the wizard's rules and tools and drives the run over a WebSocket while the person
24
- * and the model talk. The studio half is ui/plugin.tsx.
25
- */
26
-
27
- const fail = (status: number, code: string, message: string): never => {
28
- throw Object.assign(new Error(message), { status, code });
29
- };
30
-
31
- const accountInput = z.object({
32
- twilioSid: z.string().trim().min(1).max(60),
33
- twilioToken: z.string().trim().max(200).optional(),
34
- smsFrom: z.string().trim().max(20).nullable().optional(),
35
- openaiProject: z.string().trim().min(1).max(80),
36
- openaiWebhookSecret: z.string().trim().max(200).optional(),
37
- region: z.enum(["eu", "us"]),
38
- });
39
-
40
- /** A form field by its name, whatever the case Twilio and Meta used. */
41
- function field(form: URLSearchParams, name: string): string | null {
42
- const lower = name.toLowerCase();
43
- for (const [key, value] of form) {
44
- if (key.toLowerCase() === lower) {
45
- return value;
46
- }
47
- }
48
- return null;
49
- }
50
-
51
- export default definePlugin((wizards) => {
52
- const { server, log } = wizards;
53
- const db = () => server.getTenantDb();
54
-
55
- server.registerMigrations("migrations");
56
-
57
- interface Live {
58
- session: CallSession;
59
- socket: WebSocket;
60
- }
61
- const live = new Map<string, Live>();
62
- const watching = new Map<string, () => void>();
63
- const timers = new Map<string, ReturnType<typeof setTimeout>>();
64
-
65
- const offered = () => server.runs.wizards(RUNNER) as Promise<Offered[]>;
66
-
67
- function watch(runId: string) {
68
- if (watching.has(runId)) {
69
- return;
70
- }
71
- const stop = server.runs.subscribe(runId, (signal) => {
72
- if (signal.event && signal.event.type !== "step_done" && signal.event.type !== "error") {
73
- return;
74
- }
75
- clearTimeout(timers.get(runId));
76
- timers.set(
77
- runId,
78
- setTimeout(() => {
79
- timers.delete(runId);
80
- for (const { session } of live.values()) {
81
- if (session.runId === runId) {
82
- void session.changed().catch((err) => log.error("after a change of the run:", err));
83
- }
84
- }
85
- }, 300),
86
- );
87
- });
88
- watching.set(runId, stop);
89
- }
90
-
91
- function unwatch(runId: string) {
92
- watching.get(runId)?.();
93
- watching.delete(runId);
94
- clearTimeout(timers.get(runId));
95
- timers.delete(runId);
96
- }
97
-
98
- server.onUnload(() => {
99
- for (const { socket } of live.values()) {
100
- socket.close();
101
- }
102
- live.clear();
103
- for (const stop of watching.values()) {
104
- stop();
105
- }
106
- watching.clear();
107
- for (const timer of timers.values()) {
108
- clearTimeout(timer);
109
- }
110
- });
111
-
112
- // ── The door ───────────────────────────────────────────────────────────────
113
-
114
- server.registerRunner({
115
- id: RUNNER,
116
- label: { de: "Anruf", en: "Call" },
117
- kind: "channel",
118
- capabilities: {
119
- input: ASKS,
120
- output: { shows: ["text", "data"], pictures: [] },
121
- asks: ["confirm"],
122
- review: ["accept", "regenerate"],
123
- waits: true,
124
- handoff: ["sms"],
125
- },
126
- problem: async () => {
127
- if (!(await getAccount(db()))) {
128
- return "Keine Nummer verbunden (Einstellungen → Anrufe)";
129
- }
130
- try {
131
- await server.speech.conversation();
132
- return null;
133
- } catch (err) {
134
- return (err as Error).message;
135
- }
136
- },
137
- });
138
-
139
- // Twilio: a call came in on the SIP domain (WhatsApp) or on the number (phone). The TwiML
140
- // hands it to OpenAI's SIP endpoint with a token that names the call to us.
141
- server.registerPublicRoute({
142
- method: "POST",
143
- path: "/voice",
144
- handler: async ({ request }) => {
145
- const account = await getAccount(db());
146
- if (!account) {
147
- return emptyTwiml();
148
- }
149
- const form = new URLSearchParams(await request.text());
150
- const urls = [await server.publicUrl("/voice"), request.url];
151
- if (
152
- !signatureOk(account.twilioToken, urls, form, request.headers.get("x-twilio-signature"))
153
- ) {
154
- return fail(403, "bad_signature", "The signature does not match.");
155
- }
156
- const caller = e164(form.get("From") ?? "");
157
- if (caller.length < 6) {
158
- return fail(400, "no_caller", "No caller.");
159
- }
160
- const wacid = field(form, "SipHeader_x-wa-meta-wacid");
161
- const payload =
162
- field(form, "SipHeader_x-wa-meta-cta-payload") ??
163
- field(form, "SipHeader_x-wa-meta-deeplink-payload");
164
- const row = await newCall(db(), {
165
- caller,
166
- channel: wacid ? "whatsapp" : "phone",
167
- payload,
168
- twilioCallSid: form.get("CallSid"),
169
- });
170
- return new Response(dialTwiml(sipUri(account.openaiProject, account.region), row.token), {
171
- headers: { "content-type": "text/xml" },
172
- });
173
- },
174
- });
175
-
176
- // OpenAI: the call reached the project. Picked up with the wizard's rules; from here on the
177
- // model speaks and this plugin answers its tool calls over the WebSocket.
178
- server.registerPublicRoute({
179
- method: "POST",
180
- path: "/openai",
181
- handler: async ({ request }) => {
182
- const account = await getAccount(db());
183
- if (!account) {
184
- return fail(404, "not_connected", "No account.");
185
- }
186
- const raw = await request.text();
187
- const h = (name: string) => request.headers.get(name);
188
- if (
189
- !webhookOk(
190
- account.openaiWebhookSecret,
191
- {
192
- id: h("webhook-id"),
193
- timestamp: h("webhook-timestamp"),
194
- signature: h("webhook-signature"),
195
- },
196
- raw,
197
- )
198
- ) {
199
- return fail(401, "bad_signature", "The signature does not match.");
200
- }
201
- let body: unknown;
202
- try {
203
- body = JSON.parse(raw);
204
- } catch {
205
- return fail(400, "bad_body", "Not JSON.");
206
- }
207
- const incoming = incomingCall(body);
208
- if (!incoming) {
209
- return { ok: true };
210
- }
211
- const { apiKey, model } = await server.speech.conversation();
212
- const token = incoming.headers.get("x-engenty-call");
213
- const row = token ? await callByToken(db(), token) : null;
214
- if (row?.status !== "ringing") {
215
- // Not a call of ours, or one answered already.
216
- await rejectCall(apiKey, incoming.callId).catch((err) => log.warn("reject:", err));
217
- return { ok: true };
218
- }
219
- const on = await offered();
220
- const byPayload = row.payload
221
- ? on.find((w) => w.token === row.payload || w.wizardId === row.payload)
222
- : undefined;
223
- const first = byPayload ?? (on.length === 1 ? on[0] : null);
224
- const lang = first?.lang ?? on[0]?.lang ?? "de";
225
- let socket: WebSocket | null = null;
226
- const session = new CallSession(
227
- {
228
- runs: server.runs,
229
- send: (event) => {
230
- if (socket?.readyState === WebSocket.OPEN) {
231
- socket.send(JSON.stringify(event));
232
- }
233
- },
234
- text: async (body) => {
235
- if (!account.smsFrom) {
236
- throw new Error("No number to text from (Settings → Calls).");
237
- }
238
- await sendText(
239
- { sid: account.twilioSid, token: account.twilioToken },
240
- account.smsFrom,
241
- row.caller,
242
- body,
243
- );
244
- },
245
- hangUp: async () => {
246
- await hangUp(apiKey, incoming.callId).catch((err) => log.warn("hangup:", err));
247
- // The tool's answer still goes out; the socket closes once it did.
248
- setTimeout(() => socket?.close(), 1000);
249
- },
250
- watch,
251
- unwatch,
252
- log,
253
- },
254
- row.caller,
255
- row.channel,
256
- first ? [] : on,
257
- lang,
258
- );
259
- if (first) {
260
- await session.start(first);
261
- }
262
- await acceptCall(apiKey, incoming.callId, {
263
- model,
264
- instructions: instructions(
265
- lang,
266
- first ? { title: first.title } : null,
267
- first ? [] : on.map((w) => w.title),
268
- ),
269
- tools: [...CALL_TOOLS],
270
- voice: VOICES[lang],
271
- });
272
- await updateCall(db(), row.token, {
273
- openaiCallId: incoming.callId,
274
- runId: session.runId,
275
- status: "live",
276
- });
277
- // The socket's events arrive outside any request: bound to this call's tenant here.
278
- const bound = <A extends unknown[]>(fn: (...args: A) => void) => AsyncResource.bind(fn);
279
- socket = openSocket(apiKey, incoming.callId);
280
- live.set(row.token, { session, socket });
281
- socket.addEventListener(
282
- "open",
283
- bound(() => {
284
- void session.hello().catch((err) => log.error("hello:", err));
285
- }),
286
- );
287
- socket.addEventListener(
288
- "message",
289
- bound((event: MessageEvent) => {
290
- void onMessage(session, String(event.data)).catch((err) => log.error("event:", err));
291
- }),
292
- );
293
- socket.addEventListener(
294
- "close",
295
- bound(() => {
296
- session.stop();
297
- live.delete(row.token);
298
- void updateCall(db(), row.token, { status: "done" }).catch(() => undefined);
299
- }),
300
- );
301
- socket.addEventListener(
302
- "error",
303
- bound(() => log.warn(`call ${row.token}: the socket to the model failed`)),
304
- );
305
- return { ok: true };
306
- },
307
- });
308
-
309
- /** What the model sent: a tool call is run and answered; the rest is noise here. */
310
- async function onMessage(session: CallSession, data: string): Promise<void> {
311
- let event: {
312
- type?: string;
313
- name?: string;
314
- arguments?: string;
315
- call_id?: string;
316
- error?: unknown;
317
- };
318
- try {
319
- event = JSON.parse(data);
320
- } catch {
321
- return;
322
- }
323
- if (event.type === "response.function_call_arguments.done" && event.name) {
324
- let args: Record<string, unknown> = {};
325
- try {
326
- args = event.arguments ? (JSON.parse(event.arguments) as Record<string, unknown>) : {};
327
- } catch {
328
- args = {};
329
- }
330
- const output = await session
331
- .call(event.name, args)
332
- .catch((err) => `Failed: ${(err as Error).message}`);
333
- const found = [...live.values()].find((l) => l.session === session);
334
- if (found?.socket.readyState === WebSocket.OPEN) {
335
- found.socket.send(
336
- JSON.stringify({
337
- type: "conversation.item.create",
338
- item: { type: "function_call_output", call_id: event.call_id, output },
339
- }),
340
- );
341
- found.socket.send(JSON.stringify({ type: "response.create" }));
342
- }
343
- } else if (event.type === "error") {
344
- log.warn("the model reported:", JSON.stringify(event.error));
345
- }
346
- }
347
-
348
- // ── The studio's side ──────────────────────────────────────────────────────
349
-
350
- const admin = (request: PluginRequest) => {
351
- if (request.user.role === "member") {
352
- fail(403, "forbidden", "Owners and admins only.");
353
- }
354
- };
355
-
356
- async function accountView() {
357
- const account = await getAccount(db());
358
- return {
359
- connected: Boolean(account),
360
- twilioSid: account?.twilioSid ?? "",
361
- hasToken: Boolean(account?.twilioToken),
362
- smsFrom: account?.smsFrom ?? "",
363
- openaiProject: account?.openaiProject ?? "",
364
- hasSecret: Boolean(account?.openaiWebhookSecret),
365
- region: account?.region ?? "eu",
366
- voiceWebhook: await server.publicUrl("/voice"),
367
- openaiWebhook: await server.publicUrl("/openai"),
368
- sipUri: account ? sipUri(account.openaiProject, account.region) : null,
369
- };
370
- }
371
-
372
- server.registerHttpRoute({
373
- method: "GET",
374
- path: "/account",
375
- role: "admin",
376
- handler: accountView,
377
- });
378
-
379
- server.registerHttpRoute({
380
- method: "PUT",
381
- path: "/account",
382
- role: "admin",
383
- handler: async (request) => {
384
- admin(request);
385
- const parsed = accountInput.safeParse(await request.json());
386
- if (!parsed.success) {
387
- return fail(400, "invalid", parsed.error.issues[0]?.message ?? "invalid");
388
- }
389
- await putAccount(db(), {
390
- ...parsed.data,
391
- smsFrom: parsed.data.smsFrom ? e164(parsed.data.smsFrom) : null,
392
- });
393
- return accountView();
394
- },
395
- });
396
-
397
- server.registerHttpRoute({
398
- method: "DELETE",
399
- path: "/account",
400
- role: "admin",
401
- handler: async (request) => {
402
- admin(request);
403
- await dropAccount(db());
404
- return accountView();
405
- },
406
- });
407
- });