@worca/ui 0.9.0 → 0.10.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/app/main.bundle.js +900 -803
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +210 -8
- package/app/utils/state-actions.js +55 -0
- package/package.json +6 -4
- package/server/app.js +291 -6
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +231 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +212 -14
- package/server/project-routes.js +210 -44
- package/server/settings-validator.js +250 -0
- package/server/ws-beads-watcher.js +22 -6
- package/server/ws-message-router.js +1 -1
package/server/app.js
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { createHmac, randomUUID } from 'node:crypto';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import express from 'express';
|
|
10
10
|
|
|
11
11
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
12
|
+
import { RAW_BODY } from './integrations/index.js';
|
|
13
|
+
import { verify } from './integrations/verify.js';
|
|
12
14
|
import { ProcessManager } from './process-manager.js';
|
|
13
15
|
import { scanDirectory } from './project-registry.js';
|
|
14
16
|
import {
|
|
@@ -16,19 +18,34 @@ import {
|
|
|
16
18
|
createProjectScopedRoutes,
|
|
17
19
|
projectResolver,
|
|
18
20
|
} from './project-routes.js';
|
|
21
|
+
import { validateIntegrationsConfig } from './settings-validator.js';
|
|
19
22
|
import { discoverSubagents } from './subagents-discovery.js';
|
|
23
|
+
import { checkWorcaVersion } from './version-check.js';
|
|
20
24
|
import { getVersionInfo } from './versions.js';
|
|
21
25
|
import { createInbox } from './webhook-inbox.js';
|
|
22
26
|
|
|
23
27
|
export function createApp(options = {}) {
|
|
24
28
|
const app = express();
|
|
25
29
|
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
26
|
-
const {
|
|
30
|
+
const {
|
|
31
|
+
settingsPath,
|
|
32
|
+
worcaDir,
|
|
33
|
+
projectRoot,
|
|
34
|
+
prefsDir,
|
|
35
|
+
serverHost,
|
|
36
|
+
serverPort,
|
|
37
|
+
} = options;
|
|
27
38
|
// subagentDirs is a test-injection seam; production calls omit it and we
|
|
28
39
|
// resolve from homedir() + projectRoot.
|
|
29
40
|
const subagentDirs = options.subagentDirs || null;
|
|
30
41
|
|
|
31
|
-
app.use(
|
|
42
|
+
app.use(
|
|
43
|
+
express.json({
|
|
44
|
+
verify: (req, _res, buf) => {
|
|
45
|
+
req.rawBody = buf;
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
32
49
|
|
|
33
50
|
// ─── Security headers ──────────────────────────────────────────────────
|
|
34
51
|
app.use((_req, res, next) => {
|
|
@@ -89,12 +106,13 @@ export function createApp(options = {}) {
|
|
|
89
106
|
? new ProcessManager({
|
|
90
107
|
worcaDir,
|
|
91
108
|
projectRoot: projectRoot || process.cwd(),
|
|
109
|
+
settingsPath,
|
|
92
110
|
})
|
|
93
111
|
: null,
|
|
94
112
|
};
|
|
95
113
|
next();
|
|
96
114
|
},
|
|
97
|
-
createProjectScopedRoutes(),
|
|
115
|
+
createProjectScopedRoutes({ serverHost, serverPort }),
|
|
98
116
|
);
|
|
99
117
|
|
|
100
118
|
// ─── Unique routes (not in project-scoped router) ──────────────────────
|
|
@@ -282,6 +300,16 @@ export function createApp(options = {}) {
|
|
|
282
300
|
|
|
283
301
|
// POST /api/webhooks/inbox — receive webhook events
|
|
284
302
|
app.post('/api/webhooks/inbox', (req, res) => {
|
|
303
|
+
const integrations = app.locals.integrations;
|
|
304
|
+
if (integrations?.strictInboxVerification) {
|
|
305
|
+
const ok = verify(
|
|
306
|
+
req.rawBody || Buffer.alloc(0),
|
|
307
|
+
req.headers['x-worca-signature'],
|
|
308
|
+
integrations.secrets || [],
|
|
309
|
+
);
|
|
310
|
+
if (!ok)
|
|
311
|
+
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
312
|
+
}
|
|
285
313
|
const headers = {
|
|
286
314
|
'x-worca-event': req.headers['x-worca-event'] || '',
|
|
287
315
|
'x-worca-delivery': req.headers['x-worca-delivery'] || '',
|
|
@@ -298,9 +326,11 @@ export function createApp(options = {}) {
|
|
|
298
326
|
envelope: req.body || {},
|
|
299
327
|
projectId,
|
|
300
328
|
});
|
|
329
|
+
if (req.rawBody) stored[RAW_BODY] = req.rawBody;
|
|
301
330
|
if (app.locals.broadcast) {
|
|
302
331
|
app.locals.broadcast('webhook-inbox-event', stored);
|
|
303
332
|
}
|
|
333
|
+
app.locals.integrations?.onEvent(stored);
|
|
304
334
|
res.json({ control: { action: webhookInbox.getControlAction() } });
|
|
305
335
|
});
|
|
306
336
|
|
|
@@ -332,6 +362,16 @@ export function createApp(options = {}) {
|
|
|
332
362
|
|
|
333
363
|
// PUT /api/webhooks/inbox/control — set control action
|
|
334
364
|
app.put('/api/webhooks/inbox/control', (req, res) => {
|
|
365
|
+
const integrations = app.locals.integrations;
|
|
366
|
+
if (integrations?.strictInboxVerification) {
|
|
367
|
+
const ok = verify(
|
|
368
|
+
req.rawBody || Buffer.alloc(0),
|
|
369
|
+
req.headers['x-worca-signature'],
|
|
370
|
+
integrations.secrets || [],
|
|
371
|
+
);
|
|
372
|
+
if (!ok)
|
|
373
|
+
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
374
|
+
}
|
|
335
375
|
const { action } = req.body || {};
|
|
336
376
|
if (!['continue', 'pause', 'abort'].includes(action)) {
|
|
337
377
|
return res.status(400).json({
|
|
@@ -464,6 +504,10 @@ export function createApp(options = {}) {
|
|
|
464
504
|
app.get('/api/versions', async (req, res) => {
|
|
465
505
|
const force = req.query.force === '1';
|
|
466
506
|
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
507
|
+
// Re-check installed worca-cc version on force refresh
|
|
508
|
+
if (force) {
|
|
509
|
+
app.locals.worcaVersion = await checkWorcaVersion();
|
|
510
|
+
}
|
|
467
511
|
const worcaVersion = app.locals.worcaVersion || null;
|
|
468
512
|
try {
|
|
469
513
|
const data = await getVersionInfo({ prefsPath, worcaVersion, force });
|
|
@@ -475,14 +519,255 @@ export function createApp(options = {}) {
|
|
|
475
519
|
|
|
476
520
|
// ─── Multi-project routes ──────────────────────────────────────────────
|
|
477
521
|
if (prefsDir) {
|
|
478
|
-
app.use(
|
|
522
|
+
app.use(
|
|
523
|
+
'/api/projects',
|
|
524
|
+
createProjectRoutes({ prefsDir, projectRoot, serverHost, serverPort }),
|
|
525
|
+
);
|
|
479
526
|
app.use(
|
|
480
527
|
'/api/projects/:projectId',
|
|
481
528
|
projectResolver({ prefsDir, projectRoot }),
|
|
482
|
-
createProjectScopedRoutes({ prefsDir }),
|
|
529
|
+
createProjectScopedRoutes({ prefsDir, serverHost, serverPort }),
|
|
483
530
|
);
|
|
484
531
|
}
|
|
485
532
|
|
|
533
|
+
// POST /api/integrations/telegram/detect — find chat IDs from recent messages.
|
|
534
|
+
// If the Telegram adapter is running, temporarily pauses its poll loop so
|
|
535
|
+
// getUpdates returns results instead of being consumed by the long-poller.
|
|
536
|
+
app.post('/api/integrations/telegram/detect', async (req, res) => {
|
|
537
|
+
let token = req.body?.token;
|
|
538
|
+
if (!token) {
|
|
539
|
+
try {
|
|
540
|
+
const cfgRaw = readFileSync(
|
|
541
|
+
join(prefsDir, 'integrations', 'config.json'),
|
|
542
|
+
'utf8',
|
|
543
|
+
);
|
|
544
|
+
token = JSON.parse(cfgRaw).telegram?.bot_token;
|
|
545
|
+
} catch {
|
|
546
|
+
/* no config */
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (!token) token = process.env.TELEGRAM_BOT_TOKEN;
|
|
550
|
+
if (!token) {
|
|
551
|
+
return res.status(400).json({ error: 'No bot token provided' });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Pause the running adapter so getUpdates isn't consumed by the poll loop
|
|
555
|
+
const integrations = app.locals.integrations;
|
|
556
|
+
const adapterEntry = integrations?._getAdapter?.('telegram');
|
|
557
|
+
let wasStopped = false;
|
|
558
|
+
if (adapterEntry) {
|
|
559
|
+
try {
|
|
560
|
+
await adapterEntry.adapter.stop();
|
|
561
|
+
wasStopped = true;
|
|
562
|
+
// Brief delay to let the in-flight long-poll request complete
|
|
563
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
564
|
+
} catch {
|
|
565
|
+
/* ignore */
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const meRes = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
|
571
|
+
const me = await meRes.json();
|
|
572
|
+
const botUsername = me.ok ? me.result.username : null;
|
|
573
|
+
|
|
574
|
+
const updRes = await fetch(
|
|
575
|
+
`https://api.telegram.org/bot${token}/getUpdates?timeout=0&limit=20`,
|
|
576
|
+
);
|
|
577
|
+
const upd = await updRes.json();
|
|
578
|
+
|
|
579
|
+
const chats = [];
|
|
580
|
+
if (upd.ok) {
|
|
581
|
+
for (const u of upd.result) {
|
|
582
|
+
const msg = u.message;
|
|
583
|
+
if (msg?.chat?.id) {
|
|
584
|
+
const existing = chats.find((c) => c.id === msg.chat.id);
|
|
585
|
+
if (!existing) {
|
|
586
|
+
chats.push({
|
|
587
|
+
id: msg.chat.id,
|
|
588
|
+
type: msg.chat.type,
|
|
589
|
+
title:
|
|
590
|
+
msg.chat.title || msg.chat.first_name || String(msg.chat.id),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
res.json({ ok: true, botUsername, chats });
|
|
598
|
+
} catch (err) {
|
|
599
|
+
res.status(500).json({ error: err.message });
|
|
600
|
+
} finally {
|
|
601
|
+
// Restart the adapter if we paused it
|
|
602
|
+
if (wasStopped && adapterEntry) {
|
|
603
|
+
adapterEntry.adapter.start().catch(() => {});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// GET /api/integrations/status — adapter states, chat states, counters
|
|
609
|
+
app.get('/api/integrations/status', (_req, res) => {
|
|
610
|
+
const integrations = app.locals.integrations;
|
|
611
|
+
if (!integrations) return res.json({ enabled: false });
|
|
612
|
+
res.json(integrations.status());
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// GET /api/integrations/config — return saved config (secrets redacted)
|
|
616
|
+
app.get('/api/integrations/config', (_req, res) => {
|
|
617
|
+
const configPath = join(prefsDir, 'integrations', 'config.json');
|
|
618
|
+
let cfg;
|
|
619
|
+
try {
|
|
620
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
621
|
+
} catch {
|
|
622
|
+
return res.json({});
|
|
623
|
+
}
|
|
624
|
+
res.json(cfg);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// DELETE /api/integrations/config/:adapter — remove an adapter
|
|
628
|
+
// PATCH /api/integrations/config/:adapter/enabled — toggle adapter on/off
|
|
629
|
+
app.patch('/api/integrations/config/:adapter/enabled', async (req, res) => {
|
|
630
|
+
const { adapter } = req.params;
|
|
631
|
+
const { enabled } = req.body;
|
|
632
|
+
if (typeof enabled !== 'boolean') {
|
|
633
|
+
return res.status(400).json({ error: 'enabled must be a boolean' });
|
|
634
|
+
}
|
|
635
|
+
const adapterKeys = ['telegram', 'discord', 'slack'];
|
|
636
|
+
if (!adapterKeys.includes(adapter)) {
|
|
637
|
+
return res.status(400).json({ error: `Invalid adapter: ${adapter}` });
|
|
638
|
+
}
|
|
639
|
+
const configPath = join(prefsDir, 'integrations', 'config.json');
|
|
640
|
+
let cfg;
|
|
641
|
+
try {
|
|
642
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
643
|
+
} catch {
|
|
644
|
+
return res.status(404).json({ error: 'No integrations config' });
|
|
645
|
+
}
|
|
646
|
+
if (!cfg[adapter]) {
|
|
647
|
+
return res
|
|
648
|
+
.status(404)
|
|
649
|
+
.json({ error: `Adapter ${adapter} not configured` });
|
|
650
|
+
}
|
|
651
|
+
cfg[adapter].enabled = enabled;
|
|
652
|
+
writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
653
|
+
|
|
654
|
+
// Hot-reload: if disabling, remove the adapter; if enabling, reload it
|
|
655
|
+
if (app.locals.ensureIntegrations) app.locals.ensureIntegrations();
|
|
656
|
+
if (enabled) {
|
|
657
|
+
if (app.locals.integrations?.reloadAdapter) {
|
|
658
|
+
await app.locals.integrations.reloadAdapter(adapter);
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
if (app.locals.integrations?.removeAdapter) {
|
|
662
|
+
await app.locals.integrations.removeAdapter(adapter);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
res.json({ ok: true, enabled });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
app.delete('/api/integrations/config/:adapter', async (req, res) => {
|
|
669
|
+
const { adapter } = req.params;
|
|
670
|
+
const adapterKeys = ['telegram', 'discord', 'slack'];
|
|
671
|
+
if (!adapterKeys.includes(adapter)) {
|
|
672
|
+
return res.status(400).json({ error: `Invalid adapter: ${adapter}` });
|
|
673
|
+
}
|
|
674
|
+
const configDir = join(prefsDir, 'integrations');
|
|
675
|
+
const configPath = join(configDir, 'config.json');
|
|
676
|
+
let cfg;
|
|
677
|
+
try {
|
|
678
|
+
cfg = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
679
|
+
} catch {
|
|
680
|
+
return res.json({ ok: true });
|
|
681
|
+
}
|
|
682
|
+
delete cfg[adapter];
|
|
683
|
+
const hasAdapters = adapterKeys.some((k) => cfg[k]?.enabled);
|
|
684
|
+
if (!hasAdapters) cfg.enabled = false;
|
|
685
|
+
try {
|
|
686
|
+
writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
687
|
+
} catch (err) {
|
|
688
|
+
return res
|
|
689
|
+
.status(500)
|
|
690
|
+
.json({ error: `Failed to write config: ${err.message}` });
|
|
691
|
+
}
|
|
692
|
+
if (app.locals.integrations?.removeAdapter) {
|
|
693
|
+
await app.locals.integrations.removeAdapter(adapter);
|
|
694
|
+
}
|
|
695
|
+
res.json({ ok: true });
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// POST /api/integrations/config — save adapter config
|
|
699
|
+
const ADAPTER_SCHEMA = {
|
|
700
|
+
telegram: { tokenKey: 'bot_token', idKey: 'chat_id' },
|
|
701
|
+
discord: { tokenKey: 'bot_token', idKey: 'channel_id' },
|
|
702
|
+
slack: { tokenKey: 'webhook_url', idKey: 'chat_id' },
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
app.post('/api/integrations/config', async (req, res) => {
|
|
706
|
+
const { adapter, token, chatId, events } = req.body;
|
|
707
|
+
if (
|
|
708
|
+
!adapter ||
|
|
709
|
+
!token ||
|
|
710
|
+
!chatId ||
|
|
711
|
+
!Array.isArray(events) ||
|
|
712
|
+
events.length === 0
|
|
713
|
+
) {
|
|
714
|
+
return res.status(400).json({
|
|
715
|
+
error: 'Missing required fields: adapter, token, chatId, events',
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const schema = ADAPTER_SCHEMA[adapter];
|
|
719
|
+
if (!schema) {
|
|
720
|
+
return res.status(400).json({
|
|
721
|
+
error: `Invalid adapter: ${adapter}. Must be one of: ${Object.keys(ADAPTER_SCHEMA).join(', ')}`,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const configDir = join(prefsDir, 'integrations');
|
|
726
|
+
const configPath = join(configDir, 'config.json');
|
|
727
|
+
|
|
728
|
+
// Load existing config or start fresh
|
|
729
|
+
let cfg = { schema_version: 1, enabled: true };
|
|
730
|
+
try {
|
|
731
|
+
const raw = readFileSync(configPath, 'utf8');
|
|
732
|
+
cfg = JSON.parse(raw);
|
|
733
|
+
} catch {
|
|
734
|
+
/* start fresh */
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Build adapter block — store token directly in config
|
|
738
|
+
const adapterBlock = { enabled: true, events };
|
|
739
|
+
adapterBlock[schema.tokenKey] = token;
|
|
740
|
+
adapterBlock[schema.idKey] = chatId;
|
|
741
|
+
|
|
742
|
+
cfg[adapter] = adapterBlock;
|
|
743
|
+
cfg.enabled = true;
|
|
744
|
+
if (!cfg.schema_version) cfg.schema_version = 1;
|
|
745
|
+
|
|
746
|
+
const result = validateIntegrationsConfig(cfg);
|
|
747
|
+
if (!result.valid) {
|
|
748
|
+
return res
|
|
749
|
+
.status(400)
|
|
750
|
+
.json({ error: `Validation failed: ${result.details.join('; ')}` });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
mkdirSync(configDir, { recursive: true });
|
|
755
|
+
writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
return res
|
|
758
|
+
.status(500)
|
|
759
|
+
.json({ error: `Failed to write config: ${err.message}` });
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Hot-reload just this adapter (no full restart)
|
|
763
|
+
if (app.locals.ensureIntegrations) app.locals.ensureIntegrations();
|
|
764
|
+
if (app.locals.integrations?.reloadAdapter) {
|
|
765
|
+
await app.locals.integrations.reloadAdapter(adapter);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
res.json({ ok: true, path: configPath });
|
|
769
|
+
});
|
|
770
|
+
|
|
486
771
|
// ─── Dynamic favicon ──────────────────────────────────────────────────
|
|
487
772
|
// Serve mode-specific favicon before express.static so it takes precedence.
|
|
488
773
|
app.get('/favicon.svg', (_req, res) => {
|
package/server/beads-reader.js
CHANGED
|
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
|
|
7
7
|
async function runBd(args, dbPath) {
|
|
8
|
-
const fullArgs = [...args, '--json', '--db', dbPath
|
|
8
|
+
const fullArgs = [...args, '--json', '--db', dbPath];
|
|
9
9
|
const { stdout } = await execFileAsync('bd', fullArgs, {
|
|
10
10
|
encoding: 'utf8',
|
|
11
11
|
timeout: 10000,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
|
|
5
|
+
export function resolvePythonCmd() {
|
|
6
|
+
if (process.env.WORCA_PYTHON) {
|
|
7
|
+
return [process.env.WORCA_PYTHON];
|
|
8
|
+
}
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
return ['py', 'python3', 'python'];
|
|
11
|
+
}
|
|
12
|
+
return ['python3', 'python'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function dispatchExternal({
|
|
16
|
+
runDir,
|
|
17
|
+
settingsPath,
|
|
18
|
+
eventType,
|
|
19
|
+
payload,
|
|
20
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
21
|
+
}) {
|
|
22
|
+
const candidates = resolvePythonCmd();
|
|
23
|
+
const args = [
|
|
24
|
+
'-m',
|
|
25
|
+
'worca.events.dispatch_external',
|
|
26
|
+
'--run-dir',
|
|
27
|
+
runDir,
|
|
28
|
+
'--settings',
|
|
29
|
+
settingsPath,
|
|
30
|
+
'--event-type',
|
|
31
|
+
eventType,
|
|
32
|
+
'--payload-json',
|
|
33
|
+
JSON.stringify(payload),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
let candidateIdx = 0;
|
|
37
|
+
|
|
38
|
+
function trySpawn(resolve) {
|
|
39
|
+
if (candidateIdx >= candidates.length) {
|
|
40
|
+
resolve({ ok: false, reason: 'python_not_found' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cmd = candidates[candidateIdx];
|
|
45
|
+
const spawnArgs = cmd === 'py' ? ['-3', ...args] : args;
|
|
46
|
+
|
|
47
|
+
const child = spawn(cmd, spawnArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
|
+
|
|
49
|
+
let stdoutBuf = '';
|
|
50
|
+
let stderrBuf = '';
|
|
51
|
+
let settled = false;
|
|
52
|
+
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
if (!settled) {
|
|
55
|
+
settled = true;
|
|
56
|
+
child.kill();
|
|
57
|
+
resolve({ ok: false, reason: 'timeout' });
|
|
58
|
+
}
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
|
|
61
|
+
child.stdout.on('data', (chunk) => {
|
|
62
|
+
stdoutBuf += chunk;
|
|
63
|
+
});
|
|
64
|
+
child.stderr.on('data', (chunk) => {
|
|
65
|
+
stderrBuf += chunk;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.on('error', (err) => {
|
|
69
|
+
if (!settled && err.code === 'ENOENT') {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
candidateIdx++;
|
|
72
|
+
trySpawn(resolve);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!settled) {
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve({ ok: false, reason: 'spawn_error', stderr: err.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
child.on('close', (code) => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
|
|
87
|
+
if (code !== 0) {
|
|
88
|
+
resolve({
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: `exit_code_${code}`,
|
|
91
|
+
stderr: stderrBuf,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(stdoutBuf);
|
|
98
|
+
resolve(parsed);
|
|
99
|
+
} catch {
|
|
100
|
+
resolve({ ok: false, reason: 'invalid_response', stdout: stdoutBuf });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve) => trySpawn(resolve));
|
|
106
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ensure-webhook.js — auto-configure a webhook pointing to this worca-ui instance
|
|
2
|
+
// in a project's settings.local.json so the pipeline sends events to the UI.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { localPathFor } from './settings-merge.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensure a webhook entry exists in the project's settings.local.json
|
|
10
|
+
* pointing to the worca-ui inbox at the given host:port.
|
|
11
|
+
*
|
|
12
|
+
* Skips if a webhook for this host:port already exists.
|
|
13
|
+
* Creates settings.local.json if it doesn't exist.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} projectPath — absolute path to the project root
|
|
16
|
+
* @param {{ host: string, port: number }} server — worca-ui server address
|
|
17
|
+
*/
|
|
18
|
+
export function ensureWebhookForUi(projectPath, { host, port }) {
|
|
19
|
+
const settingsPath = join(projectPath, '.claude', 'settings.json');
|
|
20
|
+
const localPath = localPathFor(settingsPath);
|
|
21
|
+
// Use localhost instead of 127.0.0.1 — the pipeline validator only allows
|
|
22
|
+
// https:// or http://localhost for security.
|
|
23
|
+
const displayHost =
|
|
24
|
+
host === '127.0.0.1' || host === '::1' ? 'localhost' : host;
|
|
25
|
+
const inboxUrl = `http://${displayHost}:${port}/api/webhooks/inbox`;
|
|
26
|
+
|
|
27
|
+
// Read existing local settings (or start fresh)
|
|
28
|
+
let local = {};
|
|
29
|
+
if (existsSync(localPath)) {
|
|
30
|
+
try {
|
|
31
|
+
local = JSON.parse(readFileSync(localPath, 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
local = {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!local.worca) local.worca = {};
|
|
38
|
+
if (!Array.isArray(local.worca.webhooks)) local.worca.webhooks = [];
|
|
39
|
+
|
|
40
|
+
// Check if a webhook for this URL already exists
|
|
41
|
+
const exists = local.worca.webhooks.some((wh) => wh.url === inboxUrl);
|
|
42
|
+
if (exists) return false;
|
|
43
|
+
|
|
44
|
+
// Also check base settings.json (in case it was manually configured there)
|
|
45
|
+
try {
|
|
46
|
+
const base = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
47
|
+
const baseWebhooks = base?.worca?.webhooks || [];
|
|
48
|
+
if (baseWebhooks.some((wh) => wh.url === inboxUrl)) return false;
|
|
49
|
+
} catch {
|
|
50
|
+
// no base settings — proceed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
local.worca.webhooks.push({
|
|
54
|
+
url: inboxUrl,
|
|
55
|
+
events: ['pipeline.*'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Ensure events are enabled
|
|
59
|
+
if (!local.worca.events) local.worca.events = {};
|
|
60
|
+
if (local.worca.events.enabled === undefined) {
|
|
61
|
+
local.worca.events.enabled = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
writeFileSync(localPath, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
65
|
+
return true;
|
|
66
|
+
}
|
package/server/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
|
|
|
4
4
|
import { homedir, platform } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { createApp } from './app.js';
|
|
7
|
+
import { createIntegrations } from './integrations/index.js';
|
|
7
8
|
import { attachWsServer } from './ws.js';
|
|
8
9
|
|
|
9
10
|
// Parse argv
|
|
@@ -57,6 +58,8 @@ const app = createApp({
|
|
|
57
58
|
projectRoot,
|
|
58
59
|
webhookInbox,
|
|
59
60
|
prefsDir,
|
|
61
|
+
serverHost: host,
|
|
62
|
+
serverPort: port,
|
|
60
63
|
});
|
|
61
64
|
const server = createServer(app);
|
|
62
65
|
|
|
@@ -104,6 +107,25 @@ app.locals.broadcast = broadcast;
|
|
|
104
107
|
app.locals.scheduleRefresh = scheduleRefresh;
|
|
105
108
|
app.locals.resolveRunProject = resolveRunProject;
|
|
106
109
|
|
|
110
|
+
// Boot chat integrations only in global mode — project-scoped instances skip
|
|
111
|
+
// integrations to avoid duplicate Telegram long-poll connections on the same bot.
|
|
112
|
+
if (isGlobal) {
|
|
113
|
+
const integrationsOpts = {
|
|
114
|
+
port,
|
|
115
|
+
host,
|
|
116
|
+
prefsDir,
|
|
117
|
+
configPath: join(prefsDir, 'integrations', 'config.json'),
|
|
118
|
+
};
|
|
119
|
+
app.locals.integrations = createIntegrations(integrationsOpts);
|
|
120
|
+
app.locals.ensureIntegrations = () => {
|
|
121
|
+
if (!app.locals.integrations?.reloadAdapter) {
|
|
122
|
+
app.locals.integrations = createIntegrations(integrationsOpts);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
console.log('[integrations] Skipped — integrations only run in global mode');
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
// ─── worca-cc version check (non-blocking) ─────────────────────────────
|
|
108
130
|
checkWorcaVersion().then((result) => {
|
|
109
131
|
app.locals.worcaVersion = result;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {('text'|'bold'|'code'|'code_block'|'link')} MessageSegmentKind
|
|
3
|
+
*
|
|
4
|
+
* @typedef {{
|
|
5
|
+
* kind: MessageSegmentKind,
|
|
6
|
+
* value: string,
|
|
7
|
+
* href?: string
|
|
8
|
+
* }} MessageSegment
|
|
9
|
+
*
|
|
10
|
+
* @typedef {{
|
|
11
|
+
* title: string|null,
|
|
12
|
+
* body: MessageSegment[],
|
|
13
|
+
* severity: 'info'|'success'|'warning'|'error'
|
|
14
|
+
* }} NormalizedMessage
|
|
15
|
+
*
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* platform: string,
|
|
18
|
+
* chatId: string,
|
|
19
|
+
* userId: string,
|
|
20
|
+
* text: string,
|
|
21
|
+
* raw: object
|
|
22
|
+
* }} IncomingMessage
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} ChatAdapter
|
|
25
|
+
* @property {string} name
|
|
26
|
+
* @property {boolean} supportsInbound
|
|
27
|
+
* @property {() => Promise<void>} start
|
|
28
|
+
* @property {() => Promise<void>} stop
|
|
29
|
+
* @property {(chatId: string, msg: NormalizedMessage) => Promise<void>} send
|
|
30
|
+
* @property {(cb: (msg: IncomingMessage) => void) => void} onInbound
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const MESSAGE_SEGMENT_KINDS = [
|
|
34
|
+
'text',
|
|
35
|
+
'bold',
|
|
36
|
+
'code',
|
|
37
|
+
'code_block',
|
|
38
|
+
'link',
|
|
39
|
+
'markdown',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export const SEVERITY_LEVELS = ['info', 'success', 'warning', 'error'];
|
|
43
|
+
|
|
44
|
+
export const ADAPTER_INTERFACE_KEYS = [
|
|
45
|
+
'name',
|
|
46
|
+
'supportsInbound',
|
|
47
|
+
'start',
|
|
48
|
+
'stop',
|
|
49
|
+
'send',
|
|
50
|
+
'onInbound',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** @param {unknown} seg @returns {seg is MessageSegment} */
|
|
54
|
+
export function isValidSegment(seg) {
|
|
55
|
+
if (!seg || typeof seg !== 'object') return false;
|
|
56
|
+
return (
|
|
57
|
+
MESSAGE_SEGMENT_KINDS.includes(seg.kind) && typeof seg.value === 'string'
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {unknown} msg @returns {msg is NormalizedMessage} */
|
|
62
|
+
export function isValidMessage(msg) {
|
|
63
|
+
if (!msg || typeof msg !== 'object') return false;
|
|
64
|
+
if (msg.title !== null && typeof msg.title !== 'string') return false;
|
|
65
|
+
if (!Array.isArray(msg.body) || !msg.body.every(isValidSegment)) return false;
|
|
66
|
+
return SEVERITY_LEVELS.includes(msg.severity);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {unknown} inc @returns {inc is IncomingMessage} */
|
|
70
|
+
export function isValidIncoming(inc) {
|
|
71
|
+
if (!inc || typeof inc !== 'object') return false;
|
|
72
|
+
return (
|
|
73
|
+
typeof inc.platform === 'string' &&
|
|
74
|
+
typeof inc.chatId === 'string' &&
|
|
75
|
+
typeof inc.userId === 'string' &&
|
|
76
|
+
typeof inc.text === 'string' &&
|
|
77
|
+
inc.raw !== undefined
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {unknown} adapter @returns {adapter is ChatAdapter} */
|
|
82
|
+
export function isValidAdapter(adapter) {
|
|
83
|
+
if (!adapter || typeof adapter !== 'object') return false;
|
|
84
|
+
return (
|
|
85
|
+
typeof adapter.name === 'string' &&
|
|
86
|
+
typeof adapter.supportsInbound === 'boolean' &&
|
|
87
|
+
typeof adapter.start === 'function' &&
|
|
88
|
+
typeof adapter.send === 'function' &&
|
|
89
|
+
typeof adapter.onInbound === 'function'
|
|
90
|
+
);
|
|
91
|
+
}
|