nothumanallowed 15.1.39 → 15.1.40
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.1.
|
|
3
|
+
"version": "15.1.40",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '15.1.
|
|
8
|
+
export const VERSION = '15.1.40';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -2,19 +2,31 @@
|
|
|
2
2
|
* Connectors — visual workflow automation backend
|
|
3
3
|
* Workflows stored in ~/.nha/workflows/*.json
|
|
4
4
|
* Execution: each node runs a real NHA tool or AI call
|
|
5
|
+
*
|
|
6
|
+
* 15.1.40 — closes the gap vs n8n:
|
|
7
|
+
* • Workflow versioning (auto-snapshot on each save)
|
|
8
|
+
* • Webhook triggers (HTTP endpoint per workflow)
|
|
9
|
+
* • Credentials manager (AES-256-GCM at rest, ${cred.NAME} interpolation)
|
|
10
|
+
* • Subworkflows (one workflow can call another)
|
|
5
11
|
*/
|
|
6
12
|
import fs from 'fs';
|
|
13
|
+
import os from 'os';
|
|
7
14
|
import path from 'path';
|
|
15
|
+
import crypto from 'crypto';
|
|
8
16
|
import { NHA_DIR } from '../../constants.mjs';
|
|
9
17
|
import { loadConfig } from '../../config.mjs';
|
|
10
18
|
import { sendJSON, sendError, parseBody } from '../index.mjs';
|
|
11
19
|
import { executeTool } from '../../services/tool-executor.mjs';
|
|
12
20
|
import { callLLM } from '../../services/llm.mjs';
|
|
13
21
|
|
|
14
|
-
const WORKFLOWS_DIR
|
|
22
|
+
const WORKFLOWS_DIR = path.join(NHA_DIR, 'workflows');
|
|
23
|
+
const VERSIONS_DIR = path.join(NHA_DIR, 'workflows', '_versions');
|
|
24
|
+
const CREDS_FILE = path.join(NHA_DIR, 'credentials.json');
|
|
25
|
+
const MAX_VERSIONS_PER_WF = 50;
|
|
15
26
|
|
|
16
27
|
function ensureDir() {
|
|
17
28
|
if (!fs.existsSync(WORKFLOWS_DIR)) fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
|
|
29
|
+
if (!fs.existsSync(VERSIONS_DIR)) fs.mkdirSync(VERSIONS_DIR, { recursive: true });
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
function listWorkflows() {
|
|
@@ -30,12 +42,144 @@ function listWorkflows() {
|
|
|
30
42
|
|
|
31
43
|
function saveWorkflow(wf) {
|
|
32
44
|
ensureDir();
|
|
33
|
-
|
|
45
|
+
const file = path.join(WORKFLOWS_DIR, `${wf.id}.json`);
|
|
46
|
+
// 1. Snapshot previous version BEFORE overwriting — that's how we get
|
|
47
|
+
// a real history viewer like n8n's. Pruned to MAX_VERSIONS_PER_WF.
|
|
48
|
+
if (fs.existsSync(file)) {
|
|
49
|
+
const wfVersionsDir = path.join(VERSIONS_DIR, wf.id);
|
|
50
|
+
if (!fs.existsSync(wfVersionsDir)) fs.mkdirSync(wfVersionsDir, { recursive: true });
|
|
51
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
52
|
+
try {
|
|
53
|
+
const prev = fs.readFileSync(file, 'utf-8');
|
|
54
|
+
fs.writeFileSync(path.join(wfVersionsDir, `${ts}.json`), prev);
|
|
55
|
+
// Prune oldest
|
|
56
|
+
const versions = fs.readdirSync(wfVersionsDir).filter((f) => f.endsWith('.json')).sort();
|
|
57
|
+
while (versions.length > MAX_VERSIONS_PER_WF) {
|
|
58
|
+
try { fs.unlinkSync(path.join(wfVersionsDir, versions.shift())); } catch {}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* snapshot failure shouldn't block the save */ }
|
|
61
|
+
}
|
|
62
|
+
fs.writeFileSync(file, JSON.stringify(wf, null, 2));
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
function deleteWorkflow(id) {
|
|
37
66
|
const p = path.join(WORKFLOWS_DIR, `${id}.json`);
|
|
38
67
|
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
68
|
+
// Also delete versions
|
|
69
|
+
const vd = path.join(VERSIONS_DIR, id);
|
|
70
|
+
if (fs.existsSync(vd)) fs.rmSync(vd, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function listVersions(id) {
|
|
74
|
+
const vd = path.join(VERSIONS_DIR, id);
|
|
75
|
+
if (!fs.existsSync(vd)) return [];
|
|
76
|
+
return fs.readdirSync(vd)
|
|
77
|
+
.filter((f) => f.endsWith('.json'))
|
|
78
|
+
.map((f) => {
|
|
79
|
+
const ts = f.replace(/\.json$/, '');
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.statSync(path.join(vd, f));
|
|
82
|
+
return { ts, mtime: stat.mtimeMs, size: stat.size };
|
|
83
|
+
} catch { return null; }
|
|
84
|
+
})
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadVersion(id, ts) {
|
|
90
|
+
const p = path.join(VERSIONS_DIR, id, `${ts}.json`);
|
|
91
|
+
if (!fs.existsSync(p)) return null;
|
|
92
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Credentials manager (AES-256-GCM at rest, machine-bound key) ────────────
|
|
96
|
+
//
|
|
97
|
+
// Why this implementation: storing API keys plain-text in config.json is the
|
|
98
|
+
// #1 footgun for self-hosted automation tools. n8n encrypts credentials with
|
|
99
|
+
// an encryption key (env var). We do the same but derive the key from the
|
|
100
|
+
// machine identity (username + hostname + a stable salt file in ~/.nha/) so
|
|
101
|
+
// the user doesn't have to set ENV vars. Tamper-detection via GCM auth tag.
|
|
102
|
+
|
|
103
|
+
function _credKey() {
|
|
104
|
+
const saltFile = path.join(NHA_DIR, '.cred-salt');
|
|
105
|
+
let salt;
|
|
106
|
+
if (fs.existsSync(saltFile)) {
|
|
107
|
+
salt = fs.readFileSync(saltFile);
|
|
108
|
+
} else {
|
|
109
|
+
salt = crypto.randomBytes(32);
|
|
110
|
+
fs.mkdirSync(path.dirname(saltFile), { recursive: true });
|
|
111
|
+
fs.writeFileSync(saltFile, salt, { mode: 0o600 });
|
|
112
|
+
}
|
|
113
|
+
const identity = `${os.userInfo().username}|${os.hostname()}|nha-creds-v1`;
|
|
114
|
+
return crypto.scryptSync(identity, salt, 32);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _encrypt(plaintext) {
|
|
118
|
+
const key = _credKey();
|
|
119
|
+
const iv = crypto.randomBytes(12);
|
|
120
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
121
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
|
122
|
+
const tag = cipher.getAuthTag();
|
|
123
|
+
return Buffer.concat([iv, tag, ciphertext]).toString('base64');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function _decrypt(b64) {
|
|
127
|
+
const buf = Buffer.from(b64, 'base64');
|
|
128
|
+
const iv = buf.subarray(0, 12);
|
|
129
|
+
const tag = buf.subarray(12, 28);
|
|
130
|
+
const ciphertext = buf.subarray(28);
|
|
131
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', _credKey(), iv);
|
|
132
|
+
decipher.setAuthTag(tag);
|
|
133
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadCredentials() {
|
|
137
|
+
if (!fs.existsSync(CREDS_FILE)) return {};
|
|
138
|
+
try {
|
|
139
|
+
const raw = JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8'));
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
142
|
+
try { out[name] = { value: _decrypt(entry.enc), updatedAt: entry.updatedAt, description: entry.description || '' }; }
|
|
143
|
+
catch { out[name] = { value: '', updatedAt: entry.updatedAt, error: 'decryption failed (machine changed?)' }; }
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
} catch { return {}; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function saveCredential(name, value, description = '') {
|
|
150
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name)) throw new Error('Invalid credential name (letters, digits, _ only)');
|
|
151
|
+
const raw = fs.existsSync(CREDS_FILE) ? JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8')) : {};
|
|
152
|
+
raw[name] = { enc: _encrypt(String(value)), updatedAt: new Date().toISOString(), description };
|
|
153
|
+
if (!fs.existsSync(path.dirname(CREDS_FILE))) fs.mkdirSync(path.dirname(CREDS_FILE), { recursive: true });
|
|
154
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function deleteCredential(name) {
|
|
158
|
+
if (!fs.existsSync(CREDS_FILE)) return;
|
|
159
|
+
const raw = JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8'));
|
|
160
|
+
delete raw[name];
|
|
161
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Interpolate ${cred.NAME} placeholders in any string value within a config.
|
|
166
|
+
* Non-strings are returned as-is. Missing creds resolve to '' and emit a warning.
|
|
167
|
+
*/
|
|
168
|
+
function interpolateCredentials(value, creds) {
|
|
169
|
+
if (typeof value === 'string') {
|
|
170
|
+
return value.replace(/\$\{cred\.([A-Za-z_][A-Za-z0-9_]{0,63})\}/g, (_, name) => {
|
|
171
|
+
const c = creds[name];
|
|
172
|
+
if (!c) return '';
|
|
173
|
+
return c.value || '';
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(value)) return value.map((v) => interpolateCredentials(v, creds));
|
|
177
|
+
if (value && typeof value === 'object') {
|
|
178
|
+
const out = {};
|
|
179
|
+
for (const [k, v] of Object.entries(value)) out[k] = interpolateCredentials(v, creds);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
39
183
|
}
|
|
40
184
|
|
|
41
185
|
/** Seed example workflows on first run */
|
|
@@ -202,6 +346,22 @@ async function executeNode(node, nodeDef, ctx, config) {
|
|
|
202
346
|
// Error handler wraps previous node execution — handled in runWorkflow
|
|
203
347
|
return ctx.output || cfg.fallback || '';
|
|
204
348
|
}
|
|
349
|
+
if (node.defId === 'logic_subworkflow') {
|
|
350
|
+
// Run another workflow inline. The target workflow's final node output
|
|
351
|
+
// becomes this node's output. inputMapping allows passing the parent's
|
|
352
|
+
// output (default) or a custom expression.
|
|
353
|
+
const targetId = cfg.workflowId;
|
|
354
|
+
if (!targetId) throw new Error('Subworkflow: workflowId not configured');
|
|
355
|
+
const wfPath = path.join(WORKFLOWS_DIR, `${targetId}.json`);
|
|
356
|
+
if (!fs.existsSync(wfPath)) throw new Error(`Subworkflow: target "${targetId}" not found`);
|
|
357
|
+
const subWf = JSON.parse(fs.readFileSync(wfPath, 'utf-8'));
|
|
358
|
+
const subInput = (cfg.inputMapping && cfg.inputMapping !== '{{output}}')
|
|
359
|
+
? cfg.inputMapping.replace(/\{\{output\}\}/g, ctx.output || '')
|
|
360
|
+
: (ctx.output || '');
|
|
361
|
+
const subSteps = await runWorkflow(subWf, subInput, ctx.__config || config, { depth: (ctx.__depth ?? 0) + 1 });
|
|
362
|
+
const lastStep = subSteps.filter((s) => s.nodeId !== '__error').pop();
|
|
363
|
+
return lastStep?.output ?? '';
|
|
364
|
+
}
|
|
205
365
|
return ctx.output || '';
|
|
206
366
|
}
|
|
207
367
|
|
|
@@ -236,6 +396,12 @@ async function executeNode(node, nodeDef, ctx, config) {
|
|
|
236
396
|
|
|
237
397
|
// Trigger nodes
|
|
238
398
|
if (nodeDef.type === 'trigger') {
|
|
399
|
+
// For webhook triggers, prefer the live HTTP payload if present
|
|
400
|
+
if (node.defId === 'trigger_webhook' && ctx.triggerPayload !== undefined) {
|
|
401
|
+
return typeof ctx.triggerPayload === 'string'
|
|
402
|
+
? ctx.triggerPayload
|
|
403
|
+
: JSON.stringify(ctx.triggerPayload);
|
|
404
|
+
}
|
|
239
405
|
return ctx.output || cfg.input || '';
|
|
240
406
|
}
|
|
241
407
|
|
|
@@ -245,10 +411,31 @@ async function executeNode(node, nodeDef, ctx, config) {
|
|
|
245
411
|
/**
|
|
246
412
|
* Run a workflow from start to finish.
|
|
247
413
|
* Returns array of { nodeId, output, error? } step results.
|
|
414
|
+
*
|
|
415
|
+
* @param {object} wf - workflow definition
|
|
416
|
+
* @param {string} initialInput - text input piped into start node
|
|
417
|
+
* @param {object} config - NHA config
|
|
418
|
+
* @param {object} [opts] - { depth: number, triggerPayload: any }
|
|
419
|
+
* depth: protects against subworkflow recursion (max 5).
|
|
420
|
+
* triggerPayload: when the workflow was started by a webhook, this is the
|
|
421
|
+
* raw request body, injected into ctx.trigger for downstream nodes.
|
|
248
422
|
*/
|
|
249
|
-
async function runWorkflow(wf, initialInput, config) {
|
|
423
|
+
async function runWorkflow(wf, initialInput, config, opts = {}) {
|
|
424
|
+
const depth = opts.depth ?? 0;
|
|
425
|
+
if (depth > 5) return [{ nodeId: '__error', nodeLabel: 'Error', nodeIcon: '❌', output: 'Subworkflow recursion depth limit (5) exceeded.' }];
|
|
426
|
+
|
|
427
|
+
// Resolve credentials placeholders ${cred.NAME} in every node config BEFORE
|
|
428
|
+
// executing. This way every executeNode call receives plain values — the
|
|
429
|
+
// node implementation doesn't need to know about the credentials system.
|
|
430
|
+
const creds = loadCredentials();
|
|
431
|
+
const interpolatedNodes = wf.nodes.map((n) => ({
|
|
432
|
+
...n,
|
|
433
|
+
config: interpolateCredentials(n.config || {}, creds),
|
|
434
|
+
}));
|
|
435
|
+
const interpolatedWf = { ...wf, nodes: interpolatedNodes };
|
|
436
|
+
|
|
250
437
|
const steps = [];
|
|
251
|
-
const nodeMap = Object.fromEntries(
|
|
438
|
+
const nodeMap = Object.fromEntries(interpolatedWf.nodes.map((n) => [n.id, n]));
|
|
252
439
|
const defMap = Object.fromEntries((wf.nodeDefs || []).map((d) => [d.id, d]));
|
|
253
440
|
|
|
254
441
|
// Build adjacency: from → [{to, fromPort}]
|
|
@@ -270,7 +457,14 @@ async function runWorkflow(wf, initialInput, config) {
|
|
|
270
457
|
if (startCandidates.length === 0) return [{ nodeId: '__error', nodeLabel: 'Error', nodeIcon: '❌', output: 'No start node found.' }];
|
|
271
458
|
|
|
272
459
|
// BFS execution with branching support
|
|
273
|
-
const
|
|
460
|
+
const baseCtx = {
|
|
461
|
+
output: initialInput || '',
|
|
462
|
+
input: initialInput || '',
|
|
463
|
+
triggerPayload: opts.triggerPayload,
|
|
464
|
+
__depth: depth,
|
|
465
|
+
__config: config,
|
|
466
|
+
};
|
|
467
|
+
const queue = startCandidates.map((n) => ({ nodeId: n.id, ctx: { ...baseCtx } }));
|
|
274
468
|
const visited = new Set();
|
|
275
469
|
|
|
276
470
|
while (queue.length > 0) {
|
|
@@ -442,4 +636,94 @@ export function register(router) {
|
|
|
442
636
|
sendError(res, 500, e.message);
|
|
443
637
|
}
|
|
444
638
|
});
|
|
639
|
+
|
|
640
|
+
// ── Versioning ───────────────────────────────────────────────────────────
|
|
641
|
+
router.get('/api/workflows/:id/versions', async (req, res) => {
|
|
642
|
+
try { sendJSON(res, 200, { versions: listVersions(req.params?.id) }); }
|
|
643
|
+
catch (e) { sendError(res, 500, e.message); }
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
router.get('/api/workflows/:id/versions/:ts', async (req, res) => {
|
|
647
|
+
try {
|
|
648
|
+
const wf = loadVersion(req.params?.id, req.params?.ts);
|
|
649
|
+
if (!wf) return sendError(res, 404, 'Version not found');
|
|
650
|
+
sendJSON(res, 200, { workflow: wf });
|
|
651
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
router.post('/api/workflows/:id/restore/:ts', async (req, res) => {
|
|
655
|
+
try {
|
|
656
|
+
const wf = loadVersion(req.params?.id, req.params?.ts);
|
|
657
|
+
if (!wf) return sendError(res, 404, 'Version not found');
|
|
658
|
+
saveWorkflow(wf); // creates a new snapshot of current state automatically
|
|
659
|
+
sendJSON(res, 200, { ok: true, workflow: wf });
|
|
660
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ── Credentials manager ─────────────────────────────────────────────────
|
|
664
|
+
router.get('/api/credentials', async (_req, res) => {
|
|
665
|
+
try {
|
|
666
|
+
const creds = loadCredentials();
|
|
667
|
+
// NEVER return raw values — only names + metadata
|
|
668
|
+
const list = Object.entries(creds).map(([name, c]) => ({
|
|
669
|
+
name, updatedAt: c.updatedAt, description: c.description, hasError: !!c.error,
|
|
670
|
+
}));
|
|
671
|
+
sendJSON(res, 200, { credentials: list });
|
|
672
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
router.post('/api/credentials', async (req, res) => {
|
|
676
|
+
try {
|
|
677
|
+
const body = await parseBody(req);
|
|
678
|
+
if (!body.name || !body.value) return sendError(res, 400, 'name and value required');
|
|
679
|
+
saveCredential(body.name, body.value, body.description || '');
|
|
680
|
+
sendJSON(res, 200, { ok: true });
|
|
681
|
+
} catch (e) { sendError(res, 400, e.message); }
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
router.delete('/api/credentials/:name', async (req, res) => {
|
|
685
|
+
try {
|
|
686
|
+
deleteCredential(req.params?.name);
|
|
687
|
+
sendJSON(res, 200, { ok: true });
|
|
688
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// ── Webhook triggers ─────────────────────────────────────────────────────
|
|
692
|
+
//
|
|
693
|
+
// ANY method to /api/webhooks/<slug> triggers a workflow whose start node
|
|
694
|
+
// is a trigger_webhook with config.slug matching. The HTTP body is injected
|
|
695
|
+
// as triggerPayload — text/plain stays as string, application/json gets
|
|
696
|
+
// parsed. We respond with the last node's output (or 200 OK if no output).
|
|
697
|
+
const _handleWebhook = async (req, res) => {
|
|
698
|
+
try {
|
|
699
|
+
const slug = req.params?.slug;
|
|
700
|
+
if (!slug) return sendError(res, 400, 'webhook slug required');
|
|
701
|
+
const all = listWorkflows();
|
|
702
|
+
const wf = all.find((w) => (w.nodes || []).some((n) =>
|
|
703
|
+
n.defId === 'trigger_webhook' && (n.config?.slug === slug || n.config?.path === slug),
|
|
704
|
+
));
|
|
705
|
+
if (!wf) return sendError(res, 404, `No workflow registered for webhook /${slug}`);
|
|
706
|
+
if (wf.enabled === false) return sendError(res, 403, 'Workflow is disabled');
|
|
707
|
+
|
|
708
|
+
// Parse body — try JSON, fall back to text
|
|
709
|
+
let payload = '';
|
|
710
|
+
try { payload = await parseBody(req); }
|
|
711
|
+
catch { payload = ''; }
|
|
712
|
+
|
|
713
|
+
const config = loadConfig();
|
|
714
|
+
const steps = await runWorkflow(wf, typeof payload === 'string' ? payload : JSON.stringify(payload), config, { triggerPayload: payload });
|
|
715
|
+
const last = steps.filter((s) => s.nodeId !== '__error').pop();
|
|
716
|
+
const output = last?.output ?? '';
|
|
717
|
+
// If the output looks like JSON, return it as JSON; otherwise as text
|
|
718
|
+
try { sendJSON(res, 200, JSON.parse(output)); }
|
|
719
|
+
catch {
|
|
720
|
+
res.writeHead(200, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
721
|
+
res.end(String(output));
|
|
722
|
+
}
|
|
723
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
724
|
+
};
|
|
725
|
+
router.post('/api/webhooks/:slug', _handleWebhook);
|
|
726
|
+
router.get( '/api/webhooks/:slug', _handleWebhook);
|
|
727
|
+
router.put( '/api/webhooks/:slug', _handleWebhook);
|
|
728
|
+
router.delete('/api/webhooks/:slug', _handleWebhook);
|
|
445
729
|
}
|
|
@@ -296,7 +296,7 @@ TOOLS:
|
|
|
296
296
|
|
|
297
297
|
41. slack_send(channel: string, text: string, threadTs?: string)
|
|
298
298
|
Send a message to a Slack channel. ALWAYS confirm before sending.
|
|
299
|
-
If
|
|
299
|
+
If threadTs is provided, post as a thread reply instead of a top-level message.
|
|
300
300
|
|
|
301
301
|
41b. slack_search(query: string, count?: number)
|
|
302
302
|
Full-text search messages across the whole Slack workspace. Returns the most
|
|
@@ -305,18 +305,18 @@ TOOLS:
|
|
|
305
305
|
su X", "did anyone post about the release", etc.
|
|
306
306
|
|
|
307
307
|
41c. slack_dm(user: string, text?: string)
|
|
308
|
-
Open or send a direct message to a user.
|
|
309
|
-
username, real name, or email — the tool resolves them
|
|
310
|
-
If
|
|
311
|
-
channel is just opened.
|
|
308
|
+
Open or send a direct message to a user. The user parameter accepts a Slack
|
|
309
|
+
user ID (Uxxx), username, real name, or email — the tool resolves them
|
|
310
|
+
automatically. If text is provided, the message is sent immediately; otherwise
|
|
311
|
+
the DM channel is just opened.
|
|
312
312
|
|
|
313
313
|
41d. slack_thread(channel: string, ts: string)
|
|
314
|
-
List all replies in a thread.
|
|
314
|
+
List all replies in a thread. ts is the parent message timestamp (returned
|
|
315
315
|
by slack_messages or slack_search). Use this before posting a contextual
|
|
316
316
|
reply with slack_send + threadTs.
|
|
317
317
|
|
|
318
318
|
41e. slack_react(channel: string, ts: string, emoji: string)
|
|
319
|
-
Add an emoji reaction to a message.
|
|
319
|
+
Add an emoji reaction to a message. emoji is the name without colons
|
|
320
320
|
(e.g. "thumbsup", "rocket", "white_check_mark"). Confirm before reacting on
|
|
321
321
|
someone else's message.
|
|
322
322
|
|