@emotion-machine/claw-messenger 0.1.12 → 0.1.13

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/dist/channel.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ChannelPlugin } from "openclaw/plugin-sdk";
2
2
  import { type ClawMessengerConfig } from "./config.js";
3
3
  import { WsClient } from "./ws/client.js";
4
+ import { type SendResult } from "./outbound/send.js";
4
5
  interface ResolvedAccount {
5
6
  accountId: string;
6
7
  enabled: boolean;
@@ -18,8 +19,6 @@ export declare function getConnectionStatus(): {
18
19
  };
19
20
  export declare function createGroup(to: string[], text: string): Promise<{
20
21
  ok: boolean;
21
- messageId: string;
22
- chatId: string;
23
- }>;
22
+ } & SendResult>;
24
23
  export declare const clawMessengerPlugin: ChannelPlugin<ResolvedAccount>;
25
24
  export {};
package/dist/channel.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as pluginSdk from "openclaw/plugin-sdk";
2
2
  import { ClawMessengerConfigSchema } from "./config.js";
3
+ import { isInboundMessageEvent, normalizeInboundMessage, validateInboundRoute, } from "./inbound.js";
3
4
  import { getRuntime } from "./runtime.js";
4
5
  import { WsClient } from "./ws/client.js";
5
6
  import { normalizeDirectTarget, sendText, sendMedia, sendToGroup, sendGroupMedia, sendToNewGroup, } from "./outbound/send.js";
@@ -194,6 +195,7 @@ export const clawMessengerPlugin = {
194
195
  "You can react to messages using iMessage tapbacks via the react action. Available: love (❤️), like (👍), dislike (👎), laugh (😂), emphasize (‼️), question (❓).",
195
196
  "Use reactions naturally — the way a real person would in iMessage.",
196
197
  "If you encounter connection issues, the server is WebSocket-only — do NOT test with HTTP. Full API docs and troubleshooting: https://clawmessenger.com/docs",
198
+ "If a user is stuck connecting, the fix is almost always: make sure the Claw Messenger plugin is enabled and the API key is set. Verified setup guides to hand them: OpenClaw setup https://www.clawmessenger.com/blog/openclaw-imessage-setup-guide, Hermes setup https://www.clawmessenger.com/blog/hermes-imessage-setup, troubleshooting https://www.clawmessenger.com/blog/openclaw-imessage-troubleshooting.",
197
199
  ],
198
200
  },
199
201
  // Legacy API (OpenClaw <2026.3.22): kept for backward compatibility
@@ -369,7 +371,7 @@ export const clawMessengerPlugin = {
369
371
  ctx.log?.warn?.(`[${resolvedAccountId}] Disconnected from claw-messenger`);
370
372
  },
371
373
  onMessage: async (data) => {
372
- if (data.type === "message") {
374
+ if (isInboundMessageEvent(data)) {
373
375
  // Track last message timestamp for sync on reconnect
374
376
  lastMessageAt.set(resolvedAccountId, new Date().toISOString());
375
377
  await handleInboundMessage(data, resolvedAccountId, account, ctx);
@@ -438,24 +440,37 @@ export const clawMessengerPlugin = {
438
440
  clawMessengerPlugin.describeMessageTool = describeClawMessageTool;
439
441
  // -- Inbound message handler --
440
442
  async function handleInboundMessage(data, accountId, account, ctx) {
441
- const from = data.from;
442
- const text = data.text ?? "";
443
- const messageId = data.messageId ?? "";
444
- const attachments = data.attachments ?? [];
445
- const isGroup = data.isGroup === true;
446
- const chatId = data.chatId;
447
- const participants = data.participants ?? [];
443
+ const normalized = normalizeInboundMessage(data);
444
+ if (!normalized.ok) {
445
+ ctx.log?.warn?.(`[${accountId}] Dropping inbound message: ${normalized.reason}`);
446
+ return;
447
+ }
448
+ const { from, text, messageId, attachments, isGroup, chatId, participants, } = normalized.message;
448
449
  const runtime = getRuntime();
449
450
  const cfg = runtime.config.loadConfig();
450
451
  // Resolve routing — group by chatId, DM by sender phone
451
- const route = runtime.channel.routing.resolveAgentRoute({
452
- cfg,
453
- channel: "claw-messenger",
454
- accountId,
455
- peer: isGroup
456
- ? { kind: "group", id: chatId }
457
- : { kind: "dm", id: from },
458
- });
452
+ let rawRoute;
453
+ try {
454
+ rawRoute = runtime.channel.routing.resolveAgentRoute({
455
+ cfg,
456
+ channel: "claw-messenger",
457
+ accountId,
458
+ peer: isGroup
459
+ ? { kind: "group", id: chatId }
460
+ : { kind: "dm", id: from },
461
+ });
462
+ }
463
+ catch (err) {
464
+ ctx.log?.warn?.(`[${accountId}] Dropping inbound message from ${from}: route resolution failed (${err})`);
465
+ return;
466
+ }
467
+ const routeValidation = validateInboundRoute(rawRoute);
468
+ if (!routeValidation.ok) {
469
+ ctx.log?.warn?.(`[${accountId}] Dropping inbound message from ${from}: ${routeValidation.reason}`);
470
+ return;
471
+ }
472
+ const route = routeValidation.route;
473
+ const routeSessionKey = route.sessionKey;
459
474
  // Download media
460
475
  const allMedia = [];
461
476
  for (const attachment of attachments) {
@@ -473,7 +488,7 @@ async function handleInboundMessage(data, accountId, account, ctx) {
473
488
  agentId: route.agentId,
474
489
  });
475
490
  const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
476
- const previousTimestamp = runtime.channel.session.readSessionUpdatedAt(storePath, route.sessionKey);
491
+ const previousTimestamp = runtime.channel.session.readSessionUpdatedAt(storePath, routeSessionKey);
477
492
  const rawBody = text || (allMedia.length > 0 ? "<media:image>" : "");
478
493
  if (!rawBody)
479
494
  return;
@@ -493,8 +508,8 @@ async function handleInboundMessage(data, accountId, account, ctx) {
493
508
  CommandBody: rawBody,
494
509
  From: `claw-messenger:${from}`,
495
510
  To: `claw-messenger:shared`,
496
- SessionKey: route.sessionKey,
497
- AccountId: route.accountId,
511
+ SessionKey: routeSessionKey,
512
+ AccountId: route.accountId || accountId,
498
513
  ChatType: isGroup ? "group" : "direct",
499
514
  ConversationLabel: isGroup ? chatId : from,
500
515
  SenderId: from,
@@ -515,7 +530,7 @@ async function handleInboundMessage(data, accountId, account, ctx) {
515
530
  });
516
531
  void runtime.channel.session.recordSessionMetaFromInbound({
517
532
  storePath,
518
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
533
+ sessionKey: ctxPayload.SessionKey ?? routeSessionKey,
519
534
  ctx: ctxPayload,
520
535
  }).catch(() => { });
521
536
  const ws = wsClients.get(accountId);
@@ -0,0 +1,37 @@
1
+ export interface InboundAttachment {
2
+ url: string;
3
+ mimeType?: string;
4
+ }
5
+ export interface NormalizedInboundMessage {
6
+ from: string;
7
+ text: string;
8
+ messageId: string;
9
+ attachments: InboundAttachment[];
10
+ isGroup: boolean;
11
+ chatId: string;
12
+ participants: string[];
13
+ }
14
+ export interface ResolvedInboundRoute {
15
+ sessionKey: string;
16
+ accountId: string;
17
+ agentId?: string;
18
+ mainSessionKey?: string;
19
+ [key: string]: unknown;
20
+ }
21
+ export type InboundMessageValidation = {
22
+ ok: true;
23
+ message: NormalizedInboundMessage;
24
+ } | {
25
+ ok: false;
26
+ reason: string;
27
+ };
28
+ export type InboundRouteValidation = {
29
+ ok: true;
30
+ route: ResolvedInboundRoute;
31
+ } | {
32
+ ok: false;
33
+ reason: string;
34
+ };
35
+ export declare function isInboundMessageEvent(data: Record<string, unknown>): boolean;
36
+ export declare function normalizeInboundMessage(data: Record<string, unknown>): InboundMessageValidation;
37
+ export declare function validateInboundRoute(route: unknown): InboundRouteValidation;
@@ -0,0 +1,67 @@
1
+ function nonBlankString(value) {
2
+ if (typeof value !== "string")
3
+ return null;
4
+ const trimmed = value.trim();
5
+ return trimmed ? trimmed : null;
6
+ }
7
+ function normalizeAttachments(value) {
8
+ if (!Array.isArray(value))
9
+ return [];
10
+ return value.flatMap((attachment) => {
11
+ if (!attachment || typeof attachment !== "object")
12
+ return [];
13
+ const url = nonBlankString(attachment.url);
14
+ if (!url)
15
+ return [];
16
+ const mimeType = nonBlankString(attachment.mimeType) ?? undefined;
17
+ return [{ url, mimeType }];
18
+ });
19
+ }
20
+ function normalizeStringArray(value) {
21
+ if (!Array.isArray(value))
22
+ return [];
23
+ return value.map(nonBlankString).filter((item) => Boolean(item));
24
+ }
25
+ export function isInboundMessageEvent(data) {
26
+ return data.type === "message";
27
+ }
28
+ export function normalizeInboundMessage(data) {
29
+ const isGroup = data.isGroup === true;
30
+ const from = nonBlankString(data.from);
31
+ if (!from) {
32
+ return { ok: false, reason: "missing sender" };
33
+ }
34
+ const chatId = nonBlankString(data.chatId) ?? "";
35
+ if (isGroup && !chatId) {
36
+ return { ok: false, reason: "missing group chatId" };
37
+ }
38
+ return {
39
+ ok: true,
40
+ message: {
41
+ from,
42
+ text: nonBlankString(data.text) ?? "",
43
+ messageId: nonBlankString(data.messageId) ?? "",
44
+ attachments: normalizeAttachments(data.attachments),
45
+ isGroup,
46
+ chatId,
47
+ participants: normalizeStringArray(data.participants),
48
+ },
49
+ };
50
+ }
51
+ export function validateInboundRoute(route) {
52
+ if (!route || typeof route !== "object") {
53
+ return { ok: false, reason: "missing route" };
54
+ }
55
+ const sessionKey = nonBlankString(route.sessionKey);
56
+ if (!sessionKey) {
57
+ return { ok: false, reason: "missing route sessionKey" };
58
+ }
59
+ return {
60
+ ok: true,
61
+ route: {
62
+ ...route,
63
+ sessionKey,
64
+ accountId: nonBlankString(route.accountId) ?? "",
65
+ },
66
+ };
67
+ }
@@ -2,6 +2,19 @@ import type { WsClient } from "../ws/client.js";
2
2
  export interface SendResult {
3
3
  messageId: string;
4
4
  chatId: string;
5
+ status?: string;
6
+ requestedService?: string;
7
+ selectedService?: string;
8
+ fallbackAllowed?: boolean;
9
+ fallbackReason?: string;
10
+ deliveryStage?: string;
11
+ errorCode?: string;
12
+ retryable?: boolean;
13
+ setupProof?: {
14
+ required?: boolean;
15
+ status?: string;
16
+ message?: string;
17
+ };
5
18
  }
6
19
  export declare function normalizeDirectTarget(target: string): string | null;
7
20
  export declare function sendText(ws: WsClient, to: string, text: string, service?: string): Promise<SendResult>;
@@ -60,10 +60,7 @@ export async function sendMessage(ws, to, parts, service) {
60
60
  ...(service ? { service } : {}),
61
61
  });
62
62
  if (resp.ok) {
63
- return {
64
- messageId: resp.messageId ?? "",
65
- chatId: resp.chatId ?? "",
66
- };
63
+ return sendResultFromResponse(resp);
67
64
  }
68
65
  throw new Error(resp.error ?? "Send failed");
69
66
  }
@@ -86,10 +83,7 @@ async function sendGroupMessage(ws, chatId, parts, service) {
86
83
  ...(service ? { service } : {}),
87
84
  });
88
85
  if (resp.ok) {
89
- return {
90
- messageId: resp.messageId ?? "",
91
- chatId,
92
- };
86
+ return sendResultFromResponse(resp, chatId);
93
87
  }
94
88
  throw new Error(resp.error ?? "Group send failed");
95
89
  }
@@ -107,10 +101,22 @@ export async function sendToNewGroup(ws, to, text, service) {
107
101
  ...(service ? { service } : {}),
108
102
  });
109
103
  if (resp.ok) {
110
- return {
111
- messageId: resp.messageId ?? "",
112
- chatId: resp.chatId ?? "",
113
- };
104
+ return sendResultFromResponse(resp);
114
105
  }
115
106
  throw new Error(resp.error ?? "Group creation failed");
116
107
  }
108
+ function sendResultFromResponse(resp, fallbackChatId = "") {
109
+ return {
110
+ messageId: resp.messageId ?? "",
111
+ chatId: resp.chatId ?? fallbackChatId,
112
+ status: resp.status,
113
+ requestedService: resp.requestedService,
114
+ selectedService: resp.selectedService,
115
+ fallbackAllowed: resp.fallbackAllowed,
116
+ fallbackReason: resp.fallbackReason,
117
+ deliveryStage: resp.deliveryStage,
118
+ errorCode: resp.errorCode,
119
+ retryable: resp.retryable,
120
+ setupProof: resp.setupProof,
121
+ };
122
+ }
package/dist/ws/client.js CHANGED
@@ -7,6 +7,18 @@
7
7
  * against HTTP/2 servers like Render.com).
8
8
  */
9
9
  import WebSocket from "ws";
10
+ import { createRequire } from "node:module";
11
+ // Client fingerprint sent on the WS handshake so the server can tell which
12
+ // integration (and version) is connecting. Version is read at runtime from
13
+ // package.json; falls back to "unknown" in bundled environments without it.
14
+ const CLIENT_ID = `openclaw-plugin/${(() => {
15
+ try {
16
+ return createRequire(import.meta.url)("../../package.json").version;
17
+ }
18
+ catch {
19
+ return "unknown";
20
+ }
21
+ })()}`;
10
22
  const MAX_RECONNECT_DELAY_MS = 30_000;
11
23
  const MAX_RECONNECT_ATTEMPTS = 50;
12
24
  const MAX_DIAGNOSTIC_ENTRIES = 50;
@@ -119,6 +131,7 @@ export class WsClient {
119
131
  url.pathname = url.pathname.replace(/\/$/, "") + "/ws";
120
132
  }
121
133
  url.searchParams.set("key", this.opts.apiKey);
134
+ url.searchParams.set("client", CLIENT_ID);
122
135
  this.opts.log?.(`Connecting to ${url.origin}${url.pathname}...`);
123
136
  // Close the previous socket if it's still open, so we don't leak
124
137
  // connections on the server. Set this.ws to null first so the old
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emotion-machine/claw-messenger",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "iMessage, RCS & SMS channel plugin for OpenClaw — no phone or Mac Mini required",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -33,6 +33,7 @@
33
33
  "build": "tsc",
34
34
  "dev": "tsc --watch",
35
35
  "clean": "rm -rf dist",
36
+ "test": "npm run build && node --test tests/*.test.mjs",
36
37
  "prepare": "test -d dist || npm run build",
37
38
  "prepublishOnly": "npm run clean && npm run build"
38
39
  },