webhookagent 1.0.1 → 1.3.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 (3) hide show
  1. package/README.md +11 -7
  2. package/lib/heartbeat.js +227 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -13,14 +13,17 @@ npm install -g @avniyayin/webhookagent
13
13
  ```bash
14
14
  # Sign up at https://webhookagent.com/dashboard and get your API key
15
15
 
16
- # Start the heartbeat agent
17
- webhookagent --key=wha_your_key --auto-restart
16
+ # Save your key (one-time — loads automatically after this)
17
+ webhookagent config --key=wha_your_key
18
18
 
19
- # Single check
20
- webhookagent --key=wha_your_key --once
19
+ # Start heartbeat — polls, breaks on events, outputs ACTION:{json}, exits
20
+ webhookagent --fetch --ack
21
21
 
22
- # Fetch + auto-ack items
23
- webhookagent --key=wha_your_key --auto-restart --fetch --ack
22
+ # Single check (one poll, then exit)
23
+ webhookagent --once
24
+
25
+ # Continuous monitoring (for dashboards/debugging, not AI agents)
26
+ webhookagent --auto-restart --fetch --ack
24
27
  ```
25
28
 
26
29
  ## How It Works (Break-on-Action)
@@ -38,10 +41,11 @@ No open ports needed. Works behind any firewall. Events are queued so nothing is
38
41
  |------|---------|-------------|
39
42
  | `--key=KEY` | - | API key (or set `WHA_API_KEY` env var) |
40
43
  | `--url=URL` | `https://webhookagent.com/api` | Base API URL |
41
- | `--interval=N` | 30 | Seconds between polls |
44
+ | `--interval=N` | 30 | Seconds between polls (auto-adjusts to plan minimum) |
42
45
  | `--cycles=N` | 120 | Max polls before exit (~1 hour) |
43
46
  | `--once` | - | Single check then exit |
44
47
  | `--auto-restart` | - | Never exit — restart after break + max cycles |
48
+ | `--show` | - | Show events without breaking (debug/monitor mode) |
45
49
  | `--quiet` | - | Only output ACTION lines (for piping) |
46
50
  | `--webhooks=IDS` | all | Comma-separated webhook IDs to filter |
47
51
  | `--fetch` | - | Auto-fetch full queue items when actions arrive |
package/lib/heartbeat.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * --key=KEY API key (or set WHA_API_KEY env var)
21
21
  * --url=URL Base URL (default: https://webhookagent.com/api)
22
22
  * --interval=N Seconds between polls (default: 30)
23
- * --cycles=N Max polls before exit (default: 120 = ~1 hour)
23
+ * --cycles=N Max polls before exit (default: unlimited)
24
24
  * --once Single heartbeat check, then exit
25
25
  * --auto-restart Never exit — restart after break + after max cycles
26
26
  * --quiet Only output ACTION lines, no status logs
@@ -54,20 +54,78 @@
54
54
  // CONFIG
55
55
  // ============================================================
56
56
 
57
- const args = parseArgs(process.argv.slice(2));
58
- const API_KEY = args.key || process.env.WHA_API_KEY || '';
59
- const BASE_URL = (args.url || process.env.WHA_URL || 'https://webhookagent.com/api').replace(/\/$/, '');
60
- const INTERVAL = parseInt(args.interval || '30') * 1000;
61
- const MAX_CYCLES = args.once ? 1 : parseInt(args.cycles || '120');
57
+ const fs = require('fs');
58
+ const path = require('path');
59
+
60
+ const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.webhookagent');
61
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
62
+
63
+ // Handle subcommands
64
+ const rawArgs = process.argv.slice(2);
65
+ if (rawArgs[0] === 'config') {
66
+ handleConfig(rawArgs.slice(1));
67
+ process.exit(0);
68
+ }
69
+
70
+ const args = parseArgs(rawArgs);
71
+
72
+ if (args.help || args.h || rawArgs[0] === 'help') {
73
+ console.log(`WebhookAgent Heartbeat Runtime v1.2
74
+
75
+ Usage: webhookagent [options]
76
+ webhookagent config --key=wha_...
77
+
78
+ Options:
79
+ --key=KEY API key (or save with: webhookagent config --key=wha_...)
80
+ --interval=N Seconds between polls (auto-adjusts to plan minimum)
81
+ --cycles=N Max polls before exit (default: unlimited)
82
+ --once Single heartbeat check, then exit
83
+ --auto-restart Never exit — restart after break + after max cycles
84
+ --show Show events without breaking (debug/monitor mode)
85
+ --fetch Auto-fetch full queue items when actions arrive
86
+ --ack Auto-acknowledge items after outputting them
87
+ --webhooks=IDS Comma-separated webhook IDs or names to filter
88
+ --json Output full heartbeat JSON instead of ACTION lines
89
+ --quiet Only output ACTION lines, no status logs
90
+ --url=URL API base URL (default: https://webhookagent.com/api)
91
+
92
+ Config:
93
+ webhookagent config --key=wha_... Save API key (recommended)
94
+ webhookagent config --webhooks=ID Save webhook filter
95
+ webhookagent config --show Show saved config
96
+ webhookagent config --clear Delete saved config
97
+
98
+ Examples:
99
+ webhookagent --fetch --ack Poll, fetch events, auto-ack
100
+ webhookagent --once --fetch Single check, fetch items
101
+ webhookagent --auto-restart --fetch --ack Continuous loop
102
+ webhookagent --quiet --fetch | node app.js Pipe to your processor
103
+
104
+ Docs: https://webhookagent.com/docs`);
105
+ process.exit(0);
106
+ }
107
+
108
+ // Load saved config (if exists)
109
+ const savedConfig = loadConfig();
110
+
111
+ const API_KEY = args.key || process.env.WHA_API_KEY || savedConfig.api_key || '';
112
+ const BASE_URL = (args.url || process.env.WHA_URL || savedConfig.api_url || 'https://webhookagent.com/api').replace(/\/$/, '');
113
+ const USER_INTERVAL = args.interval ? parseInt(args.interval) * 1000 : null;
114
+ let INTERVAL = USER_INTERVAL || 30000;
115
+ const MAX_CYCLES = args.once ? 1 : (args.cycles ? parseInt(args.cycles) : Infinity);
62
116
  const AUTO_RESTART = !!args['auto-restart'];
63
117
  const QUIET = !!args.quiet;
64
- const WEBHOOKS_FILTER = args.webhooks || 'all';
118
+ const WEBHOOKS_FILTER = args.webhooks || savedConfig.webhooks || 'all';
65
119
  const AUTO_FETCH = !!args.fetch;
66
120
  const AUTO_ACK = !!args.ack;
67
121
  const JSON_MODE = !!args.json;
122
+ const SHOW_MODE = !!args.show;
68
123
 
69
124
  if (!API_KEY) {
70
- console.error('ERROR: API key required. Use --key=wha_... or set WHA_API_KEY env var');
125
+ console.error('ERROR: API key required.');
126
+ console.error(' Option 1: webhookagent config --key=wha_... (saves securely, recommended)');
127
+ console.error(' Option 2: webhookagent --key=wha_... (passed each time)');
128
+ console.error(' Option 3: set WHA_API_KEY env var');
71
129
  process.exit(1);
72
130
  }
73
131
 
@@ -86,6 +144,91 @@ function parseArgs(argv) {
86
144
  return result;
87
145
  }
88
146
 
147
+ function loadConfig() {
148
+ try {
149
+ if (fs.existsSync(CONFIG_FILE)) {
150
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
151
+ }
152
+ } catch (e) { /* ignore */ }
153
+ return {};
154
+ }
155
+
156
+ function saveConfig(data) {
157
+ if (!fs.existsSync(CONFIG_DIR)) {
158
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
159
+ }
160
+ const existing = loadConfig();
161
+ const merged = { ...existing, ...data };
162
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
163
+ return merged;
164
+ }
165
+
166
+ function handleConfig(argv) {
167
+ const configArgs = parseArgs(argv);
168
+
169
+ // webhookagent config --key=wha_...
170
+ if (configArgs.key) {
171
+ saveConfig({ api_key: configArgs.key });
172
+ console.log(`API key saved to ${CONFIG_FILE}`);
173
+ console.log('You can now run: webhookagent --fetch --ack');
174
+ return;
175
+ }
176
+
177
+ // webhookagent config --webhooks=wh-abc,wh-def
178
+ if (configArgs.webhooks) {
179
+ saveConfig({ webhooks: configArgs.webhooks });
180
+ console.log(`Webhook filter saved: ${configArgs.webhooks}`);
181
+ return;
182
+ }
183
+
184
+ // webhookagent config --url=https://...
185
+ if (configArgs.url) {
186
+ saveConfig({ api_url: configArgs.url });
187
+ console.log(`API URL saved: ${configArgs.url}`);
188
+ return;
189
+ }
190
+
191
+ // webhookagent config --show
192
+ if (configArgs.show) {
193
+ const cfg = loadConfig();
194
+ if (Object.keys(cfg).length === 0) {
195
+ console.log('No config saved. Use: webhookagent config --key=wha_...');
196
+ } else {
197
+ const display = { ...cfg };
198
+ if (display.api_key) display.api_key = display.api_key.slice(0, 8) + '...' + display.api_key.slice(-4);
199
+ console.log(JSON.stringify(display, null, 2));
200
+ }
201
+ return;
202
+ }
203
+
204
+ // webhookagent config --clear
205
+ if (configArgs.clear) {
206
+ try { fs.unlinkSync(CONFIG_FILE); } catch (e) { /* ignore */ }
207
+ console.log('Config cleared.');
208
+ return;
209
+ }
210
+
211
+ // No args — show help
212
+ console.log('WebhookAgent Config');
213
+ console.log(' webhookagent config --key=wha_... Save API key');
214
+ console.log(' webhookagent config --webhooks=ID1,ID2 Save webhook filter');
215
+ console.log(' webhookagent config --show Show saved config');
216
+ console.log(' webhookagent config --clear Delete saved config');
217
+ }
218
+
219
+ function buildRestartCommand() {
220
+ const parts = ['webhookagent'];
221
+ // Only include --key if it was passed explicitly (not from config/env)
222
+ if (args.key) parts.push(`--key=${args.key}`);
223
+ if (AUTO_FETCH) parts.push('--fetch');
224
+ if (AUTO_ACK) parts.push('--ack');
225
+ if (WEBHOOKS_FILTER !== 'all' && args.webhooks) parts.push(`--webhooks=${WEBHOOKS_FILTER}`);
226
+ if (args.interval) parts.push(`--interval=${args.interval}`);
227
+ if (SHOW_MODE) parts.push('--show');
228
+ if (QUIET) parts.push('--quiet');
229
+ return parts.join(' ');
230
+ }
231
+
89
232
  function log(msg) {
90
233
  if (QUIET) return;
91
234
  process.stderr.write(`${new Date().toLocaleTimeString()} ${msg}\n`);
@@ -95,7 +238,7 @@ async function api(path, method = 'GET', body = null) {
95
238
  const headers = {
96
239
  'Authorization': `Bearer ${API_KEY}`,
97
240
  'Content-Type': 'application/json',
98
- 'User-Agent': 'WebhookAgent-Heartbeat/1.0 (Node.js)',
241
+ 'User-Agent': 'WebhookAgent-Heartbeat/1.2 (Node.js)',
99
242
  };
100
243
  const opts = { method, headers };
101
244
  if (body) opts.body = JSON.stringify(body);
@@ -103,6 +246,11 @@ async function api(path, method = 'GET', body = null) {
103
246
  try {
104
247
  const res = await fetch(`${BASE_URL}${path}`, opts);
105
248
  const data = await res.json();
249
+ if (res.status === 429 && data.retry_after) {
250
+ log(`RATE LIMITED: ${data.error || 'Too fast'}. Waiting ${data.retry_after}s...`);
251
+ await sleep(data.retry_after * 1000);
252
+ return { _rate_limited: true };
253
+ }
106
254
  if (!res.ok) {
107
255
  log(`ERROR: HTTP ${res.status} on ${method} ${path}: ${data.error || 'Unknown'}`);
108
256
  return null;
@@ -155,12 +303,25 @@ async function processActions(actions, heartbeatData) {
155
303
  source_ip: item.source_ip,
156
304
  received_at: item.received_at,
157
305
  };
306
+ // When --ack is not used, include acknowledge instructions with each item
307
+ if (!AUTO_ACK) {
308
+ output.needs_ack = true;
309
+ output.ack_hint = `After you've handled this event, mark it as done so it leaves the queue.`;
310
+ output.ack_command = `curl -s -X POST "${BASE_URL}/webhooks/${whId}/queue/ack" -H "Authorization: Bearer ${API_KEY}" -H "Content-Type: application/json" -d '{"item_ids":[${item.id}]}'`;
311
+ }
158
312
  console.log('ACTION:' + JSON.stringify(output));
159
313
  }
160
314
 
161
315
  if (AUTO_ACK && ids.length > 0) {
162
316
  await api(`/webhooks/${whId}/queue/ack`, 'POST', { item_ids: ids });
163
317
  log(` >> Acked ${ids.length} items from ${whName}`);
318
+ } else if (!AUTO_ACK && ids.length > 0) {
319
+ log(` >> ${ids.length} items fetched from ${whName} — remember to acknowledge them when done`);
320
+ console.log('ACK_ALL:' + JSON.stringify({
321
+ hint: `You have ${ids.length} items to process. Once you're done with each one, acknowledge it so it's cleared from the queue. You can ack them all at once:`,
322
+ command: `curl -s -X POST "${BASE_URL}/webhooks/${whId}/queue/ack" -H "Authorization: Bearer ${API_KEY}" -H "Content-Type: application/json" -d '{"item_ids":[${ids.join(',')}]}'`,
323
+ item_ids: ids
324
+ }));
164
325
  }
165
326
  }
166
327
  } else {
@@ -171,6 +332,7 @@ async function processActions(actions, heartbeatData) {
171
332
  webhook_name: whName,
172
333
  pending: pending,
173
334
  items_preview: action.items_preview || [],
335
+ fetch_hint: `There are ${pending} events waiting. Fetch the full payloads, process them, and acknowledge when done.`,
174
336
  fetch: action.fetch,
175
337
  ack: action.ack,
176
338
  }));
@@ -183,9 +345,35 @@ async function processActions(actions, heartbeatData) {
183
345
  // ============================================================
184
346
 
185
347
  async function main() {
186
- log('WebhookAgent Heartbeat Runtime v1.0');
187
- log(`Polling ${BASE_URL} every ${INTERVAL / 1000}s, max ${MAX_CYCLES} cycles${AUTO_RESTART ? ' (continuous)' : ''}`);
188
- if (WEBHOOKS_FILTER !== 'all') log(`Filtering webhooks: ${WEBHOOKS_FILTER}`);
348
+ log('WebhookAgent Heartbeat Runtime v1.3');
349
+ log(`Polling ${BASE_URL} every ${INTERVAL / 1000}s${MAX_CYCLES === Infinity ? '' : `, max ${MAX_CYCLES} cycles`}${AUTO_RESTART ? ' (continuous)' : ''}${SHOW_MODE ? ' (show mode — no break)' : ''}`);
350
+
351
+ // Resolve webhook names to IDs if needed
352
+ let resolvedFilter = WEBHOOKS_FILTER;
353
+ if (WEBHOOKS_FILTER !== 'all') {
354
+ const filterParts = WEBHOOKS_FILTER.split(',').map(s => s.trim());
355
+ const hasNames = filterParts.some(p => !p.startsWith('wh-') && !/^\d+$/.test(p));
356
+ if (hasNames) {
357
+ const webhooks = await api('/webhooks');
358
+ if (webhooks && webhooks.webhooks) {
359
+ const resolved = filterParts.map(f => {
360
+ // If it looks like an ID already, keep it
361
+ if (f.startsWith('wh-') || /^\d+$/.test(f)) return f;
362
+ // Otherwise match by name (case-insensitive)
363
+ const match = webhooks.webhooks.find(w => w.name.toLowerCase() === f.toLowerCase());
364
+ if (match) {
365
+ log(`Resolved "${f}" → ${match.webhook_id}`);
366
+ return match.webhook_id;
367
+ }
368
+ log(`WARN: webhook name "${f}" not found, skipping`);
369
+ return null;
370
+ }).filter(Boolean);
371
+ resolvedFilter = resolved.join(',');
372
+ }
373
+ }
374
+ log(`Filtering webhooks: ${resolvedFilter}`);
375
+ }
376
+ if (SHOW_MODE) log('Show mode: ON (events displayed, no break)');
189
377
  if (AUTO_FETCH) log('Auto-fetch: ON');
190
378
  if (AUTO_ACK) log('Auto-ack: ON');
191
379
  log('---');
@@ -195,7 +383,7 @@ async function main() {
195
383
  let brokeOnAction = false;
196
384
 
197
385
  for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
198
- const path = `/heartbeat?webhooks=${encodeURIComponent(WEBHOOKS_FILTER)}`;
386
+ const path = `/heartbeat?webhooks=${encodeURIComponent(resolvedFilter)}`;
199
387
  const data = await api(path);
200
388
 
201
389
  if (!data) {
@@ -208,20 +396,45 @@ async function main() {
208
396
  await sleep(10000);
209
397
  continue;
210
398
  }
399
+ if (data._rate_limited) { continue; }
211
400
  retries = 0;
212
401
 
213
402
  const user = data.user || {};
214
403
  const summary = data.summary || {};
215
404
  const actions = data.actions || [];
216
405
 
406
+ // Auto-adjust poll interval from server recommendation
407
+ if (user.poll_interval && user.poll_interval > 0) {
408
+ const serverInterval = user.poll_interval * 1000;
409
+ if (USER_INTERVAL && USER_INTERVAL < serverInterval) {
410
+ log(`WARN: --interval=${USER_INTERVAL / 1000}s is below plan minimum (${user.poll_interval}s). Using server value.`);
411
+ INTERVAL = serverInterval;
412
+ } else if (!USER_INTERVAL && serverInterval !== INTERVAL) {
413
+ INTERVAL = serverInterval;
414
+ log(`Poll interval adjusted to ${user.poll_interval}s (${user.plan} plan)`);
415
+ }
416
+ }
417
+
217
418
  if (actions.length === 0) {
218
419
  log(`ok | plan=${user.plan} events=${user.events_used}/${user.events_limit} (${user.events_remaining} left) | pending=${summary.total_pending}`);
420
+ } else if (SHOW_MODE) {
421
+ // Show mode: display events but keep polling (no break)
422
+ log(`SHOW | ${actions.length} webhook(s) with pending events | plan=${user.plan} events_left=${user.events_remaining}`);
423
+ await processActions(actions, data);
219
424
  } else {
220
425
  log(`BREAK | ${actions.length} webhook(s) with pending events | plan=${user.plan} events_left=${user.events_remaining}`);
221
426
 
222
427
  await processActions(actions, data);
223
428
 
224
429
  brokeOnAction = true;
430
+
431
+ // Tell the agent exactly how to restart
432
+ if (!AUTO_RESTART) {
433
+ const cmd = buildRestartCommand();
434
+ console.log('RESTART:' + cmd);
435
+ log(`Process events above, then run: ${cmd}`);
436
+ }
437
+
225
438
  break; // ← THE BREAK — exit loop so parent process can handle
226
439
  }
227
440
 
@@ -230,7 +443,7 @@ async function main() {
230
443
  }
231
444
  }
232
445
 
233
- if (!brokeOnAction) {
446
+ if (!brokeOnAction && MAX_CYCLES !== Infinity) {
234
447
  log(`Max cycles reached (${MAX_CYCLES}), exiting cleanly`);
235
448
  }
236
449
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhookagent",
3
- "version": "1.0.1",
3
+ "version": "1.3.0",
4
4
  "description": "WebhookAgent heartbeat runtime — poll, break, process webhook events at your agent's pace",
5
5
  "main": "lib/heartbeat.js",
6
6
  "bin": {