@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.
Files changed (2) hide show
  1. package/dist/index.js +209 -1
  2. 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": "1.1.1",
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": {