fathom-mcp 0.4.12 → 0.5.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/package.json +3 -2
- package/src/index.js +304 -11
- package/src/server-client.js +82 -0
- package/src/ws-connection.js +250 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fathom-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server for Fathom — vault operations, search, rooms, and cross-workspace communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
26
|
-
"js-yaml": "^4.1.0"
|
|
26
|
+
"js-yaml": "^4.1.0",
|
|
27
|
+
"ws": "^8.18.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@eslint/js": "^9.39.3",
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
|
|
21
21
|
import { resolveConfig } from "./config.js";
|
|
22
22
|
import { createClient } from "./server-client.js";
|
|
23
|
+
import { createWSConnection } from "./ws-connection.js";
|
|
23
24
|
import {
|
|
24
25
|
handleVaultWrite,
|
|
25
26
|
handleVaultAppend,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
|
|
33
34
|
const config = resolveConfig();
|
|
34
35
|
const client = createClient(config);
|
|
36
|
+
let wsConn = null;
|
|
35
37
|
|
|
36
38
|
// --- Tool definitions --------------------------------------------------------
|
|
37
39
|
|
|
@@ -227,9 +229,8 @@ const tools = [
|
|
|
227
229
|
"to the latest message. Default: 60 minutes before the latest message. Use start to look " +
|
|
228
230
|
"further back. Example: minutes=15, start=120 returns 15 minutes of conversation starting " +
|
|
229
231
|
"2 hours before the latest message. Response includes window metadata with has_older flag " +
|
|
230
|
-
"for pseudo-pagination.
|
|
231
|
-
"
|
|
232
|
-
"For regular rooms, automatically marks as read unless mark_read=false.",
|
|
232
|
+
"for pseudo-pagination. All rooms are persistent — messages are never deleted on read. " +
|
|
233
|
+
"Automatically marks the room as read unless mark_read=false.",
|
|
233
234
|
inputSchema: {
|
|
234
235
|
type: "object",
|
|
235
236
|
properties: {
|
|
@@ -246,8 +247,8 @@ const tools = [
|
|
|
246
247
|
description:
|
|
247
248
|
"List all rooms with activity summary — message count, last activity time, last sender, " +
|
|
248
249
|
"description, and per-room unread_count for this workspace. Use to discover active rooms " +
|
|
249
|
-
"and see which have new messages.
|
|
250
|
-
"
|
|
250
|
+
"and see which have new messages. DM rooms (dm:a+b) are filtered by workspace param — " +
|
|
251
|
+
"only participants see them. Mention rooms (mentions:{workspace}) visible only to the target workspace.",
|
|
251
252
|
inputSchema: {
|
|
252
253
|
type: "object",
|
|
253
254
|
properties: {},
|
|
@@ -282,10 +283,9 @@ const tools = [
|
|
|
282
283
|
{
|
|
283
284
|
name: "fathom_send",
|
|
284
285
|
description:
|
|
285
|
-
"Send a message to another workspace's agent instance — stored in
|
|
286
|
-
"
|
|
287
|
-
"
|
|
288
|
-
"Message format: 'Message from workspace ({from}): {message}'",
|
|
286
|
+
"Send a message to another workspace's agent instance — stored in a shared dm:a+b room " +
|
|
287
|
+
"visible to both participants. Use fathom_workspaces first to discover valid targets. " +
|
|
288
|
+
"DMs are persistent and appear in both participants' room lists via fathom_room_list.",
|
|
289
289
|
inputSchema: {
|
|
290
290
|
type: "object",
|
|
291
291
|
properties: {
|
|
@@ -344,6 +344,99 @@ const tools = [
|
|
|
344
344
|
required: [],
|
|
345
345
|
},
|
|
346
346
|
},
|
|
347
|
+
{
|
|
348
|
+
name: "fathom_routine_list",
|
|
349
|
+
description:
|
|
350
|
+
"List all ping routines for a workspace with their status, intervals, " +
|
|
351
|
+
"enabled state, and next fire time. Use this to see what routines exist " +
|
|
352
|
+
"before updating or deleting them.",
|
|
353
|
+
inputSchema: {
|
|
354
|
+
type: "object",
|
|
355
|
+
properties: {
|
|
356
|
+
workspace: WORKSPACE_PROP,
|
|
357
|
+
},
|
|
358
|
+
required: [],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: "fathom_routine_update",
|
|
363
|
+
description:
|
|
364
|
+
"Update an existing ping routine. Only provided fields are changed — " +
|
|
365
|
+
"omitted fields keep their current values. Use fathom_routine_list first " +
|
|
366
|
+
"to find the routine_id.",
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: "object",
|
|
369
|
+
properties: {
|
|
370
|
+
routine_id: { type: "string", description: "The routine ID to update. Use fathom_routine_list to find IDs." },
|
|
371
|
+
name: { type: "string", description: "New routine name." },
|
|
372
|
+
enabled: { type: "boolean", description: "Enable or disable the routine." },
|
|
373
|
+
interval_minutes: { type: "integer", description: "New interval in minutes.", minimum: 1 },
|
|
374
|
+
single_fire: { type: "boolean", description: "Auto-disable after firing once." },
|
|
375
|
+
workspace: WORKSPACE_PROP,
|
|
376
|
+
context_sources: {
|
|
377
|
+
type: "object",
|
|
378
|
+
description: "What to inject on each ping.",
|
|
379
|
+
properties: {
|
|
380
|
+
time: { type: "boolean", description: "Include current time/date. Default: true." },
|
|
381
|
+
scripts: {
|
|
382
|
+
type: "array",
|
|
383
|
+
description: "Shell commands to run and inject output.",
|
|
384
|
+
items: {
|
|
385
|
+
type: "object",
|
|
386
|
+
properties: {
|
|
387
|
+
label: { type: "string" },
|
|
388
|
+
command: { type: "string" },
|
|
389
|
+
enabled: { type: "boolean" },
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
texts: {
|
|
394
|
+
type: "array",
|
|
395
|
+
description: "Static text blocks to inject.",
|
|
396
|
+
items: {
|
|
397
|
+
type: "object",
|
|
398
|
+
properties: {
|
|
399
|
+
label: { type: "string" },
|
|
400
|
+
content: { type: "string" },
|
|
401
|
+
enabled: { type: "boolean" },
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
required: ["routine_id"],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "fathom_routine_delete",
|
|
413
|
+
description:
|
|
414
|
+
"Delete a ping routine permanently. Use fathom_routine_list first to find " +
|
|
415
|
+
"the routine_id. This cannot be undone — create a new routine if needed.",
|
|
416
|
+
inputSchema: {
|
|
417
|
+
type: "object",
|
|
418
|
+
properties: {
|
|
419
|
+
routine_id: { type: "string", description: "The routine ID to delete." },
|
|
420
|
+
workspace: WORKSPACE_PROP,
|
|
421
|
+
},
|
|
422
|
+
required: ["routine_id"],
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: "fathom_routine_fire",
|
|
427
|
+
description:
|
|
428
|
+
"Fire a ping routine immediately, regardless of its schedule. Non-blocking — " +
|
|
429
|
+
"returns immediately while the routine fires in the background. The routine's " +
|
|
430
|
+
"next scheduled fire time is not affected.",
|
|
431
|
+
inputSchema: {
|
|
432
|
+
type: "object",
|
|
433
|
+
properties: {
|
|
434
|
+
routine_id: { type: "string", description: "The routine ID to fire." },
|
|
435
|
+
workspace: WORKSPACE_PROP,
|
|
436
|
+
},
|
|
437
|
+
required: ["routine_id"],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
347
440
|
];
|
|
348
441
|
|
|
349
442
|
// --- Vault routing by mode ---------------------------------------------------
|
|
@@ -381,6 +474,77 @@ function resolveVault(args) {
|
|
|
381
474
|
}
|
|
382
475
|
}
|
|
383
476
|
|
|
477
|
+
// --- Telegram tools (primary agent only) -------------------------------------
|
|
478
|
+
|
|
479
|
+
const telegramTools = [
|
|
480
|
+
{
|
|
481
|
+
name: "fathom_telegram_contacts",
|
|
482
|
+
description:
|
|
483
|
+
"List Telegram contacts who have messaged. Returns name, username, chat_id, " +
|
|
484
|
+
"last_message time, and unread count.",
|
|
485
|
+
inputSchema: { type: "object", properties: {} },
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: "fathom_telegram_read",
|
|
489
|
+
description:
|
|
490
|
+
"Read recent Telegram messages from a contact. Same windowing as fathom_room_read — " +
|
|
491
|
+
"anchored to latest message, default 60 minutes. Use start to look further back.",
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
contact: { type: "string", description: "Contact name, @username, or chat_id" },
|
|
496
|
+
minutes: { type: "number", description: "Window duration in minutes. Default: 60." },
|
|
497
|
+
start: { type: "number", description: "Offset in minutes from latest message. Default: 0." },
|
|
498
|
+
mark_read: { type: "boolean", description: "Mark messages as read. Default: true." },
|
|
499
|
+
},
|
|
500
|
+
required: ["contact"],
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "fathom_telegram_send",
|
|
505
|
+
description:
|
|
506
|
+
"Send a Telegram message to a contact via the persistent Telethon client.",
|
|
507
|
+
inputSchema: {
|
|
508
|
+
type: "object",
|
|
509
|
+
properties: {
|
|
510
|
+
contact: { type: "string", description: "Contact name, @username, or chat_id" },
|
|
511
|
+
message: { type: "string", description: "Message text to send" },
|
|
512
|
+
},
|
|
513
|
+
required: ["contact", "message"],
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
name: "fathom_telegram_image",
|
|
518
|
+
description:
|
|
519
|
+
"Read a Telegram message's attached image and return it as base64 so Claude can perceive it. " +
|
|
520
|
+
"Use after fathom_telegram_read shows a message has media: true. " +
|
|
521
|
+
"Supports jpg, jpeg, png, gif, webp. Max 5MB.",
|
|
522
|
+
inputSchema: {
|
|
523
|
+
type: "object",
|
|
524
|
+
properties: {
|
|
525
|
+
message_id: { type: "number", description: "The message ID from fathom_telegram_read results" },
|
|
526
|
+
},
|
|
527
|
+
required: ["message_id"],
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "fathom_telegram_send_image",
|
|
532
|
+
description:
|
|
533
|
+
"Send an image to a Telegram contact via the persistent Telethon client. " +
|
|
534
|
+
"Provide an absolute file path to a local image. Optionally include a caption. " +
|
|
535
|
+
"Contact can be a name, @username, or chat_id number.",
|
|
536
|
+
inputSchema: {
|
|
537
|
+
type: "object",
|
|
538
|
+
properties: {
|
|
539
|
+
contact: { type: "string", description: "Contact name, @username, or chat_id" },
|
|
540
|
+
file_path: { type: "string", description: "Absolute path to the image file to send" },
|
|
541
|
+
caption: { type: "string", description: "Optional caption text for the image (max 1024 chars)" },
|
|
542
|
+
},
|
|
543
|
+
required: ["contact", "file_path"],
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
|
|
384
548
|
// --- Server setup & dispatch -------------------------------------------------
|
|
385
549
|
|
|
386
550
|
const server = new Server(
|
|
@@ -388,7 +552,19 @@ const server = new Server(
|
|
|
388
552
|
{ capabilities: { tools: {} } },
|
|
389
553
|
);
|
|
390
554
|
|
|
391
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
555
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
556
|
+
// Telegram tools only visible to the primary (default) workspace
|
|
557
|
+
let isPrimary = false;
|
|
558
|
+
try {
|
|
559
|
+
const settings = await client.getSettings();
|
|
560
|
+
const primaryAgent = settings.default_workspace;
|
|
561
|
+
isPrimary = config.workspace === primaryAgent;
|
|
562
|
+
} catch {
|
|
563
|
+
// If settings unavailable, hide telegram tools
|
|
564
|
+
}
|
|
565
|
+
const allTools = [...tools, ...(isPrimary ? telegramTools : [])];
|
|
566
|
+
return { tools: allTools };
|
|
567
|
+
});
|
|
392
568
|
|
|
393
569
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
394
570
|
const { name, arguments: args } = request.params;
|
|
@@ -543,6 +719,118 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
543
719
|
result = await client.createRoutine(routineParams, args.workspace || config.workspace);
|
|
544
720
|
break;
|
|
545
721
|
}
|
|
722
|
+
case "fathom_routine_list":
|
|
723
|
+
result = await client.listRoutines(args.workspace || config.workspace);
|
|
724
|
+
break;
|
|
725
|
+
case "fathom_routine_update": {
|
|
726
|
+
const updateParams = {};
|
|
727
|
+
if (args.name != null) updateParams.name = args.name;
|
|
728
|
+
if (args.enabled != null) updateParams.enabled = args.enabled;
|
|
729
|
+
if (args.interval_minutes != null) updateParams.intervalMinutes = args.interval_minutes;
|
|
730
|
+
if (args.single_fire != null) updateParams.singleFire = args.single_fire;
|
|
731
|
+
if (args.context_sources != null) updateParams.contextSources = args.context_sources;
|
|
732
|
+
result = await client.updateRoutine(args.routine_id, updateParams, args.workspace || config.workspace);
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
case "fathom_routine_delete":
|
|
736
|
+
result = await client.deleteRoutine(args.routine_id, args.workspace || config.workspace);
|
|
737
|
+
break;
|
|
738
|
+
case "fathom_routine_fire":
|
|
739
|
+
result = await client.fireRoutine(args.routine_id, args.workspace || config.workspace);
|
|
740
|
+
break;
|
|
741
|
+
// --- Telegram ---
|
|
742
|
+
case "fathom_telegram_contacts":
|
|
743
|
+
result = await client.telegramContacts(config.workspace);
|
|
744
|
+
break;
|
|
745
|
+
case "fathom_telegram_read": {
|
|
746
|
+
// Resolve contact name to chat_id via contacts list
|
|
747
|
+
const contacts = await client.telegramContacts(config.workspace);
|
|
748
|
+
const contactList = contacts?.contacts || [];
|
|
749
|
+
const contactArg = (args.contact || "").trim();
|
|
750
|
+
let chatId = parseInt(contactArg, 10);
|
|
751
|
+
if (isNaN(chatId)) {
|
|
752
|
+
const lower = contactArg.toLowerCase().replace(/^@/, "");
|
|
753
|
+
const match = contactList.find(c =>
|
|
754
|
+
(c.username || "").toLowerCase() === lower ||
|
|
755
|
+
(c.first_name || "").toLowerCase() === lower ||
|
|
756
|
+
(c.first_name || "").toLowerCase().includes(lower)
|
|
757
|
+
);
|
|
758
|
+
chatId = match ? match.chat_id : null;
|
|
759
|
+
}
|
|
760
|
+
if (!chatId) {
|
|
761
|
+
result = { error: `Contact not found: ${contactArg}. Use fathom_telegram_contacts to list known contacts.` };
|
|
762
|
+
} else {
|
|
763
|
+
result = await client.telegramRead(
|
|
764
|
+
chatId, args.minutes, args.start,
|
|
765
|
+
args.mark_read !== false ? config.workspace : undefined,
|
|
766
|
+
args.mark_read,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case "fathom_telegram_send": {
|
|
772
|
+
const sendContacts = await client.telegramContacts(config.workspace);
|
|
773
|
+
const sendList = sendContacts?.contacts || [];
|
|
774
|
+
const sendArg = (args.contact || "").trim();
|
|
775
|
+
let sendChatId = parseInt(sendArg, 10);
|
|
776
|
+
if (isNaN(sendChatId)) {
|
|
777
|
+
const lower = sendArg.toLowerCase().replace(/^@/, "");
|
|
778
|
+
const match = sendList.find(c =>
|
|
779
|
+
(c.username || "").toLowerCase() === lower ||
|
|
780
|
+
(c.first_name || "").toLowerCase() === lower ||
|
|
781
|
+
(c.first_name || "").toLowerCase().includes(lower)
|
|
782
|
+
);
|
|
783
|
+
sendChatId = match ? match.chat_id : null;
|
|
784
|
+
}
|
|
785
|
+
if (!sendChatId) {
|
|
786
|
+
result = { error: `Contact not found: ${sendArg}. Use fathom_telegram_contacts to list known contacts.` };
|
|
787
|
+
} else {
|
|
788
|
+
result = await client.telegramSend(sendChatId, args.message);
|
|
789
|
+
}
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
case "fathom_telegram_image": {
|
|
793
|
+
const msgId = args.message_id;
|
|
794
|
+
if (!msgId) {
|
|
795
|
+
result = { error: "message_id is required" };
|
|
796
|
+
} else {
|
|
797
|
+
// Check local WebSocket cache first (avoids HTTP round-trip for pushed images)
|
|
798
|
+
const cached = wsConn?.getCachedImage(msgId);
|
|
799
|
+
if (cached) {
|
|
800
|
+
result = { _image: true, data: cached.data, mimeType: cached.mimeType };
|
|
801
|
+
} else {
|
|
802
|
+
result = await client.telegramImage(msgId);
|
|
803
|
+
if (result?.data && result?.mimeType) {
|
|
804
|
+
result = { _image: true, data: result.data, mimeType: result.mimeType };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
case "fathom_telegram_send_image": {
|
|
811
|
+
const imgContactArg = args.contact;
|
|
812
|
+
if (!imgContactArg) { result = { error: "contact is required" }; break; }
|
|
813
|
+
if (!args.file_path) { result = { error: "file_path is required" }; break; }
|
|
814
|
+
|
|
815
|
+
const imgContacts = await client.telegramContacts(config.workspace);
|
|
816
|
+
const imgList = imgContacts?.contacts || [];
|
|
817
|
+
let imgChatId = parseInt(imgContactArg, 10);
|
|
818
|
+
if (isNaN(imgChatId)) {
|
|
819
|
+
const lower = imgContactArg.toLowerCase().replace(/^@/, "");
|
|
820
|
+
const match = imgList.find(c =>
|
|
821
|
+
(c.username || "").toLowerCase() === lower ||
|
|
822
|
+
(c.first_name || "").toLowerCase() === lower ||
|
|
823
|
+
(c.first_name || "").toLowerCase().includes(lower)
|
|
824
|
+
);
|
|
825
|
+
imgChatId = match ? match.chat_id : null;
|
|
826
|
+
}
|
|
827
|
+
if (!imgChatId) {
|
|
828
|
+
result = { error: `Contact not found: ${imgContactArg}. Use fathom_telegram_contacts to list known contacts.` };
|
|
829
|
+
} else {
|
|
830
|
+
result = await client.telegramSendImage(imgChatId, args.file_path, args.caption);
|
|
831
|
+
}
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
546
834
|
default:
|
|
547
835
|
result = { error: `Unknown tool: ${name}` };
|
|
548
836
|
}
|
|
@@ -628,7 +916,12 @@ async function main() {
|
|
|
628
916
|
// Startup sync for synced mode (fire-and-forget)
|
|
629
917
|
startupSync().catch(() => {});
|
|
630
918
|
|
|
631
|
-
//
|
|
919
|
+
// WebSocket push channel — receives server-pushed messages
|
|
920
|
+
if (config.server && config.workspace && config.apiKey) {
|
|
921
|
+
wsConn = createWSConnection(config);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Heartbeat — report liveness to server every 30s (kept for backwards compat)
|
|
632
925
|
if (config.server && config.workspace) {
|
|
633
926
|
const beat = () =>
|
|
634
927
|
client
|
package/src/server-client.js
CHANGED
|
@@ -205,6 +205,37 @@ export function createClient(config) {
|
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
async function listRoutines(ws) {
|
|
209
|
+
return request("GET", "/api/activation/ping/routines", {
|
|
210
|
+
params: { workspace: ws },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function updateRoutine(routineId, params, ws) {
|
|
215
|
+
const body = {};
|
|
216
|
+
if (params.name != null) body.name = params.name;
|
|
217
|
+
if (params.enabled != null) body.enabled = params.enabled;
|
|
218
|
+
if (params.intervalMinutes != null) body.intervalMinutes = params.intervalMinutes;
|
|
219
|
+
if (params.singleFire != null) body.singleFire = params.singleFire;
|
|
220
|
+
if (params.contextSources != null) body.contextSources = params.contextSources;
|
|
221
|
+
return request("POST", `/api/activation/ping/routines/${encodeURIComponent(routineId)}`, {
|
|
222
|
+
params: { workspace: ws },
|
|
223
|
+
body,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function deleteRoutine(routineId, ws) {
|
|
228
|
+
return request("DELETE", `/api/activation/ping/routines/${encodeURIComponent(routineId)}`, {
|
|
229
|
+
params: { workspace: ws },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function fireRoutine(routineId, ws) {
|
|
234
|
+
return request("POST", `/api/activation/ping/routines/${encodeURIComponent(routineId)}/now`, {
|
|
235
|
+
params: { workspace: ws },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
208
239
|
// --- Heartbeat -------------------------------------------------------------
|
|
209
240
|
|
|
210
241
|
async function heartbeat(ws, agent, vaultMode) {
|
|
@@ -214,6 +245,46 @@ export function createClient(config) {
|
|
|
214
245
|
return request("POST", `/api/workspaces/${encodeURIComponent(ws)}/heartbeat`, { body });
|
|
215
246
|
}
|
|
216
247
|
|
|
248
|
+
// --- Telegram --------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
async function telegramContacts(ws) {
|
|
251
|
+
return request("GET", "/api/telegram/contacts", {
|
|
252
|
+
params: { workspace: ws },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function telegramRead(chatId, minutes, start, ws, markRead) {
|
|
257
|
+
return request("GET", `/api/telegram/messages/${chatId}`, {
|
|
258
|
+
params: { minutes, start, workspace: ws, mark_read: markRead },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function telegramSend(chatId, message) {
|
|
263
|
+
return request("POST", `/api/telegram/send/${chatId}`, {
|
|
264
|
+
body: { message },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function telegramImage(messageId) {
|
|
269
|
+
return request("GET", `/api/telegram/image/${messageId}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function telegramSendImage(chatId, filePath, caption) {
|
|
273
|
+
return request("POST", `/api/telegram/send-image/${chatId}`, {
|
|
274
|
+
body: { file_path: filePath, caption: caption || "" },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function telegramStatus() {
|
|
279
|
+
return request("GET", "/api/telegram/status");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- Settings --------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
async function getSettings() {
|
|
285
|
+
return request("GET", "/api/settings");
|
|
286
|
+
}
|
|
287
|
+
|
|
217
288
|
// --- Auth ------------------------------------------------------------------
|
|
218
289
|
|
|
219
290
|
async function getApiKey() {
|
|
@@ -254,7 +325,18 @@ export function createClient(config) {
|
|
|
254
325
|
pushFile,
|
|
255
326
|
syncManifest,
|
|
256
327
|
createRoutine,
|
|
328
|
+
listRoutines,
|
|
329
|
+
updateRoutine,
|
|
330
|
+
deleteRoutine,
|
|
331
|
+
fireRoutine,
|
|
257
332
|
heartbeat,
|
|
333
|
+
telegramContacts,
|
|
334
|
+
telegramRead,
|
|
335
|
+
telegramSend,
|
|
336
|
+
telegramImage,
|
|
337
|
+
telegramSendImage,
|
|
338
|
+
telegramStatus,
|
|
339
|
+
getSettings,
|
|
258
340
|
getApiKey,
|
|
259
341
|
healthCheck,
|
|
260
342
|
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket push channel — receives server-pushed messages and handles them locally.
|
|
3
|
+
*
|
|
4
|
+
* Connects to fathom-server's /ws/agent/{workspace} endpoint. Receives:
|
|
5
|
+
* - inject / ping_fire → tmux send-keys into local pane
|
|
6
|
+
* - image → cache base64 data to .fathom/telegram-cache/
|
|
7
|
+
* - ping → respond with pong
|
|
8
|
+
*
|
|
9
|
+
* Auto-reconnects with exponential backoff (1s → 60s cap).
|
|
10
|
+
* HTTP heartbeat still runs separately for backwards compat with old servers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import WebSocket from "ws";
|
|
18
|
+
|
|
19
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
20
|
+
const INITIAL_RECONNECT_MS = 1_000;
|
|
21
|
+
const MAX_RECONNECT_MS = 60_000;
|
|
22
|
+
const IMAGE_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} config — resolved config from config.js
|
|
26
|
+
* @returns {{ getCachedImage: (messageId: number) => {data: string, mimeType: string} | null, close: () => void }}
|
|
27
|
+
*/
|
|
28
|
+
export function createWSConnection(config) {
|
|
29
|
+
const workspace = config.workspace;
|
|
30
|
+
const agent = config.agents?.[0] || "unknown";
|
|
31
|
+
const vaultMode = config.vaultMode || "local";
|
|
32
|
+
|
|
33
|
+
// Derive WS URL from HTTP server URL
|
|
34
|
+
const serverUrl = config.server || "http://localhost:4243";
|
|
35
|
+
const wsUrl = serverUrl
|
|
36
|
+
.replace(/^http:/, "ws:")
|
|
37
|
+
.replace(/^https:/, "wss:")
|
|
38
|
+
+ `/ws/agent/${encodeURIComponent(workspace)}`
|
|
39
|
+
+ `?token=${encodeURIComponent(config.apiKey || "")}`;
|
|
40
|
+
|
|
41
|
+
// Image cache directory
|
|
42
|
+
const cacheDir = path.join(os.homedir(), ".fathom", "telegram-cache");
|
|
43
|
+
|
|
44
|
+
let ws = null;
|
|
45
|
+
let reconnectDelay = INITIAL_RECONNECT_MS;
|
|
46
|
+
let keepaliveTimer = null;
|
|
47
|
+
let closed = false;
|
|
48
|
+
|
|
49
|
+
// Clean up old cached images on startup
|
|
50
|
+
cleanupImageCache();
|
|
51
|
+
|
|
52
|
+
connect();
|
|
53
|
+
|
|
54
|
+
function connect() {
|
|
55
|
+
if (closed) return;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
ws = new WebSocket(wsUrl);
|
|
59
|
+
} catch {
|
|
60
|
+
scheduleReconnect();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ws.on("open", () => {
|
|
65
|
+
reconnectDelay = INITIAL_RECONNECT_MS;
|
|
66
|
+
|
|
67
|
+
// Send hello handshake
|
|
68
|
+
ws.send(JSON.stringify({
|
|
69
|
+
type: "hello",
|
|
70
|
+
agent,
|
|
71
|
+
vault_mode: vaultMode,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// Start keepalive pong timer
|
|
75
|
+
startKeepalive();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
ws.on("message", (raw) => {
|
|
79
|
+
let msg;
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(raw.toString());
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
switch (msg.type) {
|
|
87
|
+
case "welcome":
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case "inject":
|
|
91
|
+
case "ping_fire":
|
|
92
|
+
injectToTmux(msg.text || "");
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case "image":
|
|
96
|
+
cacheImage(msg);
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case "ping":
|
|
100
|
+
safeSend({ type: "pong" });
|
|
101
|
+
break;
|
|
102
|
+
|
|
103
|
+
case "error":
|
|
104
|
+
// Server rejected us — don't reconnect immediately
|
|
105
|
+
reconnectDelay = MAX_RECONNECT_MS;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
ws.on("close", () => {
|
|
111
|
+
stopKeepalive();
|
|
112
|
+
if (!closed) scheduleReconnect();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
ws.on("error", () => {
|
|
116
|
+
// Error always followed by close event — reconnect handled there
|
|
117
|
+
stopKeepalive();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function safeSend(obj) {
|
|
122
|
+
try {
|
|
123
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
124
|
+
ws.send(JSON.stringify(obj));
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Swallow — close event will trigger reconnect
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function startKeepalive() {
|
|
132
|
+
stopKeepalive();
|
|
133
|
+
keepaliveTimer = setInterval(() => {
|
|
134
|
+
safeSend({ type: "pong" });
|
|
135
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stopKeepalive() {
|
|
139
|
+
if (keepaliveTimer) {
|
|
140
|
+
clearInterval(keepaliveTimer);
|
|
141
|
+
keepaliveTimer = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function scheduleReconnect() {
|
|
146
|
+
if (closed) return;
|
|
147
|
+
setTimeout(connect, reconnectDelay);
|
|
148
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_MS);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── tmux injection ──────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function injectToTmux(text) {
|
|
154
|
+
if (!text) return;
|
|
155
|
+
const pane = resolvePaneTarget();
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Send the text literally, then press Enter
|
|
159
|
+
execSync(`tmux send-keys -t ${shellEscape(pane)} -l ${shellEscape(text)}`, {
|
|
160
|
+
timeout: 5000,
|
|
161
|
+
stdio: "ignore",
|
|
162
|
+
});
|
|
163
|
+
execSync(`tmux send-keys -t ${shellEscape(pane)} Enter`, {
|
|
164
|
+
timeout: 5000,
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
// tmux not available or pane not found — non-fatal
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolvePaneTarget() {
|
|
173
|
+
// Check for explicit pane ID file
|
|
174
|
+
const paneIdFile = path.join(os.homedir(), ".config", "fathom", `${workspace}-pane-id`);
|
|
175
|
+
try {
|
|
176
|
+
const paneId = fs.readFileSync(paneIdFile, "utf-8").trim();
|
|
177
|
+
if (paneId) return paneId;
|
|
178
|
+
} catch {
|
|
179
|
+
// Fall through to default
|
|
180
|
+
}
|
|
181
|
+
return `${workspace}_fathom-session`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function shellEscape(s) {
|
|
185
|
+
// Escape for shell — wrap in single quotes, escape internal single quotes
|
|
186
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Image cache ─────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function cacheImage(msg) {
|
|
192
|
+
if (!msg.message_id || !msg.data) return;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
196
|
+
const ext = (msg.filename || "").split(".").pop() || "jpg";
|
|
197
|
+
const filename = `${msg.message_id}.${ext}`;
|
|
198
|
+
const filePath = path.join(cacheDir, filename);
|
|
199
|
+
fs.writeFileSync(filePath, Buffer.from(msg.data, "base64"));
|
|
200
|
+
} catch {
|
|
201
|
+
// Cache write failure is non-fatal
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getCachedImage(messageId) {
|
|
206
|
+
try {
|
|
207
|
+
const dir = fs.readdirSync(cacheDir);
|
|
208
|
+
const match = dir.find(f => f.startsWith(`${messageId}.`));
|
|
209
|
+
if (!match) return null;
|
|
210
|
+
|
|
211
|
+
const filePath = path.join(cacheDir, match);
|
|
212
|
+
const data = fs.readFileSync(filePath);
|
|
213
|
+
const ext = path.extname(match).slice(1).toLowerCase();
|
|
214
|
+
const mimeMap = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp" };
|
|
215
|
+
return {
|
|
216
|
+
data: data.toString("base64"),
|
|
217
|
+
mimeType: mimeMap[ext] || "image/jpeg",
|
|
218
|
+
};
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cleanupImageCache() {
|
|
225
|
+
try {
|
|
226
|
+
if (!fs.existsSync(cacheDir)) return;
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
for (const file of fs.readdirSync(cacheDir)) {
|
|
229
|
+
const filePath = path.join(cacheDir, file);
|
|
230
|
+
const stat = fs.statSync(filePath);
|
|
231
|
+
if (now - stat.mtimeMs > IMAGE_CACHE_MAX_AGE_MS) {
|
|
232
|
+
fs.unlinkSync(filePath);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Cleanup failure is non-fatal
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function close() {
|
|
241
|
+
closed = true;
|
|
242
|
+
stopKeepalive();
|
|
243
|
+
if (ws) {
|
|
244
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
245
|
+
ws = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { getCachedImage, close };
|
|
250
|
+
}
|