@worca/ui 0.9.0 → 0.11.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.
Files changed (33) hide show
  1. package/app/main.bundle.js +895 -813
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +216 -9
  4. package/app/utils/state-actions.js +55 -0
  5. package/package.json +6 -4
  6. package/server/app.js +291 -6
  7. package/server/beads-reader.js +1 -1
  8. package/server/dispatch-external.js +106 -0
  9. package/server/ensure-webhook.js +66 -0
  10. package/server/index.js +22 -0
  11. package/server/integrations/adapter.js +91 -0
  12. package/server/integrations/adapters/discord.js +109 -0
  13. package/server/integrations/adapters/slack.js +106 -0
  14. package/server/integrations/adapters/telegram.js +231 -0
  15. package/server/integrations/adapters/webhook_out.js +253 -0
  16. package/server/integrations/allowlist.js +19 -0
  17. package/server/integrations/chat_context.js +68 -0
  18. package/server/integrations/commands/control.js +120 -0
  19. package/server/integrations/commands/global.js +239 -0
  20. package/server/integrations/commands/parser.js +29 -0
  21. package/server/integrations/commands/project.js +394 -0
  22. package/server/integrations/config-loader.js +40 -0
  23. package/server/integrations/index.js +390 -0
  24. package/server/integrations/markdown.js +220 -0
  25. package/server/integrations/rate_limiter.js +131 -0
  26. package/server/integrations/renderers.js +191 -0
  27. package/server/integrations/rest_client.js +17 -0
  28. package/server/integrations/verify.js +23 -0
  29. package/server/process-manager.js +217 -14
  30. package/server/project-routes.js +210 -44
  31. package/server/settings-validator.js +250 -0
  32. package/server/ws-beads-watcher.js +22 -6
  33. 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 { settingsPath, worcaDir, projectRoot, prefsDir } = options;
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(express.json());
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('/api/projects', createProjectRoutes({ prefsDir, projectRoot }));
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) => {
@@ -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, '--readonly'];
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
+ }