@wahooks/channel 1.1.1 → 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 +209 -1
- 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 ───────────────────────────────────────────────────
|
|
@@ -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) {
|
|
@@ -396,6 +531,55 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
396
531
|
});
|
|
397
532
|
return { content: [{ type: "text", text: `Contact sent to ${args.to}` }] };
|
|
398
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
|
+
}
|
|
399
583
|
default:
|
|
400
584
|
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
401
585
|
}
|
|
@@ -534,11 +718,35 @@ function connectWebSocket() {
|
|
|
534
718
|
async function main() {
|
|
535
719
|
connectionId = await resolveConnection();
|
|
536
720
|
console.error(`[wahooks-channel] Using connection: ${connectionId}`);
|
|
537
|
-
// Start MCP transport
|
|
721
|
+
// Start MCP transport with connection tracking
|
|
538
722
|
const transport = new StdioServerTransport();
|
|
723
|
+
transport.onclose = () => {
|
|
724
|
+
claudeConnected = false;
|
|
725
|
+
console.error("[wahooks-channel] Claude disconnected");
|
|
726
|
+
};
|
|
539
727
|
await mcp.connect(transport);
|
|
728
|
+
claudeConnected = true;
|
|
540
729
|
// Connect to real-time event stream
|
|
541
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);
|
|
542
750
|
console.error("[wahooks-channel] Ready — WhatsApp messages will appear in Claude Code");
|
|
543
751
|
}
|
|
544
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": {
|