@laburen/openclaw-plugin-whatsapp-api 0.2.2 → 0.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.
package/README.md CHANGED
@@ -10,6 +10,14 @@ openclaw plugins install @laburen/openclaw-plugin-whatsapp-api
10
10
 
11
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
12
 
13
+ ## Context window reminder (cron scripts)
14
+
15
+ The plugin stores the last inbound message so **`check.sh`** can remind the user before Meta’s 24-hour window ends. Run **`setup.sh`** by path (no `cd` needed—the script resolves `check.sh` next to itself):
16
+
17
+ ```bash
18
+ bash ~/.openclaw/extensions/whatsapp-api/src/scripts/setup.sh
19
+ ```
20
+
13
21
  ## Setup
14
22
 
15
23
  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).
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,4 +1,4 @@
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
4
  //#region src/core/accounts.ts
@@ -1079,7 +1079,7 @@ async function handleWhatsAppApiWebhook(params) {
1079
1079
  //#endregion
1080
1080
  //#region src/channel/plugin.ts
1081
1081
  const CHANNEL_ID = "whatsapp-api";
1082
- const log$1 = waLogger("channel");
1082
+ const log$3 = waLogger("channel");
1083
1083
  const DEFAULT_ACCOUNT_ID = "default";
1084
1084
  /**
1085
1085
  * Resolves when `signal` aborts, or never if `signal` is undefined.
@@ -1148,7 +1148,7 @@ async function dispatchInboundToAgent(params) {
1148
1148
  const mediaAttachment = await downloadInboundMediaAttachment({
1149
1149
  account,
1150
1150
  message,
1151
- log: { warn: (msg) => log$1.warn(msg) }
1151
+ log: { warn: (msg) => log$3.warn(msg) }
1152
1152
  });
1153
1153
  const replyApi = rt.channel.reply;
1154
1154
  const ctxPayload = replyApi.finalizeInboundContext({
@@ -1188,17 +1188,17 @@ async function dispatchInboundToAgent(params) {
1188
1188
  account
1189
1189
  });
1190
1190
  if (sent.length === 0) {
1191
- log$1.info("skipping outbound: reply payload empty", { messageId: message.messageId });
1191
+ log$3.info("skipping outbound: reply payload empty", { messageId: message.messageId });
1192
1192
  return;
1193
1193
  }
1194
- log$1.info(`sent ${sent.length} outbound message(s)`, {
1194
+ log$3.info(`sent ${sent.length} outbound message(s)`, {
1195
1195
  to: message.from,
1196
1196
  messageId: message.messageId
1197
1197
  });
1198
1198
  onOutbound?.();
1199
1199
  },
1200
1200
  onError: (err, info) => {
1201
- log$1.error(`${info?.kind ?? "reply"} dispatch failed`, {
1201
+ log$3.error(`${info?.kind ?? "reply"} dispatch failed`, {
1202
1202
  accountId,
1203
1203
  messageId: message.messageId,
1204
1204
  error: String(err)
@@ -1335,10 +1335,136 @@ function createWhatsAppApiChannel(api) {
1335
1335
  };
1336
1336
  }
1337
1337
  //#endregion
1338
+ //#region src/plugin/plugin-state-dir.ts
1339
+ /**
1340
+ * SPDX-License-Identifier: MIT
1341
+ *
1342
+ * OpenClaw provides `ctx.stateDir` only inside a registered service's `start`.
1343
+ * Pipeline hooks (e.g. `message_received`) do not receive that context, so any
1344
+ * hook that must read/write under the plugin state tree needs the same path.
1345
+ *
1346
+ * Only code that actually persists or reads plugin state should import this module.
1347
+ * Other hooks/services stay unaware of it.
1348
+ */
1349
+ let serviceStateDir = null;
1350
+ /**
1351
+ * @returns The last `stateDir` set by a plugin service `start`, or `null` if
1352
+ * no service has started or after `stop` cleared it.
1353
+ */
1354
+ function getPluginServiceStateDir() {
1355
+ return serviceStateDir;
1356
+ }
1357
+ /**
1358
+ * Updates the path exposed to hooks. Called from service lifecycle (typically
1359
+ * `start` / `stop` of whichever service owns the OpenClaw service context).
1360
+ */
1361
+ function setPluginServiceStateDir(dir) {
1362
+ serviceStateDir = dir;
1363
+ }
1364
+ //#endregion
1365
+ //#region src/utils/last-inbound-persistence/store.ts
1366
+ /**
1367
+ * SPDX-License-Identifier: MIT
1368
+ *
1369
+ * Atomic JSON persistence for {@link LastInboundMessageSnapshot} under the
1370
+ * plugin state directory. Shared util for hooks and services.
1371
+ */
1372
+ function getLastInboundMessagePath(stateDir) {
1373
+ return path.join(stateDir, "whatsapp-api", "last-inbound-message.json");
1374
+ }
1375
+ /**
1376
+ * Persists the last inbound message snapshot (write-to-temp + rename).
1377
+ */
1378
+ async function writeLastInboundMessage(stateDir, data) {
1379
+ const filePath = getLastInboundMessagePath(stateDir);
1380
+ const dir = path.dirname(filePath);
1381
+ await fs.mkdir(dir, { recursive: true });
1382
+ const tmpPath = `${filePath}.tmp`;
1383
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8");
1384
+ await fs.rename(tmpPath, filePath);
1385
+ }
1386
+ //#endregion
1387
+ //#region src/hooks/message-received/handlers/persist-last-inbound.ts
1388
+ /**
1389
+ * SPDX-License-Identifier: MIT
1390
+ *
1391
+ * Persist last inbound message snapshot (used by background services).
1392
+ */
1393
+ const log$2 = waLogger("message-received/persist-last-inbound");
1394
+ async function handlePersistLastInbound(event, ctx) {
1395
+ const stateDir = getPluginServiceStateDir();
1396
+ if (!stateDir) return;
1397
+ const now = Date.now();
1398
+ const inboundTimestamp = typeof event.timestamp === "number" && Number.isFinite(event.timestamp) ? event.timestamp : now;
1399
+ const payload = {
1400
+ from: event.from,
1401
+ content: event.content,
1402
+ timestamp: inboundTimestamp,
1403
+ channelId: ctx.channelId,
1404
+ accountId: ctx.accountId,
1405
+ conversationId: ctx.conversationId,
1406
+ notified: false
1407
+ };
1408
+ try {
1409
+ await writeLastInboundMessage(stateDir, payload);
1410
+ log$2.debug("last inbound message persisted");
1411
+ } catch (error) {
1412
+ log$2.warn(`failed to persist last inbound message: ${String(error)}`);
1413
+ }
1414
+ }
1415
+ //#endregion
1416
+ //#region src/hooks/message-received/register.ts
1417
+ function registerMessageReceivedHook(api) {
1418
+ api.on("message_received", async (event, ctx) => {
1419
+ await handlePersistLastInbound(event, ctx);
1420
+ });
1421
+ }
1422
+ //#endregion
1423
+ //#region src/hooks/register.ts
1424
+ /**
1425
+ * Registers all plugin-managed OpenClaw hooks for this extension.
1426
+ */
1427
+ function registerAllPluginHooks(api) {
1428
+ registerMessageReceivedHook(api);
1429
+ }
1430
+ //#endregion
1431
+ //#region src/services/context-window-check/register.ts
1432
+ const log$1 = waLogger("context-window-check");
1433
+ /**
1434
+ * Registers the `whatsapp-api` plugin service (state dir wiring; see `start` for
1435
+ * the commented in-process poll vs OpenClaw cron + scripts, described in the
1436
+ * module comment above).
1437
+ */
1438
+ function registerContextWindowCheckService(api) {
1439
+ api.registerService({
1440
+ id: "whatsapp-api",
1441
+ start: async (ctx) => {
1442
+ setPluginServiceStateDir(ctx.stateDir);
1443
+ log$1.info("service started");
1444
+ },
1445
+ stop: async (_ctx) => {
1446
+ setPluginServiceStateDir(null);
1447
+ log$1.info("service stopped");
1448
+ }
1449
+ });
1450
+ }
1451
+ //#endregion
1452
+ //#region src/services/register.ts
1453
+ /**
1454
+ * Registers all plugin-managed OpenClaw services for this extension.
1455
+ */
1456
+ function registerAllPluginServices(api) {
1457
+ registerContextWindowCheckService(api);
1458
+ }
1459
+ //#endregion
1338
1460
  //#region index.ts
1339
1461
  const log = waLogger("plugin");
1340
1462
  /**
1341
- * Default export: OpenClaw plugin manifest + `register` hook.
1463
+ * OpenClaw plugin metadata and registration hook. Default export of this package.
1464
+ *
1465
+ * Channel behaviour is implemented by {@link createWhatsAppApiChannel}; hooks and
1466
+ * services are aggregated via {@link registerAllPluginHooks} and
1467
+ * {@link registerAllPluginServices}.
1342
1468
  */
1343
1469
  const plugin = {
1344
1470
  id: "whatsapp-api",
@@ -1348,6 +1474,8 @@ const plugin = {
1348
1474
  setPluginApi(api);
1349
1475
  api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
1350
1476
  log.info("plugin registered");
1477
+ registerAllPluginHooks(api);
1478
+ registerAllPluginServices(api);
1351
1479
  }
1352
1480
  };
1353
1481
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laburen/openclaw-plugin-whatsapp-api",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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."