@engenty/wizards-runtime 0.7.11 → 0.8.0
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/README.md +5 -1
- package/apps/runtime/dist/authoring/guide.js +14 -2
- package/apps/runtime/dist/cli/exec.js +4 -1
- package/apps/runtime/dist/connectors/builtin.js +4 -2
- package/apps/runtime/dist/connectors/index.js +35 -4
- package/apps/runtime/dist/connectors/smtp.js +221 -0
- package/apps/runtime/dist/db/migrations/tenant/0018_share_job.sql +18 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0018_snapshot.json +3460 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +19 -0
- package/apps/runtime/dist/docs/dev/index.md +25 -13
- package/apps/runtime/dist/docs/dev/meta.json +8 -2
- package/apps/runtime/dist/docs/dev/wizard-format/agent-steps.md +112 -0
- package/apps/runtime/dist/docs/dev/wizard-format/branches.md +63 -0
- package/apps/runtime/dist/docs/dev/wizard-format/film-steps.md +47 -0
- package/apps/runtime/dist/docs/dev/wizard-format/generate-steps.md +57 -0
- package/apps/runtime/dist/docs/dev/wizard-format/index.md +188 -0
- package/apps/runtime/dist/docs/dev/wizard-format/lists-and-connections.md +65 -0
- package/apps/runtime/dist/docs/dev/wizard-format/meta.json +19 -0
- package/apps/runtime/dist/docs/dev/wizard-format/pages-and-fields.md +102 -0
- package/apps/runtime/dist/docs/dev/wizard-format/review-and-result.md +64 -0
- package/apps/runtime/dist/docs/dev/wizard-format/templates.md +57 -0
- package/apps/runtime/dist/docs/dev/wizard-format/validate.md +63 -0
- package/apps/runtime/dist/docs/dev/wizard-format/views-and-widgets.md +103 -0
- package/apps/runtime/dist/docs/dev/wizard-format/wizard-file.md +147 -0
- package/apps/runtime/dist/docs/user/run.md +21 -4
- package/apps/runtime/dist/docs/user/steps.md +26 -1
- package/apps/runtime/dist/docs/user/test-and-share.md +3 -2
- package/apps/runtime/dist/engine/requirements.js +22 -7
- package/apps/runtime/dist/engine/runner.js +17 -8
- package/apps/runtime/dist/env.js +5 -0
- package/apps/runtime/dist/harness/antigravity.js +2 -10
- package/apps/runtime/dist/harness/claude.js +2 -10
- package/apps/runtime/dist/harness/codex.js +5 -11
- package/apps/runtime/dist/harness/cursor.js +2 -10
- package/apps/runtime/dist/harness/env.js +23 -0
- package/apps/runtime/dist/harness/index.js +6 -3
- package/apps/runtime/dist/routes/runs.js +106 -6
- package/apps/runtime/dist/routes/studio.js +12 -0
- package/apps/runtime/dist/sharing/agent.js +65 -0
- package/apps/runtime/dist/sharing/files.js +121 -0
- package/apps/runtime/dist/sharing/index.js +298 -0
- package/apps/runtime/dist/sharing/jobs.js +186 -0
- package/apps/runtime/dist/sharing/state.js +116 -0
- package/apps/runtime/dist/sharing/targets/browser.js +131 -0
- package/apps/runtime/dist/sharing/targets/connector.js +63 -0
- package/apps/runtime/dist/sharing/targets/email.js +98 -0
- package/apps/runtime/dist/sharing/targets/slack.js +54 -0
- package/apps/runtime/dist/sharing/targets/storage.js +164 -0
- package/apps/runtime/dist/sharing/types.js +13 -0
- package/apps/runtime/dist/texts.js +108 -0
- package/apps/runtime/dist/tools/browser.js +48 -0
- package/apps/runtime/dist/tools/connector.js +3 -2
- package/apps/runtime/dist/tools/store.js +18 -1
- package/apps/web/dist/assets/AboutDialog-CiiSwL4t.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-CZrd-MgC.js → ChangelogDialog-BEyz7LNE.js} +1 -1
- package/apps/web/dist/assets/ChatView-9RkWts5F.js +10 -0
- package/apps/web/dist/assets/EditorPage-CCsKKcYo.js +4 -0
- package/apps/web/dist/assets/{FlowPreview-oX_Emm7u.js → FlowPreview-CLf8WHiG.js} +1 -1
- package/apps/web/dist/assets/{Harness-DiB8CBdY.js → Harness-jc3jpWvI.js} +2 -2
- package/apps/web/dist/assets/NewWizardChannels-BPGZ4pfP.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-tidycL7Y.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-DtVuaIuv.js +1 -0
- package/apps/web/dist/assets/ProjectPage-BsWgCP5z.js +1 -0
- package/apps/web/dist/assets/PublicRunner-CkA_etKX.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-CD5YWvrW.js → ReadOnly-BVwbqvpi.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-CkFpnWCC.js +4 -0
- package/apps/web/dist/assets/{RunnerView-BW1h4x63.js → RunnerView-CxTFM-lx.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-DM1wNAE7.js +1 -0
- package/apps/web/dist/assets/SetupPage-BKnQqiab.js +1 -0
- package/apps/web/dist/assets/{SharePage-B6u9kS12.js → SharePage-CTTSudtp.js} +1 -1
- package/apps/web/dist/assets/changelog-DBEwyB1f.js +1 -0
- package/apps/web/dist/assets/{esm-C-ZWeuhU.js → esm-DtNAg8uF.js} +1 -1
- package/apps/web/dist/assets/index-V1M59Yez.css +1 -0
- package/apps/web/dist/assets/index-xIz8znhq.js +14 -0
- package/apps/web/dist/assets/{oss-credits-Bc05Qv5H.js → oss-credits-BWE03Xax.js} +1 -1
- package/apps/web/dist/assets/{phone-BjmhIKuT.js → phone-5SYD9OrJ.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-Dpkp3f56.js → settings-menu-Bn5-7Lqk.js} +1 -1
- package/apps/web/dist/assets/ui-s39eGOtW.js +137 -0
- package/apps/web/dist/assets/{upload-JX9BcMTq.js → upload-Bevcvx5V.js} +1 -1
- package/apps/web/dist/assets/{wrench-BSxnPs2_.js → wrench-ZD5SCsz9.js} +1 -1
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/mcp/flow.html +53 -53
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +22 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.js +26 -0
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +6 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +2 -0
- package/node_modules/@engenty-wizards/shared/dist/sharing.d.ts +135 -0
- package/node_modules/@engenty-wizards/shared/dist/sharing.js +11 -0
- package/node_modules/@engenty-wizards/shared/dist/store.d.ts +8 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/apps/runtime/dist/docs/dev/wizard-format.md +0 -125
- package/apps/web/dist/assets/AboutDialog-CPRAlD2H.js +0 -1
- package/apps/web/dist/assets/ChatView-DVeERQdi.js +0 -10
- package/apps/web/dist/assets/EditorPage-DhUoKcss.js +0 -4
- package/apps/web/dist/assets/NewWizardChannels-Ba3XGpro.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-C2Oh2sMM.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-D_4ShWrL.js +0 -1
- package/apps/web/dist/assets/ProjectPage-C58rq50b.js +0 -1
- package/apps/web/dist/assets/PublicRunner-CE0ohzyd.js +0 -1
- package/apps/web/dist/assets/RunDrawer-Cd8-qHV7.js +0 -4
- package/apps/web/dist/assets/SettingsPage-ChYLv-MG.js +0 -1
- package/apps/web/dist/assets/SetupPage-hX7deF0g.js +0 -1
- package/apps/web/dist/assets/changelog-Dn9LT4MS.js +0 -1
- package/apps/web/dist/assets/index-4jH2mkp7.js +0 -14
- package/apps/web/dist/assets/index-BwW652E-.css +0 -1
- package/apps/web/dist/assets/ui-DMmT-cSO.js +0 -137
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { db, schema } from "../db/client.js";
|
|
4
|
+
import { pendingAsk } from "../engine/asks.js";
|
|
5
|
+
import { signalChanged } from "../engine/events.js";
|
|
6
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
7
|
+
import { serverText } from "../texts.js";
|
|
8
|
+
/** Jobs this process is working on, by run. One at a time per run: they share its browser. */
|
|
9
|
+
const working = new Map();
|
|
10
|
+
export function workingJob(runId) {
|
|
11
|
+
return working.get(runId) ?? null;
|
|
12
|
+
}
|
|
13
|
+
export function setWorking(runId, job) {
|
|
14
|
+
if (job) {
|
|
15
|
+
working.set(runId, job);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
working.delete(runId);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function view(row) {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
target: row.target,
|
|
25
|
+
name: row.name,
|
|
26
|
+
icon: row.icon,
|
|
27
|
+
status: row.status,
|
|
28
|
+
events: row.events,
|
|
29
|
+
ask: row.status === "running" ? await pendingAsk(row.runId) : null,
|
|
30
|
+
result: row.result ?? null,
|
|
31
|
+
error: row.error,
|
|
32
|
+
createdAt: row.createdAt.toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** A job left "running" by a server that stopped: nobody works on it any more. */
|
|
36
|
+
async function settled(row) {
|
|
37
|
+
if (row.status !== "running" || working.get(row.runId)?.jobId === row.id) {
|
|
38
|
+
return row;
|
|
39
|
+
}
|
|
40
|
+
const error = serverText(row.request.lang ?? "en", "shareInterrupted");
|
|
41
|
+
await db
|
|
42
|
+
.update(schema.shareJob)
|
|
43
|
+
.set({ status: "failed", error, updatedAt: new Date() })
|
|
44
|
+
.where(eq(schema.shareJob.id, row.id));
|
|
45
|
+
return { ...row, status: "failed", error };
|
|
46
|
+
}
|
|
47
|
+
export async function createJob(input) {
|
|
48
|
+
const [row] = await db
|
|
49
|
+
.insert(schema.shareJob)
|
|
50
|
+
// The column's default counts whole seconds; jobs of one second must still keep their order.
|
|
51
|
+
.values({
|
|
52
|
+
id: nanoid(14),
|
|
53
|
+
tenantId: currentTenant(),
|
|
54
|
+
status: "running",
|
|
55
|
+
createdAt: new Date(),
|
|
56
|
+
...input,
|
|
57
|
+
})
|
|
58
|
+
.returning();
|
|
59
|
+
signalChanged(input.runId);
|
|
60
|
+
return view(row);
|
|
61
|
+
}
|
|
62
|
+
export async function addJobEvent(runId, jobId, message) {
|
|
63
|
+
const row = await db.query.shareJob.findFirst({ where: eq(schema.shareJob.id, jobId) });
|
|
64
|
+
if (!row) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const events = [
|
|
68
|
+
...row.events,
|
|
69
|
+
{ at: new Date().toISOString(), message: message.slice(0, 300) },
|
|
70
|
+
].slice(-40);
|
|
71
|
+
await db
|
|
72
|
+
.update(schema.shareJob)
|
|
73
|
+
.set({ events, updatedAt: new Date() })
|
|
74
|
+
.where(eq(schema.shareJob.id, jobId));
|
|
75
|
+
signalChanged(runId);
|
|
76
|
+
}
|
|
77
|
+
export async function finishJob(runId, jobId, end) {
|
|
78
|
+
await db
|
|
79
|
+
.update(schema.shareJob)
|
|
80
|
+
.set({
|
|
81
|
+
status: end.status,
|
|
82
|
+
result: end.status === "done" ? end.result : null,
|
|
83
|
+
error: end.status === "failed" ? end.error : null,
|
|
84
|
+
updatedAt: new Date(),
|
|
85
|
+
})
|
|
86
|
+
.where(and(eq(schema.shareJob.id, jobId), eq(schema.shareJob.status, "running")));
|
|
87
|
+
signalChanged(runId);
|
|
88
|
+
}
|
|
89
|
+
export async function shareJobs(runId, limit = 12) {
|
|
90
|
+
const rows = await db.query.shareJob.findMany({
|
|
91
|
+
where: eq(schema.shareJob.runId, runId),
|
|
92
|
+
orderBy: [desc(schema.shareJob.createdAt), desc(schema.shareJob.id)],
|
|
93
|
+
limit,
|
|
94
|
+
});
|
|
95
|
+
return Promise.all(rows.map(async (row) => view(await settled(row))));
|
|
96
|
+
}
|
|
97
|
+
/** The run's newest job: the one on its way, or the last one that arrived or failed. */
|
|
98
|
+
export async function latestShareJob(runId) {
|
|
99
|
+
return (await shareJobs(runId, 1))[0] ?? null;
|
|
100
|
+
}
|
|
101
|
+
/** How many jobs a run has started, whatever became of them. */
|
|
102
|
+
export async function jobCount(runId) {
|
|
103
|
+
const rows = await db.query.shareJob.findMany({
|
|
104
|
+
where: eq(schema.shareJob.runId, runId),
|
|
105
|
+
columns: { id: true },
|
|
106
|
+
});
|
|
107
|
+
return rows.length;
|
|
108
|
+
}
|
|
109
|
+
/** How often a target has sent for a run. */
|
|
110
|
+
export async function sentCount(runId, target) {
|
|
111
|
+
const rows = await db.query.shareJob.findMany({
|
|
112
|
+
where: and(eq(schema.shareJob.runId, runId), eq(schema.shareJob.target, target), eq(schema.shareJob.status, "done")),
|
|
113
|
+
columns: { id: true },
|
|
114
|
+
});
|
|
115
|
+
return rows.length;
|
|
116
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createTool } from "@mastra/core/tools";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { env } from "../../env.js";
|
|
5
|
+
import { browserTools } from "../../tools/browser.js";
|
|
6
|
+
import { runShareAgent } from "../agent.js";
|
|
7
|
+
import { line } from "../types.js";
|
|
8
|
+
const RULES = [
|
|
9
|
+
"You work in a browser the person watches. Open the address first, then find the place the request names.",
|
|
10
|
+
"Sign-ins are the person's: on a login form call browser_request_credentials, for anything else in the way (a captcha, 'continue with Google', a cookie wall) browser_request_user. Never type a password or a code yourself. When the person skips a sign-in, stop and report it.",
|
|
11
|
+
"Attach files with browser_upload.",
|
|
12
|
+
"Right before the step that makes it public or sends it (Post, Publish, Send, Submit), call confirm_publish with where and exactly what goes out. Go on only when it answers allowed. If the person asked for a draft, save the draft and publish nothing.",
|
|
13
|
+
"Afterwards look for the address of what you published and report it.",
|
|
14
|
+
].join("\n");
|
|
15
|
+
/** Leave to publish: the person sees where and what, and says yes or no. */
|
|
16
|
+
function confirmTool(step, site) {
|
|
17
|
+
return createTool({
|
|
18
|
+
id: "confirm_publish",
|
|
19
|
+
description: "Ask the person for leave right before the step that makes the result public or sends it. They see where and what. WAITS for the person.",
|
|
20
|
+
inputSchema: z.object({
|
|
21
|
+
where: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(200)
|
|
25
|
+
.describe("One sentence in the person's language: the site and the place it goes to."),
|
|
26
|
+
what: z.string().min(1).describe("The exact text and the files that will go out."),
|
|
27
|
+
}),
|
|
28
|
+
execute: async ({ where, what }) => {
|
|
29
|
+
const answer = await step.ask({
|
|
30
|
+
kind: "confirm",
|
|
31
|
+
reason: where,
|
|
32
|
+
service: site,
|
|
33
|
+
action: { id: "publish", summary: where, destructive: false },
|
|
34
|
+
input: what.slice(0, 4000),
|
|
35
|
+
once: true,
|
|
36
|
+
});
|
|
37
|
+
if (!answer) {
|
|
38
|
+
return {
|
|
39
|
+
allowed: false,
|
|
40
|
+
note: "Nobody is there to allow it. Publish nothing and report it.",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return answer.type === "done"
|
|
44
|
+
? { allowed: true }
|
|
45
|
+
: {
|
|
46
|
+
allowed: false,
|
|
47
|
+
note: "The person did not allow it. Publish nothing; report that it was not published.",
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const POST = line("sharePostInstructions");
|
|
53
|
+
/**
|
|
54
|
+
* Free style: the wizard opens a website in its browser, the person signs in themselves, and an
|
|
55
|
+
* agent posts the result there the way the person describes it. For every place that has no
|
|
56
|
+
* connector.
|
|
57
|
+
*/
|
|
58
|
+
export const browserTarget = {
|
|
59
|
+
id: "browser",
|
|
60
|
+
name: "Website",
|
|
61
|
+
description: line("shareWebsiteDescription"),
|
|
62
|
+
icon: "globe",
|
|
63
|
+
takes: "files",
|
|
64
|
+
agent: true,
|
|
65
|
+
available: () => Boolean(env.browserCdpUrl) || existsSync(env.chromePath),
|
|
66
|
+
fields: [
|
|
67
|
+
{
|
|
68
|
+
id: "url",
|
|
69
|
+
kind: "url",
|
|
70
|
+
required: true,
|
|
71
|
+
label: line("shareWebsiteAddress"),
|
|
72
|
+
placeholder: "https://…",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "instructions",
|
|
76
|
+
kind: "textarea",
|
|
77
|
+
required: true,
|
|
78
|
+
label: line("shareWhatThere"),
|
|
79
|
+
placeholder: line("shareWhatTherePlaceholder"),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
presets: [
|
|
83
|
+
{
|
|
84
|
+
label: "LinkedIn",
|
|
85
|
+
values: {
|
|
86
|
+
url: "https://www.linkedin.com/feed/",
|
|
87
|
+
instructions: POST,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: "X",
|
|
92
|
+
values: {
|
|
93
|
+
url: "https://x.com/compose/post",
|
|
94
|
+
instructions: POST,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: "Facebook",
|
|
99
|
+
values: {
|
|
100
|
+
url: "https://www.facebook.com/",
|
|
101
|
+
instructions: POST,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
label: "Instagram",
|
|
106
|
+
values: {
|
|
107
|
+
url: "https://www.instagram.com/",
|
|
108
|
+
instructions: POST,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
label: "Meta Ads",
|
|
113
|
+
values: {
|
|
114
|
+
url: "https://adsmanager.facebook.com/",
|
|
115
|
+
instructions: line("shareAdInstructions"),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
send(input, ctx) {
|
|
120
|
+
const url = input.values.url.trim();
|
|
121
|
+
return runShareAgent(ctx, input, {
|
|
122
|
+
task: `On ${url}: ${input.values.instructions}`,
|
|
123
|
+
rules: RULES,
|
|
124
|
+
maxSteps: 60,
|
|
125
|
+
tools: (step, files) => ({
|
|
126
|
+
...browserTools(step, [], files),
|
|
127
|
+
confirm_publish: confirmTool(step, new URL(url).hostname),
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { refreshConnector, resolveConnector } from "../../connectors/external.js";
|
|
2
|
+
import { toolsOf } from "../../tools/connector.js";
|
|
3
|
+
import { runShareAgent } from "../agent.js";
|
|
4
|
+
import { line, ShareError } from "../types.js";
|
|
5
|
+
const RULES = [
|
|
6
|
+
"You work in the person's account through the service's actions. Look first (search, list) where the result belongs, then write.",
|
|
7
|
+
"Actions that change the account ask the person before they run. When they say no, stop and report it — do not try another way.",
|
|
8
|
+
"To send a file, give `file:<name>` with the file's name as listed, where an action's input asks for `*_base64`.",
|
|
9
|
+
].join("\n");
|
|
10
|
+
/**
|
|
11
|
+
* The result through any connector that can write: one the product ships, or one the project
|
|
12
|
+
* imported from the integrations registry (Notion, WordPress, an ads account …). The person
|
|
13
|
+
* says in their words what should happen; an agent does it with the connector's actions.
|
|
14
|
+
*/
|
|
15
|
+
export function connectorTarget(connector, imported) {
|
|
16
|
+
const id = `connector:${connector.id}`;
|
|
17
|
+
const connection = (actions) => ({
|
|
18
|
+
id: `share:${id}`,
|
|
19
|
+
connector: connector.id,
|
|
20
|
+
actions,
|
|
21
|
+
title: connector.name,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
name: connector.name,
|
|
26
|
+
description: line("shareConnectorDescription"),
|
|
27
|
+
icon: "plug",
|
|
28
|
+
takes: "files",
|
|
29
|
+
agent: true,
|
|
30
|
+
// What the project imported it wants to use; what ships with the product is named by the wizard.
|
|
31
|
+
byDefault: imported,
|
|
32
|
+
connection: () => connection(connector.actions.map((a) => a.id)),
|
|
33
|
+
fields: [
|
|
34
|
+
{
|
|
35
|
+
id: "instructions",
|
|
36
|
+
kind: "textarea",
|
|
37
|
+
required: true,
|
|
38
|
+
label: line("shareWhatHappens"),
|
|
39
|
+
placeholder: line("shareWhatHappensPlaceholder"),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
async send(input, ctx) {
|
|
43
|
+
const account = await ctx.account();
|
|
44
|
+
if (!account) {
|
|
45
|
+
throw new ShareError(line("shareConnectFirst", { name: connector.name }));
|
|
46
|
+
}
|
|
47
|
+
// An MCP server that lists its tools only to a signed-in account: ask it now.
|
|
48
|
+
if (imported && !connector.actions.length) {
|
|
49
|
+
await refreshConnector(ctx.projectId, connector.id, account.ctx.accessToken).catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
const current = (await resolveConnector(ctx.projectId, connector.id))?.connector ?? connector;
|
|
52
|
+
return runShareAgent(ctx, input, {
|
|
53
|
+
task: `In ${connector.name}: ${input.values.instructions}`,
|
|
54
|
+
rules: RULES,
|
|
55
|
+
tools: (step, files) => toolsOf(connection(current.actions.map((a) => a.id)), step, files),
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** A connector can carry a result somewhere when it has an action that writes. */
|
|
61
|
+
export function canPublish(connector) {
|
|
62
|
+
return !connector.actions.length || connector.actions.some((a) => a.group !== "read");
|
|
63
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { MAIL_SENDERS, MailTooLarge, sendAsAccount, sendFromServer, serverSender, } from "../../connectors/smtp.js";
|
|
2
|
+
import { megabytes } from "../files.js";
|
|
3
|
+
import { line, ShareError } from "../types.js";
|
|
4
|
+
/** What mail providers take in one message, with room for the encoding. */
|
|
5
|
+
const ATTACH_LIMIT = 18_000_000;
|
|
6
|
+
/** From the server's own address, people without an account send this much per run. */
|
|
7
|
+
const VISITOR_MAILS = 3;
|
|
8
|
+
const VISITOR_RECIPIENTS = 3;
|
|
9
|
+
const RECIPIENTS = 20;
|
|
10
|
+
const ADDRESS = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
|
|
11
|
+
/** "a@b.at, c@d.at; e@f.at" → the addresses; throws on one that is none. */
|
|
12
|
+
export function parseRecipients(raw) {
|
|
13
|
+
const list = raw
|
|
14
|
+
.split(/[\s,;]+/)
|
|
15
|
+
.map((a) => a.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
const bad = list.find((a) => !ADDRESS.test(a));
|
|
18
|
+
if (bad) {
|
|
19
|
+
throw new ShareError(line("shareNotAnAddress", { address: bad }));
|
|
20
|
+
}
|
|
21
|
+
return [...new Set(list)];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The result by e-mail: the picked files as attachments. Sent from the mailbox the person
|
|
25
|
+
* connects (Gmail, Outlook, or any mailbox over SMTP) — or, where the server has an address of
|
|
26
|
+
* its own, without any account.
|
|
27
|
+
*/
|
|
28
|
+
export const emailTarget = {
|
|
29
|
+
id: "email",
|
|
30
|
+
name: line("shareEmailName"),
|
|
31
|
+
description: line("shareEmailDescription"),
|
|
32
|
+
icon: "mail",
|
|
33
|
+
takes: "files",
|
|
34
|
+
connection: () => serverSender()
|
|
35
|
+
? null
|
|
36
|
+
: {
|
|
37
|
+
id: "share:email",
|
|
38
|
+
target: { connectors: [...MAIL_SENDERS] },
|
|
39
|
+
actions: ["send_message"],
|
|
40
|
+
title: "E-Mail",
|
|
41
|
+
},
|
|
42
|
+
fields: [
|
|
43
|
+
{
|
|
44
|
+
id: "to",
|
|
45
|
+
kind: "emails",
|
|
46
|
+
required: true,
|
|
47
|
+
label: line("shareTo"),
|
|
48
|
+
placeholder: line("shareToPlaceholder"),
|
|
49
|
+
},
|
|
50
|
+
{ id: "subject", kind: "text", required: true, label: line("shareSubject") },
|
|
51
|
+
{ id: "message", kind: "textarea", label: line("shareMessage") },
|
|
52
|
+
],
|
|
53
|
+
defaults: (scope) => ({ subject: scope.title, message: scope.message ?? "" }),
|
|
54
|
+
async send({ values, files }, ctx) {
|
|
55
|
+
const to = parseRecipients(values.to ?? "");
|
|
56
|
+
if (!to.length) {
|
|
57
|
+
throw new ShareError(line("shareNoAddress"));
|
|
58
|
+
}
|
|
59
|
+
if (to.length > RECIPIENTS) {
|
|
60
|
+
throw new ShareError(line("shareTooManyAddresses", { max: RECIPIENTS }));
|
|
61
|
+
}
|
|
62
|
+
const size = files.reduce((n, f) => n + f.data.byteLength, 0);
|
|
63
|
+
if (size > ATTACH_LIMIT) {
|
|
64
|
+
throw new ShareError(line("shareAttachmentsTooLarge", { size: megabytes(size) }));
|
|
65
|
+
}
|
|
66
|
+
const message = {
|
|
67
|
+
to,
|
|
68
|
+
subject: (values.subject ?? "").trim() || ctx.title,
|
|
69
|
+
text: (values.message ?? "").trim(),
|
|
70
|
+
attachments: files.map((f) => ({ name: f.name, mime: f.mime, data: f.data })),
|
|
71
|
+
};
|
|
72
|
+
const account = await ctx.account();
|
|
73
|
+
if (account) {
|
|
74
|
+
await ctx.emit(line("shareSendingFrom", { account: account.label }));
|
|
75
|
+
try {
|
|
76
|
+
await sendAsAccount(account.connector, account.ctx, message);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof MailTooLarge) {
|
|
80
|
+
throw new ShareError(line("shareMailboxLimit"));
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (serverSender()) {
|
|
86
|
+
// The server's address is everyone's to send from: a visitor gets a few mails per run.
|
|
87
|
+
if (!ctx.member && (ctx.sentBefore >= VISITOR_MAILS || to.length > VISITOR_RECIPIENTS)) {
|
|
88
|
+
throw new ShareError(line("shareVisitorMails", { mails: VISITOR_MAILS, recipients: VISITOR_RECIPIENTS }));
|
|
89
|
+
}
|
|
90
|
+
await ctx.emit(line("shareSendingFrom", { account: serverSender() ?? "" }));
|
|
91
|
+
await sendFromServer(message);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
throw new ShareError(line("shareConnectMailbox"));
|
|
95
|
+
}
|
|
96
|
+
return { summary: line("shareSentTo", { to: to.join(", ") }) };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { line, ShareError } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A message in a Slack channel, as the person: their text and the link to the result page.
|
|
4
|
+
* The connector posts text only, so files travel as the link.
|
|
5
|
+
*/
|
|
6
|
+
export const slackTarget = {
|
|
7
|
+
id: "slack",
|
|
8
|
+
name: "Slack",
|
|
9
|
+
description: line("shareSlackDescription"),
|
|
10
|
+
icon: "chat",
|
|
11
|
+
takes: "link",
|
|
12
|
+
connection: () => ({
|
|
13
|
+
id: "share:slack",
|
|
14
|
+
target: { connectors: ["slack"] },
|
|
15
|
+
actions: ["list_channels", "post_message"],
|
|
16
|
+
title: "Slack",
|
|
17
|
+
}),
|
|
18
|
+
fields: [
|
|
19
|
+
{
|
|
20
|
+
id: "channel",
|
|
21
|
+
kind: "select",
|
|
22
|
+
required: true,
|
|
23
|
+
fromAccount: true,
|
|
24
|
+
label: line("shareChannel"),
|
|
25
|
+
},
|
|
26
|
+
{ id: "message", kind: "textarea", required: true, label: line("shareMessage") },
|
|
27
|
+
],
|
|
28
|
+
defaults: (scope) => ({
|
|
29
|
+
message: [scope.title, scope.text?.slice(0, 2500)].filter(Boolean).join("\n\n"),
|
|
30
|
+
}),
|
|
31
|
+
async choices(field, ctx) {
|
|
32
|
+
const account = field === "channel" ? await ctx.account() : null;
|
|
33
|
+
const list = account?.connector.actions.find((a) => a.id === "list_channels");
|
|
34
|
+
if (!account || !list) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const { channels } = (await list.handler({ limit: 50 }, account.ctx));
|
|
38
|
+
return channels
|
|
39
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
40
|
+
.map((c) => ({ value: c.id, label: `${c.is_private ? "🔒 " : "#"}${c.name}` }));
|
|
41
|
+
},
|
|
42
|
+
async send({ values }, ctx) {
|
|
43
|
+
const account = await ctx.account();
|
|
44
|
+
const post = account?.connector.actions.find((a) => a.id === "post_message");
|
|
45
|
+
if (!account || !post) {
|
|
46
|
+
throw new ShareError(line("shareConnectFirst", { name: "Slack" }));
|
|
47
|
+
}
|
|
48
|
+
const link = await ctx.link();
|
|
49
|
+
const text = [values.message?.trim(), link].filter(Boolean).join("\n\n");
|
|
50
|
+
await ctx.emit(line("sharePostingAs", { account: account.label }));
|
|
51
|
+
await post.handler({ channel: values.channel, text }, account.ctx);
|
|
52
|
+
return { summary: line("sharePostedSlack") };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { BUILTIN_CONNECTORS } from "../../connectors/builtin.js";
|
|
2
|
+
import { line, ShareError } from "../types.js";
|
|
3
|
+
async function call(ctx, url, init = {}) {
|
|
4
|
+
const res = await ctx.fetchImpl(url, {
|
|
5
|
+
...init,
|
|
6
|
+
headers: { Authorization: `Bearer ${ctx.accessToken}`, ...init.headers },
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
throw new Error(`${new URL(url).hostname} (${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
10
|
+
}
|
|
11
|
+
return res;
|
|
12
|
+
}
|
|
13
|
+
const segments = (folder) => folder
|
|
14
|
+
.split("/")
|
|
15
|
+
.map((s) => s.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
// --- Google Drive -----------------------------------------------------------------
|
|
18
|
+
// With the scope `drive.file` the wizard sees only what it created itself: its folders are
|
|
19
|
+
// found again by name, and nothing else in the Drive is readable.
|
|
20
|
+
const DRIVE = "https://www.googleapis.com/drive/v3/files";
|
|
21
|
+
const DRIVE_FOLDER = "application/vnd.google-apps.folder";
|
|
22
|
+
async function driveFolder(ctx, name, parent) {
|
|
23
|
+
const q = [
|
|
24
|
+
`name = '${name.replace(/['\\]/g, "\\$&")}'`,
|
|
25
|
+
`mimeType = '${DRIVE_FOLDER}'`,
|
|
26
|
+
"trashed = false",
|
|
27
|
+
`'${parent ?? "root"}' in parents`,
|
|
28
|
+
].join(" and ");
|
|
29
|
+
const found = (await (await call(ctx, `${DRIVE}?q=${encodeURIComponent(q)}&fields=files(id)&pageSize=1`)).json());
|
|
30
|
+
if (found.files?.[0]) {
|
|
31
|
+
return found.files[0].id;
|
|
32
|
+
}
|
|
33
|
+
const created = (await (await call(ctx, `${DRIVE}?fields=id`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
name,
|
|
38
|
+
mimeType: DRIVE_FOLDER,
|
|
39
|
+
...(parent ? { parents: [parent] } : {}),
|
|
40
|
+
}),
|
|
41
|
+
})).json());
|
|
42
|
+
return created.id;
|
|
43
|
+
}
|
|
44
|
+
const driveUpload = async (ctx, folder, files, each) => {
|
|
45
|
+
let parent = null;
|
|
46
|
+
for (const name of segments(folder)) {
|
|
47
|
+
parent = await driveFolder(ctx, name, parent);
|
|
48
|
+
}
|
|
49
|
+
let link = parent ? `https://drive.google.com/drive/folders/${parent}` : null;
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
await each(file);
|
|
52
|
+
const boundary = `wz${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
53
|
+
const meta = JSON.stringify({ name: file.name, ...(parent ? { parents: [parent] } : {}) });
|
|
54
|
+
const body = Buffer.concat([
|
|
55
|
+
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n--${boundary}\r\nContent-Type: ${file.mime}\r\n\r\n`),
|
|
56
|
+
Buffer.from(file.data),
|
|
57
|
+
Buffer.from(`\r\n--${boundary}--`),
|
|
58
|
+
]);
|
|
59
|
+
const made = (await (await call(ctx, "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,webViewLink", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
62
|
+
body: new Uint8Array(body),
|
|
63
|
+
})).json());
|
|
64
|
+
link ??= made.webViewLink ?? null;
|
|
65
|
+
}
|
|
66
|
+
return link;
|
|
67
|
+
};
|
|
68
|
+
// --- OneDrive -----------------------------------------------------------------------
|
|
69
|
+
const GRAPH = "https://graph.microsoft.com/v1.0/me/drive";
|
|
70
|
+
const oneDriveUpload = async (ctx, folder, files, each) => {
|
|
71
|
+
const path = segments(folder).map(encodeURIComponent).join("/");
|
|
72
|
+
let link = null;
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
await each(file);
|
|
75
|
+
const at = [path, encodeURIComponent(file.name)].filter(Boolean).join("/");
|
|
76
|
+
const item = (await (await call(ctx, `${GRAPH}/root:/${at}:/content`, {
|
|
77
|
+
method: "PUT",
|
|
78
|
+
headers: { "Content-Type": file.mime },
|
|
79
|
+
body: new Uint8Array(file.data),
|
|
80
|
+
})).json());
|
|
81
|
+
link ??= item.webUrl ?? null;
|
|
82
|
+
}
|
|
83
|
+
if (path) {
|
|
84
|
+
const dir = await call(ctx, `${GRAPH}/root:/${path}`).then((res) => res.json(), () => null);
|
|
85
|
+
link = dir?.webUrl ?? link;
|
|
86
|
+
}
|
|
87
|
+
return link;
|
|
88
|
+
};
|
|
89
|
+
const UPLOADS = {
|
|
90
|
+
"google-drive": driveUpload,
|
|
91
|
+
"microsoft-onedrive": oneDriveUpload,
|
|
92
|
+
};
|
|
93
|
+
/** The action whose provider rights an upload needs, per connector. */
|
|
94
|
+
const UPLOAD_ACTION = {
|
|
95
|
+
"google-drive": "create_file",
|
|
96
|
+
"microsoft-onedrive": "upload_file",
|
|
97
|
+
};
|
|
98
|
+
/** A connector that says itself how files are written into it (S3). */
|
|
99
|
+
function capabilityUpload(connector) {
|
|
100
|
+
const storage = connector.storage;
|
|
101
|
+
if (!storage) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return async (ctx, folder, files, each) => {
|
|
105
|
+
let link = null;
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
await each(file);
|
|
108
|
+
const entry = await storage.write(ctx, {
|
|
109
|
+
folder_ref: segments(folder).join("/") || null,
|
|
110
|
+
name: file.name,
|
|
111
|
+
mime_type: file.mime,
|
|
112
|
+
content_base64: Buffer.from(file.data).toString("base64"),
|
|
113
|
+
});
|
|
114
|
+
link ??= entry.web_url ?? null;
|
|
115
|
+
}
|
|
116
|
+
return link;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** The connectors files can be saved into: `storage:<id>` is a share target for each. */
|
|
120
|
+
export function storageConnectors() {
|
|
121
|
+
return BUILTIN_CONNECTORS.filter((c) => UPLOADS[c.id] || c.storage);
|
|
122
|
+
}
|
|
123
|
+
/** The picked files, saved into a folder of the person's cloud storage. */
|
|
124
|
+
export function storageTarget(connector) {
|
|
125
|
+
const upload = UPLOADS[connector.id] ?? capabilityUpload(connector);
|
|
126
|
+
if (!upload) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const id = `storage:${connector.id}`;
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
name: connector.name,
|
|
133
|
+
description: line("shareStorageDescription"),
|
|
134
|
+
icon: "folder",
|
|
135
|
+
takes: "files",
|
|
136
|
+
needsFiles: true,
|
|
137
|
+
connection: () => ({
|
|
138
|
+
id: `share:${id}`,
|
|
139
|
+
target: { connectors: [connector.id] },
|
|
140
|
+
actions: UPLOAD_ACTION[connector.id] ? [UPLOAD_ACTION[connector.id]] : ["files_write"],
|
|
141
|
+
title: connector.name,
|
|
142
|
+
}),
|
|
143
|
+
fields: [
|
|
144
|
+
{
|
|
145
|
+
id: "folder",
|
|
146
|
+
kind: "text",
|
|
147
|
+
label: line("shareFolder"),
|
|
148
|
+
placeholder: line("shareFolderPlaceholder"),
|
|
149
|
+
help: line("shareFolderHelp"),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
defaults: (scope) => ({ folder: scope.run.definition.title.replace(/\//g, "-") }),
|
|
153
|
+
async send({ values, files }, ctx) {
|
|
154
|
+
const account = await ctx.account();
|
|
155
|
+
if (!account) {
|
|
156
|
+
throw new ShareError(line("shareConnectFirst", { name: connector.name }));
|
|
157
|
+
}
|
|
158
|
+
const folder = (values.folder ?? "").trim();
|
|
159
|
+
const link = await upload(account.ctx, folder, files, (file) => ctx.emit(line("shareUploading", { name: file.name })));
|
|
160
|
+
const where = [connector.name, folder].filter(Boolean).join(" › ");
|
|
161
|
+
return { summary: line("shareSavedFiles", { count: files.length, where }), url: link };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NotedError } from "../model-errors.js";
|
|
2
|
+
import { serverText } from "../texts.js";
|
|
3
|
+
export const line = (server, params) => ({
|
|
4
|
+
server,
|
|
5
|
+
params,
|
|
6
|
+
});
|
|
7
|
+
/** What is said, in a language. */
|
|
8
|
+
export function sayIn(lang, said) {
|
|
9
|
+
return typeof said === "string" ? said : serverText(lang, said.server, said.params);
|
|
10
|
+
}
|
|
11
|
+
/** A failure the person can read and act on; anything else is reported as "did not work". */
|
|
12
|
+
export class ShareError extends NotedError {
|
|
13
|
+
}
|