tide-commander 1.97.0 → 1.99.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/dist/assets/{BossLogsModal-CT25hD17.js → BossLogsModal-CDel834o.js} +1 -1
- package/dist/assets/{BossSpawnModal-9rS7AFkZ.js → BossSpawnModal-BB9wL5VV.js} +1 -1
- package/dist/assets/{ControlsModal-D-mymoM7.js → ControlsModal-D5RE5MvT.js} +1 -1
- package/dist/assets/{DockerLogsModal-Ae-ZCeeP.js → DockerLogsModal-B27P1JpZ.js} +1 -1
- package/dist/assets/{EmbeddedEditor-DLOOpM0K.js → EmbeddedEditor-DP1jqsT_.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-C9NLhWLo.js → GmailOAuthSetup-DvuL5G8Q.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-1kzgrPV6.js → GoogleOAuthSetup-CG6bSCjv.js} +1 -1
- package/dist/assets/{IframeModal-DKS0IFsr.js → IframeModal-ClnUGmJV.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CBvKOeud.js → IntegrationsPanel-0vdfxZRq.js} +2 -2
- package/dist/assets/{LogViewerModal-Dlt8JfVg.js → LogViewerModal-DLQlrZ4O.js} +1 -1
- package/dist/assets/{MonitoringModal-BM1IEZv6.js → MonitoringModal-DiC9TNCy.js} +1 -1
- package/dist/assets/{PM2LogsModal-B1-HUHWZ.js → PM2LogsModal-BgPrnaP5.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-DXmYo7fp.js → RestoreArchivedAreaModal-CIN1OrOW.js} +1 -1
- package/dist/assets/{Scene2DCanvas-CuUxSaPb.js → Scene2DCanvas-Bap6brvv.js} +1 -1
- package/dist/assets/{SceneManager-UD3IHY20.js → SceneManager-CidCW0PR.js} +1 -1
- package/dist/assets/{SkillsPanel-DjRBVrO2.js → SkillsPanel-DHQTPaP2.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Csp81Dqn.js → SlackMultiInstanceSetup-CQK4D89W.js} +1 -1
- package/dist/assets/{SpawnModal-dg0mH3d9.js → SpawnModal-Cx_k3HHC.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CeBPRNNX.js → SubordinateAssignmentModal-C1DOv51H.js} +1 -1
- package/dist/assets/TriggerManagerPanel-jP5RBK2L.js +9 -0
- package/dist/assets/{WorkflowEditorPanel-IIsptZgp.js → WorkflowEditorPanel-Dh6mZ8M4.js} +1 -1
- package/dist/assets/{index-h-IcmGfB.js → index-B-ttQFx4.js} +2 -2
- package/dist/assets/index-BXnThzaG.js +11 -0
- package/dist/assets/index-CK8NcQSU.css +1 -0
- package/dist/assets/{index-CNDUxsGy.js → index-CYwFXTQZ.js} +1 -1
- package/dist/assets/{index-sDgBtEgH.js → index-Cwlm-Pqi.js} +3 -3
- package/dist/assets/index-D96LXKm4.js +1 -0
- package/dist/assets/{index-BGh9tRSy.js → index-DP5sMNS9.js} +1 -1
- package/dist/assets/{index-CsyPNc8u.js → index-DfEbuBH8.js} +1 -1
- package/dist/assets/{index-DEI-vrXk.js → index-DsKaX6TJ.js} +1 -1
- package/dist/assets/{index-CIqkVLo1.js → index-enJvXAbe.js} +1 -1
- package/dist/assets/main-B7wf_xU_.js +214 -0
- package/dist/assets/main-DLzFxLC1.css +1 -0
- package/dist/assets/{web-BmPSJLwQ.js → web-BHmmnvF7.js} +1 -1
- package/dist/assets/{web-Dggt4D4N.js → web-IGuhG0xr.js} +1 -1
- package/dist/assets/{web-BgPjNMBK.js → web-SOehUGgT.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/config.json +60 -1
- package/dist/locales/en/terminal.json +10 -0
- package/dist/src/packages/server/claude/backend.js +42 -0
- package/dist/src/packages/server/claude/permission-prompt-server.mjs +188 -0
- package/dist/src/packages/server/claude/runner/process-lifecycle.js +3 -0
- package/dist/src/packages/server/claude/runner/stdout-pipeline.js +8 -0
- package/dist/src/packages/server/claude/runner/tmux-helper.js +14 -0
- package/dist/src/packages/server/data/event-queries.js +143 -1
- package/dist/src/packages/server/data/migrations/007_whatsapp_messages.sql +48 -0
- package/dist/src/packages/server/index.js +1 -0
- package/dist/src/packages/server/integrations/gmail/gmail-client.js +139 -24
- package/dist/src/packages/server/integrations/gmail/gmail-routes.js +162 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-routes.js +81 -0
- package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +29 -0
- package/dist/src/packages/server/routes/agent-prompt.js +57 -0
- package/dist/src/packages/server/routes/index.js +8 -1
- package/dist/src/packages/server/routes/skills.js +193 -0
- package/dist/src/packages/server/routes/system.js +156 -0
- package/dist/src/packages/server/routes/trigger-routes.js +74 -17
- package/dist/src/packages/server/routes/webhook-signatures.js +20 -7
- package/dist/src/packages/server/services/agent-prompt-service.js +100 -0
- package/dist/src/packages/server/services/index.js +1 -0
- package/dist/src/packages/server/services/self-update-service.js +191 -0
- package/dist/src/packages/server/websocket/handler.js +2 -1
- package/dist/src/packages/server/websocket/listeners/agent-prompt-listeners.js +13 -0
- package/dist/src/packages/server/websocket/listeners/index.js +2 -0
- package/dist/src/packages/shared/whatsapp-types.js +1 -0
- package/package.json +2 -2
- package/dist/assets/TriggerManagerPanel-D1QPpFhP.js +0 -9
- package/dist/assets/index-BdGz_GAe.css +0 -1
- package/dist/assets/index-CR9w26tq.js +0 -1
- package/dist/assets/index-vJkimYqD.js +0 -1
- package/dist/assets/main-BV_IuaBg.css +0 -1
- package/dist/assets/main-klWBzHh0.js +0 -214
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- Migration 007: WhatsApp Messages
|
|
2
|
+
-- Mirrors slack_messages: every inbound and outbound WhatsApp message captured
|
|
3
|
+
-- by the upstream WS bridge is persisted here so chat history is queryable from
|
|
4
|
+
-- SQLite. message_id + session_id together are unique so redelivered echoes
|
|
5
|
+
-- (server restart, upstream replay) don't double-insert.
|
|
6
|
+
|
|
7
|
+
CREATE TABLE whatsapp_messages (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
session_id TEXT NOT NULL,
|
|
10
|
+
message_id TEXT,
|
|
11
|
+
chat_id TEXT NOT NULL,
|
|
12
|
+
is_group INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
group_name TEXT,
|
|
14
|
+
from_jid TEXT NOT NULL,
|
|
15
|
+
from_name TEXT,
|
|
16
|
+
direction TEXT NOT NULL,
|
|
17
|
+
body TEXT NOT NULL,
|
|
18
|
+
message_type TEXT NOT NULL,
|
|
19
|
+
media_mimetype TEXT,
|
|
20
|
+
media_size INTEGER,
|
|
21
|
+
media_filename TEXT,
|
|
22
|
+
media_path TEXT,
|
|
23
|
+
audio_transcription TEXT,
|
|
24
|
+
agent_id TEXT,
|
|
25
|
+
workflow_instance_id TEXT,
|
|
26
|
+
raw_event TEXT,
|
|
27
|
+
timestamp INTEGER NOT NULL,
|
|
28
|
+
received_at INTEGER NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE UNIQUE INDEX idx_whatsapp_messages_unique
|
|
32
|
+
ON whatsapp_messages(session_id, message_id)
|
|
33
|
+
WHERE message_id IS NOT NULL;
|
|
34
|
+
|
|
35
|
+
CREATE INDEX idx_whatsapp_messages_chat
|
|
36
|
+
ON whatsapp_messages(session_id, chat_id, timestamp DESC);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX idx_whatsapp_messages_timestamp
|
|
39
|
+
ON whatsapp_messages(timestamp);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX idx_whatsapp_messages_direction
|
|
42
|
+
ON whatsapp_messages(direction);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX idx_whatsapp_messages_workflow
|
|
45
|
+
ON whatsapp_messages(workflow_instance_id);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX idx_whatsapp_messages_agent
|
|
48
|
+
ON whatsapp_messages(agent_id);
|
|
@@ -74,6 +74,7 @@ async function main() {
|
|
|
74
74
|
eventDb: {
|
|
75
75
|
logTriggerFire: eventQueries.logTriggerFire,
|
|
76
76
|
logSlackMessage: eventQueries.logSlackMessage,
|
|
77
|
+
logWhatsAppMessage: eventQueries.logWhatsAppMessage,
|
|
77
78
|
logEmailMessage: eventQueries.logEmailMessage,
|
|
78
79
|
logApprovalEvent: eventQueries.logApprovalEvent,
|
|
79
80
|
logDocumentGeneration: eventQueries.logDocumentGeneration,
|
|
@@ -383,6 +383,140 @@ function parseGmailMessage(msg) {
|
|
|
383
383
|
attachmentsMeta: attachmentsMeta.length > 0 ? attachmentsMeta : undefined,
|
|
384
384
|
};
|
|
385
385
|
}
|
|
386
|
+
export class GmailAttachmentNotAuthenticatedError extends Error {
|
|
387
|
+
constructor() { super('Gmail not authenticated'); this.name = 'GmailAttachmentNotAuthenticatedError'; }
|
|
388
|
+
}
|
|
389
|
+
export class GmailAttachmentNotFoundError extends Error {
|
|
390
|
+
constructor() { super('attachment not found'); this.name = 'GmailAttachmentNotFoundError'; }
|
|
391
|
+
}
|
|
392
|
+
export class GmailAttachmentTooLargeError extends Error {
|
|
393
|
+
bytes;
|
|
394
|
+
cap;
|
|
395
|
+
constructor(bytes, cap) {
|
|
396
|
+
super(`attachment too large (${bytes} > ${cap})`);
|
|
397
|
+
this.bytes = bytes;
|
|
398
|
+
this.cap = cap;
|
|
399
|
+
this.name = 'GmailAttachmentTooLargeError';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
export async function dumpGmailMessageParts(messageId) {
|
|
403
|
+
if (!gmail)
|
|
404
|
+
throw new GmailAttachmentNotAuthenticatedError();
|
|
405
|
+
let res;
|
|
406
|
+
try {
|
|
407
|
+
res = await gmail.users.messages.get({
|
|
408
|
+
userId: 'me',
|
|
409
|
+
id: messageId,
|
|
410
|
+
format: 'full',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
const e = err;
|
|
415
|
+
if (e.code === 404 || e.status === 404)
|
|
416
|
+
return null;
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
const out = [];
|
|
420
|
+
function walk(part, depth) {
|
|
421
|
+
if (!part)
|
|
422
|
+
return;
|
|
423
|
+
out.push({
|
|
424
|
+
partId: part.partId ?? '',
|
|
425
|
+
mimeType: part.mimeType ?? '',
|
|
426
|
+
filename: part.filename ?? '',
|
|
427
|
+
attachmentId: part.body?.attachmentId ?? null,
|
|
428
|
+
size: part.body?.size ?? 0,
|
|
429
|
+
depth,
|
|
430
|
+
});
|
|
431
|
+
if (part.parts)
|
|
432
|
+
for (const p of part.parts)
|
|
433
|
+
walk(p, depth + 1);
|
|
434
|
+
}
|
|
435
|
+
walk(res.data.payload ?? undefined, 0);
|
|
436
|
+
return { found: true, parts: out };
|
|
437
|
+
}
|
|
438
|
+
export async function resolveGmailAttachmentPart(messageId, identifier, filenameHint) {
|
|
439
|
+
if (!gmail)
|
|
440
|
+
throw new GmailAttachmentNotAuthenticatedError();
|
|
441
|
+
let res;
|
|
442
|
+
try {
|
|
443
|
+
res = await gmail.users.messages.get({
|
|
444
|
+
userId: 'me',
|
|
445
|
+
id: messageId,
|
|
446
|
+
format: 'full',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
const e = err;
|
|
451
|
+
if (e.code === 404 || e.status === 404)
|
|
452
|
+
return null;
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
const candidates = [];
|
|
456
|
+
function walk(part) {
|
|
457
|
+
if (!part)
|
|
458
|
+
return;
|
|
459
|
+
const attId = part.body?.attachmentId;
|
|
460
|
+
if (attId) {
|
|
461
|
+
candidates.push({
|
|
462
|
+
realAttachmentId: attId,
|
|
463
|
+
filename: part.filename || '',
|
|
464
|
+
mimeType: part.mimeType || 'application/octet-stream',
|
|
465
|
+
size: part.body?.size ?? 0,
|
|
466
|
+
partId: part.partId ?? '',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (part.parts)
|
|
470
|
+
for (const p of part.parts)
|
|
471
|
+
walk(p);
|
|
472
|
+
}
|
|
473
|
+
walk(res.data.payload ?? undefined);
|
|
474
|
+
const id = identifier.trim();
|
|
475
|
+
const norm = (s) => s.normalize('NFC').toLowerCase().trim();
|
|
476
|
+
const hint = filenameHint ? norm(filenameHint) : undefined;
|
|
477
|
+
for (const c of candidates)
|
|
478
|
+
if (c.realAttachmentId === id)
|
|
479
|
+
return c;
|
|
480
|
+
for (const c of candidates)
|
|
481
|
+
if (c.partId === id)
|
|
482
|
+
return c;
|
|
483
|
+
if (hint) {
|
|
484
|
+
for (const c of candidates)
|
|
485
|
+
if (norm(c.filename) === hint)
|
|
486
|
+
return c;
|
|
487
|
+
}
|
|
488
|
+
for (const c of candidates)
|
|
489
|
+
if (norm(c.filename) === norm(id))
|
|
490
|
+
return c;
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
export async function fetchGmailAttachmentBuffer(messageId, attachmentId) {
|
|
494
|
+
if (!gmail)
|
|
495
|
+
throw new GmailAttachmentNotAuthenticatedError();
|
|
496
|
+
const { MAX_ATTACHMENT_BYTES } = await import('../../services/attachment-downloader.js');
|
|
497
|
+
let res;
|
|
498
|
+
try {
|
|
499
|
+
res = await gmail.users.messages.attachments.get({
|
|
500
|
+
userId: 'me',
|
|
501
|
+
messageId,
|
|
502
|
+
id: attachmentId,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const e = err;
|
|
507
|
+
if (e.code === 404 || e.status === 404)
|
|
508
|
+
throw new GmailAttachmentNotFoundError();
|
|
509
|
+
throw err;
|
|
510
|
+
}
|
|
511
|
+
const b64 = res.data?.data;
|
|
512
|
+
if (!b64)
|
|
513
|
+
throw new GmailAttachmentNotFoundError();
|
|
514
|
+
const buf = Buffer.from(b64, 'base64url');
|
|
515
|
+
if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
516
|
+
throw new GmailAttachmentTooLargeError(buf.byteLength, MAX_ATTACHMENT_BYTES);
|
|
517
|
+
}
|
|
518
|
+
return { buffer: buf, size: buf.byteLength };
|
|
519
|
+
}
|
|
386
520
|
/**
|
|
387
521
|
* Download a single Gmail attachment by id and persist it to disk under
|
|
388
522
|
* `<TEMP_DIR>/triggers/gmail/<messageId>/<sanitized-filename>`. Mirrors what
|
|
@@ -396,7 +530,6 @@ export async function downloadGmailAttachment(messageId, meta) {
|
|
|
396
530
|
ctx?.log.warn('Gmail not authenticated — skipping attachment download');
|
|
397
531
|
return null;
|
|
398
532
|
}
|
|
399
|
-
// Lazy-import the shared cap + temp root so we keep one source of truth.
|
|
400
533
|
const { MAX_ATTACHMENT_BYTES, TRIGGER_ATTACHMENT_ROOT } = await import('../../services/attachment-downloader.js');
|
|
401
534
|
if (meta.size > MAX_ATTACHMENT_BYTES) {
|
|
402
535
|
ctx?.log.warn(`Gmail attachment ${meta.filename} (${meta.size}B) exceeds cap; skipping`);
|
|
@@ -404,15 +537,10 @@ export async function downloadGmailAttachment(messageId, meta) {
|
|
|
404
537
|
}
|
|
405
538
|
const path = await import('path');
|
|
406
539
|
const fs = await import('fs/promises');
|
|
407
|
-
// Sanitize segments the same way attachment-downloader does — strip
|
|
408
|
-
// path separators / `..` / control chars so a hostile filename can't
|
|
409
|
-
// escape the trigger dir.
|
|
410
540
|
const safe = (s) => s.replace(/[\\/\x00-\x1f\x7f]/g, '_').replace(/\.{2,}/g, '_').slice(0, 200) || 'file';
|
|
411
541
|
const targetDir = path.join(TRIGGER_ATTACHMENT_ROOT, 'gmail', safe(messageId));
|
|
412
542
|
const finalName = safe(meta.filename) || 'file';
|
|
413
543
|
const targetPath = path.join(targetDir, finalName);
|
|
414
|
-
// Idempotency: if a file with the expected size already lives at the
|
|
415
|
-
// target path, skip the network round-trip.
|
|
416
544
|
try {
|
|
417
545
|
const st = await fs.stat(targetPath);
|
|
418
546
|
if (st.isFile() && (meta.size === 0 || st.size === meta.size)) {
|
|
@@ -421,36 +549,23 @@ export async function downloadGmailAttachment(messageId, meta) {
|
|
|
421
549
|
}
|
|
422
550
|
catch { /* not cached */ }
|
|
423
551
|
await fs.mkdir(targetDir, { recursive: true });
|
|
424
|
-
let
|
|
552
|
+
let buffer;
|
|
425
553
|
try {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
messageId,
|
|
429
|
-
id: meta.attachmentId,
|
|
430
|
-
});
|
|
554
|
+
const fetched = await fetchGmailAttachmentBuffer(messageId, meta.attachmentId);
|
|
555
|
+
buffer = fetched.buffer;
|
|
431
556
|
}
|
|
432
557
|
catch (err) {
|
|
433
558
|
ctx?.log.warn(`Gmail attachments.get failed for ${meta.filename}: ${err}`);
|
|
434
559
|
return null;
|
|
435
560
|
}
|
|
436
|
-
const b64 = res.data?.data;
|
|
437
|
-
if (!b64) {
|
|
438
|
-
ctx?.log.warn(`Gmail attachment ${meta.filename} returned empty data`);
|
|
439
|
-
return null;
|
|
440
|
-
}
|
|
441
|
-
const buf = Buffer.from(b64, 'base64url');
|
|
442
|
-
if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
443
|
-
ctx?.log.warn(`Gmail attachment ${meta.filename} decoded to ${buf.byteLength}B (exceeds cap)`);
|
|
444
|
-
return null;
|
|
445
|
-
}
|
|
446
561
|
try {
|
|
447
|
-
await fs.writeFile(targetPath,
|
|
562
|
+
await fs.writeFile(targetPath, buffer);
|
|
448
563
|
}
|
|
449
564
|
catch (err) {
|
|
450
565
|
ctx?.log.warn(`Gmail attachment write failed for ${targetPath}: ${err}`);
|
|
451
566
|
return null;
|
|
452
567
|
}
|
|
453
|
-
return { path: targetPath, bytesOnDisk:
|
|
568
|
+
return { path: targetPath, bytesOnDisk: buffer.byteLength, filename: finalName, mimeType: meta.mimeType };
|
|
454
569
|
}
|
|
455
570
|
export async function getThread(threadId) {
|
|
456
571
|
if (!gmail)
|
|
@@ -4,10 +4,42 @@
|
|
|
4
4
|
* Mounted at /api/email/ by the integration registry.
|
|
5
5
|
*/
|
|
6
6
|
import { Router } from 'express';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as fsp from 'fs/promises';
|
|
7
9
|
import * as gmailClient from './gmail-client.js';
|
|
10
|
+
import { GmailAttachmentNotAuthenticatedError, GmailAttachmentNotFoundError, GmailAttachmentTooLargeError, } from './gmail-client.js';
|
|
8
11
|
import { createLogger } from '../../utils/logger.js';
|
|
9
12
|
const log = createLogger('GmailRoutes');
|
|
10
13
|
const router = Router();
|
|
14
|
+
const SAVE_PATH_WHITELIST = ['/home/riven/obsidian', '/home/riven/d', '/tmp'];
|
|
15
|
+
function sanitizeFilename(name) {
|
|
16
|
+
if (typeof name !== 'string')
|
|
17
|
+
return null;
|
|
18
|
+
const trimmed = name.trim();
|
|
19
|
+
if (!trimmed)
|
|
20
|
+
return null;
|
|
21
|
+
if (/[/\\]/.test(trimmed))
|
|
22
|
+
return null;
|
|
23
|
+
if (/[\x00-\x1f]/.test(trimmed))
|
|
24
|
+
return null;
|
|
25
|
+
const cleaned = trimmed.slice(0, 200);
|
|
26
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
27
|
+
}
|
|
28
|
+
function isWithinWhitelist(absPath) {
|
|
29
|
+
const resolved = path.resolve(absPath);
|
|
30
|
+
return SAVE_PATH_WHITELIST.some((root) => {
|
|
31
|
+
const rootResolved = path.resolve(root);
|
|
32
|
+
return resolved === rootResolved || resolved.startsWith(rootResolved + path.sep);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function buildContentDisposition(filename) {
|
|
36
|
+
const asciiFallback = filename
|
|
37
|
+
.replace(/[^\x20-\x7e]/g, '_')
|
|
38
|
+
.replace(/"/g, "'")
|
|
39
|
+
.slice(0, 200) || 'attachment';
|
|
40
|
+
const encoded = encodeURIComponent(filename);
|
|
41
|
+
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
|
42
|
+
}
|
|
11
43
|
// POST /api/email/send — Send an email
|
|
12
44
|
router.post('/send', async (req, res) => {
|
|
13
45
|
try {
|
|
@@ -208,4 +240,134 @@ router.post('/polling/stop', (req, res) => {
|
|
|
208
240
|
res.status(500).json({ error: `Failed to stop polling: ${err instanceof Error ? err.message : err}` });
|
|
209
241
|
}
|
|
210
242
|
});
|
|
243
|
+
router.get('/messages/:messageId/parts', async (req, res) => {
|
|
244
|
+
const messageId = (req.params.messageId ?? '').trim();
|
|
245
|
+
if (!messageId) {
|
|
246
|
+
res.status(400).json({ error: 'invalid messageId' });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const dump = await gmailClient.dumpGmailMessageParts(messageId);
|
|
251
|
+
if (!dump) {
|
|
252
|
+
res.status(404).json({ error: 'message not found' });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
res.json(dump);
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
if (err instanceof GmailAttachmentNotAuthenticatedError) {
|
|
259
|
+
res.status(503).json({ error: 'Gmail not authenticated' });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
log.error(`Gmail message parts dump error: ${err}`);
|
|
263
|
+
res.status(500).json({ error: 'failed to dump message' });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
router.post('/messages/:messageId/attachments/:attachmentId/download', async (req, res) => {
|
|
267
|
+
const messageId = (req.params.messageId ?? '').trim();
|
|
268
|
+
const attachmentId = (req.params.attachmentId ?? '').trim();
|
|
269
|
+
if (!messageId) {
|
|
270
|
+
res.status(400).json({ error: 'invalid messageId' });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!attachmentId) {
|
|
274
|
+
res.status(400).json({ error: 'invalid attachmentId' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const savePathRaw = typeof req.query.savePath === 'string' ? req.query.savePath : undefined;
|
|
278
|
+
const filenameOverrideRaw = typeof req.query.filename === 'string' ? req.query.filename : undefined;
|
|
279
|
+
let filenameOverride = null;
|
|
280
|
+
if (filenameOverrideRaw !== undefined) {
|
|
281
|
+
filenameOverride = sanitizeFilename(filenameOverrideRaw);
|
|
282
|
+
if (!filenameOverride) {
|
|
283
|
+
res.status(400).json({ error: 'invalid filename' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let resolvedSavePath = null;
|
|
288
|
+
if (savePathRaw) {
|
|
289
|
+
if (!path.isAbsolute(savePathRaw)) {
|
|
290
|
+
res.status(400).json({ error: 'savePath outside whitelist' });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
resolvedSavePath = path.resolve(savePathRaw);
|
|
294
|
+
if (!isWithinWhitelist(resolvedSavePath)) {
|
|
295
|
+
res.status(400).json({ error: 'savePath outside whitelist' });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const part = await gmailClient.resolveGmailAttachmentPart(messageId, attachmentId, filenameOverrideRaw);
|
|
301
|
+
if (!part) {
|
|
302
|
+
res.status(404).json({ error: 'attachment not found' });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const resolvedMime = part.mimeType || 'application/octet-stream';
|
|
306
|
+
const metaFilename = sanitizeFilename(part.filename) ?? 'attachment';
|
|
307
|
+
const resolvedFilename = filenameOverride ?? metaFilename;
|
|
308
|
+
const { buffer, size } = await gmailClient.fetchGmailAttachmentBuffer(messageId, part.realAttachmentId);
|
|
309
|
+
if (!resolvedSavePath) {
|
|
310
|
+
res.setHeader('Content-Type', resolvedMime);
|
|
311
|
+
res.setHeader('Content-Disposition', buildContentDisposition(resolvedFilename));
|
|
312
|
+
res.setHeader('Content-Length', String(size));
|
|
313
|
+
res.status(200).send(buffer);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let targetPath;
|
|
317
|
+
let targetDir;
|
|
318
|
+
let isDir = false;
|
|
319
|
+
try {
|
|
320
|
+
const st = await fsp.stat(resolvedSavePath);
|
|
321
|
+
isDir = st.isDirectory();
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
const raw = savePathRaw ?? '';
|
|
325
|
+
isDir = raw.endsWith('/') || raw.endsWith(path.sep);
|
|
326
|
+
}
|
|
327
|
+
if (isDir) {
|
|
328
|
+
targetDir = resolvedSavePath;
|
|
329
|
+
targetPath = path.join(resolvedSavePath, resolvedFilename);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const baseFromPath = path.basename(resolvedSavePath);
|
|
333
|
+
const safeBase = filenameOverride ?? sanitizeFilename(baseFromPath);
|
|
334
|
+
if (!safeBase) {
|
|
335
|
+
res.status(400).json({ error: 'invalid filename' });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
targetDir = path.dirname(resolvedSavePath);
|
|
339
|
+
targetPath = path.join(targetDir, safeBase);
|
|
340
|
+
}
|
|
341
|
+
if (!isWithinWhitelist(targetPath) || !isWithinWhitelist(targetDir)) {
|
|
342
|
+
res.status(400).json({ error: 'savePath outside whitelist' });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await fsp.mkdir(targetDir, { recursive: true });
|
|
347
|
+
await fsp.writeFile(targetPath, buffer);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
log.error(`Gmail attachment write failed for ${targetPath}: ${err}`);
|
|
351
|
+
res.status(500).json({ error: 'failed to write attachment' });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
res.json({ ok: true, path: targetPath, size });
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
if (err instanceof GmailAttachmentNotAuthenticatedError) {
|
|
358
|
+
res.status(503).json({ error: 'Gmail not authenticated' });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (err instanceof GmailAttachmentNotFoundError) {
|
|
362
|
+
res.status(404).json({ error: 'attachment not found' });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (err instanceof GmailAttachmentTooLargeError) {
|
|
366
|
+
res.status(413).json({ error: 'attachment too large' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
log.error(`Gmail attachment download error: ${err}`);
|
|
370
|
+
res.status(500).json({ error: 'failed to download attachment' });
|
|
371
|
+
}
|
|
372
|
+
});
|
|
211
373
|
export default router;
|
|
@@ -10,6 +10,7 @@ import { createLogger } from '../../utils/logger.js';
|
|
|
10
10
|
import { WhatsAppClient } from './whatsapp-client.js';
|
|
11
11
|
import { loadConfig, updateConfig, WHATSAPP_API_KEY_SECRET, } from './whatsapp-config.js';
|
|
12
12
|
import { syncBridge } from './index.js';
|
|
13
|
+
import { getWhatsAppChatsList, getWhatsAppMessagesByChatPaged, } from '../../data/event-queries.js';
|
|
13
14
|
import { getConfig as getNotificationConfig, updateConfig as updateNotificationConfig, clearConfig as clearNotificationConfig, getDefaultConfig as getDefaultNotificationConfig, WHATSAPP_NOTIFICATION_EVENT_TYPES, } from '../../services/whatsapp-notification-config-service.js';
|
|
14
15
|
const log = createLogger('WhatsAppRoutes');
|
|
15
16
|
/** Build the router. Closes over the integration context for secret access. */
|
|
@@ -379,6 +380,86 @@ export function createWhatsAppRoutes(ctx) {
|
|
|
379
380
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
380
381
|
}
|
|
381
382
|
});
|
|
383
|
+
// ─── GET /chats/:sessionId — chat summaries ───
|
|
384
|
+
router.get('/chats/:sessionId', (req, res) => {
|
|
385
|
+
const sessionId = req.params.sessionId.trim();
|
|
386
|
+
if (!sessionId) {
|
|
387
|
+
res.status(400).json({ error: 'sessionId is required' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const chats = getWhatsAppChatsList(sessionId);
|
|
392
|
+
const body = { chats };
|
|
393
|
+
res.json(body);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
log.error(`WhatsApp chats list error: ${err}`);
|
|
397
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
// ─── GET /chats/:sessionId/:chatId/messages — paged history ───
|
|
401
|
+
router.get('/chats/:sessionId/:chatId/messages', (req, res) => {
|
|
402
|
+
const sessionId = req.params.sessionId.trim();
|
|
403
|
+
const chatId = req.params.chatId.trim();
|
|
404
|
+
if (!sessionId || !chatId) {
|
|
405
|
+
res.status(400).json({ error: 'sessionId and chatId are required' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const cursorRaw = req.query.cursor;
|
|
409
|
+
let cursor;
|
|
410
|
+
if (typeof cursorRaw === 'string' && cursorRaw.length > 0) {
|
|
411
|
+
const parsed = Number(cursorRaw);
|
|
412
|
+
if (!Number.isFinite(parsed)) {
|
|
413
|
+
res.status(400).json({ error: 'invalid cursor' });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
cursor = parsed;
|
|
417
|
+
}
|
|
418
|
+
const limitRaw = req.query.limit;
|
|
419
|
+
let limit;
|
|
420
|
+
if (typeof limitRaw === 'string' && limitRaw.length > 0) {
|
|
421
|
+
const parsed = Number(limitRaw);
|
|
422
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
423
|
+
res.status(400).json({ error: 'invalid limit' });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
limit = parsed;
|
|
427
|
+
}
|
|
428
|
+
const directionRaw = req.query.direction;
|
|
429
|
+
let direction;
|
|
430
|
+
if (typeof directionRaw === 'string' && directionRaw.length > 0) {
|
|
431
|
+
if (directionRaw !== 'inbound' && directionRaw !== 'outbound') {
|
|
432
|
+
res.status(400).json({ error: 'invalid direction' });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
direction = directionRaw;
|
|
436
|
+
}
|
|
437
|
+
const typeRaw = req.query.type;
|
|
438
|
+
const allowedTypes = [
|
|
439
|
+
'text', 'image', 'audio', 'video', 'document',
|
|
440
|
+
'sticker', 'location', 'contact', 'reaction', 'unknown',
|
|
441
|
+
];
|
|
442
|
+
let type;
|
|
443
|
+
if (typeof typeRaw === 'string' && typeRaw.length > 0) {
|
|
444
|
+
if (!allowedTypes.includes(typeRaw)) {
|
|
445
|
+
res.status(400).json({ error: 'invalid type' });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
type = typeRaw;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const page = getWhatsAppMessagesByChatPaged(sessionId, chatId, { cursor, limit, direction, type });
|
|
452
|
+
const body = {
|
|
453
|
+
messages: page.messages,
|
|
454
|
+
nextCursor: page.nextCursor === null ? null : String(page.nextCursor),
|
|
455
|
+
};
|
|
456
|
+
res.json(body);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
log.error(`WhatsApp messages page error: ${err}`);
|
|
460
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
382
463
|
return router;
|
|
383
464
|
}
|
|
384
465
|
export default createWhatsAppRoutes;
|
|
@@ -121,6 +121,33 @@ export function createWhatsAppTriggerHandler(ctx) {
|
|
|
121
121
|
function isRunning() {
|
|
122
122
|
return client !== null;
|
|
123
123
|
}
|
|
124
|
+
function persist(payload) {
|
|
125
|
+
try {
|
|
126
|
+
ctx.eventDb.logWhatsAppMessage({
|
|
127
|
+
sessionId: payload.sessionId,
|
|
128
|
+
messageId: payload.messageId,
|
|
129
|
+
chatId: payload.chatId,
|
|
130
|
+
isGroup: payload.isGroup,
|
|
131
|
+
groupName: payload.groupName,
|
|
132
|
+
fromJid: payload.from,
|
|
133
|
+
fromName: payload.fromName,
|
|
134
|
+
direction: payload.direction,
|
|
135
|
+
body: payload.body,
|
|
136
|
+
messageType: payload.mediaType ?? 'text',
|
|
137
|
+
mediaMimetype: payload.mediaMimetype,
|
|
138
|
+
mediaSize: payload.mediaSize,
|
|
139
|
+
mediaFilename: payload.mediaFilename,
|
|
140
|
+
mediaPath: payload.mediaPath,
|
|
141
|
+
audioTranscription: payload.audioTranscription,
|
|
142
|
+
rawEvent: payload,
|
|
143
|
+
timestamp: payload.timestamp,
|
|
144
|
+
receivedAt: Date.now(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
ctx.log.warn(`WhatsApp logWhatsAppMessage failed: ${err}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
124
151
|
function extractMessageId(data) {
|
|
125
152
|
if (!data || typeof data !== 'object')
|
|
126
153
|
return undefined;
|
|
@@ -191,6 +218,7 @@ export function createWhatsAppTriggerHandler(ctx) {
|
|
|
191
218
|
const needsMediaDownload = !!payload.mediaType &&
|
|
192
219
|
!!payload.messageId;
|
|
193
220
|
if (!needsContactEnrich && !needsGroupEnrich && !needsMediaDownload) {
|
|
221
|
+
persist(payload);
|
|
194
222
|
ctx.broadcast({ type: 'whatsapp_message', payload });
|
|
195
223
|
notifyTriggerSubscribers(payload);
|
|
196
224
|
return;
|
|
@@ -292,6 +320,7 @@ export function createWhatsAppTriggerHandler(ctx) {
|
|
|
292
320
|
})()
|
|
293
321
|
: Promise.resolve();
|
|
294
322
|
void Promise.all([contactPromise, groupPromise, mediaPromise]).then(() => {
|
|
323
|
+
persist(payload);
|
|
295
324
|
ctx.broadcast({ type: 'whatsapp_message', payload });
|
|
296
325
|
notifyTriggerSubscribers(payload);
|
|
297
326
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Prompt Routes
|
|
3
|
+
*
|
|
4
|
+
* MCP perm-prompt bridge endpoints:
|
|
5
|
+
* - POST /api/agent-prompt MCP server enqueues a request; blocks until UI responds
|
|
6
|
+
* - POST /api/agent-prompt/:id/respond UI submits the user's answer
|
|
7
|
+
* - GET /api/agent-prompt/pending list pending prompts (debug / reconnect)
|
|
8
|
+
*/
|
|
9
|
+
import { Router } from 'express';
|
|
10
|
+
import * as agentPromptService from '../services/agent-prompt-service.js';
|
|
11
|
+
import { createLogger } from '../utils/logger.js';
|
|
12
|
+
const log = createLogger('AgentPromptRoutes');
|
|
13
|
+
const router = Router();
|
|
14
|
+
router.post('/agent-prompt', async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const { id, agentId, tool, input } = req.body || {};
|
|
17
|
+
if (!id || !agentId || !tool) {
|
|
18
|
+
res.status(400).json({ error: 'Missing required fields: id, agentId, tool' });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (tool !== 'AskUserQuestion' && tool !== 'ExitPlanMode') {
|
|
22
|
+
res.status(400).json({ error: `Unsupported tool: ${tool}` });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const response = await agentPromptService.createPrompt({
|
|
26
|
+
id, agentId, tool,
|
|
27
|
+
input: input || {},
|
|
28
|
+
});
|
|
29
|
+
res.json(response);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
log.error('Prompt request error:', err);
|
|
33
|
+
res.status(500).json({ requestId: req.body?.id, approved: false, reason: `Server error: ${err?.message ?? err}` });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
router.post('/agent-prompt/:id/respond', (req, res) => {
|
|
37
|
+
const { id } = req.params;
|
|
38
|
+
const { approved, answers, reason } = req.body || {};
|
|
39
|
+
if (typeof approved !== 'boolean') {
|
|
40
|
+
res.status(400).json({ error: 'approved (boolean) is required' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const ok = agentPromptService.respondToPrompt({ requestId: id, approved, answers, reason });
|
|
44
|
+
if (!ok) {
|
|
45
|
+
res.status(404).json({ error: 'No pending prompt with that id' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
res.json({ ok: true });
|
|
49
|
+
});
|
|
50
|
+
router.get('/agent-prompt/pending', (req, res) => {
|
|
51
|
+
const agentId = typeof req.query.agentId === 'string' ? req.query.agentId : undefined;
|
|
52
|
+
const prompts = agentId
|
|
53
|
+
? agentPromptService.getPendingPromptsForAgent(agentId)
|
|
54
|
+
: agentPromptService.getPendingPrompts();
|
|
55
|
+
res.json(prompts);
|
|
56
|
+
});
|
|
57
|
+
export default router;
|
|
@@ -6,6 +6,7 @@ import { Router, raw } from 'express';
|
|
|
6
6
|
import agentsRouter, { setBroadcast as setAgentsBroadcast } from './agents.js';
|
|
7
7
|
import filesRouter from './files.js';
|
|
8
8
|
import permissionsRouter from './permissions.js';
|
|
9
|
+
import agentPromptRouter from './agent-prompt.js';
|
|
9
10
|
import notificationsRouter, { setBroadcast as setNotificationBroadcast } from './notifications.js';
|
|
10
11
|
import execRouter, { setBroadcast as setExecBroadcast } from './exec.js';
|
|
11
12
|
import focusAgentRouter, { setBroadcast as setFocusAgentBroadcast } from './focus-agent.js';
|
|
@@ -25,6 +26,8 @@ import workflowRouter from './workflow-routes.js';
|
|
|
25
26
|
import sessionsRouter from './sessions.js';
|
|
26
27
|
import databaseRouter from './database.js';
|
|
27
28
|
import buildingsRouter, { setBroadcast as setBuildingsBroadcast } from './buildings.js';
|
|
29
|
+
import skillsRouter, { setBroadcast as setSkillsBroadcast } from './skills.js';
|
|
30
|
+
import systemRouter from './system.js';
|
|
28
31
|
import { getPlugins } from '../integrations/integration-registry.js';
|
|
29
32
|
const router = Router();
|
|
30
33
|
// Health check
|
|
@@ -52,6 +55,8 @@ router.use('/workflows', workflowRouter);
|
|
|
52
55
|
router.use('/sessions', sessionsRouter);
|
|
53
56
|
router.use('/database', databaseRouter);
|
|
54
57
|
router.use('/buildings', buildingsRouter);
|
|
58
|
+
router.use('/skills', skillsRouter);
|
|
59
|
+
router.use('/system', systemRouter);
|
|
55
60
|
// Integration plugin routes (e.g. /api/slack/*, /api/documents/*, /api/jira/*)
|
|
56
61
|
// Uses lazy lookup so plugins can be registered after route setup
|
|
57
62
|
router.use((req, res, next) => {
|
|
@@ -69,6 +74,8 @@ router.use((req, res, next) => {
|
|
|
69
74
|
router.use('/config', raw({ type: 'application/zip', limit: '100mb' }), configRouter);
|
|
70
75
|
// Permission routes are mounted at root level since they're called as /api/permission-request
|
|
71
76
|
router.use('/', permissionsRouter);
|
|
77
|
+
// Agent prompt routes — root-level: /api/agent-prompt and /api/agent-prompt/:id/respond
|
|
78
|
+
router.use('/', agentPromptRouter);
|
|
72
79
|
// Export the broadcast setters for WebSocket handler to use
|
|
73
|
-
export { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast };
|
|
80
|
+
export { setNotificationBroadcast, setExecBroadcast, setFocusAgentBroadcast, setAgentsBroadcast, setTriggerBroadcast, setBuildingsBroadcast, setSkillsBroadcast };
|
|
74
81
|
export default router;
|