@laburen/openclaw-plugin-whatsapp-api 0.4.0 → 0.6.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/index.js CHANGED
@@ -419,6 +419,48 @@ async function sendWhatsAppApiMedia(params) {
419
419
  }
420
420
  });
421
421
  }
422
+ /**
423
+ * Marks a message as read in the WhatsApp Cloud API.
424
+ *
425
+ * @param params.messageId - The WhatsApp message ID to mark as read
426
+ * @param params.account - Resolved account for Graph auth
427
+ * @returns Graph message id (usually just "unknown" or empty for read marks)
428
+ */
429
+ async function sendWhatsAppApiReadMark(params) {
430
+ const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
431
+ return await sendWithRetry({
432
+ endpoint: `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`,
433
+ account: params.account,
434
+ accessToken,
435
+ payload: {
436
+ messaging_product: "whatsapp",
437
+ status: "read",
438
+ message_id: params.messageId
439
+ }
440
+ });
441
+ }
442
+ /**
443
+ * Shows/hides the typing indicator in the WhatsApp Cloud API.
444
+ *
445
+ * @param params.to - Recipient WhatsApp ID
446
+ * @param params.typing - `true` for "typing_on", `false` for "typing_off"
447
+ * @param params.account - Resolved account for Graph auth
448
+ * @returns Graph message id
449
+ */
450
+ async function sendWhatsAppApiTypingIndicator(params) {
451
+ const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
452
+ return await sendWithRetry({
453
+ endpoint: `https://graph.facebook.com/${params.account.outboundApiVersion}/${phoneNumberId}/messages`,
454
+ account: params.account,
455
+ accessToken,
456
+ payload: {
457
+ messaging_product: "whatsapp",
458
+ recipient_type: "individual",
459
+ to: normalizeRecipient(params.to),
460
+ sender_action: params.typing ? "typing_on" : "typing_off"
461
+ }
462
+ });
463
+ }
422
464
  async function sendWhatsAppApiLocation(params) {
423
465
  const { phoneNumberId, accessToken } = resolveGraphAuth(params.account);
424
466
  return await sendWithRetry({
@@ -1084,7 +1126,7 @@ async function handleWhatsAppApiWebhook(params) {
1084
1126
  //#endregion
1085
1127
  //#region src/channel/plugin.ts
1086
1128
  const CHANNEL_ID = "whatsapp-api";
1087
- const log$3 = waLogger("channel");
1129
+ const log$4 = waLogger("channel");
1088
1130
  const DEFAULT_ACCOUNT_ID = "default";
1089
1131
  /**
1090
1132
  * Resolves when `signal` aborts, or never if `signal` is undefined.
@@ -1153,7 +1195,7 @@ async function dispatchInboundToAgent(params) {
1153
1195
  const mediaAttachment = await downloadInboundMediaAttachment({
1154
1196
  account,
1155
1197
  message,
1156
- log: { warn: (msg) => log$3.warn(msg) }
1198
+ log: { warn: (msg) => log$4.warn(msg) }
1157
1199
  });
1158
1200
  const replyApi = rt.channel.reply;
1159
1201
  const ctxPayload = replyApi.finalizeInboundContext({
@@ -1181,9 +1223,33 @@ async function dispatchInboundToAgent(params) {
1181
1223
  OriginatingTo: to
1182
1224
  });
1183
1225
  const dispatchFn = replyApi.dispatchReplyWithBufferedBlockDispatcher;
1226
+ sendWhatsAppApiReadMark({
1227
+ messageId: message.messageId,
1228
+ account
1229
+ }).catch((err) => log$4.warn("failed to mark read", {
1230
+ error: String(err),
1231
+ messageId: message.messageId
1232
+ }));
1184
1233
  await dispatchFn({
1185
1234
  ctx: ctxPayload,
1186
1235
  cfg,
1236
+ typing: {
1237
+ start: async () => {
1238
+ await sendWhatsAppApiTypingIndicator({
1239
+ to: message.from,
1240
+ typing: true,
1241
+ account
1242
+ });
1243
+ },
1244
+ stop: async () => {
1245
+ await sendWhatsAppApiTypingIndicator({
1246
+ to: message.from,
1247
+ typing: false,
1248
+ account
1249
+ });
1250
+ },
1251
+ onStartError: (err) => log$4.warn("failed to start typing", { error: String(err) })
1252
+ },
1187
1253
  dispatcherOptions: {
1188
1254
  deliver: async (payload) => {
1189
1255
  const account = resolveAccount(cfg, accountId);
@@ -1193,17 +1259,17 @@ async function dispatchInboundToAgent(params) {
1193
1259
  account
1194
1260
  });
1195
1261
  if (sent.length === 0) {
1196
- log$3.info("skipping outbound: reply payload empty", { messageId: message.messageId });
1262
+ log$4.info("skipping outbound: reply payload empty", { messageId: message.messageId });
1197
1263
  return;
1198
1264
  }
1199
- log$3.info(`sent ${sent.length} outbound message(s)`, {
1265
+ log$4.info(`sent ${sent.length} outbound message(s)`, {
1200
1266
  to: message.from,
1201
1267
  messageId: message.messageId
1202
1268
  });
1203
1269
  onOutbound?.();
1204
1270
  },
1205
1271
  onError: (err, info) => {
1206
- log$3.error(`${info?.kind ?? "reply"} dispatch failed`, {
1272
+ log$4.error(`${info?.kind ?? "reply"} dispatch failed`, {
1207
1273
  accountId,
1208
1274
  messageId: message.messageId,
1209
1275
  error: String(err)
@@ -1353,6 +1419,97 @@ function createWhatsAppApiChannel(api) {
1353
1419
  };
1354
1420
  }
1355
1421
  //#endregion
1422
+ //#region src/hooks/llm-output/handlers/forward-usage.ts
1423
+ /**
1424
+ * SPDX-License-Identifier: MIT
1425
+ *
1426
+ * Forwards LLM token usage from the `llm_output` hook to a configured HTTP endpoint.
1427
+ */
1428
+ const log$3 = waLogger("llm-output/forward-usage");
1429
+ /** Plugin extension config keys (see `openclaw.plugin.json`). */
1430
+ const LABUREN_WEB_APP_CONFIG_KEY = "LABUREN_WEB_APP_URL";
1431
+ const USER_PHONE_CONFIG_KEY = "userPhoneNumber";
1432
+ const USAGE_PATH = "/api/usage";
1433
+ const USAGE_FEATURE = "openclaw_run";
1434
+ const POST_TIMEOUT_MS = 3e3;
1435
+ function resolveUsageIngestUrl() {
1436
+ let baseRaw;
1437
+ try {
1438
+ baseRaw = getPluginApi().pluginConfig?.[LABUREN_WEB_APP_CONFIG_KEY];
1439
+ } catch {
1440
+ return null;
1441
+ }
1442
+ if (typeof baseRaw !== "string") return null;
1443
+ const base = baseRaw.trim().replace(/\/+$/, "");
1444
+ if (!base) return null;
1445
+ try {
1446
+ return new URL(USAGE_PATH, base).href;
1447
+ } catch {
1448
+ log$3.warn(`usage-forwarder: invalid ${LABUREN_WEB_APP_CONFIG_KEY} base URL`);
1449
+ return null;
1450
+ }
1451
+ }
1452
+ function resolveUserPhoneNumber() {
1453
+ let raw;
1454
+ try {
1455
+ raw = getPluginApi().pluginConfig?.[USER_PHONE_CONFIG_KEY];
1456
+ } catch {
1457
+ return null;
1458
+ }
1459
+ if (typeof raw !== "string") return null;
1460
+ const phone = raw.trim();
1461
+ return phone.length > 0 ? phone : null;
1462
+ }
1463
+ async function handleForwardLlmUsage(event, ctx) {
1464
+ const usage = event.usage;
1465
+ if (!usage) return;
1466
+ const endpoint = resolveUsageIngestUrl();
1467
+ if (!endpoint) return;
1468
+ const userPhoneNumber = resolveUserPhoneNumber();
1469
+ if (!userPhoneNumber) return;
1470
+ const input = usage.input ?? 0;
1471
+ const output = usage.output ?? 0;
1472
+ const cacheRead = usage.cacheRead ?? 0;
1473
+ const cacheWrite = usage.cacheWrite ?? 0;
1474
+ const total = usage.total ?? input + output + cacheRead + cacheWrite;
1475
+ const body = {
1476
+ feature: USAGE_FEATURE,
1477
+ runId: event.runId,
1478
+ sessionId: event.sessionId,
1479
+ userPhoneNumber,
1480
+ sessionKey: ctx.sessionKey,
1481
+ agentId: ctx.agentId,
1482
+ provider: event.provider,
1483
+ model: event.model,
1484
+ usage: {
1485
+ input,
1486
+ output,
1487
+ cacheRead,
1488
+ cacheWrite,
1489
+ total
1490
+ },
1491
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
1492
+ };
1493
+ try {
1494
+ const res = await fetch(endpoint, {
1495
+ method: "POST",
1496
+ headers: { "Content-Type": "application/json" },
1497
+ body: JSON.stringify(body),
1498
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS)
1499
+ });
1500
+ if (!res.ok) log$3.warn(`usage-forwarder: HTTP ${res.status}`);
1501
+ } catch (err) {
1502
+ log$3.warn(`usage-forwarder: request failed (${String(err)})`);
1503
+ }
1504
+ }
1505
+ //#endregion
1506
+ //#region src/hooks/llm-output/register.ts
1507
+ function registerLlmOutputHook(api) {
1508
+ api.on("llm_output", async (event, ctx) => {
1509
+ await handleForwardLlmUsage(event, ctx);
1510
+ });
1511
+ }
1512
+ //#endregion
1356
1513
  //#region src/plugin/plugin-state-dir.ts
1357
1514
  /**
1358
1515
  * SPDX-License-Identifier: MIT
@@ -1444,6 +1601,7 @@ function registerMessageReceivedHook(api) {
1444
1601
  */
1445
1602
  function registerAllPluginHooks(api) {
1446
1603
  registerMessageReceivedHook(api);
1604
+ registerLlmOutputHook(api);
1447
1605
  }
1448
1606
  //#endregion
1449
1607
  //#region src/services/context-window-check/register.ts
@@ -9,6 +9,14 @@
9
9
  "type": "object",
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
+ "LABUREN_WEB_APP_URL": {
13
+ "type": "string",
14
+ "description": "Laburen web app base URL (no trailing slash). LLM usage is POSTed to {base}/api/usage."
15
+ },
16
+ "userPhoneNumber": {
17
+ "type": "string",
18
+ "description": "WhatsApp user phone (E.164 or your normalised form). Sent on each usage POST so the backend can resolve the user."
19
+ },
12
20
  "defaults": {
13
21
  "type": "object",
14
22
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laburen/openclaw-plugin-whatsapp-api",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "WhatsApp API channel plugin for OpenClaw",
6
6
  "main": "index.js",