@emotion-machine/claw-messenger 0.1.12 → 0.1.14

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
@@ -57,8 +57,17 @@ The plugin registers two tools your agent can call:
57
57
  2. Create an API key from the dashboard
58
58
  3. Install the plugin: `openclaw plugins install @emotion-machine/claw-messenger`
59
59
  4. Add the config above with your API key
60
- 5. Start a conversation your agent can now send and receive messages
60
+ 5. Restart OpenClaw so the channel connects
61
+ 6. Check the listener before the first controlled send:
62
+
63
+ ```bash
64
+ curl https://claw-messenger.onrender.com/api/agent/readiness \
65
+ -H "Authorization: Bearer ${CLAW_API_KEY}"
66
+ ```
67
+
68
+ Continue only after the response is `{"ready":true,"action":"send_controlled_test"}`. If the action is `connect_agent`, restart OpenClaw and check again. Keep the key in the authorization header, never in the URL.
69
+ 7. Send one message to a phone you control, reply in the same thread, and confirm the reply reaches your agent
61
70
 
62
71
  ## License
63
72
 
64
- UNLICENSED
73
+ UNLICENSED
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,7 @@ 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>;
24
+ export declare function handleInboundMessage(data: Record<string, unknown>, accountId: string, account: ResolvedAccount, ctx: any): Promise<void>;
25
25
  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);
@@ -437,25 +439,38 @@ export const clawMessengerPlugin = {
437
439
  // versions doesn't include it. Newer OpenClaw calls this; older versions ignore it.
438
440
  clawMessengerPlugin.describeMessageTool = describeClawMessageTool;
439
441
  // -- Inbound message handler --
440
- 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 ?? [];
442
+ export async function handleInboundMessage(data, accountId, account, ctx) {
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,10 @@ 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({
492
+ storePath,
493
+ sessionKey: routeSessionKey,
494
+ });
477
495
  const rawBody = text || (allMedia.length > 0 ? "<media:image>" : "");
478
496
  if (!rawBody)
479
497
  return;
@@ -493,8 +511,8 @@ async function handleInboundMessage(data, accountId, account, ctx) {
493
511
  CommandBody: rawBody,
494
512
  From: `claw-messenger:${from}`,
495
513
  To: `claw-messenger:shared`,
496
- SessionKey: route.sessionKey,
497
- AccountId: route.accountId,
514
+ SessionKey: routeSessionKey,
515
+ AccountId: route.accountId || accountId,
498
516
  ChatType: isGroup ? "group" : "direct",
499
517
  ConversationLabel: isGroup ? chatId : from,
500
518
  SenderId: from,
@@ -515,7 +533,7 @@ async function handleInboundMessage(data, accountId, account, ctx) {
515
533
  });
516
534
  void runtime.channel.session.recordSessionMetaFromInbound({
517
535
  storePath,
518
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
536
+ sessionKey: ctxPayload.SessionKey ?? routeSessionKey,
519
537
  ctx: ctxPayload,
520
538
  }).catch(() => { });
521
539
  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
+ }
package/dist/index.js CHANGED
@@ -99,7 +99,7 @@ const plugin = {
99
99
  const ws = getWsClient(status.accountId);
100
100
  const diagnostics = ws?.getDiagnostics() ?? { errors: [], connectionLog: [] };
101
101
  const report = {
102
- plugin_version: "0.1.11",
102
+ plugin_version: "0.1.14",
103
103
  node_version: process.version,
104
104
  connected: status.connected,
105
105
  server_url: status.serverUrl,
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@emotion-machine/claw-messenger",
3
- "version": "0.1.12",
4
- "description": "iMessage, RCS & SMS channel plugin for OpenClaw no phone or Mac Mini required",
3
+ "version": "0.1.14",
4
+ "description": "iMessage, RCS & SMS channel plugin for OpenClaw agents, powered by Claw Messenger, the Mac-free iMessage API for AI agents. Send and receive real texts from Linux, Docker, Windows, or any cloud. No phone or Mac required.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -18,6 +18,10 @@
18
18
  "openclaw",
19
19
  "openclaw-plugin",
20
20
  "imessage",
21
+ "imessage-api",
22
+ "ai-agents",
23
+ "no-mac",
24
+ "claw-messenger",
21
25
  "rcs",
22
26
  "sms",
23
27
  "emotion-machine",
@@ -33,6 +37,7 @@
33
37
  "build": "tsc",
34
38
  "dev": "tsc --watch",
35
39
  "clean": "rm -rf dist",
40
+ "test": "npm run build && node --loader ./tests/openclaw-loader.mjs --test tests/*.test.mjs",
36
41
  "prepare": "test -d dist || npm run build",
37
42
  "prepublishOnly": "npm run clean && npm run build"
38
43
  },
@@ -199,7 +199,7 @@ declare module "openclaw/plugin-sdk" {
199
199
  };
200
200
  session: {
201
201
  resolveStorePath(store: any, opts?: { agentId?: string }): string;
202
- readSessionUpdatedAt(path: string, sessionKey: string): number | null;
202
+ readSessionUpdatedAt(ctx: { storePath: string; sessionKey: string }): number | null;
203
203
  recordSessionMetaFromInbound(ctx: any): Promise<void>;
204
204
  recordInboundSession(ctx: any): void;
205
205
  updateLastRoute(ctx: any): void;