@laburen/openclaw-plugin-whatsapp-api 0.2.3 → 0.3.1

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/README.md CHANGED
@@ -7,9 +7,6 @@ WhatsApp Cloud API channel for OpenClaw: your router receives Meta webhooks and
7
7
  ```bash
8
8
  openclaw plugins install @laburen/openclaw-plugin-whatsapp-api
9
9
  ```
10
-
11
- If you install from a local folder instead, copy the package into your OpenClaw extensions root and enable it in config (same plugin id: `whatsapp-api`).
12
-
13
10
  ## Setup
14
11
 
15
12
  1. In [Meta for Developers](https://developers.facebook.com/), create or open an app with **WhatsApp** product enabled and note your **Phone number ID** and a long-lived **System User** or **Temporary** access token with `whatsapp_business_messaging` (and webhook permissions as required by your setup).
@@ -47,6 +44,18 @@ openclaw gateway restart
47
44
 
48
45
  - Transient failures (`429`, `5xx`, timeouts) are retried according to `maxRetries`, `retryBackoffMs`, and `requestTimeoutMs`.
49
46
 
47
+ ### WhatsApp 24-hour context window (reminder)
48
+
49
+ WhatsApp Cloud API lets you send **session** messages (normal replies) only within about **24 hours** of the user’s last inbound message. After that window, outbound traffic is limited to **approved templates** until the user writes again.
50
+
51
+ This plugin can nudge you before the window closes:
52
+
53
+ 1. **Snapshot on inbound:** For each user message handled by the channel, a hook writes `<OpenClaw state dir>/whatsapp-api/last-inbound-message.json` with `from`, `content`, `timestamp`, channel/account/conversation ids when present, and **`notified: false`**. That reset means a new user message clears any previous “we already warned” state.
54
+ 2. **Cron from the host:** On service start, the plugin runs `setup.sh` (shipped under `src/scripts/` in the npm package). The script registers an OpenClaw cron job that periodically invokes `check.sh` via the exec tool (see the job’s description in `setup.sh`).
55
+ 3. **`check.sh` behavior:** It reads the JSON file, skips if missing or `notified` is already `true`, and compares elapsed time since `timestamp` to a threshold (default **23 hours**). If the threshold is met, it runs `openclaw message send` to the user’s phone (parsed from `from`) with a configurable warning text, then sets **`notified: true`** in the same file so the warning is not sent again until the next inbound message.
56
+
57
+ Useful environment variables for the scripts: `OPENCLAW_DIR` (defaults to `/home/<user>/.openclaw`), `THRESHOLD_MINUTES`, `WARNING_MESSAGE`, and `EVERY` for the cron interval when installing (default `30m`). To remove the job: `bash /path/to/node_modules/@laburen/openclaw-plugin-whatsapp-api/src/scripts/setup.sh --uninstall` (adjust path to your install).
58
+
50
59
  **Inbound webhook (from your router to OpenClaw)**
51
60
 
52
61
  | Item | Detail |
package/index.d.ts CHANGED
@@ -2,16 +2,21 @@ import { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
 
3
3
  //#region index.d.ts
4
4
  /**
5
- * Default export: OpenClaw plugin manifest + `register` hook.
5
+ * OpenClaw plugin metadata and registration hook. Default export of this package.
6
+ *
7
+ * Channel behaviour is implemented by {@link createWhatsAppApiChannel}; hooks and
8
+ * services are aggregated via {@link registerAllPluginHooks} and
9
+ * {@link registerAllPluginServices}.
6
10
  */
7
11
  declare const plugin: {
8
12
  id: string;
9
13
  name: string;
10
14
  description: string;
11
15
  /**
12
- * Wires the channel into OpenClaw: saves plugin API, registers `whatsapp-api`.
16
+ * Called by OpenClaw when the plugin loads: publishes the channel, wires the
17
+ * global plugin API holder, then registers pipeline hooks and plugin services.
13
18
  *
14
- * @param api - Provided by the host at plugin load time
19
+ * @param api - Plugin API injected by the host (config, runtime, registration helpers)
15
20
  */
16
21
  register(api: OpenClawPluginApi): void;
17
22
  };
package/index.js CHANGED
@@ -1,6 +1,8 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import fs, { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { tmpdir } from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
4
6
  //#region src/core/accounts.ts
5
7
  const CHANNEL_ID$1 = "whatsapp-api";
6
8
  const DEFAULT_WEBHOOK_PATH = "/webhook/whatsapp-api";
@@ -1079,7 +1081,7 @@ async function handleWhatsAppApiWebhook(params) {
1079
1081
  //#endregion
1080
1082
  //#region src/channel/plugin.ts
1081
1083
  const CHANNEL_ID = "whatsapp-api";
1082
- const log$1 = waLogger("channel");
1084
+ const log$3 = waLogger("channel");
1083
1085
  const DEFAULT_ACCOUNT_ID = "default";
1084
1086
  /**
1085
1087
  * Resolves when `signal` aborts, or never if `signal` is undefined.
@@ -1148,7 +1150,7 @@ async function dispatchInboundToAgent(params) {
1148
1150
  const mediaAttachment = await downloadInboundMediaAttachment({
1149
1151
  account,
1150
1152
  message,
1151
- log: { warn: (msg) => log$1.warn(msg) }
1153
+ log: { warn: (msg) => log$3.warn(msg) }
1152
1154
  });
1153
1155
  const replyApi = rt.channel.reply;
1154
1156
  const ctxPayload = replyApi.finalizeInboundContext({
@@ -1188,17 +1190,17 @@ async function dispatchInboundToAgent(params) {
1188
1190
  account
1189
1191
  });
1190
1192
  if (sent.length === 0) {
1191
- log$1.info("skipping outbound: reply payload empty", { messageId: message.messageId });
1193
+ log$3.info("skipping outbound: reply payload empty", { messageId: message.messageId });
1192
1194
  return;
1193
1195
  }
1194
- log$1.info(`sent ${sent.length} outbound message(s)`, {
1196
+ log$3.info(`sent ${sent.length} outbound message(s)`, {
1195
1197
  to: message.from,
1196
1198
  messageId: message.messageId
1197
1199
  });
1198
1200
  onOutbound?.();
1199
1201
  },
1200
1202
  onError: (err, info) => {
1201
- log$1.error(`${info?.kind ?? "reply"} dispatch failed`, {
1203
+ log$3.error(`${info?.kind ?? "reply"} dispatch failed`, {
1202
1204
  accountId,
1203
1205
  messageId: message.messageId,
1204
1206
  error: String(err)
@@ -1335,10 +1337,152 @@ function createWhatsAppApiChannel(api) {
1335
1337
  };
1336
1338
  }
1337
1339
  //#endregion
1340
+ //#region src/plugin/plugin-state-dir.ts
1341
+ /**
1342
+ * SPDX-License-Identifier: MIT
1343
+ *
1344
+ * OpenClaw provides `ctx.stateDir` only inside a registered service's `start`.
1345
+ * Pipeline hooks (e.g. `message_received`) do not receive that context, so any
1346
+ * hook that must read/write under the plugin state tree needs the same path.
1347
+ *
1348
+ * Only code that actually persists or reads plugin state should import this module.
1349
+ * Other hooks/services stay unaware of it.
1350
+ */
1351
+ let serviceStateDir = null;
1352
+ /**
1353
+ * @returns The last `stateDir` set by a plugin service `start`, or `null` if
1354
+ * no service has started or after `stop` cleared it.
1355
+ */
1356
+ function getPluginServiceStateDir() {
1357
+ return serviceStateDir;
1358
+ }
1359
+ /**
1360
+ * Updates the path exposed to hooks. Called from service lifecycle (typically
1361
+ * `start` / `stop` of whichever service owns the OpenClaw service context).
1362
+ */
1363
+ function setPluginServiceStateDir(dir) {
1364
+ serviceStateDir = dir;
1365
+ }
1366
+ //#endregion
1367
+ //#region src/utils/last-inbound-persistence/store.ts
1368
+ /**
1369
+ * SPDX-License-Identifier: MIT
1370
+ *
1371
+ * Atomic JSON persistence for {@link LastInboundMessageSnapshot} under the
1372
+ * plugin state directory. Shared util for hooks and services.
1373
+ */
1374
+ function getLastInboundMessagePath(stateDir) {
1375
+ return path.join(stateDir, "whatsapp-api", "last-inbound-message.json");
1376
+ }
1377
+ /**
1378
+ * Persists the last inbound message snapshot (write-to-temp + rename).
1379
+ */
1380
+ async function writeLastInboundMessage(stateDir, data) {
1381
+ const filePath = getLastInboundMessagePath(stateDir);
1382
+ const dir = path.dirname(filePath);
1383
+ await fs.mkdir(dir, { recursive: true });
1384
+ const tmpPath = `${filePath}.tmp`;
1385
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8");
1386
+ await fs.rename(tmpPath, filePath);
1387
+ }
1388
+ //#endregion
1389
+ //#region src/hooks/message-received/handlers/persist-last-inbound.ts
1390
+ /**
1391
+ * SPDX-License-Identifier: MIT
1392
+ *
1393
+ * Persist last inbound message snapshot for `check.sh` / cron window alerts.
1394
+ */
1395
+ const log$2 = waLogger("message-received/persist-last-inbound");
1396
+ async function handlePersistLastInbound(event, ctx) {
1397
+ const stateDir = getPluginServiceStateDir();
1398
+ if (!stateDir) return;
1399
+ const now = Date.now();
1400
+ const inboundTimestamp = typeof event.timestamp === "number" && Number.isFinite(event.timestamp) ? event.timestamp : now;
1401
+ const payload = {
1402
+ from: event.from,
1403
+ content: event.content,
1404
+ timestamp: inboundTimestamp,
1405
+ channelId: ctx.channelId,
1406
+ accountId: ctx.accountId,
1407
+ conversationId: ctx.conversationId,
1408
+ notified: false
1409
+ };
1410
+ try {
1411
+ await writeLastInboundMessage(stateDir, payload);
1412
+ log$2.debug("last inbound message persisted");
1413
+ } catch (error) {
1414
+ log$2.warn(`failed to persist last inbound message: ${String(error)}`);
1415
+ }
1416
+ }
1417
+ //#endregion
1418
+ //#region src/hooks/message-received/register.ts
1419
+ function registerMessageReceivedHook(api) {
1420
+ api.on("message_received", async (event, ctx) => {
1421
+ await handlePersistLastInbound(event, ctx);
1422
+ });
1423
+ }
1424
+ //#endregion
1425
+ //#region src/hooks/register.ts
1426
+ /**
1427
+ * Registers all plugin-managed OpenClaw hooks for this extension.
1428
+ */
1429
+ function registerAllPluginHooks(api) {
1430
+ registerMessageReceivedHook(api);
1431
+ }
1432
+ //#endregion
1433
+ //#region src/services/context-window-check/register.ts
1434
+ const log$1 = waLogger("context-window-check");
1435
+ const SETUP_SH = path.join(path.dirname(fileURLToPath(import.meta.url)), "src", "scripts", "setup.sh");
1436
+ function runSetupSh() {
1437
+ return new Promise((resolve, reject) => {
1438
+ const child = spawn("bash", [SETUP_SH], {
1439
+ stdio: "inherit",
1440
+ env: process.env
1441
+ });
1442
+ child.on("error", reject);
1443
+ child.on("close", (code, signal) => {
1444
+ if (code === 0) resolve();
1445
+ else reject(/* @__PURE__ */ new Error(`setup.sh exited with code ${code ?? signal}`));
1446
+ });
1447
+ });
1448
+ }
1449
+ /** Registers the `whatsapp-api` plugin service (state dir + cron setup). */
1450
+ function registerContextWindowCheckService(api) {
1451
+ api.registerService({
1452
+ id: "whatsapp-api",
1453
+ start: async (ctx) => {
1454
+ setPluginServiceStateDir(ctx.stateDir);
1455
+ log$1.info("service started");
1456
+ try {
1457
+ await runSetupSh();
1458
+ log$1.info("setup.sh finished");
1459
+ } catch (error) {
1460
+ log$1.error("setup.sh failed", { error: String(error) });
1461
+ }
1462
+ },
1463
+ stop: async (_ctx) => {
1464
+ setPluginServiceStateDir(null);
1465
+ log$1.info("service stopped");
1466
+ }
1467
+ });
1468
+ }
1469
+ //#endregion
1470
+ //#region src/services/register.ts
1471
+ /**
1472
+ * Registers all plugin-managed OpenClaw services for this extension.
1473
+ */
1474
+ function registerAllPluginServices(api) {
1475
+ registerContextWindowCheckService(api);
1476
+ }
1477
+ //#endregion
1338
1478
  //#region index.ts
1339
1479
  const log = waLogger("plugin");
1340
1480
  /**
1341
- * Default export: OpenClaw plugin manifest + `register` hook.
1481
+ * OpenClaw plugin metadata and registration hook. Default export of this package.
1482
+ *
1483
+ * Channel behaviour is implemented by {@link createWhatsAppApiChannel}; hooks and
1484
+ * services are aggregated via {@link registerAllPluginHooks} and
1485
+ * {@link registerAllPluginServices}.
1342
1486
  */
1343
1487
  const plugin = {
1344
1488
  id: "whatsapp-api",
@@ -1348,6 +1492,8 @@ const plugin = {
1348
1492
  setPluginApi(api);
1349
1493
  api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
1350
1494
  log.info("plugin registered");
1495
+ registerAllPluginHooks(api);
1496
+ registerAllPluginServices(api);
1351
1497
  }
1352
1498
  };
1353
1499
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laburen/openclaw-plugin-whatsapp-api",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "WhatsApp API channel plugin for OpenClaw",
6
6
  "main": "index.js",
@@ -8,6 +8,7 @@
8
8
  "index.js",
9
9
  "index.d.ts",
10
10
  "openclaw.plugin.json",
11
+ "src/scripts",
11
12
  "README.md",
12
13
  "LICENSE"
13
14
  ],
@@ -0,0 +1,46 @@
1
+ #!/bin/bash
2
+ # check.sh - WhatsApp context window notifier
3
+ # Reads last-inbound-message.json and sends a warning if the user hasn't
4
+ # written in more than THRESHOLD_MINUTES and notified=false.
5
+
6
+ OPENCLAW_DIR="${OPENCLAW_DIR:-/home/$(whoami)/.openclaw}"
7
+ LAST_MSG_FILE="$OPENCLAW_DIR/whatsapp-api/last-inbound-message.json"
8
+ # 23 hours in minutes
9
+ THRESHOLD_MINUTES="${THRESHOLD_MINUTES:-$((23 * 60))}"
10
+ WARNING_MESSAGE="${WARNING_MESSAGE:-Aviso: la ventana de contexto de WhatsApp esta por vencer. Si quieres seguir recibiendo mensajes, responde este chat para reabrir la ventana de 24 horas.}"
11
+
12
+ if [ ! -f "$LAST_MSG_FILE" ]; then
13
+ echo "[whatsapp-window-notifier] File not found: $LAST_MSG_FILE — skipping."
14
+ exit 0
15
+ fi
16
+
17
+ TIMESTAMP=$(jq -r '.timestamp' "$LAST_MSG_FILE")
18
+ NOTIFIED=$(jq -r '.notified' "$LAST_MSG_FILE")
19
+ FROM=$(jq -r '.from' "$LAST_MSG_FILE")
20
+
21
+ # Extract phone number: "whatsapp-api:5493496460785" → "5493496460785"
22
+ PHONE=$(echo "$FROM" | sed 's/.*://')
23
+
24
+ if [ "$NOTIFIED" = "true" ]; then
25
+ echo "[whatsapp-window-notifier] Already notified, skipping."
26
+ exit 0
27
+ fi
28
+
29
+ NOW_MS=$(date +%s%3N)
30
+ ELAPSED_MS=$((NOW_MS - TIMESTAMP))
31
+ ELAPSED_MIN=$((ELAPSED_MS / 60000))
32
+
33
+ echo "[whatsapp-window-notifier] Elapsed: ${ELAPSED_MIN}m / threshold: ${THRESHOLD_MINUTES}m / target: $PHONE"
34
+
35
+ if [ "$ELAPSED_MIN" -ge "$THRESHOLD_MINUTES" ]; then
36
+ openclaw message send \
37
+ --channel whatsapp-api \
38
+ --target "$PHONE" \
39
+ --message "$WARNING_MESSAGE"
40
+
41
+ TMP=$(mktemp)
42
+ jq '.notified = true' "$LAST_MSG_FILE" > "$TMP" && mv "$TMP" "$LAST_MSG_FILE"
43
+ echo "[whatsapp-window-notifier] Notification sent to $PHONE, notified=true set."
44
+ else
45
+ echo "[whatsapp-window-notifier] Not enough time elapsed, no action."
46
+ fi
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # setup.sh - Installs/uninstalls the whatsapp-window-notifier cron job.
3
+ # Run once per OpenClaw instance.
4
+ #
5
+ # Usage:
6
+ # bash setup.sh → installs the cron
7
+ # bash setup.sh --uninstall → removes the cron
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ CHECK_SCRIPT="$SCRIPT_DIR/check.sh"
11
+ JOB_NAME="whatsapp-window-notifier"
12
+ EVERY="${EVERY:-30m}"
13
+ UNINSTALL=false
14
+
15
+ for arg in "$@"; do
16
+ case $arg in
17
+ --uninstall) UNINSTALL=true ;;
18
+ esac
19
+ done
20
+
21
+ if [ "$UNINSTALL" = "true" ]; then
22
+ JOB_ID=$(openclaw cron list --json 2>/dev/null | jq -r ".jobs[] | select(.name == \"$JOB_NAME\") | .id")
23
+ if [ -n "$JOB_ID" ]; then
24
+ openclaw cron rm "$JOB_ID"
25
+ echo "[$JOB_NAME] Cron job removed (id: $JOB_ID)."
26
+ else
27
+ echo "[$JOB_NAME] No cron job found to remove."
28
+ fi
29
+ exit 0
30
+ fi
31
+
32
+ # Check if already installed
33
+ EXISTING=$(openclaw cron list --json 2>/dev/null | jq -r ".jobs[] | select(.name == \"$JOB_NAME\") | .id")
34
+ if [ -n "$EXISTING" ]; then
35
+ echo "[$JOB_NAME] Already installed (id: $EXISTING). Run with --uninstall first to reinstall."
36
+ exit 0
37
+ fi
38
+
39
+ chmod +x "$CHECK_SCRIPT"
40
+
41
+ openclaw cron add \
42
+ --name "$JOB_NAME" \
43
+ --every "$EVERY" \
44
+ --session isolated \
45
+ --no-deliver \
46
+ --description "Notifies user when WhatsApp 24h context window is about to expire" \
47
+ --message "Run this bash script using the exec tool and report nothing to the user: $CHECK_SCRIPT"
48
+
49
+ echo "[$JOB_NAME] Cron job installed. Runs every $EVERY."