wizards 0.4.0 → 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.
- package/apps/runtime/dist/agents/studio-assistant.js +67 -20
- package/apps/runtime/dist/docs/dev/plugins/reference.md +7 -4
- package/apps/runtime/dist/docs/dev/plugins/reloading.md +3 -3
- package/apps/runtime/dist/docs/dev/plugins/runners.md +9 -12
- package/apps/runtime/dist/docs/dev/plugins/server.md +3 -1
- package/apps/runtime/dist/docs/dev/plugins/space.md +19 -1
- package/apps/runtime/dist/docs/dev/plugins/studio.md +43 -6
- package/apps/runtime/dist/docs/dev/plugins/styling.md +8 -1
- package/apps/runtime/dist/docs/user/space.md +4 -4
- package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
- package/apps/runtime/dist/engine/steps.js +3 -2
- package/apps/runtime/dist/engine/template.js +14 -10
- package/apps/runtime/dist/film/project.js +15 -3
- package/apps/runtime/dist/plugins/loader.js +38 -2
- package/apps/runtime/dist/routes/studio.js +28 -1
- package/apps/runtime/dist/services/brand.js +61 -1
- package/apps/runtime/dist/services/projects.js +19 -10
- package/apps/runtime/dist/tools/plugin.js +11 -3
- package/apps/web/dist/assets/AboutDialog-CH1rQdia.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-DynvQuuR.js → ChangelogDialog-78MQLHfA.js} +1 -1
- package/apps/web/dist/assets/ChatView-DLJfy69U.js +10 -0
- package/apps/web/dist/assets/EditorPage-BYrZokVk.js +3 -0
- package/apps/web/dist/assets/{FlowPreview-BZLnG7GV.js → FlowPreview-xIWPdOCV.js} +1 -1
- package/apps/web/dist/assets/{Harness-CNh3nbB_.js → Harness-CsDhNLSW.js} +2 -2
- package/apps/web/dist/assets/NewWizardChannels-JB3V6DCL.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-Bkh-hjSe.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-Diw1ILjC.js +1 -0
- package/apps/web/dist/assets/ProjectPage-FgoZDzPI.js +1 -0
- package/apps/web/dist/assets/PublicRunner-ClT6B7t5.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-DTjv2Jzm.js → ReadOnly-DQPHmvS0.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-DAwTEyjM.js +4 -0
- package/apps/web/dist/assets/{RunnerView-BPPmlv9z.js → RunnerView-B_tJroFM.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-BsMN-XQs.js +1 -0
- package/apps/web/dist/assets/SetupPage-C7FuIpSt.js +1 -0
- package/apps/web/dist/assets/SharePage-CbFe83L9.js +1 -0
- package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
- package/apps/web/dist/assets/{circle-alert-CVOH4wAv.js → circle-alert-DCBV7n5k.js} +1 -1
- package/apps/web/dist/assets/{esm-BfXdeWaI.js → esm-BhIeKBJY.js} +1 -1
- package/apps/web/dist/assets/index-CmxVazOf.css +1 -0
- package/apps/web/dist/assets/index-fflYi8P1.js +14 -0
- package/apps/web/dist/assets/{lock-DHBh62LA.js → lock-DlzTfwxP.js} +1 -1
- package/apps/web/dist/assets/{phone-Bt1cyWpY.js → phone-BFD815nc.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-ace1vI3i.js → settings-menu-C5Y8SOhG.js} +1 -1
- package/apps/web/dist/assets/ui-DTuixH4y.js +134 -0
- package/apps/web/dist/assets/{upload-CCEq88l4.js → upload-BEf5-d6X.js} +1 -1
- package/apps/web/dist/index.html +4 -5
- package/apps/web/dist/mcp/flow.html +2 -2
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +36 -2
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +44 -1
- package/node_modules/@engenty-wizards/shared/dist/feature-flags.d.ts +18 -0
- package/node_modules/@engenty-wizards/shared/dist/feature-flags.js +18 -0
- package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +22 -6
- package/node_modules/@engenty-wizards/shared/dist/projects.js +20 -0
- package/package.json +1 -1
- package/apps/web/dist/assets/AboutDialog-CPQiN7H9.js +0 -1
- package/apps/web/dist/assets/ChatView-CKo_9IZD.js +0 -10
- package/apps/web/dist/assets/EditorPage-CDIJXC4T.js +0 -3
- package/apps/web/dist/assets/NewWizardChannels-C1Ss9UMY.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-2qLAbvDL.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +0 -1
- package/apps/web/dist/assets/ProjectPage-CGuLUuOs.js +0 -1
- package/apps/web/dist/assets/PublicRunner-BKw1lOW4.js +0 -1
- package/apps/web/dist/assets/RunDrawer-D1I5xqVf.js +0 -4
- package/apps/web/dist/assets/SettingsPage-zbomQfGy.js +0 -1
- package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +0 -1
- package/apps/web/dist/assets/SharePage-BY49IJgT.js +0 -1
- package/apps/web/dist/assets/changelog-DYZC2E6q.js +0 -1
- package/apps/web/dist/assets/index-B9xVkr2X.css +0 -1
- package/apps/web/dist/assets/index-BH0PYgov.js +0 -15
- package/apps/web/dist/assets/logo-C4V2_3Ar.js +0 -1
- package/apps/web/dist/assets/ui-D2SqIZeQ.js +0 -133
- package/modules/calls/dist/client.css +0 -3
- package/modules/calls/dist/client.js +0 -1
- package/modules/calls/engenty.plugin.json +0 -8
- package/modules/calls/migrations/0001_init.sql +0 -26
- package/modules/calls/package.json +0 -25
- package/modules/calls/src/openai.ts +0 -155
- package/modules/calls/src/plugin.ts +0 -407
- package/modules/calls/src/rules.ts +0 -226
- package/modules/calls/src/schema.ts +0 -39
- package/modules/calls/src/session.ts +0 -353
- package/modules/calls/src/store.ts +0 -124
- package/modules/calls/src/twilio.ts +0 -90
- package/modules/calls/tsconfig.json +0 -10
- package/modules/calls/ui/Settings.tsx +0 -137
- package/modules/calls/ui/app.ts +0 -50
- package/modules/calls/ui/messages.ts +0 -81
- package/modules/calls/ui/parts.tsx +0 -36
- package/modules/calls/ui/plugin.tsx +0 -15
- package/modules/sms/dist/client.css +0 -3
- package/modules/sms/dist/client.js +0 -1
- package/modules/sms/engenty.plugin.json +0 -8
- package/modules/sms/migrations/0001_init.sql +0 -26
- package/modules/sms/package.json +0 -24
- package/modules/sms/src/plugin.ts +0 -329
- package/modules/sms/src/schema.ts +0 -26
- package/modules/sms/src/store.ts +0 -140
- package/modules/sms/src/surface.ts +0 -70
- package/modules/sms/src/twilio.ts +0 -130
- package/modules/sms/tsconfig.json +0 -10
- package/modules/sms/ui/Settings.tsx +0 -113
- package/modules/sms/ui/Share.tsx +0 -60
- package/modules/sms/ui/app.ts +0 -51
- package/modules/sms/ui/messages.ts +0 -70
- package/modules/sms/ui/parts.tsx +0 -36
- package/modules/sms/ui/plugin.tsx +0 -20
- package/modules/whatsapp/dist/client.css +0 -3
- package/modules/whatsapp/dist/client.js +0 -1
- package/modules/whatsapp/engenty.plugin.json +0 -8
- package/modules/whatsapp/migrations/0001_init.sql +0 -30
- package/modules/whatsapp/package.json +0 -24
- package/modules/whatsapp/src/graph.ts +0 -271
- package/modules/whatsapp/src/inbound.ts +0 -143
- package/modules/whatsapp/src/plugin.ts +0 -374
- package/modules/whatsapp/src/schema.ts +0 -32
- package/modules/whatsapp/src/store.ts +0 -156
- package/modules/whatsapp/src/surface.ts +0 -127
- package/modules/whatsapp/tsconfig.json +0 -10
- package/modules/whatsapp/ui/Settings.tsx +0 -141
- package/modules/whatsapp/ui/Share.tsx +0 -60
- package/modules/whatsapp/ui/app.ts +0 -54
- package/modules/whatsapp/ui/messages.ts +0 -80
- package/modules/whatsapp/ui/parts.tsx +0 -36
- package/modules/whatsapp/ui/plugin.tsx +0 -20
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
import type { PluginRunReport, PluginRuns } from "@engenty-wizards/plugin-sdk";
|
|
2
|
-
import { type ThreadLang, toIso } from "@engenty-wizards/plugin-sdk/thread";
|
|
3
|
-
import { ASKS, RUNNER, stateText } from "./rules";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* One call: the run it drives, the answers collected for the current page, and the tools the
|
|
7
|
-
* model calls over the WebSocket. The model hears the state of the run as text after every
|
|
8
|
-
* change, and a link is texted for what a voice cannot do.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export interface Offered {
|
|
12
|
-
wizardId: string;
|
|
13
|
-
token: string;
|
|
14
|
-
title: string;
|
|
15
|
-
lang: ThreadLang;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface SessionDeps {
|
|
19
|
-
runs: PluginRuns;
|
|
20
|
-
/** Sends one event to the model. */
|
|
21
|
-
send(event: Record<string, unknown>): void;
|
|
22
|
-
/** Texts the caller. */
|
|
23
|
-
text(body: string): Promise<void>;
|
|
24
|
-
hangUp(): Promise<void>;
|
|
25
|
-
/** Hear the run, and call `changed` when it moves. */
|
|
26
|
-
watch(runId: string): void;
|
|
27
|
-
unwatch(runId: string): void;
|
|
28
|
-
log: { warn(...args: unknown[]): void };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const asks = new Set(ASKS);
|
|
32
|
-
|
|
33
|
-
const YES = new Set(["true", "yes", "ja", "1", "on"]);
|
|
34
|
-
const NO = new Set(["false", "no", "nein", "0", "off"]);
|
|
35
|
-
|
|
36
|
-
export class CallSession {
|
|
37
|
-
runId: string | null = null;
|
|
38
|
-
lang: ThreadLang;
|
|
39
|
-
private draft: { step: string; values: Record<string, unknown> } | null = null;
|
|
40
|
-
private report: PluginRunReport | null = null;
|
|
41
|
-
private linked = false;
|
|
42
|
-
private ended = false;
|
|
43
|
-
|
|
44
|
-
constructor(
|
|
45
|
-
private readonly deps: SessionDeps,
|
|
46
|
-
readonly caller: string,
|
|
47
|
-
readonly channel: "whatsapp" | "phone",
|
|
48
|
-
private lobby: Offered[],
|
|
49
|
-
lang: ThreadLang,
|
|
50
|
-
) {
|
|
51
|
-
this.lang = lang;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The wizard the call runs, started; the rules are made from it. */
|
|
55
|
-
async start(wizard: Offered): Promise<PluginRunReport | null> {
|
|
56
|
-
this.lang = wizard.lang;
|
|
57
|
-
// On WhatsApp the person is the same as in the thread: the number without its plus.
|
|
58
|
-
const id = this.channel === "whatsapp" ? this.caller.replace(/^\+/, "") : this.caller;
|
|
59
|
-
const { runId } = await this.deps.runs.start({
|
|
60
|
-
token: wizard.token,
|
|
61
|
-
person: { channel: this.channel, id },
|
|
62
|
-
runner: RUNNER,
|
|
63
|
-
});
|
|
64
|
-
this.runId = runId;
|
|
65
|
-
this.lobby = [];
|
|
66
|
-
this.draft = null;
|
|
67
|
-
this.linked = false;
|
|
68
|
-
return this.look(5);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** The state as the model reads it. */
|
|
72
|
-
state(): string {
|
|
73
|
-
return stateText(
|
|
74
|
-
this.report,
|
|
75
|
-
this.draft?.values ?? {},
|
|
76
|
-
this.lobby.map((w) => w.title),
|
|
77
|
-
asks,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Tells the model the state and lets it speak: at the start, and when the run changed. */
|
|
82
|
-
async hello(): Promise<void> {
|
|
83
|
-
await this.look(0);
|
|
84
|
-
this.say(
|
|
85
|
-
this.state(),
|
|
86
|
-
this.lang === "de" ? "Begrüße die Person und beginne." : "Greet the person and begin.",
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The run moved while nobody spoke: the model hears the new state and says what changed. */
|
|
91
|
-
async changed(): Promise<void> {
|
|
92
|
-
if (this.ended) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
await this.look(0);
|
|
96
|
-
if (this.report?.status === "done" && !this.linked) {
|
|
97
|
-
await this.link().catch((err) => this.deps.log.warn("the link was not texted:", err));
|
|
98
|
-
}
|
|
99
|
-
this.say(
|
|
100
|
-
this.state(),
|
|
101
|
-
this.lang === "de"
|
|
102
|
-
? "Sag in einem Satz, was sich getan hat, und mach weiter."
|
|
103
|
-
: "Say in one sentence what happened, and go on.",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The model called a tool: what it gets back. */
|
|
108
|
-
async call(name: string, args: Record<string, unknown>): Promise<string> {
|
|
109
|
-
switch (name) {
|
|
110
|
-
case "get_state":
|
|
111
|
-
await this.look(0);
|
|
112
|
-
return this.state();
|
|
113
|
-
case "choose_wizard": {
|
|
114
|
-
const wanted = String(args.name ?? "").toLowerCase();
|
|
115
|
-
const wizard =
|
|
116
|
-
this.lobby.find((w) => w.title.toLowerCase() === wanted) ??
|
|
117
|
-
this.lobby.find(
|
|
118
|
-
(w) => w.title.toLowerCase().includes(wanted) || wanted.includes(w.title.toLowerCase()),
|
|
119
|
-
);
|
|
120
|
-
if (!wizard) {
|
|
121
|
-
return `No wizard called "${args.name}". Offered: ${this.lobby.map((w) => w.title).join(", ")}.`;
|
|
122
|
-
}
|
|
123
|
-
await this.start(wizard);
|
|
124
|
-
return this.state();
|
|
125
|
-
}
|
|
126
|
-
case "send_link":
|
|
127
|
-
return this.link();
|
|
128
|
-
case "hang_up":
|
|
129
|
-
this.ended = true;
|
|
130
|
-
await this.deps.hangUp();
|
|
131
|
-
return "The call is over.";
|
|
132
|
-
}
|
|
133
|
-
if (!this.runId) {
|
|
134
|
-
return "No wizard runs yet: choose_wizard first.";
|
|
135
|
-
}
|
|
136
|
-
switch (name) {
|
|
137
|
-
case "set_field":
|
|
138
|
-
return this.setField(String(args.field ?? ""), args.value);
|
|
139
|
-
case "submit_page":
|
|
140
|
-
return this.submit();
|
|
141
|
-
case "accept_review":
|
|
142
|
-
return this.command(async (w) => {
|
|
143
|
-
if (!w || !("review" in w)) {
|
|
144
|
-
return "Nothing to review right now.";
|
|
145
|
-
}
|
|
146
|
-
await this.deps.runs.review(this.runId!, w.review, { type: "accept" });
|
|
147
|
-
return null;
|
|
148
|
-
});
|
|
149
|
-
case "regenerate":
|
|
150
|
-
return this.command(async (w) => {
|
|
151
|
-
if (!w || !("review" in w)) {
|
|
152
|
-
return "Nothing to review right now.";
|
|
153
|
-
}
|
|
154
|
-
await this.deps.runs.review(this.runId!, w.review, {
|
|
155
|
-
type: "regenerate",
|
|
156
|
-
target: String(args.target ?? w.show[0] ?? ""),
|
|
157
|
-
note: String(args.note ?? ""),
|
|
158
|
-
});
|
|
159
|
-
return null;
|
|
160
|
-
});
|
|
161
|
-
case "answer_ask":
|
|
162
|
-
return this.command(async (w) => {
|
|
163
|
-
if (!w || !("ask" in w)) {
|
|
164
|
-
return "No question is open right now.";
|
|
165
|
-
}
|
|
166
|
-
await this.deps.runs.answerAsk(this.runId!, w.ask, args.allow ? "allow" : "skip");
|
|
167
|
-
return null;
|
|
168
|
-
});
|
|
169
|
-
case "go_back":
|
|
170
|
-
return this.command(async () => {
|
|
171
|
-
await this.deps.runs.control(this.runId!, "back");
|
|
172
|
-
this.draft = null;
|
|
173
|
-
return null;
|
|
174
|
-
});
|
|
175
|
-
default:
|
|
176
|
-
return `Unknown tool ${name}.`;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
stop(): void {
|
|
181
|
-
this.ended = true;
|
|
182
|
-
if (this.runId) {
|
|
183
|
-
this.deps.unwatch(this.runId);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ── Tools ──────────────────────────────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
private async setField(id: string, raw: unknown): Promise<string> {
|
|
190
|
-
await this.look(0);
|
|
191
|
-
const w = this.report?.waitingFor;
|
|
192
|
-
if (!w || !("page" in w)) {
|
|
193
|
-
return "No page is open right now. get_state says what the run waits for.";
|
|
194
|
-
}
|
|
195
|
-
const field = w.fields.find((f) => f.id === id && f.shown !== false);
|
|
196
|
-
if (!field) {
|
|
197
|
-
return `No open field "${id}" on this page.`;
|
|
198
|
-
}
|
|
199
|
-
if (!asks.has(field.kind)) {
|
|
200
|
-
return `"${field.label}" needs the screen: send_link, then wait.`;
|
|
201
|
-
}
|
|
202
|
-
if (!this.draft || this.draft.step !== w.page) {
|
|
203
|
-
this.draft = { step: w.page, values: {} };
|
|
204
|
-
}
|
|
205
|
-
const value = this.coerce(field.kind, field.options ?? [], raw);
|
|
206
|
-
if (value instanceof Error) {
|
|
207
|
-
return value.message;
|
|
208
|
-
}
|
|
209
|
-
this.draft.values[id] = value;
|
|
210
|
-
// A later field's condition may read this answer: the page is read again with the draft.
|
|
211
|
-
await this.look(0);
|
|
212
|
-
return this.state();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** The answer as the page takes it, or what is wrong with it. */
|
|
216
|
-
private coerce(kind: string, options: string[], raw: unknown): unknown | Error {
|
|
217
|
-
const text = raw === undefined || raw === null ? "" : String(raw).trim();
|
|
218
|
-
switch (kind) {
|
|
219
|
-
case "select": {
|
|
220
|
-
const found = options.find((o) => o.toLowerCase() === text.toLowerCase());
|
|
221
|
-
return found ?? new Error(`Pick one of: ${options.join(" | ")}.`);
|
|
222
|
-
}
|
|
223
|
-
case "multiselect": {
|
|
224
|
-
const given = Array.isArray(raw) ? raw.map(String) : text.split(/[,;]+/);
|
|
225
|
-
const picked = given
|
|
226
|
-
.map((g) => options.find((o) => o.toLowerCase() === g.trim().toLowerCase()))
|
|
227
|
-
.filter((o): o is string => Boolean(o));
|
|
228
|
-
return picked.length ? picked : new Error(`Pick from: ${options.join(" | ")}.`);
|
|
229
|
-
}
|
|
230
|
-
case "toggle": {
|
|
231
|
-
if (typeof raw === "boolean") {
|
|
232
|
-
return raw;
|
|
233
|
-
}
|
|
234
|
-
const lower = text.toLowerCase();
|
|
235
|
-
return YES.has(lower) ? true : NO.has(lower) ? false : new Error("true or false.");
|
|
236
|
-
}
|
|
237
|
-
case "number": {
|
|
238
|
-
const n = Number(text.replace(",", "."));
|
|
239
|
-
return Number.isFinite(n) ? n : new Error("A number.");
|
|
240
|
-
}
|
|
241
|
-
case "date": {
|
|
242
|
-
const iso = toIso(text);
|
|
243
|
-
return iso ?? new Error("A date as YYYY-MM-DD or DD.MM.YYYY.");
|
|
244
|
-
}
|
|
245
|
-
case "location":
|
|
246
|
-
return text ? { label: text } : new Error("An address.");
|
|
247
|
-
default:
|
|
248
|
-
return text || new Error("An answer.");
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
private async submit(): Promise<string> {
|
|
253
|
-
await this.look(0);
|
|
254
|
-
const w = this.report?.waitingFor;
|
|
255
|
-
if (!w || !("page" in w)) {
|
|
256
|
-
return "No page is open right now.";
|
|
257
|
-
}
|
|
258
|
-
const values = this.draft?.step === w.page ? this.draft.values : {};
|
|
259
|
-
const missing = w.fields.filter(
|
|
260
|
-
(f) =>
|
|
261
|
-
f.shown !== false &&
|
|
262
|
-
f.required &&
|
|
263
|
-
asks.has(f.kind) &&
|
|
264
|
-
(values[f.id] === undefined || values[f.id] === null || values[f.id] === ""),
|
|
265
|
-
);
|
|
266
|
-
if (missing.length) {
|
|
267
|
-
return `Still open: ${missing.map((f) => `${f.id} "${f.label}"`).join(", ")}. Ask for them first.`;
|
|
268
|
-
}
|
|
269
|
-
if (w.fields.some((f) => f.shown !== false && !asks.has(f.kind))) {
|
|
270
|
-
return "This page has a field that needs the screen: send_link and wait until the state changes.";
|
|
271
|
-
}
|
|
272
|
-
try {
|
|
273
|
-
await this.deps.runs.answerPage(this.runId!, w.page, values);
|
|
274
|
-
} catch (err) {
|
|
275
|
-
const fields = (err as { data?: { fields?: { field: string; message: string }[] } }).data
|
|
276
|
-
?.fields;
|
|
277
|
-
return fields?.length
|
|
278
|
-
? `Not accepted: ${fields.map((f) => `${f.field}: ${f.message}`).join("; ")}. Ask again.`
|
|
279
|
-
: `Not accepted: ${(err as Error).message}`;
|
|
280
|
-
}
|
|
281
|
-
this.draft = null;
|
|
282
|
-
await this.look(15);
|
|
283
|
-
return this.state();
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** A command on the run, then the new state. `act` answers with text where the command does not fit. */
|
|
287
|
-
private async command(
|
|
288
|
-
act: (waiting: PluginRunReport["waitingFor"]) => Promise<string | null>,
|
|
289
|
-
): Promise<string> {
|
|
290
|
-
await this.look(0);
|
|
291
|
-
try {
|
|
292
|
-
const answer = await act(this.report?.waitingFor);
|
|
293
|
-
if (answer) {
|
|
294
|
-
return answer;
|
|
295
|
-
}
|
|
296
|
-
} catch (err) {
|
|
297
|
-
return `Not accepted: ${(err as Error).message}`;
|
|
298
|
-
}
|
|
299
|
-
await this.look(15);
|
|
300
|
-
return this.state();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Texts the link to the run page, once per state; the run is watched from then on. */
|
|
304
|
-
private async link(): Promise<string> {
|
|
305
|
-
if (!this.runId) {
|
|
306
|
-
return "No wizard runs yet.";
|
|
307
|
-
}
|
|
308
|
-
const url = await this.deps.runs.handoffUrl(this.runId);
|
|
309
|
-
const done = this.report?.status === "done";
|
|
310
|
-
await this.deps.text(
|
|
311
|
-
this.lang === "de"
|
|
312
|
-
? done
|
|
313
|
-
? `Fertig! Alles liegt hier: ${url}`
|
|
314
|
-
: `Hier geht es weiter: ${url}`
|
|
315
|
-
: done
|
|
316
|
-
? `Done! Everything is here: ${url}`
|
|
317
|
-
: `It goes on here: ${url}`,
|
|
318
|
-
);
|
|
319
|
-
this.linked = done;
|
|
320
|
-
this.deps.watch(this.runId);
|
|
321
|
-
return "The link was texted. Say so; then wait for the state to change.";
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ── The run ────────────────────────────────────────────────────────────────
|
|
325
|
-
|
|
326
|
-
/** Reads the run as it stands, waiting a little while it works. */
|
|
327
|
-
private async look(waitSeconds: number): Promise<PluginRunReport | null> {
|
|
328
|
-
if (!this.runId) {
|
|
329
|
-
this.report = null;
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
332
|
-
this.report = await this.deps.runs.report(this.runId, {
|
|
333
|
-
waitSeconds,
|
|
334
|
-
draft: this.draft?.values,
|
|
335
|
-
});
|
|
336
|
-
if (!this.report.waitingFor && this.report.status === "running") {
|
|
337
|
-
this.deps.watch(this.runId);
|
|
338
|
-
}
|
|
339
|
-
if (this.report.status === "done" || this.report.status === "failed") {
|
|
340
|
-
this.deps.unwatch(this.runId);
|
|
341
|
-
}
|
|
342
|
-
return this.report;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** A message with the state, and a turn of the model with what to do about it. */
|
|
346
|
-
private say(state: string, instructions: string): void {
|
|
347
|
-
this.deps.send({
|
|
348
|
-
type: "conversation.item.create",
|
|
349
|
-
item: { type: "message", role: "user", content: [{ type: "input_text", text: state }] },
|
|
350
|
-
});
|
|
351
|
-
this.deps.send({ type: "response.create", response: { instructions } });
|
|
352
|
-
}
|
|
353
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import type { PluginDb } from "@engenty-wizards/plugin-sdk";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
4
|
-
import { account, call } from "./schema";
|
|
5
|
-
|
|
6
|
-
/** The plugin's rows: the accounts, the calls. */
|
|
7
|
-
|
|
8
|
-
export interface Account {
|
|
9
|
-
twilioSid: string;
|
|
10
|
-
twilioToken: string;
|
|
11
|
-
openaiProject: string;
|
|
12
|
-
openaiWebhookSecret: string;
|
|
13
|
-
region: "eu" | "us";
|
|
14
|
-
/** The Twilio number the links are texted from; none: no texts. */
|
|
15
|
-
smsFrom: string | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function getAccount(db: PluginDb): Promise<Account | null> {
|
|
19
|
-
const [row] = await db.select().from(account).where(eq(account.id, 1));
|
|
20
|
-
return row
|
|
21
|
-
? {
|
|
22
|
-
twilioSid: row.twilioSid,
|
|
23
|
-
twilioToken: row.twilioToken,
|
|
24
|
-
openaiProject: row.openaiProject,
|
|
25
|
-
openaiWebhookSecret: row.openaiWebhookSecret,
|
|
26
|
-
region: row.region,
|
|
27
|
-
smsFrom: row.smsFrom,
|
|
28
|
-
}
|
|
29
|
-
: null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface AccountInput {
|
|
33
|
-
twilioSid: string;
|
|
34
|
-
/** Left out: stays as it is. */
|
|
35
|
-
twilioToken?: string;
|
|
36
|
-
openaiProject: string;
|
|
37
|
-
/** Left out: stays as it is. */
|
|
38
|
-
openaiWebhookSecret?: string;
|
|
39
|
-
region: "eu" | "us";
|
|
40
|
-
smsFrom?: string | null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function putAccount(db: PluginDb, input: AccountInput): Promise<Account> {
|
|
44
|
-
const before = await getAccount(db);
|
|
45
|
-
const twilioToken = input.twilioToken || before?.twilioToken;
|
|
46
|
-
const openaiWebhookSecret = input.openaiWebhookSecret || before?.openaiWebhookSecret;
|
|
47
|
-
if (!twilioToken) {
|
|
48
|
-
throw Object.assign(new Error("Twilio's auth token is needed."), {
|
|
49
|
-
status: 400,
|
|
50
|
-
code: "no_token",
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
if (!openaiWebhookSecret) {
|
|
54
|
-
throw Object.assign(new Error("OpenAI's webhook secret is needed."), {
|
|
55
|
-
status: 400,
|
|
56
|
-
code: "no_secret",
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
const next = {
|
|
60
|
-
twilioSid: input.twilioSid,
|
|
61
|
-
twilioToken,
|
|
62
|
-
openaiProject: input.openaiProject,
|
|
63
|
-
openaiWebhookSecret,
|
|
64
|
-
region: input.region,
|
|
65
|
-
smsFrom: input.smsFrom || null,
|
|
66
|
-
updatedAt: new Date(),
|
|
67
|
-
};
|
|
68
|
-
await db
|
|
69
|
-
.insert(account)
|
|
70
|
-
.values({ id: 1, ...next })
|
|
71
|
-
.onConflictDoUpdate({ target: account.id, set: next });
|
|
72
|
-
const { updatedAt: _at, ...saved } = next;
|
|
73
|
-
return saved;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export async function dropAccount(db: PluginDb): Promise<void> {
|
|
77
|
-
await db.delete(account).where(eq(account.id, 1));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Calls ───────────────────────────────────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
export type CallRow = typeof call.$inferSelect;
|
|
83
|
-
|
|
84
|
-
export async function newCall(
|
|
85
|
-
db: PluginDb,
|
|
86
|
-
input: {
|
|
87
|
-
caller: string;
|
|
88
|
-
channel: "whatsapp" | "phone";
|
|
89
|
-
payload: string | null;
|
|
90
|
-
twilioCallSid: string | null;
|
|
91
|
-
},
|
|
92
|
-
): Promise<CallRow> {
|
|
93
|
-
const now = new Date();
|
|
94
|
-
const row: CallRow = {
|
|
95
|
-
token: randomBytes(18).toString("base64url"),
|
|
96
|
-
caller: input.caller,
|
|
97
|
-
channel: input.channel,
|
|
98
|
-
payload: input.payload,
|
|
99
|
-
twilioCallSid: input.twilioCallSid,
|
|
100
|
-
openaiCallId: null,
|
|
101
|
-
runId: null,
|
|
102
|
-
status: "ringing",
|
|
103
|
-
createdAt: now,
|
|
104
|
-
updatedAt: now,
|
|
105
|
-
};
|
|
106
|
-
await db.insert(call).values(row);
|
|
107
|
-
return row;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export async function callByToken(db: PluginDb, token: string): Promise<CallRow | null> {
|
|
111
|
-
const [row] = await db.select().from(call).where(eq(call.token, token));
|
|
112
|
-
return row ?? null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export async function updateCall(
|
|
116
|
-
db: PluginDb,
|
|
117
|
-
token: string,
|
|
118
|
-
patch: Partial<Pick<CallRow, "openaiCallId" | "runId" | "status">>,
|
|
119
|
-
): Promise<void> {
|
|
120
|
-
await db
|
|
121
|
-
.update(call)
|
|
122
|
-
.set({ ...patch, updatedAt: new Date() })
|
|
123
|
-
.where(eq(call.token, token));
|
|
124
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Twilio carries the call: a SIP domain takes Meta's INVITE (a WhatsApp call), a Twilio number
|
|
5
|
-
* takes a phone call, and either asks this plugin for TwiML. The same account texts the links.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export const API = "https://api.twilio.com/2010-04-01";
|
|
9
|
-
|
|
10
|
-
export interface TwilioAccount {
|
|
11
|
-
sid: string;
|
|
12
|
-
token: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class TwilioError extends Error {
|
|
16
|
-
constructor(
|
|
17
|
-
message: string,
|
|
18
|
-
readonly status: number,
|
|
19
|
-
) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = "TwilioError";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** A number as a phone writes it: +, then digits. `sip:+43…@host` gives the same. */
|
|
26
|
-
export function e164(raw: string): string {
|
|
27
|
-
const user = raw.replace(/^sips?:/, "").split("@")[0];
|
|
28
|
-
const digits = user.replace(/[^\d]/g, "");
|
|
29
|
-
return `+${digits}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Sends one text, from the number the call came in on. */
|
|
33
|
-
export async function sendText(
|
|
34
|
-
account: TwilioAccount,
|
|
35
|
-
from: string,
|
|
36
|
-
to: string,
|
|
37
|
-
body: string,
|
|
38
|
-
fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args),
|
|
39
|
-
): Promise<void> {
|
|
40
|
-
const res = await fetchImpl(`${API}/Accounts/${encodeURIComponent(account.sid)}/Messages.json`, {
|
|
41
|
-
method: "POST",
|
|
42
|
-
headers: {
|
|
43
|
-
authorization: `Basic ${Buffer.from(`${account.sid}:${account.token}`).toString("base64")}`,
|
|
44
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
45
|
-
},
|
|
46
|
-
body: new URLSearchParams({ To: to, From: from, Body: body }).toString(),
|
|
47
|
-
signal: AbortSignal.timeout(20_000),
|
|
48
|
-
});
|
|
49
|
-
if (!res.ok) {
|
|
50
|
-
const text = await res.text();
|
|
51
|
-
let message = `Twilio answered ${res.status}.`;
|
|
52
|
-
try {
|
|
53
|
-
message = (JSON.parse(text) as { message?: string }).message ?? message;
|
|
54
|
-
} catch {
|
|
55
|
-
// Not JSON: the status says enough.
|
|
56
|
-
}
|
|
57
|
-
throw new TwilioError(message, res.status);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** `X-Twilio-Signature`: HMAC-SHA1, base64, of the address with the sorted form fields appended. */
|
|
62
|
-
export function signatureOk(
|
|
63
|
-
token: string,
|
|
64
|
-
urls: string[],
|
|
65
|
-
form: URLSearchParams,
|
|
66
|
-
header: string | null,
|
|
67
|
-
): boolean {
|
|
68
|
-
if (!header) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
const given = Buffer.from(header);
|
|
72
|
-
const names = [...new Set(form.keys())].sort();
|
|
73
|
-
return urls.some((url) => {
|
|
74
|
-
const data = names.reduce((acc, name) => acc + name + form.getAll(name).join(""), url);
|
|
75
|
-
const expected = Buffer.from(createHmac("sha1", token).update(data, "utf8").digest("base64"));
|
|
76
|
-
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** TwiML that hands the call to a SIP address, with a header that names the call to us. */
|
|
81
|
-
export function dialTwiml(sipUri: string, token: string): string {
|
|
82
|
-
const uri = `${sipUri}?x-engenty-call=${encodeURIComponent(token)}`;
|
|
83
|
-
const safe = uri.replace(/&/g, "&").replace(/</g, "<");
|
|
84
|
-
return `<?xml version="1.0" encoding="UTF-8"?><Response><Dial answerOnBridge="true"><Sip>${safe}</Sip></Dial></Response>`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export const emptyTwiml = () =>
|
|
88
|
-
new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
|
|
89
|
-
headers: { "content-type": "text/xml" },
|
|
90
|
-
});
|