@wahooks/channel 1.1.0 → 2.0.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/index.js +213 -4
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -158,6 +158,106 @@ async function resolveConnection() {
|
|
|
158
158
|
}
|
|
159
159
|
// ─── State ──────────────────────────────────────────────────────────────
|
|
160
160
|
let connectionId;
|
|
161
|
+
// ─── Connection tracking ────────────────────────────────────────────────
|
|
162
|
+
let claudeConnected = false;
|
|
163
|
+
// ─── Reminder files ─────────────────────────────────────────────────────
|
|
164
|
+
const WAHOOKS_DIR = path.join(os.homedir(), ".wahooks");
|
|
165
|
+
const REMINDERS_FILE = path.join(WAHOOKS_DIR, "reminders.json");
|
|
166
|
+
const PENDING_FILE = path.join(WAHOOKS_DIR, "pending.json");
|
|
167
|
+
const LOCK_FILE = path.join(WAHOOKS_DIR, ".pending.lock");
|
|
168
|
+
function acquireLock() {
|
|
169
|
+
try {
|
|
170
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
try {
|
|
175
|
+
const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8"));
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, 0);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid));
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function releaseLock() {
|
|
191
|
+
try {
|
|
192
|
+
fs.unlinkSync(LOCK_FILE);
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
}
|
|
196
|
+
function readReminders() {
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(fs.readFileSync(REMINDERS_FILE, "utf-8"));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function writeReminders(reminders) {
|
|
205
|
+
fs.mkdirSync(WAHOOKS_DIR, { recursive: true });
|
|
206
|
+
fs.writeFileSync(REMINDERS_FILE, JSON.stringify(reminders, null, 2) + "\n", { mode: 0o600 });
|
|
207
|
+
}
|
|
208
|
+
function readPending() {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(fs.readFileSync(PENDING_FILE, "utf-8"));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function writePending(items) {
|
|
217
|
+
fs.mkdirSync(WAHOOKS_DIR, { recursive: true });
|
|
218
|
+
fs.writeFileSync(PENDING_FILE, JSON.stringify(items, null, 2) + "\n", { mode: 0o600 });
|
|
219
|
+
}
|
|
220
|
+
async function processPendingQueue() {
|
|
221
|
+
if (!claudeConnected)
|
|
222
|
+
return;
|
|
223
|
+
if (!acquireLock())
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
const pending = readPending();
|
|
227
|
+
if (pending.length === 0)
|
|
228
|
+
return;
|
|
229
|
+
// Prune stale items (>24h)
|
|
230
|
+
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
231
|
+
const active = pending.filter((p) => new Date(p.addedAt) > cutoff);
|
|
232
|
+
for (const item of active) {
|
|
233
|
+
try {
|
|
234
|
+
await mcp.notification({
|
|
235
|
+
method: "notifications/claude/channel",
|
|
236
|
+
params: {
|
|
237
|
+
content: `[Scheduled Reminder] ${item.task}`,
|
|
238
|
+
meta: {
|
|
239
|
+
from: item.chatId,
|
|
240
|
+
reminder_id: item.reminderId,
|
|
241
|
+
scheduled_for: item.scheduledFor,
|
|
242
|
+
type: "reminder",
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
console.error(`[wahooks-channel] Delivered reminder ${item.reminderId}: ${item.task.slice(0, 60)}`);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
claudeConnected = false;
|
|
250
|
+
console.error("[wahooks-channel] Claude disconnected during reminder delivery");
|
|
251
|
+
return; // stop processing, items stay in queue
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// All delivered — clear the queue
|
|
255
|
+
writePending([]);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
releaseLock();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
161
261
|
// ─── MCP Server ─────────────────────────────────────────────────────────
|
|
162
262
|
const mcp = new Server({ name: "wahooks-channel", version: "0.1.0" }, {
|
|
163
263
|
capabilities: {
|
|
@@ -175,6 +275,8 @@ const mcp = new Server({ name: "wahooks-channel", version: "0.1.0" }, {
|
|
|
175
275
|
"Use wahooks_reply to respond in the same chat. Use wahooks_send to message any phone or group.",
|
|
176
276
|
"Media tools: wahooks_send_image, wahooks_send_video, wahooks_send_audio, wahooks_send_document (accept url or file_path).",
|
|
177
277
|
"Also available: wahooks_send_location (lat/lng) and wahooks_send_contact (name/phone).",
|
|
278
|
+
"Reminders: use wahooks_schedule_reminder to schedule tasks (one-time or recurring via cron). Use wahooks_list_reminders and wahooks_cancel_reminder to manage them.",
|
|
279
|
+
"When a reminder fires, it arrives as a <channel> event with type=\"reminder\". Execute the task described and send results to the specified chatId.",
|
|
178
280
|
].join(" "),
|
|
179
281
|
});
|
|
180
282
|
// ─── Permission relay ───────────────────────────────────────────────────
|
|
@@ -205,7 +307,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
205
307
|
properties: {
|
|
206
308
|
to: { type: "string", description: "Chat ID (from the channel tag 'from' attribute)" },
|
|
207
309
|
message_id: { type: "string", description: "Message ID to react to (from the channel tag 'message_id' attribute)" },
|
|
208
|
-
reaction: { type: "string", description: "Emoji
|
|
310
|
+
reaction: { type: "string", description: "Emoji to react with (e.g. 👍, ❤️, 😂, 👀). Also accepts 'emoji' as an alias." },
|
|
209
311
|
},
|
|
210
312
|
required: ["to", "message_id", "reaction"],
|
|
211
313
|
},
|
|
@@ -314,6 +416,39 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
314
416
|
required: ["to", "contact_name", "contact_phone"],
|
|
315
417
|
},
|
|
316
418
|
},
|
|
419
|
+
{
|
|
420
|
+
name: "wahooks_schedule_reminder",
|
|
421
|
+
description: "Schedule a reminder to perform a task at a specific time. The task will be delivered as a channel event when it's due.",
|
|
422
|
+
inputSchema: {
|
|
423
|
+
type: "object",
|
|
424
|
+
properties: {
|
|
425
|
+
task: { type: "string", description: "What to do when the reminder fires (e.g. 'Read top HN stories and send a briefing')" },
|
|
426
|
+
chat_id: { type: "string", description: "Chat ID to send results to (from the channel tag 'from' attribute)" },
|
|
427
|
+
schedule: { type: "string", description: "Cron expression (e.g. '0 8 * * 1-5' for 8am weekdays, '30 9 * * *' for 9:30am daily)" },
|
|
428
|
+
one_time: { type: "boolean", description: "If true, fires once then auto-deletes. Default: false (recurring)." },
|
|
429
|
+
},
|
|
430
|
+
required: ["task", "chat_id", "schedule"],
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
name: "wahooks_list_reminders",
|
|
435
|
+
description: "List all active scheduled reminders.",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
type: "object",
|
|
438
|
+
properties: {},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "wahooks_cancel_reminder",
|
|
443
|
+
description: "Cancel a scheduled reminder by ID.",
|
|
444
|
+
inputSchema: {
|
|
445
|
+
type: "object",
|
|
446
|
+
properties: {
|
|
447
|
+
reminder_id: { type: "string", description: "Reminder ID to cancel" },
|
|
448
|
+
},
|
|
449
|
+
required: ["reminder_id"],
|
|
450
|
+
},
|
|
451
|
+
},
|
|
317
452
|
],
|
|
318
453
|
}));
|
|
319
454
|
function toChatId(id) {
|
|
@@ -349,12 +484,13 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
349
484
|
}
|
|
350
485
|
case "wahooks_react": {
|
|
351
486
|
const chatId = toChatId(args.to);
|
|
487
|
+
const reaction = args.reaction ?? args.emoji ?? "👍";
|
|
352
488
|
await api("POST", `/connections/${connectionId}/react`, {
|
|
353
489
|
chatId,
|
|
354
490
|
messageId: args.message_id,
|
|
355
|
-
reaction
|
|
491
|
+
reaction,
|
|
356
492
|
});
|
|
357
|
-
return { content: [{ type: "text", text: `Reacted ${
|
|
493
|
+
return { content: [{ type: "text", text: `Reacted ${reaction} to message` }] };
|
|
358
494
|
}
|
|
359
495
|
case "wahooks_send_image":
|
|
360
496
|
case "wahooks_send_document":
|
|
@@ -395,6 +531,55 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
395
531
|
});
|
|
396
532
|
return { content: [{ type: "text", text: `Contact sent to ${args.to}` }] };
|
|
397
533
|
}
|
|
534
|
+
case "wahooks_schedule_reminder": {
|
|
535
|
+
const id = `rem_${Date.now().toString(36)}`;
|
|
536
|
+
const oneTime = args.one_time === "true";
|
|
537
|
+
// Calculate next run
|
|
538
|
+
let nextRunAt;
|
|
539
|
+
try {
|
|
540
|
+
const { parseExpression } = await import("cron-parser");
|
|
541
|
+
const interval = parseExpression(args.schedule);
|
|
542
|
+
nextRunAt = interval.next().toDate().toISOString();
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return { content: [{ type: "text", text: `Invalid cron expression: ${args.schedule}` }] };
|
|
546
|
+
}
|
|
547
|
+
const reminder = {
|
|
548
|
+
id,
|
|
549
|
+
task: args.task,
|
|
550
|
+
chatId: args.chat_id,
|
|
551
|
+
schedule: args.schedule,
|
|
552
|
+
oneTime,
|
|
553
|
+
createdAt: new Date().toISOString(),
|
|
554
|
+
lastFiredAt: null,
|
|
555
|
+
nextRunAt,
|
|
556
|
+
};
|
|
557
|
+
const reminders = readReminders();
|
|
558
|
+
reminders[id] = reminder;
|
|
559
|
+
writeReminders(reminders);
|
|
560
|
+
console.error(`[wahooks-channel] Scheduled reminder ${id}: ${args.task.slice(0, 60)} (${args.schedule})`);
|
|
561
|
+
return { content: [{ type: "text", text: `Reminder scheduled: ${id}\nTask: ${args.task}\nSchedule: ${args.schedule}\nNext run: ${nextRunAt}\nOne-time: ${oneTime}` }] };
|
|
562
|
+
}
|
|
563
|
+
case "wahooks_list_reminders": {
|
|
564
|
+
const reminders = readReminders();
|
|
565
|
+
const entries = Object.values(reminders);
|
|
566
|
+
if (entries.length === 0) {
|
|
567
|
+
return { content: [{ type: "text", text: "No active reminders." }] };
|
|
568
|
+
}
|
|
569
|
+
const list = entries.map((r) => `${r.id}: "${r.task}" — ${r.schedule} (next: ${r.nextRunAt}, ${r.oneTime ? "one-time" : "recurring"})`).join("\n");
|
|
570
|
+
return { content: [{ type: "text", text: `Active reminders:\n${list}` }] };
|
|
571
|
+
}
|
|
572
|
+
case "wahooks_cancel_reminder": {
|
|
573
|
+
const reminders = readReminders();
|
|
574
|
+
const id = args.reminder_id;
|
|
575
|
+
if (!reminders[id]) {
|
|
576
|
+
return { content: [{ type: "text", text: `Reminder ${id} not found.` }] };
|
|
577
|
+
}
|
|
578
|
+
delete reminders[id];
|
|
579
|
+
writeReminders(reminders);
|
|
580
|
+
console.error(`[wahooks-channel] Cancelled reminder ${id}`);
|
|
581
|
+
return { content: [{ type: "text", text: `Reminder ${id} cancelled.` }] };
|
|
582
|
+
}
|
|
398
583
|
default:
|
|
399
584
|
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
400
585
|
}
|
|
@@ -533,11 +718,35 @@ function connectWebSocket() {
|
|
|
533
718
|
async function main() {
|
|
534
719
|
connectionId = await resolveConnection();
|
|
535
720
|
console.error(`[wahooks-channel] Using connection: ${connectionId}`);
|
|
536
|
-
// Start MCP transport
|
|
721
|
+
// Start MCP transport with connection tracking
|
|
537
722
|
const transport = new StdioServerTransport();
|
|
723
|
+
transport.onclose = () => {
|
|
724
|
+
claudeConnected = false;
|
|
725
|
+
console.error("[wahooks-channel] Claude disconnected");
|
|
726
|
+
};
|
|
538
727
|
await mcp.connect(transport);
|
|
728
|
+
claudeConnected = true;
|
|
539
729
|
// Connect to real-time event stream
|
|
540
730
|
connectWebSocket();
|
|
731
|
+
// Process any pending reminders immediately
|
|
732
|
+
await processPendingQueue();
|
|
733
|
+
// Watch pending.json for changes (daemon writes to it)
|
|
734
|
+
fs.mkdirSync(WAHOOKS_DIR, { recursive: true });
|
|
735
|
+
// Ensure file exists for watching
|
|
736
|
+
if (!fs.existsSync(PENDING_FILE)) {
|
|
737
|
+
fs.writeFileSync(PENDING_FILE, "[]", { mode: 0o600 });
|
|
738
|
+
}
|
|
739
|
+
// Use both fs.watch and polling for robustness
|
|
740
|
+
try {
|
|
741
|
+
fs.watch(PENDING_FILE, () => {
|
|
742
|
+
setTimeout(() => processPendingQueue(), 500); // debounce
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// fs.watch not supported — fall back to polling only
|
|
747
|
+
}
|
|
748
|
+
// Poll every 10s as fallback
|
|
749
|
+
setInterval(() => processPendingQueue(), 10_000);
|
|
541
750
|
console.error("[wahooks-channel] Ready — WhatsApp messages will appear in Claude Code");
|
|
542
751
|
}
|
|
543
752
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wahooks/channel",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "WhatsApp channel for Claude Code — chat with Claude via WhatsApp",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
33
|
+
"cron-parser": "^4.9.0",
|
|
33
34
|
"ws": "^8.20.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|