@wiro-ai/wiro-mcp 1.3.3 → 1.4.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 +173 -16
- package/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -20
- package/dist/client.js.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +25 -0
- package/dist/instructions.js.map +1 -0
- package/dist/playbooks.d.ts +8 -0
- package/dist/playbooks.d.ts.map +1 -0
- package/dist/playbooks.js +665 -0
- package/dist/playbooks.js.map +1 -0
- package/dist/prompts.d.ts +4 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +133 -0
- package/dist/prompts.js.map +1 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +9 -3
- package/dist/server.js.map +1 -1
- package/dist/tools/apply-agent-skills.d.ts +4 -0
- package/dist/tools/apply-agent-skills.d.ts.map +1 -0
- package/dist/tools/apply-agent-skills.js +545 -0
- package/dist/tools/apply-agent-skills.js.map +1 -0
- package/dist/tools/cancel-agent-message.d.ts +4 -0
- package/dist/tools/cancel-agent-message.d.ts.map +1 -0
- package/dist/tools/cancel-agent-message.js +126 -0
- package/dist/tools/cancel-agent-message.js.map +1 -0
- package/dist/tools/cancel-task.js +3 -3
- package/dist/tools/cancel-task.js.map +1 -1
- package/dist/tools/clone-workflow.d.ts +4 -0
- package/dist/tools/clone-workflow.d.ts.map +1 -0
- package/dist/tools/clone-workflow.js +83 -0
- package/dist/tools/clone-workflow.js.map +1 -0
- package/dist/tools/connect-agent-oauth.d.ts +4 -0
- package/dist/tools/connect-agent-oauth.d.ts.map +1 -0
- package/dist/tools/connect-agent-oauth.js +508 -0
- package/dist/tools/connect-agent-oauth.js.map +1 -0
- package/dist/tools/control-agent.d.ts +4 -0
- package/dist/tools/control-agent.d.ts.map +1 -0
- package/dist/tools/control-agent.js +274 -0
- package/dist/tools/control-agent.js.map +1 -0
- package/dist/tools/create-workflow.d.ts +100 -0
- package/dist/tools/create-workflow.d.ts.map +1 -0
- package/dist/tools/create-workflow.js +103 -0
- package/dist/tools/create-workflow.js.map +1 -0
- package/dist/tools/delete-agent-session.d.ts +6 -0
- package/dist/tools/delete-agent-session.d.ts.map +1 -0
- package/dist/tools/delete-agent-session.js +134 -0
- package/dist/tools/delete-agent-session.js.map +1 -0
- package/dist/tools/delete-agent.d.ts +4 -0
- package/dist/tools/delete-agent.d.ts.map +1 -0
- package/dist/tools/delete-agent.js +144 -0
- package/dist/tools/delete-agent.js.map +1 -0
- package/dist/tools/delete-workflow.d.ts +4 -0
- package/dist/tools/delete-workflow.d.ts.map +1 -0
- package/dist/tools/delete-workflow.js +77 -0
- package/dist/tools/delete-workflow.js.map +1 -0
- package/dist/tools/deploy-agent.d.ts +4 -0
- package/dist/tools/deploy-agent.d.ts.map +1 -0
- package/dist/tools/deploy-agent.js +455 -0
- package/dist/tools/deploy-agent.js.map +1 -0
- package/dist/tools/disconnect-agent-oauth.d.ts +4 -0
- package/dist/tools/disconnect-agent-oauth.d.ts.map +1 -0
- package/dist/tools/disconnect-agent-oauth.js +118 -0
- package/dist/tools/disconnect-agent-oauth.js.map +1 -0
- package/dist/tools/get-account.d.ts +4 -0
- package/dist/tools/get-account.d.ts.map +1 -0
- package/dist/tools/get-account.js +110 -0
- package/dist/tools/get-account.js.map +1 -0
- package/dist/tools/get-agent-activity.d.ts +4 -0
- package/dist/tools/get-agent-activity.d.ts.map +1 -0
- package/dist/tools/get-agent-activity.js +383 -0
- package/dist/tools/get-agent-activity.js.map +1 -0
- package/dist/tools/get-agent-conversation.d.ts +4 -0
- package/dist/tools/get-agent-conversation.d.ts.map +1 -0
- package/dist/tools/get-agent-conversation.js +224 -0
- package/dist/tools/get-agent-conversation.js.map +1 -0
- package/dist/tools/get-agent-message.d.ts +4 -0
- package/dist/tools/get-agent-message.d.ts.map +1 -0
- package/dist/tools/get-agent-message.js +68 -0
- package/dist/tools/get-agent-message.js.map +1 -0
- package/dist/tools/get-agent-template.d.ts +4 -0
- package/dist/tools/get-agent-template.d.ts.map +1 -0
- package/dist/tools/get-agent-template.js +107 -0
- package/dist/tools/get-agent-template.js.map +1 -0
- package/dist/tools/get-model-schema.d.ts +5 -1
- package/dist/tools/get-model-schema.d.ts.map +1 -1
- package/dist/tools/get-model-schema.js +136 -8
- package/dist/tools/get-model-schema.js.map +1 -1
- package/dist/tools/get-my-agent.d.ts +4 -0
- package/dist/tools/get-my-agent.d.ts.map +1 -0
- package/dist/tools/get-my-agent.js +78 -0
- package/dist/tools/get-my-agent.js.map +1 -0
- package/dist/tools/get-playbook.d.ts +3 -0
- package/dist/tools/get-playbook.d.ts.map +1 -0
- package/dist/tools/get-playbook.js +50 -0
- package/dist/tools/get-playbook.js.map +1 -0
- package/dist/tools/get-task-price.d.ts.map +1 -1
- package/dist/tools/get-task-price.js +42 -15
- package/dist/tools/get-task-price.js.map +1 -1
- package/dist/tools/get-task.d.ts.map +1 -1
- package/dist/tools/get-task.js +4 -3
- package/dist/tools/get-task.js.map +1 -1
- package/dist/tools/get-workflow.d.ts +9 -0
- package/dist/tools/get-workflow.d.ts.map +1 -0
- package/dist/tools/get-workflow.js +99 -0
- package/dist/tools/get-workflow.js.map +1 -0
- package/dist/tools/index.d.ts +62 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +127 -14
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/kill-task.d.ts.map +1 -1
- package/dist/tools/kill-task.js +10 -6
- package/dist/tools/kill-task.js.map +1 -1
- package/dist/tools/list-agent-skills.d.ts +4 -0
- package/dist/tools/list-agent-skills.d.ts.map +1 -0
- package/dist/tools/list-agent-skills.js +242 -0
- package/dist/tools/list-agent-skills.js.map +1 -0
- package/dist/tools/list-agent-templates.d.ts +4 -0
- package/dist/tools/list-agent-templates.d.ts.map +1 -0
- package/dist/tools/list-agent-templates.js +110 -0
- package/dist/tools/list-agent-templates.js.map +1 -0
- package/dist/tools/list-my-agents.d.ts +4 -0
- package/dist/tools/list-my-agents.d.ts.map +1 -0
- package/dist/tools/list-my-agents.js +90 -0
- package/dist/tools/list-my-agents.js.map +1 -0
- package/dist/tools/list-studio-results.d.ts +4 -0
- package/dist/tools/list-studio-results.d.ts.map +1 -0
- package/dist/tools/list-studio-results.js +95 -0
- package/dist/tools/list-studio-results.js.map +1 -0
- package/dist/tools/list-tasks.d.ts.map +1 -1
- package/dist/tools/list-tasks.js +7 -5
- package/dist/tools/list-tasks.js.map +1 -1
- package/dist/tools/list-workflow-runs.d.ts +4 -0
- package/dist/tools/list-workflow-runs.d.ts.map +1 -0
- package/dist/tools/list-workflow-runs.js +110 -0
- package/dist/tools/list-workflow-runs.js.map +1 -0
- package/dist/tools/list-workflows.d.ts +4 -0
- package/dist/tools/list-workflows.d.ts.map +1 -0
- package/dist/tools/list-workflows.js +143 -0
- package/dist/tools/list-workflows.js.map +1 -0
- package/dist/tools/manage-agent-billing.d.ts +4 -0
- package/dist/tools/manage-agent-billing.d.ts.map +1 -0
- package/dist/tools/manage-agent-billing.js +764 -0
- package/dist/tools/manage-agent-billing.js.map +1 -0
- package/dist/tools/manage-agent-custom-skill.d.ts +4 -0
- package/dist/tools/manage-agent-custom-skill.d.ts.map +1 -0
- package/dist/tools/manage-agent-custom-skill.js +537 -0
- package/dist/tools/manage-agent-custom-skill.js.map +1 -0
- package/dist/tools/recommend-model.d.ts.map +1 -1
- package/dist/tools/recommend-model.js +6 -5
- package/dist/tools/recommend-model.js.map +1 -1
- package/dist/tools/resume-workflow-run.d.ts +4 -0
- package/dist/tools/resume-workflow-run.d.ts.map +1 -0
- package/dist/tools/resume-workflow-run.js +69 -0
- package/dist/tools/resume-workflow-run.js.map +1 -0
- package/dist/tools/run-model.d.ts.map +1 -1
- package/dist/tools/run-model.js +39 -3
- package/dist/tools/run-model.js.map +1 -1
- package/dist/tools/run-workflow.d.ts +6 -0
- package/dist/tools/run-workflow.d.ts.map +1 -0
- package/dist/tools/run-workflow.js +132 -0
- package/dist/tools/run-workflow.js.map +1 -0
- package/dist/tools/save-workflow-from-agent.d.ts +4 -0
- package/dist/tools/save-workflow-from-agent.d.ts.map +1 -0
- package/dist/tools/save-workflow-from-agent.js +283 -0
- package/dist/tools/save-workflow-from-agent.js.map +1 -0
- package/dist/tools/schemas.d.ts +62 -12
- package/dist/tools/schemas.d.ts.map +1 -1
- package/dist/tools/schemas.js +17 -3
- package/dist/tools/schemas.js.map +1 -1
- package/dist/tools/search-models.js +6 -6
- package/dist/tools/search-models.js.map +1 -1
- package/dist/tools/send-agent-message.d.ts +4 -0
- package/dist/tools/send-agent-message.d.ts.map +1 -0
- package/dist/tools/send-agent-message.js +156 -0
- package/dist/tools/send-agent-message.js.map +1 -0
- package/dist/tools/set-agent-credentials.d.ts +4 -0
- package/dist/tools/set-agent-credentials.d.ts.map +1 -0
- package/dist/tools/set-agent-credentials.js +529 -0
- package/dist/tools/set-agent-credentials.js.map +1 -0
- package/dist/tools/update-agent.d.ts +4 -0
- package/dist/tools/update-agent.d.ts.map +1 -0
- package/dist/tools/update-agent.js +178 -0
- package/dist/tools/update-agent.js.map +1 -0
- package/dist/tools/update-workflow.d.ts +201 -0
- package/dist/tools/update-workflow.d.ts.map +1 -0
- package/dist/tools/update-workflow.js +168 -0
- package/dist/tools/update-workflow.js.map +1 -0
- package/dist/tools/upload-file.d.ts.map +1 -1
- package/dist/tools/upload-file.js +4 -3
- package/dist/tools/upload-file.js.map +1 -1
- package/dist/tools/wait-for-workflow-run.d.ts +259 -0
- package/dist/tools/wait-for-workflow-run.d.ts.map +1 -0
- package/dist/tools/wait-for-workflow-run.js +190 -0
- package/dist/tools/wait-for-workflow-run.js.map +1 -0
- package/dist/types.d.ts +22 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/agent-catalog.d.ts +1256 -0
- package/dist/utils/agent-catalog.d.ts.map +1 -0
- package/dist/utils/agent-catalog.js +388 -0
- package/dist/utils/agent-catalog.js.map +1 -0
- package/dist/utils/agent-config.d.ts +441 -0
- package/dist/utils/agent-config.d.ts.map +1 -0
- package/dist/utils/agent-config.js +1667 -0
- package/dist/utils/agent-config.js.map +1 -0
- package/dist/utils/agent-message.d.ts +115 -0
- package/dist/utils/agent-message.d.ts.map +1 -0
- package/dist/utils/agent-message.js +246 -0
- package/dist/utils/agent-message.js.map +1 -0
- package/dist/utils/agent-targets.d.ts +76 -0
- package/dist/utils/agent-targets.d.ts.map +1 -0
- package/dist/utils/agent-targets.js +408 -0
- package/dist/utils/agent-targets.js.map +1 -0
- package/dist/utils/agent-turns.d.ts +354 -0
- package/dist/utils/agent-turns.d.ts.map +1 -0
- package/dist/utils/agent-turns.js +623 -0
- package/dist/utils/agent-turns.js.map +1 -0
- package/dist/utils/api.d.ts +40 -0
- package/dist/utils/api.d.ts.map +1 -0
- package/dist/utils/api.js +111 -0
- package/dist/utils/api.js.map +1 -0
- package/dist/utils/format.d.ts +42 -1
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/format.js +173 -14
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/media.d.ts +28 -0
- package/dist/utils/media.d.ts.map +1 -0
- package/dist/utils/media.js +65 -0
- package/dist/utils/media.js.map +1 -0
- package/dist/utils/poll.d.ts +39 -0
- package/dist/utils/poll.d.ts.map +1 -0
- package/dist/utils/poll.js +111 -0
- package/dist/utils/poll.js.map +1 -0
- package/dist/utils/remote-file.js +2 -2
- package/dist/utils/remote-file.js.map +1 -1
- package/dist/utils/structured.d.ts +2 -0
- package/dist/utils/structured.d.ts.map +1 -1
- package/dist/utils/structured.js +10 -10
- package/dist/utils/structured.js.map +1 -1
- package/dist/utils/studio-runs.d.ts +87 -0
- package/dist/utils/studio-runs.d.ts.map +1 -0
- package/dist/utils/studio-runs.js +167 -0
- package/dist/utils/studio-runs.js.map +1 -0
- package/dist/utils/user-agent.d.ts +1140 -0
- package/dist/utils/user-agent.d.ts.map +1 -0
- package/dist/utils/user-agent.js +802 -0
- package/dist/utils/user-agent.js.map +1 -0
- package/dist/utils/workflow-agent.d.ts +151 -0
- package/dist/utils/workflow-agent.d.ts.map +1 -0
- package/dist/utils/workflow-agent.js +292 -0
- package/dist/utils/workflow-agent.js.map +1 -0
- package/dist/utils/workflow-artifacts.d.ts +160 -0
- package/dist/utils/workflow-artifacts.d.ts.map +1 -0
- package/dist/utils/workflow-artifacts.js +801 -0
- package/dist/utils/workflow-artifacts.js.map +1 -0
- package/dist/utils/workflow-save.d.ts +123 -0
- package/dist/utils/workflow-save.d.ts.map +1 -0
- package/dist/utils/workflow-save.js +734 -0
- package/dist/utils/workflow-save.js.map +1 -0
- package/dist/utils/workflow.d.ts +559 -0
- package/dist/utils/workflow.d.ts.map +1 -0
- package/dist/utils/workflow.js +613 -0
- package/dist/utils/workflow.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { ApiResultError, asString, hasErrorCode, isPlainObject, requireApiSuccess, toNumber, truncate, unixToIso, } from './api.js';
|
|
3
|
+
// Helpers for configuring a deployed agent: its registry skills, credentials,
|
|
4
|
+
// sign-in connections and custom skills. The pure functions mirror the server
|
|
5
|
+
// rules closely so a bad request is caught before anything is written.
|
|
6
|
+
export const DEFAULT_CRON_INTERVAL = '0 10 * * 1';
|
|
7
|
+
export const OAUTH_LINK_TTL_SECONDS = 900;
|
|
8
|
+
const DASHBOARD_AGENT_URL = 'https://wiro.ai/panel/agents/';
|
|
9
|
+
const OAUTH_CALLBACK_BASE = 'https://api.wiro.ai/v1';
|
|
10
|
+
const REGISTRY_TTL_MS = 5 * 60_000;
|
|
11
|
+
function stringList(value) {
|
|
12
|
+
return Array.isArray(value)
|
|
13
|
+
? value.map(item => asString(item)).filter((item) => !!item)
|
|
14
|
+
: [];
|
|
15
|
+
}
|
|
16
|
+
export function formatUsd(amount) {
|
|
17
|
+
const sign = amount < 0 ? '-' : '';
|
|
18
|
+
return `${sign}$${Math.abs(amount).toFixed(2)}`;
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Agent row
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export const AGENT_STATUS_LABELS = {
|
|
24
|
+
0: 'stopped',
|
|
25
|
+
1: 'stopping',
|
|
26
|
+
2: 'queued',
|
|
27
|
+
3: 'starting',
|
|
28
|
+
4: 'running',
|
|
29
|
+
5: 'error',
|
|
30
|
+
6: 'setup required',
|
|
31
|
+
};
|
|
32
|
+
export function agentStatusLabel(status) {
|
|
33
|
+
return status !== null && AGENT_STATUS_LABELS[status] ? AGENT_STATUS_LABELS[status] : 'unknown';
|
|
34
|
+
}
|
|
35
|
+
/** Configuration writes restart an agent only while it is starting or running. */
|
|
36
|
+
export function isAgentRunning(status) {
|
|
37
|
+
return status === 3 || status === 4;
|
|
38
|
+
}
|
|
39
|
+
/** The agent's page in the Wiro dashboard, in the form the dashboard itself uses as an OAuth return address. */
|
|
40
|
+
export function dashboardAgentUrl(agentGuid) {
|
|
41
|
+
const guid = encodeURIComponent(agentGuid);
|
|
42
|
+
return `${DASHBOARD_AGENT_URL}${guid}?id=${guid}`;
|
|
43
|
+
}
|
|
44
|
+
export function restartNote(status, what = 'change') {
|
|
45
|
+
switch (status) {
|
|
46
|
+
case 3:
|
|
47
|
+
case 4:
|
|
48
|
+
return `The agent restarts to load the ${what}, which takes about a minute. Follow it with get_my_agent.`;
|
|
49
|
+
case 1:
|
|
50
|
+
return `The agent is already stopping or restarting; it loads the ${what} when it starts again.`;
|
|
51
|
+
case 2:
|
|
52
|
+
return `The agent is queued to start and loads the ${what} when it does.`;
|
|
53
|
+
case 6:
|
|
54
|
+
return `The agent still needs setup; the ${what} takes effect once it starts.`;
|
|
55
|
+
case 0:
|
|
56
|
+
case 5:
|
|
57
|
+
return `The agent is ${agentStatusLabel(status)}, so the ${what} takes effect the next time it starts.`;
|
|
58
|
+
default:
|
|
59
|
+
return `The ${what} takes effect the next time the agent starts.`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function readUserAgent(client, agentGuid, signal) {
|
|
63
|
+
const body = requireApiSuccess(await client.post('/UserAgent/Detail', { guid: agentGuid }, signal), 'Could not read the agent.');
|
|
64
|
+
const row = Array.isArray(body.useragents) ? body.useragents[0] : undefined;
|
|
65
|
+
if (!isPlainObject(row))
|
|
66
|
+
throw new Error(`Agent ${agentGuid} was not found.`);
|
|
67
|
+
return row;
|
|
68
|
+
}
|
|
69
|
+
export function agentStatusOf(row) {
|
|
70
|
+
return toNumber(row.status);
|
|
71
|
+
}
|
|
72
|
+
/** Names of the registry skills that are on. Detail lists only enabled skills. */
|
|
73
|
+
export function enabledSkillNames(row) {
|
|
74
|
+
if (!Array.isArray(row.skills))
|
|
75
|
+
return [];
|
|
76
|
+
return row.skills
|
|
77
|
+
.map(skill => (isPlainObject(skill)
|
|
78
|
+
? (skill.enabled === false ? undefined : asString(skill.name))
|
|
79
|
+
: asString(skill)))
|
|
80
|
+
.filter((name) => !!name);
|
|
81
|
+
}
|
|
82
|
+
export function isCustomBuild(row) {
|
|
83
|
+
return isPlainObject(row.agent) && row.agent.custom === true;
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Registry reads (anonymous, identical for every caller, so cached briefly)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
const registryCache = new WeakMap();
|
|
89
|
+
async function cachedRead(owner, key, load) {
|
|
90
|
+
let entries = registryCache.get(owner);
|
|
91
|
+
if (!entries) {
|
|
92
|
+
entries = new Map();
|
|
93
|
+
registryCache.set(owner, entries);
|
|
94
|
+
}
|
|
95
|
+
const hit = entries.get(key);
|
|
96
|
+
if (hit && Date.now() - hit.at < REGISTRY_TTL_MS)
|
|
97
|
+
return hit.value;
|
|
98
|
+
const value = await load();
|
|
99
|
+
entries.set(key, { at: Date.now(), value });
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
export function parseSkillRegistry(body) {
|
|
103
|
+
const skills = new Map();
|
|
104
|
+
for (const raw of Array.isArray(body.skills) ? body.skills : []) {
|
|
105
|
+
if (!isPlainObject(raw))
|
|
106
|
+
continue;
|
|
107
|
+
const name = asString(raw.name);
|
|
108
|
+
if (!name)
|
|
109
|
+
continue;
|
|
110
|
+
const pricing = isPlainObject(raw.pricing) ? raw.pricing : {};
|
|
111
|
+
const primary = asString(raw.credential_key);
|
|
112
|
+
skills.set(name, {
|
|
113
|
+
name,
|
|
114
|
+
title: asString(raw.title) ?? name,
|
|
115
|
+
dependsOn: stringList(raw.depends_on),
|
|
116
|
+
conflictsWith: stringList(raw.conflicts_with),
|
|
117
|
+
deprecated: raw.deprecated === true,
|
|
118
|
+
...(asString(raw.replacement) ? { replacement: asString(raw.replacement) } : {}),
|
|
119
|
+
requiresCredentials: raw.requires_credentials === true,
|
|
120
|
+
credentialKeys: [...(primary ? [primary] : []), ...stringList(raw.additional_credential_keys)],
|
|
121
|
+
monthlyPriceUsd: toNumber(pricing.monthly_price_weight_usd ?? raw.monthly_price_weight_usd),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const channels = new Map();
|
|
125
|
+
for (const raw of Array.isArray(body.channels) ? body.channels : []) {
|
|
126
|
+
if (!isPlainObject(raw))
|
|
127
|
+
continue;
|
|
128
|
+
const id = asString(raw.id);
|
|
129
|
+
if (!id)
|
|
130
|
+
continue;
|
|
131
|
+
channels.set(id, {
|
|
132
|
+
id,
|
|
133
|
+
title: asString(raw.title) ?? id,
|
|
134
|
+
credentialKey: asString(raw.credential_key) ?? id,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return { skills, channels };
|
|
138
|
+
}
|
|
139
|
+
export async function loadSkillRegistry(client, signal) {
|
|
140
|
+
return cachedRead(client, 'skills', async () => parseSkillRegistry(requireApiSuccess(await client.post('/Skills/List', {}, signal), 'Could not read the skill list.')));
|
|
141
|
+
}
|
|
142
|
+
function suggestSkills(name, registry) {
|
|
143
|
+
const tokens = new Set(name.toLowerCase().split(/[^a-z0-9]+/).filter(token => token.length > 2));
|
|
144
|
+
return [...registry.skills.keys()]
|
|
145
|
+
.map(candidate => ({
|
|
146
|
+
candidate,
|
|
147
|
+
score: candidate.split('-').filter(token => tokens.has(token)).length,
|
|
148
|
+
}))
|
|
149
|
+
.filter(entry => entry.score > 0)
|
|
150
|
+
.sort((left, right) => right.score - left.score)
|
|
151
|
+
.slice(0, 3)
|
|
152
|
+
.map(entry => entry.candidate);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Validates the requested names against the registry and, when asked, adds
|
|
156
|
+
* the skills they depend on. The server refuses a change whose enabled set is
|
|
157
|
+
* missing a dependency. Names are checked against the registry here, so only
|
|
158
|
+
* known skills are sent.
|
|
159
|
+
*/
|
|
160
|
+
export function planSkillChanges(input, registry, addDependencies) {
|
|
161
|
+
const errors = new Set();
|
|
162
|
+
const requested = {};
|
|
163
|
+
for (const [rawName, enabled] of Object.entries(input)) {
|
|
164
|
+
const name = rawName.trim();
|
|
165
|
+
if (!name) {
|
|
166
|
+
errors.add('Skill names cannot be empty.');
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (name.startsWith('cs-') || name.startsWith('cron-')) {
|
|
170
|
+
errors.add(`"${name}" is a custom skill or scheduled task. Use manage_agent_custom_skill for it.`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const channel = registry.channels.get(name);
|
|
174
|
+
if (channel) {
|
|
175
|
+
errors.add(`"${name}" is a channel, not a skill. It turns on when its fields are filled with set_agent_credentials (credential "${channel.credentialKey}").`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const skill = registry.skills.get(name);
|
|
179
|
+
if (!skill) {
|
|
180
|
+
const suggestions = suggestSkills(name, registry);
|
|
181
|
+
errors.add(`Unknown skill "${name}".${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ''} Use list_agent_skills for the full list.`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (enabled && skill.deprecated) {
|
|
185
|
+
errors.add(`${name} is retired and cannot be turned on${skill.replacement ? `. Use ${skill.replacement} instead` : ''}.`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
requested[name] = enabled;
|
|
189
|
+
}
|
|
190
|
+
const added = [];
|
|
191
|
+
if (addDependencies) {
|
|
192
|
+
const queue = Object.keys(requested).filter(name => requested[name]);
|
|
193
|
+
while (queue.length > 0) {
|
|
194
|
+
const name = queue.shift();
|
|
195
|
+
for (const dependency of registry.skills.get(name)?.dependsOn ?? []) {
|
|
196
|
+
if (requested[dependency] === false) {
|
|
197
|
+
errors.add(`${name} needs ${dependency}, which this change turns off. Keep ${dependency} on, or leave ${name} off.`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (requested[dependency] === true || !registry.skills.has(dependency))
|
|
201
|
+
continue;
|
|
202
|
+
requested[dependency] = true;
|
|
203
|
+
added.push(dependency);
|
|
204
|
+
queue.push(dependency);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { requested, addedDependencies: added, errors: [...errors] };
|
|
209
|
+
}
|
|
210
|
+
/** Only real changes are sent, since a skill change restarts a running agent. */
|
|
211
|
+
export function diffSkills(requested, enabledNow) {
|
|
212
|
+
const current = new Set(enabledNow);
|
|
213
|
+
const diff = { changes: {}, enable: [], disable: [], alreadySet: [] };
|
|
214
|
+
for (const [name, enabled] of Object.entries(requested)) {
|
|
215
|
+
if (enabled === current.has(name)) {
|
|
216
|
+
diff.alreadySet.push(name);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
diff.changes[name] = enabled;
|
|
220
|
+
(enabled ? diff.enable : diff.disable).push(name);
|
|
221
|
+
}
|
|
222
|
+
return diff;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The same check the API runs before it writes: every skill that ends up on
|
|
226
|
+
* needs its dependencies on, and no two conflicting skills may be on.
|
|
227
|
+
*/
|
|
228
|
+
export function checkSkillSet(enabledNow, changes, registry) {
|
|
229
|
+
const finalSet = new Set(enabledNow);
|
|
230
|
+
for (const [name, enabled] of Object.entries(changes)) {
|
|
231
|
+
if (enabled)
|
|
232
|
+
finalSet.add(name);
|
|
233
|
+
else
|
|
234
|
+
finalSet.delete(name);
|
|
235
|
+
}
|
|
236
|
+
const deps = [];
|
|
237
|
+
const conflicts = [];
|
|
238
|
+
for (const name of finalSet) {
|
|
239
|
+
const skill = registry.skills.get(name);
|
|
240
|
+
if (!skill)
|
|
241
|
+
continue;
|
|
242
|
+
for (const dependency of skill.dependsOn) {
|
|
243
|
+
if (!finalSet.has(dependency))
|
|
244
|
+
deps.push({ skill: name, requires: dependency });
|
|
245
|
+
}
|
|
246
|
+
for (const other of skill.conflictsWith) {
|
|
247
|
+
if (finalSet.has(other) && !conflicts.some(pair => pair.a === other && pair.b === name)) {
|
|
248
|
+
conflicts.push({ a: name, b: other });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { deps, conflicts };
|
|
253
|
+
}
|
|
254
|
+
/** Credential keys the newly enabled skills read, skipping the ones the agent already has. */
|
|
255
|
+
export function credentialsNeededFor(skills, registry, row) {
|
|
256
|
+
const groups = row && isPlainObject(row.credentials) ? row.credentials : {};
|
|
257
|
+
// An OAuth credential is ready only once connected: a saved shop domain or
|
|
258
|
+
// own-app key alone does not let the agent sign in.
|
|
259
|
+
const ready = (key) => {
|
|
260
|
+
const group = groups[key];
|
|
261
|
+
if (!isPlainObject(group))
|
|
262
|
+
return false;
|
|
263
|
+
const oauth = isPlainObject(group._schema) && isPlainObject(group._schema.oauth_provider);
|
|
264
|
+
return group._connected === true || (!oauth && group._filled === true);
|
|
265
|
+
};
|
|
266
|
+
return skills
|
|
267
|
+
.map(skill => ({
|
|
268
|
+
skill,
|
|
269
|
+
credentials: (registry.skills.get(skill)?.requiresCredentials
|
|
270
|
+
? registry.skills.get(skill)?.credentialKeys ?? []
|
|
271
|
+
: []).filter(key => !ready(key)),
|
|
272
|
+
}))
|
|
273
|
+
.filter(entry => entry.credentials.length > 0);
|
|
274
|
+
}
|
|
275
|
+
const IDEMPOTENCY_KEY_RE = /^mcp-[0-9a-f]{16}-([0-9a-f]{10})-([0-9a-f]{8})$/;
|
|
276
|
+
/** Fingerprint of one logical skill change, independent of the agent's current state. */
|
|
277
|
+
export function skillChangeDigest(agentGuid, requested, tier) {
|
|
278
|
+
const entries = Object.keys(requested).sort().map(name => `${name}=${requested[name] ? 1 : 0}`);
|
|
279
|
+
return crypto.createHash('sha256')
|
|
280
|
+
.update([agentGuid, tier ?? '', ...entries].join('\n'))
|
|
281
|
+
.digest('hex')
|
|
282
|
+
.slice(0, 10);
|
|
283
|
+
}
|
|
284
|
+
function cents(value) {
|
|
285
|
+
return value === null || !Number.isFinite(value) ? 'none' : String(Math.round(value * 100));
|
|
286
|
+
}
|
|
287
|
+
/** Fingerprint of the monthly price before and after a change, as it was quoted. */
|
|
288
|
+
export function skillPriceTag(currentUsd, newUsd) {
|
|
289
|
+
return crypto.createHash('sha256')
|
|
290
|
+
.update(`${cents(currentUsd)}:${cents(newUsd)}`)
|
|
291
|
+
.digest('hex')
|
|
292
|
+
.slice(0, 8);
|
|
293
|
+
}
|
|
294
|
+
export function newSkillChangeKey(digest, priceTag) {
|
|
295
|
+
return `mcp-${crypto.randomBytes(8).toString('hex')}-${digest}-${priceTag}`;
|
|
296
|
+
}
|
|
297
|
+
/** The change and price fingerprints of a key minted by this package, or null for any other key. */
|
|
298
|
+
export function parseSkillChangeKey(key) {
|
|
299
|
+
const match = IDEMPOTENCY_KEY_RE.exec(key.trim());
|
|
300
|
+
return match ? { digest: match[1], priceTag: match[2] } : null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Keys minted by this package carry the fingerprint of their change. Wiro
|
|
304
|
+
* answers a reused key with the saved result of the call that first used it,
|
|
305
|
+
* so a key is only sent with the change it was issued for.
|
|
306
|
+
*/
|
|
307
|
+
export function idempotencyKeyMismatch(key, digest) {
|
|
308
|
+
const parsed = parseSkillChangeKey(key);
|
|
309
|
+
return parsed !== null && parsed.digest !== digest;
|
|
310
|
+
}
|
|
311
|
+
export function planBilling(row) {
|
|
312
|
+
const wallet = asString(row.teamguid) ? 'team' : 'personal';
|
|
313
|
+
const subscription = isPlainObject(row.subscription) ? row.subscription : null;
|
|
314
|
+
if (!subscription) {
|
|
315
|
+
return { plan: 'none', status: null, periodEnd: null, daysRemaining: null, wallet, cancelAtPeriodEnd: false };
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
plan: asString(subscription.provider) === 'prepaid' ? 'prepaid' : 'card',
|
|
319
|
+
status: asString(subscription.status) ?? null,
|
|
320
|
+
periodEnd: toNumber(subscription.currentperiodend),
|
|
321
|
+
daysRemaining: toNumber(subscription.daysremaining),
|
|
322
|
+
wallet,
|
|
323
|
+
cancelAtPeriodEnd: subscription.pendingdowngrade === 'cancel' || row.stripecancelatperiodend === true,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Wiro charges a prepaid plan the monthly difference for the days left in the
|
|
328
|
+
* period. The estimate assumes a 30-day period.
|
|
329
|
+
*/
|
|
330
|
+
export function estimateProratedUsd(deltaUsd, periodEndUnix, nowMs = Date.now(), periodDays = 30) {
|
|
331
|
+
if (periodEndUnix === null || !Number.isFinite(deltaUsd))
|
|
332
|
+
return null;
|
|
333
|
+
const remainingDays = Math.max(0, Math.round((periodEndUnix - nowMs / 1000) / 86400));
|
|
334
|
+
return Math.round((deltaUsd / periodDays) * remainingDays * 100) / 100;
|
|
335
|
+
}
|
|
336
|
+
const SKILL_PAYMENT_FAILED = 'The payment for this change did not go through, so the change was undone and nothing was charged.';
|
|
337
|
+
const DEPOSIT_PAGE = 'https://wiro.ai/panel/wallet/deposit/credit-card (for a team project, select that team in the dashboard first)';
|
|
338
|
+
/**
|
|
339
|
+
* What to do after a skill change whose payment failed, for the agent's plan
|
|
340
|
+
* when it is known. A wallet that does not cover the charge is refused before
|
|
341
|
+
* any payment (code 103), so a wallet plan needs no funds added here.
|
|
342
|
+
*/
|
|
343
|
+
function skillPaymentGuidance(plan) {
|
|
344
|
+
if (plan === 'prepaid')
|
|
345
|
+
return `${SKILL_PAYMENT_FAILED} The balance is not the cause, so no funds need to be added. Ask for a new quote and confirm it once the user approves.`;
|
|
346
|
+
if (plan === 'card')
|
|
347
|
+
return `${SKILL_PAYMENT_FAILED} Update the card in the Wiro dashboard, then ask for a new quote.`;
|
|
348
|
+
return `${SKILL_PAYMENT_FAILED} For a plan paid by card, update the card in the Wiro dashboard first. Then ask for a new quote.`;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Turns a refused SkillsApply into an explanation and the step that fixes it.
|
|
352
|
+
* With the agent's plan, a payment failure names only the fix for that plan.
|
|
353
|
+
*/
|
|
354
|
+
export function describeSkillsApplyError(error, plan) {
|
|
355
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
356
|
+
const body = error instanceof ApiResultError ? error.body : {};
|
|
357
|
+
const codes = error instanceof ApiResultError ? error.codes.map(code => toNumber(code)) : [];
|
|
358
|
+
const code = codes.find(candidate => candidate !== null && candidate >= 95 && candidate <= 109) ?? null;
|
|
359
|
+
const base = { code, message, retrySameKey: false };
|
|
360
|
+
switch (code) {
|
|
361
|
+
case 101: {
|
|
362
|
+
const deps = (Array.isArray(body.deps) ? body.deps : [])
|
|
363
|
+
.filter(isPlainObject)
|
|
364
|
+
.map(dep => ({ skill: asString(dep.skill) ?? '', requires: asString(dep.requires) ?? '' }));
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
deps,
|
|
368
|
+
guidance: `Some skills need others that would be off: ${deps.map(dep => `${dep.skill} needs ${dep.requires}`).join('; ')}. Turn the needed skills on in the same change (add_dependencies does this for skills you turn on), or keep them on when you turn other skills off.`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
case 102: {
|
|
372
|
+
const conflicts = (Array.isArray(body.conflicts) ? body.conflicts : [])
|
|
373
|
+
.filter(isPlainObject)
|
|
374
|
+
.map(pair => ({ a: asString(pair.a) ?? '', b: asString(pair.b) ?? '' }));
|
|
375
|
+
return {
|
|
376
|
+
...base,
|
|
377
|
+
conflicts,
|
|
378
|
+
guidance: `These skills cannot be on together: ${conflicts.map(pair => `${pair.a} and ${pair.b}`).join('; ')}. Keep only one of each pair.`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
case 103: {
|
|
382
|
+
const requiredUsd = toNumber(body.requiredUsd);
|
|
383
|
+
const availableUsd = toNumber(body.availableUsd);
|
|
384
|
+
return {
|
|
385
|
+
...base,
|
|
386
|
+
requiredUsd,
|
|
387
|
+
availableUsd,
|
|
388
|
+
guidance: `The wallet does not cover the charge for the rest of this period${requiredUsd !== null ? `: it needs ${formatUsd(requiredUsd)}` : ''}${availableUsd !== null ? ` and has ${formatUsd(availableUsd)}` : ''}. Nothing was changed. Funds are added at ${DEPOSIT_PAGE}; once the user has added them and says yes, confirm again.`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
case 104:
|
|
392
|
+
return {
|
|
393
|
+
...base,
|
|
394
|
+
retrySameKey: true,
|
|
395
|
+
guidance: 'Another skill change for this agent is still running. Wait a few seconds, then repeat the call with the same idempotency_key.',
|
|
396
|
+
};
|
|
397
|
+
case 105:
|
|
398
|
+
return {
|
|
399
|
+
...base,
|
|
400
|
+
guidance: 'A Pro plan cannot be moved back to Starter here. Cancel the plan with manage_agent_billing and subscribe again at Starter once it ends.',
|
|
401
|
+
};
|
|
402
|
+
case 106:
|
|
403
|
+
return {
|
|
404
|
+
...base,
|
|
405
|
+
guidance: 'The agent\'s subscription is not active, so its skills cannot change. Renew it with manage_agent_billing first.',
|
|
406
|
+
};
|
|
407
|
+
case 107:
|
|
408
|
+
return {
|
|
409
|
+
...base,
|
|
410
|
+
guidance: 'Repeat the call without idempotency_key to start a fresh change.',
|
|
411
|
+
};
|
|
412
|
+
case 108:
|
|
413
|
+
return {
|
|
414
|
+
...base,
|
|
415
|
+
retrySameKey: true,
|
|
416
|
+
guidance: 'Nothing was changed. Repeat the call with the same idempotency_key.',
|
|
417
|
+
};
|
|
418
|
+
case 109:
|
|
419
|
+
return {
|
|
420
|
+
...base,
|
|
421
|
+
guidance: skillPaymentGuidance(plan),
|
|
422
|
+
};
|
|
423
|
+
case 95:
|
|
424
|
+
case 96:
|
|
425
|
+
case 97:
|
|
426
|
+
return {
|
|
427
|
+
...base,
|
|
428
|
+
guidance: 'Only the agent\'s owner or an admin of the team that owns it can change its skills.',
|
|
429
|
+
};
|
|
430
|
+
default:
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
if (/pricing snapshot|not found in registry/i.test(message)) {
|
|
434
|
+
return {
|
|
435
|
+
...base,
|
|
436
|
+
guidance: 'The new price could not be computed, so nothing was changed. Check the skill names with list_agent_skills.',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return { ...base, guidance: workspaceHint(message) ?? '' };
|
|
440
|
+
}
|
|
441
|
+
/** The API names a dashboard concept ("team context"); here the workspace is the connected project's. */
|
|
442
|
+
export function workspaceHint(message) {
|
|
443
|
+
return /switch to the (team|personal)|different team|personal workspace/i.test(message)
|
|
444
|
+
? 'Connect with a project in the workspace (personal or team) that owns this agent, by its API key or a browser sign-in.'
|
|
445
|
+
: undefined;
|
|
446
|
+
}
|
|
447
|
+
// A field's setup help is shown whole: its last steps, such as sharing a
|
|
448
|
+
// calendar with the service account, are part of the setup.
|
|
449
|
+
export const FIELD_HELP_MAX = 4_000;
|
|
450
|
+
export function htmlToText(value, maxLength = 400) {
|
|
451
|
+
const html = asString(value);
|
|
452
|
+
if (!html)
|
|
453
|
+
return undefined;
|
|
454
|
+
const text = html
|
|
455
|
+
.replace(/<\s*(br|\/p|\/li|\/div)\s*\/?>/gi, ' ')
|
|
456
|
+
.replace(/<[^>]*>/g, '')
|
|
457
|
+
.replace(/ /g, ' ')
|
|
458
|
+
.replace(/</g, '<')
|
|
459
|
+
.replace(/>/g, '>')
|
|
460
|
+
.replace(/"/g, '"')
|
|
461
|
+
.replace(/'/g, '\'')
|
|
462
|
+
.replace(/&/g, '&')
|
|
463
|
+
.replace(/\s+/g, ' ')
|
|
464
|
+
.trim();
|
|
465
|
+
return text ? truncate(text, maxLength) : undefined;
|
|
466
|
+
}
|
|
467
|
+
function itemSchemaOf(raw) {
|
|
468
|
+
if (Array.isArray(raw.item_schema))
|
|
469
|
+
return raw.item_schema.filter(isPlainObject);
|
|
470
|
+
const config = isPlainObject(raw.custom_component_config) ? raw.custom_component_config : null;
|
|
471
|
+
if (!config)
|
|
472
|
+
return [];
|
|
473
|
+
for (const [key, value] of Object.entries(config)) {
|
|
474
|
+
if (key.startsWith('per_') && key.endsWith('_schema') && Array.isArray(value)) {
|
|
475
|
+
return value.filter(isPlainObject);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
function parseOptions(value) {
|
|
481
|
+
if (!Array.isArray(value))
|
|
482
|
+
return undefined;
|
|
483
|
+
const options = value
|
|
484
|
+
.map(option => (isPlainObject(option) ? asString(option.value) ?? '' : asString(option)))
|
|
485
|
+
.filter((option) => option !== undefined);
|
|
486
|
+
return options.length > 0 ? options : undefined;
|
|
487
|
+
}
|
|
488
|
+
export function parseCredentialField(raw) {
|
|
489
|
+
const key = asString(raw.key) ?? '';
|
|
490
|
+
return {
|
|
491
|
+
key,
|
|
492
|
+
label: asString(raw.label) ?? key,
|
|
493
|
+
type: asString(raw.type) ?? 'text',
|
|
494
|
+
required: raw.required === true,
|
|
495
|
+
encrypted: raw.encrypted === true,
|
|
496
|
+
oauthManaged: raw.oauth_managed === true,
|
|
497
|
+
autoFilled: raw.auto_filled_by_oauth === true,
|
|
498
|
+
serverManaged: raw.server_managed === true,
|
|
499
|
+
platformManaged: raw.platform_managed === true,
|
|
500
|
+
onlyInModes: Array.isArray(raw.only_in_modes) ? stringList(raw.only_in_modes) : null,
|
|
501
|
+
...(asString(raw.pattern) ? { pattern: asString(raw.pattern) } : {}),
|
|
502
|
+
...(toNumber(raw.minlength) !== null ? { minLength: toNumber(raw.minlength) } : {}),
|
|
503
|
+
...(toNumber(raw.maxlength) !== null ? { maxLength: toNumber(raw.maxlength) } : {}),
|
|
504
|
+
...(parseOptions(raw.options) ? { options: parseOptions(raw.options) } : {}),
|
|
505
|
+
...(Array.isArray(raw.filetypes) ? { fileTypes: stringList(raw.filetypes) } : {}),
|
|
506
|
+
...(toNumber(raw.maxsize) !== null ? { maxSizeKb: toNumber(raw.maxsize) } : {}),
|
|
507
|
+
...(asString(raw.item_type) ? { itemType: asString(raw.item_type) } : {}),
|
|
508
|
+
itemSchema: itemSchemaOf(raw).map(parseCredentialField).filter(field => field.key),
|
|
509
|
+
...(htmlToText(raw.help, FIELD_HELP_MAX) ? { help: htmlToText(raw.help, FIELD_HELP_MAX) } : {}),
|
|
510
|
+
...(asString(raw.placeholder) ? { placeholder: asString(raw.placeholder) } : {}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function parsePicker(raw) {
|
|
514
|
+
if (!isPlainObject(raw) || raw.enabled === false)
|
|
515
|
+
return null;
|
|
516
|
+
const valueField = asString(raw.item_value_field);
|
|
517
|
+
if (!valueField)
|
|
518
|
+
return null;
|
|
519
|
+
const urlItemFields = {};
|
|
520
|
+
if (isPlainObject(raw.url_item_fields)) {
|
|
521
|
+
for (const [saved, source] of Object.entries(raw.url_item_fields)) {
|
|
522
|
+
if (asString(source))
|
|
523
|
+
urlItemFields[saved] = asString(source);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const nextRaw = isPlainObject(raw.next) && raw.next.enabled !== false ? raw.next : null;
|
|
527
|
+
const next = nextRaw && asString(nextRaw.key) && asString(nextRaw.item_value_field)
|
|
528
|
+
? {
|
|
529
|
+
key: asString(nextRaw.key),
|
|
530
|
+
label: asString(nextRaw.modal_title)?.replace(/^select\s+/i, '') ?? asString(nextRaw.key),
|
|
531
|
+
optional: nextRaw.optional === true,
|
|
532
|
+
parentValueField: asString(nextRaw.parent_value_field) ?? valueField,
|
|
533
|
+
parentLabelField: asString(nextRaw.parent_label_field) ?? asString(raw.item_label_field) ?? valueField,
|
|
534
|
+
itemValueField: asString(nextRaw.item_value_field),
|
|
535
|
+
itemLabelField: asString(nextRaw.item_label_field) ?? asString(nextRaw.item_value_field),
|
|
536
|
+
persistField: asString(nextRaw.persist_field) ?? '',
|
|
537
|
+
}
|
|
538
|
+
: null;
|
|
539
|
+
return {
|
|
540
|
+
multiSelect: raw.multi_select === true,
|
|
541
|
+
valueField,
|
|
542
|
+
...(asString(raw.item_label_field) ? { labelField: asString(raw.item_label_field) } : {}),
|
|
543
|
+
fieldsToSave: stringList(raw.item_fields_to_save).length > 0 ? stringList(raw.item_fields_to_save) : [valueField],
|
|
544
|
+
urlItemFields,
|
|
545
|
+
pendingCache: isPlainObject(raw.pending_cache),
|
|
546
|
+
next,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
export function parseCredentialMeta(raw) {
|
|
550
|
+
const key = asString(raw.key) ?? '';
|
|
551
|
+
const fields = (Array.isArray(raw.credential_schema) ? raw.credential_schema : [])
|
|
552
|
+
.filter(isPlainObject)
|
|
553
|
+
.map(parseCredentialField)
|
|
554
|
+
.filter(field => field.key);
|
|
555
|
+
const provider = isPlainObject(raw.oauth_provider) ? raw.oauth_provider : null;
|
|
556
|
+
let oauth = null;
|
|
557
|
+
if (provider) {
|
|
558
|
+
const flow = isPlainObject(provider.oauth_flow) ? provider.oauth_flow : {};
|
|
559
|
+
const probe = isPlainObject(provider.direct_probe) ? provider.direct_probe : null;
|
|
560
|
+
const tokenField = probe ? asString(probe.token_field) : undefined;
|
|
561
|
+
oauth = {
|
|
562
|
+
...(asString(flow.callback_path) ? { callbackPath: asString(flow.callback_path) } : {}),
|
|
563
|
+
stateFields: stringList(flow.state_fields),
|
|
564
|
+
connectRequirements: (Array.isArray(flow.connect_requirements) ? flow.connect_requirements : [])
|
|
565
|
+
.filter(isPlainObject)
|
|
566
|
+
.map(requirement => asString(requirement.message) ?? '')
|
|
567
|
+
.filter(Boolean),
|
|
568
|
+
directProbe: probe
|
|
569
|
+
? {
|
|
570
|
+
mode: asString(probe.mode) ?? 'api_key',
|
|
571
|
+
...(asString(probe.mode_label) ? { label: asString(probe.mode_label) } : {}),
|
|
572
|
+
requiredFields: stringList(probe.required_fields).length > 0
|
|
573
|
+
? stringList(probe.required_fields)
|
|
574
|
+
: (tokenField ? [tokenField] : []),
|
|
575
|
+
}
|
|
576
|
+
: null,
|
|
577
|
+
picker: parsePicker(provider.account_picker),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const channel = isPlainObject(raw.channel)
|
|
581
|
+
? { id: asString(raw.channel.id) ?? key, requiredFields: stringList(raw.channel.required_fields) }
|
|
582
|
+
: null;
|
|
583
|
+
return {
|
|
584
|
+
key,
|
|
585
|
+
title: asString(raw.title) ?? key,
|
|
586
|
+
connectionModes: stringList(raw.connection_modes),
|
|
587
|
+
...(asString(raw.default_connection_mode) ? { defaultConnectionMode: asString(raw.default_connection_mode) } : {}),
|
|
588
|
+
wiroPending: raw.wiro_connect_pending === true,
|
|
589
|
+
fields,
|
|
590
|
+
channel,
|
|
591
|
+
oauth,
|
|
592
|
+
platformOnly: raw.credential_mode === 'rule_only'
|
|
593
|
+
|| (!provider && fields.length > 0 && fields.every(field => field.platformManaged)),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/** The modes a connection can use now: wiro is left out while the registry marks it pending. */
|
|
597
|
+
export function usableConnectionModes(meta) {
|
|
598
|
+
return meta.connectionModes.filter(mode => mode !== 'wiro' || !meta.wiroPending);
|
|
599
|
+
}
|
|
600
|
+
/** Returns null when the registry has no credential with that key. */
|
|
601
|
+
export async function loadCredentialMeta(client, key, signal) {
|
|
602
|
+
try {
|
|
603
|
+
return await cachedRead(client, `credential:${key}`, async () => {
|
|
604
|
+
const body = requireApiSuccess(await client.post('/Credentials/Detail', { key }, signal), `Could not read the credential "${key}".`);
|
|
605
|
+
if (!isPlainObject(body.credential)) {
|
|
606
|
+
throw new ApiResultError(`Credential not found: ${key}`, [404], body);
|
|
607
|
+
}
|
|
608
|
+
return parseCredentialMeta(body.credential);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (hasErrorCode(error, 404))
|
|
613
|
+
return null;
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const OBJECT_LIST_TYPES = new Set([
|
|
618
|
+
'custom:apps-list-ios',
|
|
619
|
+
'custom:apps-list-android',
|
|
620
|
+
'custom:apps-list-adjust',
|
|
621
|
+
'custom:urls-list',
|
|
622
|
+
'custom:firebase-accounts-list',
|
|
623
|
+
'custom:drive-folder-picker',
|
|
624
|
+
]);
|
|
625
|
+
/** Mirrors how the API stores a field: lists of rows, lists of strings, files or one value. */
|
|
626
|
+
export function credentialFieldShape(field) {
|
|
627
|
+
const type = field.type;
|
|
628
|
+
if (type === 'object-array' || OBJECT_LIST_TYPES.has(type))
|
|
629
|
+
return 'object-array';
|
|
630
|
+
if (type === 'string-array')
|
|
631
|
+
return 'string-array';
|
|
632
|
+
if (type === 'array') {
|
|
633
|
+
if (field.itemType === 'object')
|
|
634
|
+
return 'object-array';
|
|
635
|
+
if (field.itemType === 'string')
|
|
636
|
+
return 'string-array';
|
|
637
|
+
return field.itemSchema.length > 0 ? 'object-array' : 'string-array';
|
|
638
|
+
}
|
|
639
|
+
if (type === 'fileinput')
|
|
640
|
+
return 'file';
|
|
641
|
+
if (type === 'fileinput-base64')
|
|
642
|
+
return 'base64-file';
|
|
643
|
+
if (type === 'boolean')
|
|
644
|
+
return 'boolean';
|
|
645
|
+
if (type === 'number')
|
|
646
|
+
return 'number';
|
|
647
|
+
if (type === 'select')
|
|
648
|
+
return 'select';
|
|
649
|
+
return 'text';
|
|
650
|
+
}
|
|
651
|
+
/** Fields the connection flow or Wiro fills; customers cannot write them. */
|
|
652
|
+
export function isWritableField(field) {
|
|
653
|
+
return !field.autoFilled && !field.serverManaged && !field.platformManaged && field.type !== 'computed';
|
|
654
|
+
}
|
|
655
|
+
// Token state the connection flow owns.
|
|
656
|
+
const CONNECTION_STATE_FIELDS = new Set([
|
|
657
|
+
'accesstoken',
|
|
658
|
+
'refreshtoken',
|
|
659
|
+
'connectedat',
|
|
660
|
+
'tokenexpiresat',
|
|
661
|
+
'refreshtokenexpiresat',
|
|
662
|
+
]);
|
|
663
|
+
/**
|
|
664
|
+
* Base64 file fields hold the file's bytes as base64. Callers often have the
|
|
665
|
+
* file's text instead (a service account JSON, a .p8 key), so anything that is
|
|
666
|
+
* not already base64 is encoded here.
|
|
667
|
+
*/
|
|
668
|
+
export function normalizeBase64FileValue(value) {
|
|
669
|
+
const dataUrl = /^data:[^,]*;base64,(.*)$/s.exec(value.trim());
|
|
670
|
+
if (dataUrl)
|
|
671
|
+
return (dataUrl[1] ?? '').replace(/\s+/g, '');
|
|
672
|
+
const compact = value.replace(/\s+/g, '');
|
|
673
|
+
if (compact && compact.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(compact))
|
|
674
|
+
return compact;
|
|
675
|
+
return Buffer.from(value, 'utf8').toString('base64');
|
|
676
|
+
}
|
|
677
|
+
function isJsonObjectText(text) {
|
|
678
|
+
try {
|
|
679
|
+
return isPlainObject(JSON.parse(text));
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** A key file field's value as Wiro stores it, or why it is refused. The value itself is never repeated. */
|
|
686
|
+
function keyFileValue(field, value, label) {
|
|
687
|
+
if (typeof value !== 'string')
|
|
688
|
+
return { error: `${label} takes the file's content as text or base64.` };
|
|
689
|
+
// Key files take the file's text; refuse a link before anything is saved.
|
|
690
|
+
if (/^\s*https?:\/\//i.test(value)) {
|
|
691
|
+
return { error: `${label} takes the file's content as text or base64, not a link. The user can paste the file's text or upload the file on the agent's page in the Wiro dashboard.` };
|
|
692
|
+
}
|
|
693
|
+
const stored = normalizeBase64FileValue(value);
|
|
694
|
+
// A JSON key, such as a service account file, must hold a JSON object, so a
|
|
695
|
+
// path, a share link or an id is refused before anything is saved.
|
|
696
|
+
if (field.fileTypes?.includes('application/json') && !isJsonObjectText(Buffer.from(stored, 'base64').toString('utf8'))) {
|
|
697
|
+
return { error: `${label} takes the text of the JSON key file. This value is not JSON. The user can paste the file's text or upload the file on the agent's page in the Wiro dashboard.` };
|
|
698
|
+
}
|
|
699
|
+
return { value: stored };
|
|
700
|
+
}
|
|
701
|
+
function isEmptyValue(value) {
|
|
702
|
+
if (value === null || value === undefined)
|
|
703
|
+
return true;
|
|
704
|
+
if (typeof value === 'string')
|
|
705
|
+
return value.trim() === '';
|
|
706
|
+
if (Array.isArray(value))
|
|
707
|
+
return value.length === 0;
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
// Short values (select options, flags) are not secrets and would garble messages.
|
|
711
|
+
function collectStrings(value, into) {
|
|
712
|
+
if (typeof value === 'string') {
|
|
713
|
+
if (value.trim().length >= 8)
|
|
714
|
+
into.push(value.trim());
|
|
715
|
+
}
|
|
716
|
+
else if (Array.isArray(value)) {
|
|
717
|
+
for (const item of value)
|
|
718
|
+
collectStrings(item, into);
|
|
719
|
+
}
|
|
720
|
+
else if (isPlainObject(value)) {
|
|
721
|
+
for (const item of Object.values(value))
|
|
722
|
+
collectStrings(item, into);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function patternError(field, value, label) {
|
|
726
|
+
if (field.minLength !== undefined && value.length < field.minLength) {
|
|
727
|
+
return `${label} must be at least ${field.minLength} characters.`;
|
|
728
|
+
}
|
|
729
|
+
if (field.maxLength !== undefined && value.length > field.maxLength) {
|
|
730
|
+
return `${label} must be at most ${field.maxLength} characters.`;
|
|
731
|
+
}
|
|
732
|
+
if (field.pattern) {
|
|
733
|
+
try {
|
|
734
|
+
if (!new RegExp(field.pattern).test(value))
|
|
735
|
+
return `${label} has an invalid format (expected pattern ${field.pattern}).`;
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
function fieldList(fields) {
|
|
744
|
+
return fields.filter(isWritableField).map(field => field.key).join(', ');
|
|
745
|
+
}
|
|
746
|
+
function scalarValue(field, value, label) {
|
|
747
|
+
const shape = credentialFieldShape(field);
|
|
748
|
+
if (shape === 'boolean') {
|
|
749
|
+
return typeof value === 'boolean' ? { value } : { error: `${label} takes true or false.` };
|
|
750
|
+
}
|
|
751
|
+
if (shape === 'number') {
|
|
752
|
+
const number = toNumber(value);
|
|
753
|
+
return number === null ? { error: `${label} takes a number.` } : { value: number };
|
|
754
|
+
}
|
|
755
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
756
|
+
return { error: `${label} takes a single text value.` };
|
|
757
|
+
}
|
|
758
|
+
const text = String(value);
|
|
759
|
+
if (shape === 'select' && field.options && !field.options.includes(text)) {
|
|
760
|
+
return { error: `${label} must be one of: ${field.options.map(option => option || '(empty)').join(', ')}.` };
|
|
761
|
+
}
|
|
762
|
+
const problem = text.trim() ? patternError(field, text.trim(), label) : null;
|
|
763
|
+
return problem ? { error: problem } : { value: text };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* A list field's value. Items and rows are named the way Wiro names them,
|
|
767
|
+
* counting from 1: "Allowed Telegram DM User IDs #2" at the top level, and
|
|
768
|
+
* itemLabel names the items of a text list inside an entry.
|
|
769
|
+
*/
|
|
770
|
+
function listValue(field, value, label, itemLabel = index => `${label} #${index + 1}`) {
|
|
771
|
+
const shape = credentialFieldShape(field);
|
|
772
|
+
if (!Array.isArray(value))
|
|
773
|
+
return { error: `${label} takes a list (JSON array).` };
|
|
774
|
+
if (shape === 'string-array') {
|
|
775
|
+
const items = [];
|
|
776
|
+
for (const [index, item] of value.entries()) {
|
|
777
|
+
if (typeof item !== 'string' && typeof item !== 'number') {
|
|
778
|
+
return { error: `${field.label} takes a list of text values; ${itemLabel(index)} is not one.` };
|
|
779
|
+
}
|
|
780
|
+
const text = String(item).trim();
|
|
781
|
+
if (!text)
|
|
782
|
+
continue;
|
|
783
|
+
const problem = patternError(field, text, itemLabel(index));
|
|
784
|
+
if (problem)
|
|
785
|
+
return { error: problem };
|
|
786
|
+
items.push(text);
|
|
787
|
+
}
|
|
788
|
+
return { value: items };
|
|
789
|
+
}
|
|
790
|
+
return rowsValue(field, value, index => `${label} #${index + 1}`);
|
|
791
|
+
}
|
|
792
|
+
function isListField(field) {
|
|
793
|
+
const shape = credentialFieldShape(field);
|
|
794
|
+
return shape === 'object-array' || shape === 'string-array';
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* The rows of a list of objects, checked the way Wiro checks them: only the
|
|
798
|
+
* keys the item schema declares, each value by its own rules, and a list inside
|
|
799
|
+
* a row (such as the apps of one Firebase account) by its own shape. Errors
|
|
800
|
+
* name the row the way Wiro does: "App ID in Apps #2 in Firebase Accounts #1".
|
|
801
|
+
*/
|
|
802
|
+
function rowsValue(field, value, rowLabel) {
|
|
803
|
+
const allowed = new Set(field.itemSchema.map(item => item.key));
|
|
804
|
+
const rows = [];
|
|
805
|
+
for (const [index, item] of value.entries()) {
|
|
806
|
+
const label = rowLabel(index);
|
|
807
|
+
if (!isPlainObject(item))
|
|
808
|
+
return { error: `${field.label} takes a list of objects; ${label} is not one.` };
|
|
809
|
+
if (allowed.size > 0) {
|
|
810
|
+
const unknown = Object.keys(item).filter(key => !allowed.has(key));
|
|
811
|
+
if (unknown.length > 0) {
|
|
812
|
+
return { error: `${label} has unknown keys (${unknown.join(', ')}). Allowed: ${[...allowed].join(', ')}.` };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const row = { ...item };
|
|
816
|
+
for (const entry of field.itemSchema) {
|
|
817
|
+
const checked = isListField(entry)
|
|
818
|
+
? nestedListValue(entry, row[entry.key], label)
|
|
819
|
+
: rowFieldValue(entry, row[entry.key], `${entry.label} in ${label}`);
|
|
820
|
+
if (checked.error)
|
|
821
|
+
return { error: checked.error };
|
|
822
|
+
if (Object.prototype.hasOwnProperty.call(row, entry.key))
|
|
823
|
+
row[entry.key] = checked.value;
|
|
824
|
+
}
|
|
825
|
+
rows.push(row);
|
|
826
|
+
}
|
|
827
|
+
return { value: rows };
|
|
828
|
+
}
|
|
829
|
+
/** One value in a list row: required, options, length and pattern, as Wiro checks it. */
|
|
830
|
+
function rowFieldValue(field, value, label) {
|
|
831
|
+
if (!isFilledValue(value))
|
|
832
|
+
return field.required ? { error: `${label} is required.` } : { value };
|
|
833
|
+
const shape = credentialFieldShape(field);
|
|
834
|
+
// Key files inside a row, such as each Firebase account's service account JSON, are stored like top-level ones.
|
|
835
|
+
if (shape === 'base64-file')
|
|
836
|
+
return keyFileValue(field, value, label);
|
|
837
|
+
if (typeof value === 'object')
|
|
838
|
+
return { error: `${label} takes a single text value.` };
|
|
839
|
+
const text = String(value);
|
|
840
|
+
if (shape === 'select' && field.options && !field.options.includes(text)) {
|
|
841
|
+
return { error: `${label} must be one of: ${field.options.map(option => option || '(empty)').join(', ')}.` };
|
|
842
|
+
}
|
|
843
|
+
const problem = patternError(field, text, label);
|
|
844
|
+
return problem ? { error: problem } : { value };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* A list inside one entry of a list, such as the apps of one Firebase account,
|
|
848
|
+
* in a whole write of the outer list or on its own with parent_field: rows
|
|
849
|
+
* checked against its item schema, or text items.
|
|
850
|
+
*/
|
|
851
|
+
function nestedListValue(field, value, entryLabel) {
|
|
852
|
+
const label = `${field.label} in ${entryLabel}`;
|
|
853
|
+
if (!Array.isArray(value)) {
|
|
854
|
+
if (isFilledValue(value))
|
|
855
|
+
return { error: `${label} takes a list (JSON array).` };
|
|
856
|
+
return field.required ? { error: `${label} is required.` } : { value };
|
|
857
|
+
}
|
|
858
|
+
const checked = credentialFieldShape(field) === 'object-array'
|
|
859
|
+
? rowsValue(field, value, index => `${field.label} #${index + 1} in ${entryLabel}`)
|
|
860
|
+
: listValue({ ...field, type: 'string-array' }, value, label, index => `${label}, item #${index + 1}`);
|
|
861
|
+
if (checked.error)
|
|
862
|
+
return checked;
|
|
863
|
+
return field.required && Array.isArray(checked.value) && checked.value.length === 0
|
|
864
|
+
? { error: `${label} is required.` }
|
|
865
|
+
: checked;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Checks every requested field against the credential registry and builds the
|
|
869
|
+
* API payload. Nothing is written when any field is refused.
|
|
870
|
+
*/
|
|
871
|
+
export function prepareCredentialWrite(inputs, metas) {
|
|
872
|
+
const prepared = { apiFields: [], files: [], errors: [], secrets: [] };
|
|
873
|
+
const reportedUnknown = new Set();
|
|
874
|
+
for (const input of inputs) {
|
|
875
|
+
collectStrings(input.value, prepared.secrets);
|
|
876
|
+
const credential = input.credential.trim();
|
|
877
|
+
const fieldName = input.field.trim();
|
|
878
|
+
const meta = metas.get(credential) ?? null;
|
|
879
|
+
if (!meta) {
|
|
880
|
+
if (!reportedUnknown.has(credential)) {
|
|
881
|
+
prepared.errors.push(`Unknown credential "${credential}". The credential keys a skill uses are listed by list_agent_skills and get_my_agent.`);
|
|
882
|
+
reportedUnknown.add(credential);
|
|
883
|
+
}
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (meta.platformOnly) {
|
|
887
|
+
prepared.errors.push(`${meta.title} is managed by Wiro and cannot be set.`);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (!fieldName) {
|
|
891
|
+
prepared.errors.push(`${meta.title}: the field key is empty.`);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (fieldName.startsWith('_')) {
|
|
895
|
+
prepared.errors.push(`${meta.title}: "${fieldName}" is a reserved name.`);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (CONNECTION_STATE_FIELDS.has(fieldName)) {
|
|
899
|
+
prepared.errors.push(`${meta.title}: "${fieldName}" is filled by the connection itself. Use connect_agent_oauth.`);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (fieldName === 'authmethod') {
|
|
903
|
+
const usable = usableConnectionModes(meta);
|
|
904
|
+
if (input.parent_field || usable.length === 0) {
|
|
905
|
+
prepared.errors.push(`${meta.title} has no connection modes, so "authmethod" does not apply.`);
|
|
906
|
+
}
|
|
907
|
+
else if (typeof input.value !== 'string' || !usable.includes(input.value)) {
|
|
908
|
+
prepared.errors.push(`${meta.title}: authmethod must be one of ${usable.join(', ')}.`);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
prepared.apiFields.push({ credentialkey: credential, fieldname: 'authmethod', fieldvalue: input.value });
|
|
912
|
+
}
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (input.parent_field) {
|
|
916
|
+
const root = input.parent_field.split(/[.[\]]/)[0]?.trim() ?? '';
|
|
917
|
+
const parent = meta.fields.find(field => field.key === root);
|
|
918
|
+
if (!parent || credentialFieldShape(parent) !== 'object-array') {
|
|
919
|
+
prepared.errors.push(`${meta.title} has no list field "${root}" for parent_field. List fields: ${meta.fields.filter(field => credentialFieldShape(field) === 'object-array').map(field => field.key).join(', ') || 'none'}.`);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
// Wiro checks and stores an entry's field under the list's own key and
|
|
923
|
+
// the entry's position, so parent_field takes that key alone and the
|
|
924
|
+
// position goes in ordinal.
|
|
925
|
+
if (input.parent_field.trim() !== parent.key) {
|
|
926
|
+
prepared.errors.push(`${parent.label}: parent_field takes the list field's key alone ("${parent.key}"); pass the entry's position, from 0, as ordinal.`);
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (!isWritableField(parent)) {
|
|
930
|
+
prepared.errors.push(`${parent.label} is filled by the connection or by Wiro and cannot be set.`);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
const item = parent.itemSchema.find(candidate => candidate.key === fieldName);
|
|
934
|
+
if (parent.itemSchema.length > 0 && !item) {
|
|
935
|
+
prepared.errors.push(`${parent.label} entries have no field "${fieldName}". Entry fields: ${parent.itemSchema.map(candidate => candidate.key).join(', ')}.`);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (input.ordinal === undefined) {
|
|
939
|
+
prepared.errors.push(`${parent.label}: ordinal (the entry's position, from 0) is required with parent_field.`);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
// The entry is named the way Wiro names it, counting from 1.
|
|
943
|
+
const entryLabel = `${parent.label} #${input.ordinal + 1}`;
|
|
944
|
+
let value = input.value;
|
|
945
|
+
// An entry's field is checked as it is in a whole write of the list,
|
|
946
|
+
// empty values included, so a required one cannot be cleared.
|
|
947
|
+
if (item) {
|
|
948
|
+
const checked = isListField(item)
|
|
949
|
+
? nestedListValue(item, isEmptyValue(value) ? [] : value, entryLabel)
|
|
950
|
+
: rowFieldValue(item, value, `${item.label} in ${entryLabel}`);
|
|
951
|
+
if (checked.error) {
|
|
952
|
+
prepared.errors.push(checked.error);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
value = checked.value;
|
|
956
|
+
}
|
|
957
|
+
prepared.apiFields.push({
|
|
958
|
+
credentialkey: credential,
|
|
959
|
+
fieldname: fieldName,
|
|
960
|
+
fieldvalue: item && !isFilledValue(value) ? (isListField(item) ? [] : '') : (value ?? ''),
|
|
961
|
+
parentfield: parent.key,
|
|
962
|
+
ordinal: input.ordinal,
|
|
963
|
+
});
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const field = meta.fields.find(candidate => candidate.key === fieldName);
|
|
967
|
+
if (!field) {
|
|
968
|
+
prepared.errors.push(`${meta.title} has no field "${fieldName}". Fields you can set: ${fieldList(meta.fields) || 'none'}.`);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (field.platformManaged) {
|
|
972
|
+
prepared.errors.push(`${field.label} is managed by Wiro and cannot be set.`);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (!isWritableField(field)) {
|
|
976
|
+
prepared.errors.push(`${field.label} is filled when the account is connected with connect_agent_oauth and cannot be set directly.`);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const shape = credentialFieldShape(field);
|
|
980
|
+
// Channel groups are optional as a whole: clearing one activation field turns the channel off.
|
|
981
|
+
const mayClear = !field.required || meta.channel !== null;
|
|
982
|
+
if (isEmptyValue(input.value)) {
|
|
983
|
+
if (!mayClear) {
|
|
984
|
+
prepared.errors.push(`${field.label} is required and cannot be cleared.`);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const cleared = shape === 'string-array' || shape === 'object-array' ? [] : '';
|
|
988
|
+
prepared.apiFields.push({ credentialkey: credential, fieldname: fieldName, fieldvalue: cleared });
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
if (shape === 'file') {
|
|
992
|
+
const url = typeof input.value === 'string' ? input.value.trim() : '';
|
|
993
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
994
|
+
prepared.errors.push(`${field.label} takes the public http(s) URL of the file to upload.`);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
prepared.files.push({
|
|
998
|
+
credential,
|
|
999
|
+
field: fieldName,
|
|
1000
|
+
label: field.label,
|
|
1001
|
+
url,
|
|
1002
|
+
fileTypes: field.fileTypes ?? [],
|
|
1003
|
+
maxBytes: (field.maxSizeKb ?? 10240) * 1024,
|
|
1004
|
+
});
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
if (shape === 'base64-file') {
|
|
1008
|
+
const checked = keyFileValue(field, input.value, field.label);
|
|
1009
|
+
if (checked.error) {
|
|
1010
|
+
prepared.errors.push(checked.error);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
prepared.apiFields.push({ credentialkey: credential, fieldname: fieldName, fieldvalue: checked.value });
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const checked = shape === 'string-array' || shape === 'object-array'
|
|
1017
|
+
? listValue(field, input.value, field.label)
|
|
1018
|
+
: scalarValue(field, input.value, field.label);
|
|
1019
|
+
if (checked.error) {
|
|
1020
|
+
prepared.errors.push(checked.error);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if ((shape === 'string-array' || shape === 'object-array')
|
|
1024
|
+
&& Array.isArray(checked.value) && checked.value.length === 0 && !mayClear) {
|
|
1025
|
+
prepared.errors.push(`${field.label} is required and cannot be empty.`);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
prepared.apiFields.push({ credentialkey: credential, fieldname: fieldName, fieldvalue: checked.value });
|
|
1029
|
+
}
|
|
1030
|
+
return prepared;
|
|
1031
|
+
}
|
|
1032
|
+
/** Removes every value the caller sent from a message before it is shown. */
|
|
1033
|
+
export function redactSecrets(text, secrets) {
|
|
1034
|
+
let out = text;
|
|
1035
|
+
for (const secret of [...secrets].sort((left, right) => right.length - left.length)) {
|
|
1036
|
+
if (secret.length >= 8)
|
|
1037
|
+
out = out.split(secret).join('[hidden]');
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
const EXTENSION_BY_TYPE = {
|
|
1042
|
+
'audio/mpeg': 'mp3',
|
|
1043
|
+
'audio/mp3': 'mp3',
|
|
1044
|
+
'audio/wav': 'wav',
|
|
1045
|
+
'audio/x-wav': 'wav',
|
|
1046
|
+
'audio/ogg': 'ogg',
|
|
1047
|
+
'image/png': 'png',
|
|
1048
|
+
'image/jpeg': 'jpg',
|
|
1049
|
+
'image/webp': 'webp',
|
|
1050
|
+
'application/pdf': 'pdf',
|
|
1051
|
+
};
|
|
1052
|
+
/** The API derives the stored file's extension from its name, so one is always added. */
|
|
1053
|
+
export function uploadFileName(url, field, contentType) {
|
|
1054
|
+
let base = '';
|
|
1055
|
+
try {
|
|
1056
|
+
base = decodeURIComponent(new URL(url).pathname.split('/').pop() ?? '');
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
base = '';
|
|
1060
|
+
}
|
|
1061
|
+
base = base.replace(/[^A-Za-z0-9._-]+/g, '_').slice(-100);
|
|
1062
|
+
if (/\.[A-Za-z0-9]{2,5}$/.test(base))
|
|
1063
|
+
return base;
|
|
1064
|
+
const extension = EXTENSION_BY_TYPE[contentType] ?? 'bin';
|
|
1065
|
+
return `${base || field}.${extension}`;
|
|
1066
|
+
}
|
|
1067
|
+
// ---------------------------------------------------------------------------
|
|
1068
|
+
// Agent credential status (from /UserAgent/Detail, values never read out)
|
|
1069
|
+
// ---------------------------------------------------------------------------
|
|
1070
|
+
export function isFilledValue(value) {
|
|
1071
|
+
if (value === null || value === undefined)
|
|
1072
|
+
return false;
|
|
1073
|
+
if (typeof value === 'string')
|
|
1074
|
+
return value.trim() !== '';
|
|
1075
|
+
if (Array.isArray(value))
|
|
1076
|
+
return value.some(isFilledValue);
|
|
1077
|
+
if (isPlainObject(value))
|
|
1078
|
+
return Object.values(value).some(isFilledValue);
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
export function credentialGroup(row, key) {
|
|
1082
|
+
const groups = isPlainObject(row.credentials) ? row.credentials : null;
|
|
1083
|
+
const group = groups ? groups[key] : undefined;
|
|
1084
|
+
return isPlainObject(group) ? group : null;
|
|
1085
|
+
}
|
|
1086
|
+
export function credentialStatus(row, key, meta) {
|
|
1087
|
+
const group = credentialGroup(row, key);
|
|
1088
|
+
if (!group)
|
|
1089
|
+
return null;
|
|
1090
|
+
const schema = isPlainObject(group._schema) ? group._schema : {};
|
|
1091
|
+
const connected = group._connected === true;
|
|
1092
|
+
// A sign-in credential's required fields depend on its mode; connected is the signal there.
|
|
1093
|
+
const missingFields = connected || !meta || meta.oauth
|
|
1094
|
+
? []
|
|
1095
|
+
: meta.fields
|
|
1096
|
+
.filter(field => field.required && isWritableField(field) && !isFilledValue(group[field.key]))
|
|
1097
|
+
.map(field => field.label);
|
|
1098
|
+
return {
|
|
1099
|
+
key,
|
|
1100
|
+
title: asString(schema.title) ?? meta?.title ?? key,
|
|
1101
|
+
connected,
|
|
1102
|
+
filled: group._filled === true,
|
|
1103
|
+
requiredMissing: group._required_missing === true,
|
|
1104
|
+
optional: group.optional === true,
|
|
1105
|
+
missingFields,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
export function channelStatuses(row) {
|
|
1109
|
+
const channels = Array.isArray(row.communicationChannels) ? row.communicationChannels : [];
|
|
1110
|
+
return channels.filter(isPlainObject).map(channel => {
|
|
1111
|
+
const schema = isPlainObject(channel.schema) ? channel.schema : {};
|
|
1112
|
+
const labels = new Map();
|
|
1113
|
+
for (const field of Array.isArray(schema.fields) ? schema.fields : []) {
|
|
1114
|
+
if (isPlainObject(field) && asString(field.key)) {
|
|
1115
|
+
labels.set(asString(field.key), asString(field.label) ?? asString(field.key));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const id = asString(channel.id) ?? '';
|
|
1119
|
+
return {
|
|
1120
|
+
id,
|
|
1121
|
+
title: asString(channel.title) ?? id,
|
|
1122
|
+
credential: asString(channel.credentialkey) ?? id,
|
|
1123
|
+
enabled: channel.enabled === true,
|
|
1124
|
+
missingFields: stringList(channel.missingfields).map(field => labels.get(field) ?? field),
|
|
1125
|
+
};
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
// Sign-in connections (connect_agent_oauth)
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
/**
|
|
1132
|
+
* Wiro sends the browser to this address after sign-in with the connected
|
|
1133
|
+
* account names appended, so only the agent's own dashboard page or an
|
|
1134
|
+
* address on the user's own machine is accepted.
|
|
1135
|
+
*/
|
|
1136
|
+
export function isAllowedRedirectUrl(url, agentGuid) {
|
|
1137
|
+
let parsed;
|
|
1138
|
+
try {
|
|
1139
|
+
parsed = new URL(url.trim());
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
if (parsed.username || parsed.password)
|
|
1145
|
+
return false;
|
|
1146
|
+
if (parsed.protocol === 'http:')
|
|
1147
|
+
return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
1148
|
+
const dashboard = new URL(dashboardAgentUrl(agentGuid));
|
|
1149
|
+
return parsed.protocol === 'https:'
|
|
1150
|
+
&& parsed.host === dashboard.host
|
|
1151
|
+
&& parsed.pathname.replace(/\/+$/, '') === dashboard.pathname
|
|
1152
|
+
&& (parsed.search === '' || parsed.search === dashboard.search)
|
|
1153
|
+
&& parsed.hash === '';
|
|
1154
|
+
}
|
|
1155
|
+
/** The redirect URI a customer registers in their own OAuth app. */
|
|
1156
|
+
export function ownAppRedirectUri(meta) {
|
|
1157
|
+
return meta.oauth?.callbackPath ? `${OAUTH_CALLBACK_BASE}${meta.oauth.callbackPath}` : undefined;
|
|
1158
|
+
}
|
|
1159
|
+
function fieldsForMode(meta, mode) {
|
|
1160
|
+
if (mode === 'wiro')
|
|
1161
|
+
return [];
|
|
1162
|
+
const byKey = new Map(meta.fields.map(field => [field.key, field]));
|
|
1163
|
+
const keys = [];
|
|
1164
|
+
if (mode === meta.oauth?.directProbe?.mode) {
|
|
1165
|
+
keys.push(...meta.oauth.directProbe.requiredFields);
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
for (const field of meta.fields) {
|
|
1169
|
+
if (isWritableField(field) && field.onlyInModes?.includes(mode))
|
|
1170
|
+
keys.push(field.key);
|
|
1171
|
+
}
|
|
1172
|
+
if (mode === 'own')
|
|
1173
|
+
keys.push(...(meta.oauth?.stateFields ?? []));
|
|
1174
|
+
}
|
|
1175
|
+
return [...new Set(keys)]
|
|
1176
|
+
.map(key => byKey.get(key) ?? { ...parseCredentialField({ key }), required: true })
|
|
1177
|
+
.filter(field => isWritableField(field));
|
|
1178
|
+
}
|
|
1179
|
+
/** What each connection mode needs, with which of those fields are already saved. */
|
|
1180
|
+
export function connectionModeRequirements(meta, group) {
|
|
1181
|
+
const modes = meta.connectionModes.length > 0 ? meta.connectionModes : ['wiro'];
|
|
1182
|
+
return modes.map(mode => {
|
|
1183
|
+
const fields = fieldsForMode(meta, mode).map(field => ({
|
|
1184
|
+
field: field.key,
|
|
1185
|
+
label: field.label,
|
|
1186
|
+
isSet: group !== null && isFilledValue(group[field.key]),
|
|
1187
|
+
}));
|
|
1188
|
+
if (mode === 'wiro') {
|
|
1189
|
+
return {
|
|
1190
|
+
mode,
|
|
1191
|
+
available: !meta.wiroPending,
|
|
1192
|
+
needsBrowser: true,
|
|
1193
|
+
fields,
|
|
1194
|
+
note: meta.wiroPending
|
|
1195
|
+
? `Use another mode for ${meta.title}.`
|
|
1196
|
+
: `Sign in through Wiro's own ${meta.title} app. Nothing to set up first.`,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
if (mode === meta.oauth?.directProbe?.mode) {
|
|
1200
|
+
return {
|
|
1201
|
+
mode,
|
|
1202
|
+
available: true,
|
|
1203
|
+
needsBrowser: false,
|
|
1204
|
+
fields,
|
|
1205
|
+
note: `Save ${meta.oauth.directProbe.label ? `the ${meta.oauth.directProbe.label}` : 'these fields'} with set_agent_credentials, then connect with mode ${mode}. Wiro checks it and lists the accounts it can use. No browser needed.`,
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
if (mode === 'api_key') {
|
|
1209
|
+
return {
|
|
1210
|
+
mode,
|
|
1211
|
+
available: true,
|
|
1212
|
+
needsBrowser: false,
|
|
1213
|
+
fields,
|
|
1214
|
+
note: `Save these fields with set_agent_credentials together with authmethod ${mode}. There is no sign-in or connect step for this mode.`,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
if (mode === 'own') {
|
|
1218
|
+
const redirect = ownAppRedirectUri(meta);
|
|
1219
|
+
const requirements = meta.oauth?.connectRequirements ?? [];
|
|
1220
|
+
return {
|
|
1221
|
+
mode,
|
|
1222
|
+
available: true,
|
|
1223
|
+
needsBrowser: true,
|
|
1224
|
+
fields,
|
|
1225
|
+
note: `Use your own ${meta.title} app: save its keys with set_agent_credentials${redirect ? ` and allow ${redirect} as a redirect URI in the app` : ''}, then connect with mode own and sign in.${requirements.length > 0 ? ` ${requirements.join(' ')}` : ''}`,
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
return { mode, available: true, needsBrowser: true, fields, note: `Connect with mode ${mode}.` };
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* The mode to use when the caller did not name one: Wiro's own app when it is
|
|
1233
|
+
* available, the only remaining mode, or the first mode whose fields are
|
|
1234
|
+
* already saved (the registry's default first).
|
|
1235
|
+
*/
|
|
1236
|
+
export function chooseConnectionMode(meta, requirements) {
|
|
1237
|
+
const usable = requirements.filter(requirement => requirement.available);
|
|
1238
|
+
const wiro = usable.find(requirement => requirement.mode === 'wiro');
|
|
1239
|
+
if (wiro)
|
|
1240
|
+
return 'wiro';
|
|
1241
|
+
if (usable.length === 1)
|
|
1242
|
+
return usable[0]?.mode ?? null;
|
|
1243
|
+
const ordered = [...usable].sort((left, right) => {
|
|
1244
|
+
const preferred = meta.defaultConnectionMode;
|
|
1245
|
+
return Number(right.mode === preferred) - Number(left.mode === preferred);
|
|
1246
|
+
});
|
|
1247
|
+
return ordered.find(requirement => requirement.fields.every(field => field.isSet))?.mode ?? null;
|
|
1248
|
+
}
|
|
1249
|
+
/** Candidate or saved accounts in one shape, whatever the provider calls the fields. */
|
|
1250
|
+
export function toAccountRefs(list, picker) {
|
|
1251
|
+
if (!Array.isArray(list))
|
|
1252
|
+
return [];
|
|
1253
|
+
return list.filter(isPlainObject).map(item => {
|
|
1254
|
+
const id = asString(item.id) ?? (picker ? asString(item[picker.valueField]) : undefined) ?? '';
|
|
1255
|
+
const name = asString(item.name)
|
|
1256
|
+
?? asString(item.title)
|
|
1257
|
+
?? asString(item.displayName)
|
|
1258
|
+
?? (picker?.labelField ? asString(item[picker.labelField]) : undefined)
|
|
1259
|
+
?? '';
|
|
1260
|
+
return { id, name };
|
|
1261
|
+
}).filter(account => account.id);
|
|
1262
|
+
}
|
|
1263
|
+
/** Maps {id, name} selections onto the fields the picker saves. */
|
|
1264
|
+
export function buildPickerAccounts(picker, accounts) {
|
|
1265
|
+
if (accounts.length === 0)
|
|
1266
|
+
return { error: 'Pass at least one account.' };
|
|
1267
|
+
if (!picker.multiSelect && accounts.length !== 1)
|
|
1268
|
+
return { error: 'This connection uses exactly one account.' };
|
|
1269
|
+
const seen = new Set();
|
|
1270
|
+
const out = [];
|
|
1271
|
+
for (const account of accounts) {
|
|
1272
|
+
const id = asString(account.id) ?? asString(account[picker.valueField]) ?? '';
|
|
1273
|
+
const name = asString(account.name)
|
|
1274
|
+
?? (picker.labelField ? asString(account[picker.labelField]) : undefined)
|
|
1275
|
+
?? asString(account.title)
|
|
1276
|
+
?? asString(account.displayName)
|
|
1277
|
+
?? '';
|
|
1278
|
+
if (!id.trim())
|
|
1279
|
+
return { error: 'Every account needs an id.' };
|
|
1280
|
+
if (seen.has(id.trim()))
|
|
1281
|
+
return { error: `Account ${id} is listed twice.` };
|
|
1282
|
+
seen.add(id.trim());
|
|
1283
|
+
const mapped = {};
|
|
1284
|
+
for (const field of picker.fieldsToSave) {
|
|
1285
|
+
if (field === picker.valueField)
|
|
1286
|
+
mapped[field] = id.trim();
|
|
1287
|
+
else if (field === picker.labelField)
|
|
1288
|
+
mapped[field] = name;
|
|
1289
|
+
else if (asString(account[field]) !== undefined)
|
|
1290
|
+
mapped[field] = asString(account[field]);
|
|
1291
|
+
else
|
|
1292
|
+
mapped[field] = picker.urlItemFields[field] === 'id' ? id.trim() : name;
|
|
1293
|
+
}
|
|
1294
|
+
out.push(mapped);
|
|
1295
|
+
}
|
|
1296
|
+
return { accounts: out };
|
|
1297
|
+
}
|
|
1298
|
+
export function parseDiscoveredGroups(next, groups) {
|
|
1299
|
+
if (!Array.isArray(groups))
|
|
1300
|
+
return [];
|
|
1301
|
+
return groups.filter(isPlainObject).map(group => {
|
|
1302
|
+
const parent = isPlainObject(group.parent) ? group.parent : {};
|
|
1303
|
+
const items = Array.isArray(group.items) ? group.items.filter(isPlainObject) : [];
|
|
1304
|
+
return {
|
|
1305
|
+
parent: {
|
|
1306
|
+
id: asString(parent[next.parentValueField]) ?? '',
|
|
1307
|
+
name: asString(parent[next.parentLabelField]) ?? '',
|
|
1308
|
+
},
|
|
1309
|
+
items: items
|
|
1310
|
+
.map(item => ({
|
|
1311
|
+
id: asString(item[next.itemValueField]) ?? '',
|
|
1312
|
+
name: asString(item[next.itemLabelField]) ?? '',
|
|
1313
|
+
}))
|
|
1314
|
+
.filter(item => item.id),
|
|
1315
|
+
...(asString(group.errorcode) ? { errorCode: asString(group.errorcode) } : {}),
|
|
1316
|
+
};
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
function sameId(left, right) {
|
|
1320
|
+
if (left.trim() === right.trim())
|
|
1321
|
+
return true;
|
|
1322
|
+
const digits = (value) => value.replace(/\D+/g, '');
|
|
1323
|
+
return digits(left) !== '' && digits(left) === digits(right);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Builds the chained-step payload from a fresh discovery. The API wants every
|
|
1327
|
+
* readable parent exactly once and each item with its discovered label, so
|
|
1328
|
+
* parents the caller left out are sent with no items.
|
|
1329
|
+
*/
|
|
1330
|
+
export function buildChainedSelections(next, groups, selections) {
|
|
1331
|
+
const chosen = new Map();
|
|
1332
|
+
for (const selection of selections) {
|
|
1333
|
+
const group = groups.find(candidate => sameId(candidate.parent.id, selection.parent));
|
|
1334
|
+
if (!group) {
|
|
1335
|
+
return {
|
|
1336
|
+
skippedParents: [],
|
|
1337
|
+
error: `Account "${selection.parent}" is not among the connected accounts (${groups.map(candidate => candidate.parent.id).join(', ') || 'none'}).`,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
if (group.errorCode) {
|
|
1341
|
+
return {
|
|
1342
|
+
skippedParents: [],
|
|
1343
|
+
error: `${group.parent.name || group.parent.id} could not be read (${group.errorCode}), so nothing can be chosen for it. Leave it out.`,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
const ids = chosen.get(group.parent.id) ?? [];
|
|
1347
|
+
for (const itemId of selection.items) {
|
|
1348
|
+
const item = group.items.find(candidate => sameId(candidate.id, itemId));
|
|
1349
|
+
if (!item) {
|
|
1350
|
+
return {
|
|
1351
|
+
skippedParents: [],
|
|
1352
|
+
error: `"${itemId}" is not available under ${group.parent.name || group.parent.id}. Available: ${group.items.map(candidate => `${candidate.id} (${candidate.name})`).join(', ') || 'none'}.`,
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
if (!ids.includes(item.id))
|
|
1356
|
+
ids.push(item.id);
|
|
1357
|
+
}
|
|
1358
|
+
chosen.set(group.parent.id, ids);
|
|
1359
|
+
}
|
|
1360
|
+
const skippedParents = [];
|
|
1361
|
+
const payload = groups.filter(group => !group.errorCode).map(group => {
|
|
1362
|
+
const ids = chosen.get(group.parent.id);
|
|
1363
|
+
if (!ids)
|
|
1364
|
+
skippedParents.push(group.parent);
|
|
1365
|
+
return {
|
|
1366
|
+
parentvalue: group.parent.id,
|
|
1367
|
+
items: group.items
|
|
1368
|
+
.filter(item => ids?.includes(item.id))
|
|
1369
|
+
.map(item => ({ [next.itemValueField]: item.id, [next.itemLabelField]: item.name })),
|
|
1370
|
+
};
|
|
1371
|
+
});
|
|
1372
|
+
return { selections: payload, skippedParents };
|
|
1373
|
+
}
|
|
1374
|
+
export function parseOAuthStatus(body, picker) {
|
|
1375
|
+
let childStep = null;
|
|
1376
|
+
const next = picker?.next;
|
|
1377
|
+
const steps = isPlainObject(body.pickersteps) ? body.pickersteps : null;
|
|
1378
|
+
const step = next && steps && isPlainObject(steps[next.key]) ? steps[next.key] : null;
|
|
1379
|
+
if (next && step) {
|
|
1380
|
+
childStep = {
|
|
1381
|
+
picker: next.key,
|
|
1382
|
+
optional: step.optional === true,
|
|
1383
|
+
configured: step.configured === true,
|
|
1384
|
+
groups: parseDiscoveredGroups(next, step.groups).map(group => ({ parent: group.parent, items: group.items })),
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
connected: body.connected === true,
|
|
1389
|
+
accounts: toAccountRefs(body.accounts, picker),
|
|
1390
|
+
connectedAt: asString(body.connectedat) ?? '',
|
|
1391
|
+
tokenExpiresAt: asString(body.tokenexpiresat) ?? '',
|
|
1392
|
+
childStep,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
export async function readOAuthStatus(client, agentGuid, credential, picker, signal) {
|
|
1396
|
+
const body = requireApiSuccess(await client.post('/UserAgentOAuth/OAuthStatus', {
|
|
1397
|
+
useragentguid: agentGuid,
|
|
1398
|
+
credentialkey: credential,
|
|
1399
|
+
}, signal), 'Could not read the connection status.');
|
|
1400
|
+
return parseOAuthStatus(body, picker);
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Wiro reports a picker credential as not connected until an account is
|
|
1404
|
+
* saved, so a fresh connectedat with no accounts means "signed in, accounts
|
|
1405
|
+
* still to choose". since is the connectedat seen before the sign-in started.
|
|
1406
|
+
*/
|
|
1407
|
+
export function connectionState(status, options) {
|
|
1408
|
+
if (options.since !== undefined && status.connectedAt === options.since)
|
|
1409
|
+
return 'waiting_for_user';
|
|
1410
|
+
if (status.connected) {
|
|
1411
|
+
return status.childStep && !status.childStep.optional && !status.childStep.configured
|
|
1412
|
+
? 'pick_child_items'
|
|
1413
|
+
: 'connected';
|
|
1414
|
+
}
|
|
1415
|
+
const expiresAt = Date.parse(status.tokenExpiresAt);
|
|
1416
|
+
if (Number.isFinite(expiresAt) && expiresAt <= (options.nowMs ?? Date.now())
|
|
1417
|
+
&& (status.accounts.length > 0 || status.connectedAt)) {
|
|
1418
|
+
return 'token_expired';
|
|
1419
|
+
}
|
|
1420
|
+
if (options.hasPicker && status.connectedAt && status.accounts.length === 0)
|
|
1421
|
+
return 'pick_accounts';
|
|
1422
|
+
return 'not_connected';
|
|
1423
|
+
}
|
|
1424
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1425
|
+
/**
|
|
1426
|
+
* Same rules as the API's key normalisation: cs-cron-x and cs-x stay, cron-x
|
|
1427
|
+
* becomes cs-cron-x, and a bare slug gets cs-cron- when scheduled (or given a
|
|
1428
|
+
* non-empty interval) and cs- otherwise. The slug is 1-60 characters of
|
|
1429
|
+
* lowercase letters, digits and single dashes. Returns null when invalid.
|
|
1430
|
+
*/
|
|
1431
|
+
export function normalizeCustomSkillKey(input, options = {}) {
|
|
1432
|
+
if (typeof input !== 'string')
|
|
1433
|
+
return null;
|
|
1434
|
+
const trimmed = input.trim();
|
|
1435
|
+
if (!trimmed)
|
|
1436
|
+
return null;
|
|
1437
|
+
let prefix;
|
|
1438
|
+
let body;
|
|
1439
|
+
if (trimmed.startsWith('cs-cron-')) {
|
|
1440
|
+
prefix = 'cs-cron-';
|
|
1441
|
+
body = trimmed.slice('cs-cron-'.length);
|
|
1442
|
+
}
|
|
1443
|
+
else if (trimmed.startsWith('cs-')) {
|
|
1444
|
+
prefix = 'cs-';
|
|
1445
|
+
body = trimmed.slice('cs-'.length);
|
|
1446
|
+
}
|
|
1447
|
+
else if (trimmed.startsWith('cron-')) {
|
|
1448
|
+
prefix = 'cs-';
|
|
1449
|
+
body = trimmed;
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
let scheduled = options.scheduled === true;
|
|
1453
|
+
if (!scheduled && options.hasInterval !== undefined && options.hasInterval !== null && options.hasInterval !== false) {
|
|
1454
|
+
const interval = String(options.hasInterval).trim().toLowerCase();
|
|
1455
|
+
scheduled = interval !== '' && interval !== 'manual' && interval !== 'false';
|
|
1456
|
+
}
|
|
1457
|
+
prefix = scheduled ? 'cs-cron-' : 'cs-';
|
|
1458
|
+
body = trimmed;
|
|
1459
|
+
}
|
|
1460
|
+
if (body.length === 0 || body.length > 60 || !SLUG_RE.test(body))
|
|
1461
|
+
return null;
|
|
1462
|
+
return prefix + body;
|
|
1463
|
+
}
|
|
1464
|
+
export function customSkillKindOf(key) {
|
|
1465
|
+
return key.startsWith('cs-cron-') ? 'scheduled_task' : 'skill';
|
|
1466
|
+
}
|
|
1467
|
+
const INVALID_KEY_MESSAGE = 'Use lowercase letters, digits and single dashes, for example lead-strategy (at most 60 characters after the cs- or cs-cron- prefix).';
|
|
1468
|
+
function isBareCustomSkillKey(key) {
|
|
1469
|
+
return !key.startsWith('cs-') && !key.startsWith('cron-');
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* The key an upsert writes, computed with the API's own rules. With the
|
|
1473
|
+
* agent's entries, a bare key with no kind and no interval names the existing
|
|
1474
|
+
* entry of that name, as rename, delete, history and revert do; the API would
|
|
1475
|
+
* otherwise create a new cs- entry.
|
|
1476
|
+
*/
|
|
1477
|
+
export function upsertCustomSkillKey(input, kind, interval, existingKeys) {
|
|
1478
|
+
const trimmed = input.trim();
|
|
1479
|
+
const scheduledPrefix = trimmed.startsWith('cs-cron-') || trimmed.startsWith('cron-');
|
|
1480
|
+
if (kind === 'skill' && scheduledPrefix) {
|
|
1481
|
+
return { error: `"${input}" is a scheduled task key. Use kind scheduled_task, or a key without cron-.` };
|
|
1482
|
+
}
|
|
1483
|
+
if (kind === 'scheduled_task' && trimmed.startsWith('cs-') && !scheduledPrefix) {
|
|
1484
|
+
return { error: `"${input}" is a custom skill key. Scheduled task keys start with cs-cron-.` };
|
|
1485
|
+
}
|
|
1486
|
+
if (existingKeys && !kind && interval === undefined && isBareCustomSkillKey(trimmed)) {
|
|
1487
|
+
const asSkill = normalizeCustomSkillKey(trimmed, { scheduled: false });
|
|
1488
|
+
const asTask = normalizeCustomSkillKey(trimmed, { scheduled: true });
|
|
1489
|
+
if (asSkill && asTask && existingKeys.has(asTask)) {
|
|
1490
|
+
return existingKeys.has(asSkill)
|
|
1491
|
+
? { error: `Both ${asSkill} and ${asTask} exist on this agent. Pass the full key.` }
|
|
1492
|
+
: { key: asTask };
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const key = normalizeCustomSkillKey(trimmed, {
|
|
1496
|
+
scheduled: kind === 'scheduled_task',
|
|
1497
|
+
hasInterval: kind === 'skill' ? undefined : interval,
|
|
1498
|
+
});
|
|
1499
|
+
if (!key)
|
|
1500
|
+
return { error: `Invalid key "${input}". ${INVALID_KEY_MESSAGE}` };
|
|
1501
|
+
if (interval !== undefined && customSkillKindOf(key) === 'skill') {
|
|
1502
|
+
return { error: 'interval only applies to scheduled tasks (keys starting with cs-cron-). Use kind scheduled_task.' };
|
|
1503
|
+
}
|
|
1504
|
+
return { key };
|
|
1505
|
+
}
|
|
1506
|
+
/** The exact key of an existing entry; the API matches rename, delete, history and revert keys literally. */
|
|
1507
|
+
export function resolveExistingCustomSkillKey(input, existingKeys, kind) {
|
|
1508
|
+
const trimmed = input.trim();
|
|
1509
|
+
if (trimmed.startsWith('cs-') || trimmed.startsWith('cron-')) {
|
|
1510
|
+
const key = normalizeCustomSkillKey(trimmed);
|
|
1511
|
+
return key ? { key } : { error: `Invalid key "${input}". ${INVALID_KEY_MESSAGE}` };
|
|
1512
|
+
}
|
|
1513
|
+
const asSkill = normalizeCustomSkillKey(trimmed, { scheduled: false });
|
|
1514
|
+
const asTask = normalizeCustomSkillKey(trimmed, { scheduled: true });
|
|
1515
|
+
if (!asSkill || !asTask)
|
|
1516
|
+
return { error: `Invalid key "${input}". ${INVALID_KEY_MESSAGE}` };
|
|
1517
|
+
if (kind)
|
|
1518
|
+
return { key: kind === 'scheduled_task' ? asTask : asSkill };
|
|
1519
|
+
const matches = [asSkill, asTask].filter(key => existingKeys.has(key));
|
|
1520
|
+
if (matches.length === 1)
|
|
1521
|
+
return { key: matches[0] };
|
|
1522
|
+
if (matches.length > 1)
|
|
1523
|
+
return { error: `Both ${asSkill} and ${asTask} exist. Pass the full key.` };
|
|
1524
|
+
return { error: `This agent has no custom skill or scheduled task named "${input}". Pass the full key (cs-... or cs-cron-...) or check get_my_agent.` };
|
|
1525
|
+
}
|
|
1526
|
+
/** A light shape check; the API validates the expression fully. */
|
|
1527
|
+
export function looksLikeCron(expression) {
|
|
1528
|
+
const parts = expression.trim().split(/\s+/);
|
|
1529
|
+
if (parts.length !== 5 && parts.length !== 6)
|
|
1530
|
+
return false;
|
|
1531
|
+
return parts.every(part => /^[0-9A-Za-z*,/?#-]+$/.test(part));
|
|
1532
|
+
}
|
|
1533
|
+
/** The runtime always runs a template's scheduled task with the template's own prompt. */
|
|
1534
|
+
export function templateTaskTextError(key) {
|
|
1535
|
+
return `The text of ${key} comes from the agent's template and cannot be changed; only its schedule (interval) and on/off state (enabled) can. To change what it does, edit the related custom skill (a cs- key without cron), or create your own scheduled task.`;
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Checks deploy_agent's custom_skills the way manage_agent_custom_skill
|
|
1539
|
+
* would, before the deploy is paid for. A bare key names the template's own
|
|
1540
|
+
* entry of that name when it has one. Without the template's entries only the
|
|
1541
|
+
* checks that need no template run.
|
|
1542
|
+
*/
|
|
1543
|
+
export function resolveDeployCustomSkills(entries, template) {
|
|
1544
|
+
const skills = [];
|
|
1545
|
+
const errors = [];
|
|
1546
|
+
const seen = new Set();
|
|
1547
|
+
const known = new Map((template ?? []).map(task => [task.key, task]));
|
|
1548
|
+
for (const entry of entries) {
|
|
1549
|
+
const raw = entry.key.trim();
|
|
1550
|
+
const interval = entry.interval?.trim();
|
|
1551
|
+
let key;
|
|
1552
|
+
if (isBareCustomSkillKey(raw) && entry.interval === undefined) {
|
|
1553
|
+
const asSkill = normalizeCustomSkillKey(raw, { scheduled: false });
|
|
1554
|
+
const asTask = normalizeCustomSkillKey(raw, { scheduled: true });
|
|
1555
|
+
if (asSkill && asTask && known.has(asSkill) && known.has(asTask)) {
|
|
1556
|
+
errors.push(`The template has both ${asSkill} and ${asTask}. Pass the full key instead of "${raw}".`);
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
key = asTask && known.has(asTask) ? asTask : asSkill;
|
|
1560
|
+
}
|
|
1561
|
+
else {
|
|
1562
|
+
key = normalizeCustomSkillKey(raw, { hasInterval: entry.interval });
|
|
1563
|
+
}
|
|
1564
|
+
if (!key) {
|
|
1565
|
+
errors.push(`The custom skill key "${entry.key}" is not valid. ${INVALID_KEY_MESSAGE}`);
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (template && seen.has(key)) {
|
|
1569
|
+
errors.push(`${key} is listed twice in custom_skills.`);
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
seen.add(key);
|
|
1573
|
+
const kind = customSkillKindOf(key);
|
|
1574
|
+
if (entry.interval !== undefined && (!interval || !looksLikeCron(interval))) {
|
|
1575
|
+
errors.push(`${key}: interval must be a cron expression with five fields: minute hour day-of-month month day-of-week, for example "0 9 * * 1-5" for weekdays at 09:00.`);
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (entry.interval !== undefined && kind !== 'scheduled_task') {
|
|
1579
|
+
errors.push(`${key}: interval only applies to scheduled tasks (keys starting with cs-cron-).`);
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
const fromTemplate = known.has(key);
|
|
1583
|
+
const templateTask = fromTemplate && kind === 'scheduled_task';
|
|
1584
|
+
if (entry.value !== undefined && !entry.value.trim()) {
|
|
1585
|
+
errors.push(`${key}: value cannot be empty.${fromTemplate ? ' Leave it out to keep the template\'s text.' : ''}`);
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
if (templateTask && entry.value !== undefined) {
|
|
1589
|
+
errors.push(templateTaskTextError(key));
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
if (template && !fromTemplate && entry.value === undefined) {
|
|
1593
|
+
errors.push(`${key} is a new ${kind === 'scheduled_task' ? 'scheduled task, so it needs value: the prompt the agent runs on schedule' : 'custom skill, so it needs value: the instructions the agent follows'}.`);
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
// A scheduled task sent without a schedule is saved on the weekly
|
|
1597
|
+
// default, so a template's own task is sent with its own schedule.
|
|
1598
|
+
const schedule = interval ?? (templateTask ? known.get(key)?.interval || undefined : undefined);
|
|
1599
|
+
skills.push({
|
|
1600
|
+
key,
|
|
1601
|
+
kind,
|
|
1602
|
+
fromTemplate,
|
|
1603
|
+
...(entry.value !== undefined ? { value: entry.value } : {}),
|
|
1604
|
+
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
1605
|
+
...(schedule !== undefined ? { interval: schedule } : {}),
|
|
1606
|
+
...(entry.enabled !== undefined ? { enabled: entry.enabled } : {}),
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
return { skills, errors };
|
|
1610
|
+
}
|
|
1611
|
+
export function humanizeCustomSkillKey(key) {
|
|
1612
|
+
let body = key.trim();
|
|
1613
|
+
if (body.startsWith('cs-cron-'))
|
|
1614
|
+
body = body.slice('cs-cron-'.length);
|
|
1615
|
+
else if (body.startsWith('cs-'))
|
|
1616
|
+
body = body.slice('cs-'.length);
|
|
1617
|
+
body = body.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1618
|
+
return body ? body.charAt(0).toUpperCase() + body.slice(1) : '';
|
|
1619
|
+
}
|
|
1620
|
+
export function customSkillEntries(row) {
|
|
1621
|
+
const rows = [
|
|
1622
|
+
...(Array.isArray(row.customskills) ? row.customskills : []),
|
|
1623
|
+
...(Array.isArray(row.scheduledskills) ? row.scheduledskills : []),
|
|
1624
|
+
];
|
|
1625
|
+
return rows.filter(isPlainObject).map(entry => {
|
|
1626
|
+
const key = asString(entry.key) ?? '';
|
|
1627
|
+
const source = asString(entry._source) ?? '';
|
|
1628
|
+
return {
|
|
1629
|
+
key,
|
|
1630
|
+
kind: customSkillKindOf(key),
|
|
1631
|
+
description: asString(entry.description) ?? '',
|
|
1632
|
+
value: asString(entry.value) ?? '',
|
|
1633
|
+
interval: asString(entry.interval) ?? null,
|
|
1634
|
+
enabled: entry.enabled !== false,
|
|
1635
|
+
source,
|
|
1636
|
+
userCreated: source === 'user-created' || entry._user_created === true,
|
|
1637
|
+
requiresCredentials: stringList(entry.requires_credentials),
|
|
1638
|
+
};
|
|
1639
|
+
}).filter(entry => entry.key);
|
|
1640
|
+
}
|
|
1641
|
+
/** Readable text for the API's custom skill error codes. */
|
|
1642
|
+
export function customSkillErrorMessage(message) {
|
|
1643
|
+
if (/invalid-cron-expression|invalid cron/i.test(message)) {
|
|
1644
|
+
return 'The schedule is not a valid cron expression. Use five fields: minute hour day-of-month month day-of-week, for example "0 9 * * 1-5".';
|
|
1645
|
+
}
|
|
1646
|
+
if (/customskill-preset-description-not-editable/.test(message)) {
|
|
1647
|
+
return 'This entry comes with the agent, so its description cannot be changed. Its text and on/off state can.';
|
|
1648
|
+
}
|
|
1649
|
+
if (/customskill-(registry|preset)-not-deletable/.test(message)) {
|
|
1650
|
+
return 'This entry comes with the agent and cannot be deleted. Turn it off with action upsert and enabled: false instead.';
|
|
1651
|
+
}
|
|
1652
|
+
if (/useragent-customskill-version-not-found/.test(message)) {
|
|
1653
|
+
return 'That version was not found. List the versions with action history.';
|
|
1654
|
+
}
|
|
1655
|
+
if (/useragent-customskill-not-found|agent-customskill-not-found/.test(message)) {
|
|
1656
|
+
return 'This agent has no custom skill or scheduled task with that key.';
|
|
1657
|
+
}
|
|
1658
|
+
return message;
|
|
1659
|
+
}
|
|
1660
|
+
export function toIsoTime(value) {
|
|
1661
|
+
const iso = unixToIso(value);
|
|
1662
|
+
if (iso)
|
|
1663
|
+
return iso;
|
|
1664
|
+
const text = asString(value);
|
|
1665
|
+
return text && !/^\d+$/.test(text) ? text : undefined;
|
|
1666
|
+
}
|
|
1667
|
+
//# sourceMappingURL=agent-config.js.map
|