@emotion-machine/claw-messenger 0.1.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 ADDED
@@ -0,0 +1,68 @@
1
+ # @emotion-machine/claw-messenger
2
+
3
+ iMessage, RCS & SMS channel plugin for [OpenClaw](https://openclaw.ai) — no phone or Mac Mini required. See [Claw Messenger](https://clawmessenger.com) for more details.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ openclaw plugins install @emotion-machine/claw-messenger
9
+ ```
10
+
11
+ ## Configuration
12
+
13
+ After installing, add to your OpenClaw config under `channels`:
14
+
15
+ ```json5
16
+ {
17
+ "channels": {
18
+ "claw-messenger": {
19
+ "accounts": {
20
+ "default": {
21
+ "enabled": true,
22
+ "apiKey": "cm_live_XXXXXXXX_YYYYYYYYYYYYYY",
23
+ "serverUrl": "wss://claw-messenger.onrender.com",
24
+ "preferredService": "iMessage", // "iMessage" | "RCS" | "SMS"
25
+ "dmPolicy": "pairing", // "open" | "pairing" | "allowlist"
26
+ "allowFrom": ["+15551234567"] // only used with "allowlist" policy
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ ## Features
35
+
36
+ - **Send & receive** text messages and media (images, video, audio, documents)
37
+ - **iMessage reactions** — love, like, dislike, laugh, emphasize, question (tapback)
38
+ - **Group chats** — send to existing groups or create new ones
39
+ - **Typing indicators** — sent and received
40
+ - **DM security policies** — open, pairing-based approval, or allowlist
41
+
42
+ ## Agent Tools
43
+
44
+ The plugin registers two tools your agent can call:
45
+
46
+ | Tool | Description |
47
+ |------|-------------|
48
+ | `claw_messenger_status` | Check connection status, server URL, and preferred service |
49
+ | `claw_messenger_switch_service` | Switch the preferred messaging service at runtime |
50
+
51
+ ## Slash Commands
52
+
53
+ | Command | Description |
54
+ |---------|-------------|
55
+ | `/cm-status` | Show connection state, server URL, and preferred service |
56
+ | `/cm-switch <service>` | Switch preferred service (`iMessage`, `RCS`, or `SMS`) |
57
+
58
+ ## Getting Started
59
+
60
+ 1. Sign up at [clawmessenger.com](https://clawmessenger.com)
61
+ 2. Create an API key from the dashboard
62
+ 3. Install the plugin: `openclaw plugins install @emotion-machine/claw-messenger`
63
+ 4. Add the config above with your API key
64
+ 5. Start a conversation — your agent can now send and receive messages
65
+
66
+ ## License
67
+
68
+ UNLICENSED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Claw Messenger channel plugin — adapted from channel-linq.
3
+ * All Linq API interaction is replaced by WebSocket communication to claw-messenger.
4
+ */
5
+ import { type ChannelPlugin } from "openclaw/plugin-sdk";
6
+ import { type ClawMessengerConfig } from "./config.js";
7
+ interface ResolvedAccount {
8
+ accountId: string;
9
+ enabled: boolean;
10
+ apiKey: string;
11
+ serverUrl: string;
12
+ preferredService: string;
13
+ config: ClawMessengerConfig;
14
+ }
15
+ export declare function getConnectionStatus(): {
16
+ connected: boolean;
17
+ serverUrl: string;
18
+ preferredService: string;
19
+ accountId: string;
20
+ };
21
+ export declare const clawMessengerPlugin: ChannelPlugin<ResolvedAccount>;
22
+ export {};
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Claw Messenger channel plugin — adapted from channel-linq.
3
+ * All Linq API interaction is replaced by WebSocket communication to claw-messenger.
4
+ */
5
+ import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, formatPairingApproveHint, normalizeE164, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk";
6
+ import { ClawMessengerConfigSchema } from "./config.js";
7
+ import { getRuntime } from "./runtime.js";
8
+ import { WsClient } from "./ws/client.js";
9
+ import { sendText, sendMedia, sendToGroup, sendGroupMedia, sendToNewGroup } from "./outbound/send.js";
10
+ const EMOJI_TO_REACTION = {
11
+ "❤️": "love", "♥️": "love", "🩷": "love", "💕": "love", "😍": "love",
12
+ "👍": "like", "👍🏻": "like", "👍🏼": "like", "👍🏽": "like", "👍🏾": "like", "👍🏿": "like",
13
+ "👎": "dislike", "👎🏻": "dislike", "👎🏼": "dislike", "👎🏽": "dislike", "👎🏾": "dislike", "👎🏿": "dislike",
14
+ "😂": "laugh", "🤣": "laugh", "😆": "laugh",
15
+ "‼️": "emphasize", "❗": "emphasize", "❕": "emphasize", "⚡": "emphasize",
16
+ "❓": "question", "❔": "question", "🤔": "question",
17
+ };
18
+ function emojiToReaction(emoji) {
19
+ return EMOJI_TO_REACTION[emoji];
20
+ }
21
+ function resolveAccount(cfg, _accountId) {
22
+ const cloudConfig = (cfg.channels?.["claw-messenger"] ?? {});
23
+ return {
24
+ accountId: DEFAULT_ACCOUNT_ID,
25
+ enabled: cloudConfig.enabled !== false,
26
+ apiKey: cloudConfig.apiKey ?? "",
27
+ serverUrl: cloudConfig.serverUrl ?? "wss://claw-messenger.onrender.com",
28
+ preferredService: cloudConfig.preferredService ?? "iMessage",
29
+ config: cloudConfig,
30
+ };
31
+ }
32
+ // -- WS client per account --
33
+ const wsClients = new Map();
34
+ const lastMessageAt = new Map();
35
+ // -- Connection status helper (used by tools & commands in index.ts) --
36
+ export function getConnectionStatus() {
37
+ const runtime = getRuntime();
38
+ const cfg = runtime.config.loadConfig();
39
+ const account = resolveAccount(cfg);
40
+ const ws = wsClients.get(account.accountId);
41
+ return {
42
+ connected: Boolean(ws?.connected),
43
+ serverUrl: account.serverUrl,
44
+ preferredService: account.preferredService,
45
+ accountId: account.accountId,
46
+ };
47
+ }
48
+ // -- Channel Plugin --
49
+ const meta = {
50
+ label: "Claw Messenger",
51
+ selectionLabel: "Claw Messenger (via claw-messenger proxy)",
52
+ detailLabel: "Claw Messenger",
53
+ docsPath: "/channels/claw-messenger",
54
+ docsLabel: "claw-messenger",
55
+ blurb: "iMessage, RCS & SMS without a Mac or Linq account — via shared claw-messenger proxy.",
56
+ systemImage: "message.fill",
57
+ };
58
+ export const clawMessengerPlugin = {
59
+ id: "claw-messenger",
60
+ meta: {
61
+ ...meta,
62
+ aliases: ["claw-messenger"],
63
+ },
64
+ pairing: {
65
+ idLabel: "phoneNumber",
66
+ notifyApproval: async ({ cfg, id }) => {
67
+ const account = resolveAccount(cfg);
68
+ const ws = wsClients.get(account.accountId);
69
+ if (!ws)
70
+ throw new Error("Not connected to claw-messenger");
71
+ await sendText(ws, id, PAIRING_APPROVED_MESSAGE, account.preferredService);
72
+ },
73
+ },
74
+ capabilities: {
75
+ chatTypes: ["direct", "group"],
76
+ reactions: true,
77
+ threads: true,
78
+ media: true,
79
+ nativeCommands: false,
80
+ blockStreaming: true,
81
+ },
82
+ reload: { configPrefixes: ["channels.claw-messenger"] },
83
+ configSchema: buildChannelConfigSchema(ClawMessengerConfigSchema),
84
+ config: {
85
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
86
+ resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
87
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
88
+ setAccountEnabled: ({ cfg, enabled }) => {
89
+ return {
90
+ ...cfg,
91
+ channels: {
92
+ ...cfg.channels,
93
+ "claw-messenger": {
94
+ ...(cfg.channels?.["claw-messenger"] ?? {}),
95
+ enabled,
96
+ },
97
+ },
98
+ };
99
+ },
100
+ deleteAccount: ({ cfg }) => cfg,
101
+ isConfigured: (account) => Boolean(account.apiKey?.trim()),
102
+ describeAccount: (account) => ({
103
+ accountId: account.accountId,
104
+ enabled: account.enabled,
105
+ configured: Boolean(account.apiKey?.trim()),
106
+ serverUrl: account.serverUrl,
107
+ }),
108
+ resolveAllowFrom: ({ cfg }) => (resolveAccount(cfg).config.allowFrom ?? []).map(String),
109
+ formatAllowFrom: ({ allowFrom }) => allowFrom.map((e) => String(e).trim()).filter(Boolean),
110
+ },
111
+ security: {
112
+ resolveDmPolicy: ({ account }) => ({
113
+ policy: account.config.dmPolicy ?? "pairing",
114
+ allowFrom: account.config.allowFrom ?? [],
115
+ policyPath: "channels.claw-messenger.dmPolicy",
116
+ allowFromPath: "channels.claw-messenger.",
117
+ approveHint: formatPairingApproveHint("claw-messenger"),
118
+ }),
119
+ collectWarnings: ({ cfg }) => {
120
+ const account = resolveAccount(cfg);
121
+ const warnings = [];
122
+ if (account.serverUrl && account.serverUrl.startsWith("ws://")) {
123
+ warnings.push("Server URL uses insecure ws:// — consider switching to wss://");
124
+ }
125
+ return warnings;
126
+ },
127
+ },
128
+ messaging: {
129
+ normalizeTarget: (target) => {
130
+ const trimmed = target.trim();
131
+ if (!trimmed)
132
+ return null;
133
+ const normalized = normalizeE164(trimmed);
134
+ return normalized ?? trimmed;
135
+ },
136
+ targetResolver: {
137
+ looksLikeId: (raw) => {
138
+ const trimmed = raw?.trim();
139
+ if (!trimmed)
140
+ return false;
141
+ return /^\+?\d{10,15}$/.test(trimmed);
142
+ },
143
+ hint: "+1XXXXXXXXXX",
144
+ },
145
+ },
146
+ agentPrompt: {
147
+ messageToolHints: () => [
148
+ "You can react to messages using iMessage tapbacks via the react action. Available: love (❤️), like (👍), dislike (👎), laugh (😂), emphasize (‼️), question (❓).",
149
+ "Use reactions naturally — the way a real person would in iMessage.",
150
+ ],
151
+ },
152
+ actions: {
153
+ listActions: () => ["send", "react"],
154
+ handleAction: async ({ action, params, cfg }) => {
155
+ const account = resolveAccount(cfg);
156
+ const ws = wsClients.get(account.accountId);
157
+ if (!ws)
158
+ throw new Error("Not connected to claw-messenger");
159
+ if (action === "react") {
160
+ const messageId = params.messageId;
161
+ if (!messageId)
162
+ throw new Error("messageId is required for react action");
163
+ const emoji = params.emoji?.trim() ?? "❤️";
164
+ const remove = params.remove === true;
165
+ const reactionType = emojiToReaction(emoji) ?? "love";
166
+ ws.send({
167
+ type: "reaction",
168
+ messageId,
169
+ reactionType,
170
+ remove,
171
+ });
172
+ return {
173
+ content: [{ type: "text", text: JSON.stringify({ ok: true, action: remove ? "removed" : "added", reaction: reactionType }) }],
174
+ };
175
+ }
176
+ if (action === "send") {
177
+ const to = params.to;
178
+ const chatId = params.chatId;
179
+ const text = params.message ?? "";
180
+ const mediaUrl = params.media?.trim();
181
+ if (!text && !mediaUrl)
182
+ throw new Error("message or media is required");
183
+ if (chatId) {
184
+ // Send to existing group by chatId
185
+ if (mediaUrl) {
186
+ const result = await sendGroupMedia(ws, chatId, mediaUrl, text || undefined, account.preferredService);
187
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }) }] };
188
+ }
189
+ const result = await sendToGroup(ws, chatId, text, account.preferredService);
190
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }) }] };
191
+ }
192
+ else if (Array.isArray(to) && to.length > 1) {
193
+ // Create new group
194
+ const result = await sendToNewGroup(ws, to, text, account.preferredService);
195
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }) }] };
196
+ }
197
+ else {
198
+ // DM send
199
+ const target = (Array.isArray(to) ? to[0] : to)?.trim();
200
+ if (!target)
201
+ throw new Error("to is required");
202
+ if (mediaUrl) {
203
+ const result = await sendMedia(ws, target, mediaUrl, text || undefined, account.preferredService);
204
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }) }] };
205
+ }
206
+ const result = await sendText(ws, target, text, account.preferredService);
207
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...result }) }] };
208
+ }
209
+ }
210
+ throw new Error(`Action ${action} not supported`);
211
+ },
212
+ },
213
+ outbound: {
214
+ deliveryMode: "direct",
215
+ chunker: (text, limit) => getRuntime().channel.text.chunkText(text, limit),
216
+ chunkerMode: "text",
217
+ textChunkLimit: 10000,
218
+ sendText: async ({ to, text, cfg }) => {
219
+ if (!text?.trim())
220
+ return { channel: "claw-messenger", messageId: "", chatId: "" };
221
+ const account = resolveAccount(cfg);
222
+ const ws = wsClients.get(account.accountId);
223
+ if (!ws)
224
+ throw new Error("Not connected to claw-messenger");
225
+ const result = await sendText(ws, to, text, account.preferredService);
226
+ return { channel: "claw-messenger", ...result };
227
+ },
228
+ sendMedia: async ({ to, text, mediaUrl, cfg }) => {
229
+ const account = resolveAccount(cfg);
230
+ const ws = wsClients.get(account.accountId);
231
+ if (!ws)
232
+ throw new Error("Not connected to claw-messenger");
233
+ const result = await sendMedia(ws, to, mediaUrl, text || undefined, account.preferredService);
234
+ return { channel: "claw-messenger", ...result };
235
+ },
236
+ },
237
+ status: {
238
+ defaultRuntime: {
239
+ accountId: DEFAULT_ACCOUNT_ID,
240
+ running: false,
241
+ lastStartAt: null,
242
+ lastStopAt: null,
243
+ lastError: null,
244
+ },
245
+ collectStatusIssues: (accounts) => {
246
+ if (!Array.isArray(accounts))
247
+ return [];
248
+ return accounts.flatMap((account) => {
249
+ const issues = [];
250
+ if (!account.configured) {
251
+ issues.push({
252
+ channel: "claw-messenger",
253
+ accountId: account.accountId ?? "default",
254
+ kind: "config",
255
+ message: "API key not configured",
256
+ });
257
+ }
258
+ return issues;
259
+ });
260
+ },
261
+ buildChannelSummary: ({ snapshot }) => ({
262
+ configured: snapshot.configured ?? false,
263
+ running: snapshot.running ?? false,
264
+ lastStartAt: snapshot.lastStartAt ?? null,
265
+ lastStopAt: snapshot.lastStopAt ?? null,
266
+ lastError: snapshot.lastError ?? null,
267
+ }),
268
+ probeAccount: async ({ account }) => {
269
+ if (!account.apiKey)
270
+ return { ok: false, error: "No API key" };
271
+ const ws = wsClients.get(account.accountId);
272
+ return { ok: Boolean(ws?.connected), connected: Boolean(ws?.connected) };
273
+ },
274
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
275
+ accountId: account.accountId,
276
+ enabled: account.enabled,
277
+ configured: Boolean(account.apiKey?.trim()),
278
+ serverUrl: account.serverUrl,
279
+ running: runtime?.running ?? false,
280
+ lastStartAt: runtime?.lastStartAt ?? null,
281
+ lastStopAt: runtime?.lastStopAt ?? null,
282
+ lastError: runtime?.lastError ?? null,
283
+ mode: "websocket",
284
+ probe,
285
+ }),
286
+ },
287
+ gateway: {
288
+ startAccount: async (ctx) => {
289
+ const account = ctx.account;
290
+ const apiKey = account.apiKey?.trim();
291
+ const serverUrl = account.serverUrl?.trim();
292
+ const resolvedAccountId = account.accountId;
293
+ if (!apiKey)
294
+ throw new Error("API key not configured");
295
+ if (!serverUrl)
296
+ throw new Error("Server URL not configured");
297
+ const runtime = getRuntime();
298
+ const ws = new WsClient({
299
+ serverUrl,
300
+ apiKey,
301
+ onConnect: () => {
302
+ ctx.log?.info(`[${resolvedAccountId}] Connected to claw-messenger`);
303
+ // Send sync request to replay missed messages
304
+ const since = lastMessageAt.get(resolvedAccountId);
305
+ if (since) {
306
+ try {
307
+ ws.send({ type: "sync", since });
308
+ ctx.log?.info(`[${resolvedAccountId}] Sent sync request (since=${since})`);
309
+ }
310
+ catch { }
311
+ }
312
+ },
313
+ onDisconnect: () => {
314
+ ctx.log?.warn?.(`[${resolvedAccountId}] Disconnected from claw-messenger`);
315
+ },
316
+ onMessage: async (data) => {
317
+ if (data.type === "message") {
318
+ // Track last message timestamp for sync on reconnect
319
+ lastMessageAt.set(resolvedAccountId, new Date().toISOString());
320
+ await handleInboundMessage(data, resolvedAccountId, account, ctx);
321
+ }
322
+ else if (data.type === "reaction") {
323
+ const action = data.removed ? "removed" : "added";
324
+ ctx.log?.info(`[${resolvedAccountId}] Reaction from ${data.from}: ${data.reactionType} on ${data.messageId} (${action})`);
325
+ }
326
+ else if (data.type === "typing") {
327
+ const state = data.started ? "started" : "stopped";
328
+ ctx.log?.info(`[${resolvedAccountId}] Typing ${state} from ${data.from}`);
329
+ }
330
+ else if (data.type === "status") {
331
+ if (data.status === "failed") {
332
+ ctx.log?.error?.(`[${resolvedAccountId}] Message ${data.messageId} status: ${data.status}`);
333
+ }
334
+ else {
335
+ ctx.log?.info(`[${resolvedAccountId}] Message ${data.messageId} status: ${data.status}`);
336
+ }
337
+ }
338
+ else if (data.type === "sync.done") {
339
+ ctx.log?.info(`[${resolvedAccountId}] Sync complete: ${data.count} messages replayed`);
340
+ }
341
+ else if (data.type === "error") {
342
+ ctx.log?.error?.(`[${resolvedAccountId}] Server error: ${data.message}`);
343
+ }
344
+ },
345
+ log: (msg) => ctx.log?.debug?.(msg),
346
+ });
347
+ ws.connect();
348
+ wsClients.set(resolvedAccountId, ws);
349
+ // Handle abort
350
+ const stopHandler = () => {
351
+ ctx.log?.info(`[${resolvedAccountId}] Stopping Claw Messenger provider`);
352
+ ws.stop();
353
+ wsClients.delete(resolvedAccountId);
354
+ };
355
+ ctx.abortSignal?.addEventListener("abort", stopHandler);
356
+ return {
357
+ stop: () => {
358
+ stopHandler();
359
+ ctx.abortSignal?.removeEventListener("abort", stopHandler);
360
+ },
361
+ };
362
+ },
363
+ logoutAccount: async ({ cfg }) => {
364
+ const account = resolveAccount(cfg);
365
+ const ws = wsClients.get(account.accountId);
366
+ if (ws) {
367
+ ws.stop();
368
+ wsClients.delete(account.accountId);
369
+ }
370
+ return { cleared: false, loggedOut: true };
371
+ },
372
+ },
373
+ };
374
+ // -- Inbound message handler --
375
+ async function handleInboundMessage(data, accountId, account, ctx) {
376
+ const from = data.from;
377
+ const text = data.text ?? "";
378
+ const messageId = data.messageId ?? "";
379
+ const attachments = data.attachments ?? [];
380
+ const isGroup = data.isGroup === true;
381
+ const chatId = data.chatId;
382
+ const participants = data.participants ?? [];
383
+ const runtime = getRuntime();
384
+ const cfg = runtime.config.loadConfig();
385
+ // Resolve routing — group by chatId, DM by sender phone
386
+ const route = runtime.channel.routing.resolveAgentRoute({
387
+ cfg,
388
+ channel: "claw-messenger",
389
+ accountId,
390
+ peer: isGroup
391
+ ? { kind: "group", id: chatId }
392
+ : { kind: "dm", id: from },
393
+ });
394
+ // Download media
395
+ const allMedia = [];
396
+ for (const attachment of attachments) {
397
+ try {
398
+ const saved = await runtime.channel.media.fetchRemoteMedia({ url: attachment.url });
399
+ const stored = await runtime.channel.media.saveMediaBuffer(saved.buffer, saved.contentType ?? attachment.mimeType, "inbound", 10 * 1024 * 1024);
400
+ allMedia.push({ path: stored.path, contentType: stored.contentType });
401
+ }
402
+ catch (err) {
403
+ ctx.log?.warn?.(`[${accountId}] Failed to download attachment: ${err}`);
404
+ }
405
+ }
406
+ // Build inbound envelope
407
+ const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
408
+ agentId: route.agentId,
409
+ });
410
+ const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
411
+ const previousTimestamp = runtime.channel.session.readSessionUpdatedAt(storePath, route.sessionKey);
412
+ const rawBody = text || (allMedia.length > 0 ? "<media:image>" : "");
413
+ if (!rawBody)
414
+ return;
415
+ const body = runtime.channel.reply.formatInboundEnvelope({
416
+ channel: "Claw Messenger",
417
+ from,
418
+ timestamp: Date.now(),
419
+ body: rawBody,
420
+ chatType: isGroup ? "group" : "direct",
421
+ sender: { id: from },
422
+ previousTimestamp,
423
+ envelope: envelopeOptions,
424
+ });
425
+ const ctxPayload = runtime.channel.reply.finalizeInboundContext({
426
+ Body: body,
427
+ RawBody: rawBody,
428
+ CommandBody: rawBody,
429
+ From: `claw-messenger:${from}`,
430
+ To: `claw-messenger:shared`,
431
+ SessionKey: route.sessionKey,
432
+ AccountId: route.accountId,
433
+ ChatType: isGroup ? "group" : "direct",
434
+ ConversationLabel: isGroup ? chatId : from,
435
+ SenderId: from,
436
+ Provider: "claw-messenger",
437
+ Surface: "claw-messenger",
438
+ MessageSid: messageId,
439
+ Timestamp: Date.now(),
440
+ MediaPath: allMedia[0]?.path ?? "",
441
+ MediaType: allMedia[0]?.contentType,
442
+ MediaUrl: allMedia[0]?.path ?? "",
443
+ MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
444
+ MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
445
+ MediaTypes: allMedia.length > 0
446
+ ? allMedia.map((m) => m.contentType).filter((t) => Boolean(t))
447
+ : undefined,
448
+ OriginatingChannel: "claw-messenger",
449
+ OriginatingTo: isGroup ? `claw-messenger:group:${chatId}` : `claw-messenger:${from}`,
450
+ });
451
+ void runtime.channel.session.recordSessionMetaFromInbound({
452
+ storePath,
453
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
454
+ ctx: ctxPayload,
455
+ }).catch(() => { });
456
+ const ws = wsClients.get(accountId);
457
+ // Mark as read + start typing (DM only — skip for groups)
458
+ if (!isGroup && ws) {
459
+ ws.send({ type: "read", to: from });
460
+ ws.send({ type: "typing.start", to: from });
461
+ }
462
+ // Dispatch to auto-reply
463
+ try {
464
+ const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg);
465
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
466
+ ctx: ctxPayload,
467
+ cfg,
468
+ dispatcherOptions: {
469
+ responsePrefix: messagesConfig.responsePrefix,
470
+ deliver: async (payload) => {
471
+ const replyText = payload.text ?? "";
472
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
473
+ if (isGroup && chatId && ws) {
474
+ // Group: send by chatId
475
+ if (replyText) {
476
+ await sendToGroup(ws, chatId, replyText, account.preferredService);
477
+ }
478
+ for (const url of mediaUrls) {
479
+ await sendGroupMedia(ws, chatId, url, undefined, account.preferredService);
480
+ }
481
+ }
482
+ else if (ws) {
483
+ // DM: send by phone
484
+ if (replyText) {
485
+ await sendText(ws, from, replyText, account.preferredService);
486
+ }
487
+ for (const url of mediaUrls) {
488
+ await sendMedia(ws, from, url, undefined, account.preferredService);
489
+ }
490
+ }
491
+ },
492
+ onError: (err) => {
493
+ ctx.log?.error?.(`[${accountId}] Reply delivery failed: ${err}`);
494
+ },
495
+ },
496
+ replyOptions: {},
497
+ });
498
+ }
499
+ finally {
500
+ // Stop typing (DM only)
501
+ if (!isGroup && ws) {
502
+ ws.send({ type: "typing.stop", to: from });
503
+ }
504
+ }
505
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ export declare const ClawMessengerConfigSchema: z.ZodObject<{
3
+ enabled: z.ZodDefault<z.ZodBoolean>;
4
+ apiKey: z.ZodString;
5
+ serverUrl: z.ZodDefault<z.ZodString>;
6
+ preferredService: z.ZodDefault<z.ZodEnum<{
7
+ iMessage: "iMessage";
8
+ RCS: "RCS";
9
+ SMS: "SMS";
10
+ }>>;
11
+ dmPolicy: z.ZodDefault<z.ZodEnum<{
12
+ open: "open";
13
+ pairing: "pairing";
14
+ allowlist: "allowlist";
15
+ }>>;
16
+ allowFrom: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
+ groupPolicy: z.ZodDefault<z.ZodEnum<{
18
+ open: "open";
19
+ allowlist: "allowlist";
20
+ disabled: "disabled";
21
+ }>>;
22
+ groupAllowFrom: z.ZodOptional<z.ZodArray<z.ZodString>>;
23
+ }, z.core.$strip>;
24
+ export type ClawMessengerConfig = z.infer<typeof ClawMessengerConfigSchema>;
package/dist/config.js ADDED
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ export const ClawMessengerConfigSchema = z.object({
3
+ enabled: z.boolean().default(false),
4
+ apiKey: z.string().describe("Claw-messenger API key (cm_live_...)"),
5
+ serverUrl: z
6
+ .string()
7
+ .default("wss://claw-messenger.onrender.com")
8
+ .describe("Claw-messenger WebSocket server URL"),
9
+ preferredService: z
10
+ .enum(["iMessage", "RCS", "SMS"])
11
+ .default("iMessage")
12
+ .describe("Preferred delivery service"),
13
+ dmPolicy: z.enum(["open", "pairing", "allowlist"]).default("pairing"),
14
+ allowFrom: z.array(z.string()).optional(),
15
+ groupPolicy: z.enum(["open", "disabled", "allowlist"]).default("open"),
16
+ groupAllowFrom: z.array(z.string()).optional(),
17
+ });
@@ -0,0 +1,9 @@
1
+ import type { OpenclawPluginApi } from "openclaw/plugin-sdk";
2
+ declare const plugin: {
3
+ id: string;
4
+ name: string;
5
+ description: string;
6
+ configSchema: any;
7
+ register(api: OpenclawPluginApi): void;
8
+ };
9
+ export default plugin;