@relaymessenger/openclaw-plugin 0.2.0 → 0.3.3

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
@@ -4,10 +4,14 @@ Backs a Relay contact with an OpenClaw agent: install the plugin, point it at
4
4
  an owner-only Agent Token file, and your OpenClaw appears in Relay as a contact
5
5
  you text like a friend.
6
6
 
7
- Requires `openclaw >= 2026.7.2-beta.5`, which today means the OpenClaw beta
8
- channel: stable `2026.7.1-2` is too old, because the plugin derives its
9
- idempotency keys from the per-part delivery context that only the beta line
10
- provides. Install it with `npm install -g openclaw@beta`.
7
+ Requires `openclaw >= 2026.7.1-2`, which the stable channel satisfies today.
8
+
9
+ Cores from `2026.7.2-beta.5` onward tell the channel which part of a delivery
10
+ each chunk is, and the plugin keys its idempotent sends on that. Older cores do
11
+ not, so on those the plugin keys each chunk by a digest of the chunk's own
12
+ text. Both keep a retry replaying the same message instead of posting a second
13
+ one. The one thing an older core cannot do is separate two byte-identical
14
+ chunks of a single reply, which arrive as one message.
11
15
 
12
16
  ## Install
13
17
 
@@ -3,6 +3,17 @@
3
3
  // wiring. Transport logic lives in client/poll-loop/inbound/outbound modules;
4
4
  // this file owns the OpenClaw adapter surfaces.
5
5
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
6
+ /**
7
+ * Read core's part index without requiring it to exist. Cores before
8
+ * 2026.7.2-beta.5 have no `deliveryPartIndex` in their outbound context at all,
9
+ * so naming the field directly would not typecheck against them. Reading it
10
+ * through a widened shape keeps one source compiling on every supported core;
11
+ * `deriveRelayIdempotencyKey` handles the undefined case.
12
+ */
13
+ function deliveryPartIndexOf(ctx) {
14
+ const index = ctx.deliveryPartIndex;
15
+ return typeof index === "number" ? index : undefined;
16
+ }
6
17
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
7
18
  import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, } from "openclaw/plugin-sdk/channel-outbound";
8
19
  import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
@@ -16,6 +27,7 @@ import { buildRelayInboundFacts } from "./inbound.js";
16
27
  import { createRelayAccountLifecycleRegistry } from "./lifecycle.js";
17
28
  import { deriveRelayIdempotencyKey, RELAY_TEXT_CHUNK_LIMIT, reconcileRelayUnknownSend, sendRelayText, } from "./outbound.js";
18
29
  import { runRelayPollLoop } from "./poll-loop.js";
30
+ import { markRespondingBeforeAttempt } from "./responding.js";
19
31
  import { getRelayRuntime } from "./runtime.js";
20
32
  import { relaySenderIsAllowed, resolveRelayAllowedSenderIds } from "./security.js";
21
33
  export const RELAY_CHANNEL_ID = "relay";
@@ -131,12 +143,13 @@ const relayMessageAdapter = defineChannelMessageAdapter({
131
143
  conversationId: ctx.to,
132
144
  text: ctx.text,
133
145
  replyToId: ctx.replyToId ?? null,
134
- // Stable per (queueId, partIndex): internal retries replay the same
135
- // key, so the server-side idempotent commit makes duplicates
136
- // impossible by contract.
146
+ // Stable per (queueId, part): internal retries replay the same key,
147
+ // so the server-side idempotent commit makes duplicates impossible by
148
+ // contract. On a core with no part index the text names the part.
137
149
  idempotencyKey: deriveRelayIdempotencyKey({
138
150
  deliveryQueueId: ctx.deliveryQueueId,
139
- deliveryPartIndex: ctx.deliveryPartIndex,
151
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
152
+ partText: ctx.text,
140
153
  }),
141
154
  ...(ctx.signal ? { signal: ctx.signal } : {}),
142
155
  });
@@ -232,7 +245,13 @@ async function dispatchRelayInbound(params) {
232
245
  // Admission, runtime resolution, route/session lookup, envelope building,
233
246
  // and context finalization above are replay-safe. The durable attempt starts
234
247
  // immediately before OpenClaw can invoke the agent or its tools.
235
- await params.markAttempt();
248
+ await markRespondingBeforeAttempt({
249
+ client: params.client,
250
+ conversationId: facts.conversationId,
251
+ messageId: facts.messageId,
252
+ label: "OpenClaw",
253
+ markAttempt: params.markAttempt,
254
+ });
236
255
  await runtime.channel.inbound.dispatchReply({
237
256
  cfg: params.cfg,
238
257
  channel: RELAY_CHANNEL_ID,
@@ -422,11 +441,6 @@ async function startRelayAccount(ctx) {
422
441
  allowedSenderIds,
423
442
  markAttempt,
424
443
  });
425
- // Read watermark after the turn is handled: read implies delivered;
426
- // best effort — a failed receipt must not replay the event.
427
- await client
428
- .markRead({ conversationId: facts.conversationId, messageId: facts.messageId })
429
- .catch((error) => log(`[relay] markRead failed: ${String(error)}`));
430
444
  },
431
445
  });
432
446
  }
@@ -596,7 +610,8 @@ export const relayChannelPlugin = createChatChannelPlugin({
596
610
  // this invocation so two intentional identical sends remain two.
597
611
  idempotencyKey: deriveRelayIdempotencyKey({
598
612
  deliveryQueueId: ctx.deliveryQueueId,
599
- deliveryPartIndex: ctx.deliveryPartIndex,
613
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
614
+ partText: text,
600
615
  }),
601
616
  });
602
617
  return { messageId: result.messageId };
@@ -1,7 +1,6 @@
1
- // Thin Relay REST client for the OpenClaw channel plugin. Bespoke fetch until
2
- // the Relay SDK ships. Owns the abort-aware long poll, idempotent
3
- // sends, typing, and read watermarks. No SDK imports so unit tests run
4
- // without an OpenClaw runtime.
1
+ // Thin Relay REST client boundary for the standalone OpenClaw channel plugin.
2
+ // Owns the abort-aware long poll, idempotent sends, typing, responding, and
3
+ // read watermarks without loading an OpenClaw runtime in unit tests.
5
4
  import { isIP } from "node:net";
6
5
  export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
7
6
  function isLoopbackHostname(hostname) {
@@ -207,6 +206,17 @@ export function createRelayClient(options) {
207
206
  signal: params.signal,
208
207
  });
209
208
  },
209
+ setResponding: async (params) => {
210
+ await request({
211
+ method: "POST",
212
+ path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
213
+ body: {
214
+ message_id: params.messageId,
215
+ ...(params.label ? { label: params.label } : {}),
216
+ },
217
+ signal: params.signal,
218
+ });
219
+ },
210
220
  markRead: async (params) => {
211
221
  await request({
212
222
  method: "POST",
@@ -14,14 +14,33 @@ export const RELAY_TEXT_CHUNK_LIMIT = 2_000;
14
14
  const IDEMPOTENCY_KEY_MAX = 255;
15
15
  /**
16
16
  * Idempotency key for one logical send. When core supplies a durable delivery
17
- * queue id, the key is a stable function of (queueId, partIndex) so internal
17
+ * queue id, the key is a stable function of (queueId, part) so internal
18
18
  * retries and reconciliation replay the exact same key. Without a queue id a
19
19
  * fresh key is minted: identical intentional sends must remain distinct.
20
+ *
21
+ * The part term names WHICH piece of one delivery this is. Core supplies
22
+ * `deliveryPartIndex` from 2026.7.2-beta.5 onward and that is authoritative.
23
+ * Older cores do not: they call `enqueueDelivery` once, mint ONE queue id, and
24
+ * then hand the channel each chunk of a long reply under it. Defaulting the
25
+ * missing index to 0 would key every chunk to `...:0`, so the server would
26
+ * replay the first chunk for each of the rest and the person would receive a
27
+ * long answer truncated to its opening chunk with no error anywhere.
28
+ *
29
+ * So when core cannot say which part this is, the part term is a digest of the
30
+ * part's own text. Sibling chunks differ, so each commits; a retry reproduces
31
+ * the same text, so it replays. This is not the content-in-the-key mistake
32
+ * that defeats conflict detection: the digest stands in FOR the position core
33
+ * did not give us, it does not replace it. Two byte-identical chunks in one
34
+ * delivery do collapse to one message, which is the residual cost of an
35
+ * unindexed core and is bounded to a repeat the reader would see twice.
20
36
  */
21
37
  export function deriveRelayIdempotencyKey(params) {
22
38
  const queueId = params.deliveryQueueId?.trim();
39
+ const part = params.deliveryPartIndex ?? (params.partText === undefined
40
+ ? 0
41
+ : `t${createHash("sha256").update(params.partText).digest("hex").slice(0, 16)}`);
23
42
  const key = queueId
24
- ? `relay-send:${queueId}:${params.deliveryPartIndex ?? 0}`
43
+ ? `relay-send:${queueId}:${part}`
25
44
  : `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
26
45
  // Server accepts 8-255 chars; the prefix guarantees the minimum.
27
46
  if (key.length <= IDEMPOTENCY_KEY_MAX) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The receipt must commit before OpenClaw can run an agent or tool. A rejected
3
+ * receipt leaves the durable attempt marker untouched, so the poll loop can
4
+ * replay the event safely instead of hiding the failure after execution.
5
+ */
6
+ export async function markRespondingBeforeAttempt(params) {
7
+ await params.client.setResponding({
8
+ conversationId: params.conversationId,
9
+ messageId: params.messageId,
10
+ label: params.label,
11
+ });
12
+ await params.markAttempt();
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/openclaw-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.3",
4
4
  "description": "Relay channel plugin for OpenClaw. Text your OpenClaw like a friend.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -39,7 +39,7 @@
39
39
  "prepack": "npm run build"
40
40
  },
41
41
  "dependencies": {
42
- "@openclaw/fs-safe": "0.5.5"
42
+ "@openclaw/fs-safe": "0.5.6"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^26.2.0",
@@ -48,7 +48,7 @@
48
48
  "vitest": "^4.1.10"
49
49
  },
50
50
  "peerDependencies": {
51
- "openclaw": ">=2026.7.2-beta.5"
51
+ "openclaw": ">=2026.7.1-2"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "openclaw": {
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "openclaw": {
59
59
  "compat": {
60
- "pluginApi": ">=2026.7.2-beta.5"
60
+ "pluginApi": ">=2026.7.1-2"
61
61
  },
62
62
  "install": {
63
63
  "localPath": ".",
64
64
  "defaultChoice": "local",
65
- "minHostVersion": ">=2026.7.2-beta.5"
65
+ "minHostVersion": ">=2026.7.1-2"
66
66
  },
67
67
  "build": {
68
68
  "openclawVersion": "2026.7.2-beta.7"
package/src/channel.ts CHANGED
@@ -5,6 +5,18 @@
5
5
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
6
6
  import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
7
7
  import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
8
+
9
+ /**
10
+ * Read core's part index without requiring it to exist. Cores before
11
+ * 2026.7.2-beta.5 have no `deliveryPartIndex` in their outbound context at all,
12
+ * so naming the field directly would not typecheck against them. Reading it
13
+ * through a widened shape keeps one source compiling on every supported core;
14
+ * `deriveRelayIdempotencyKey` handles the undefined case.
15
+ */
16
+ function deliveryPartIndexOf(ctx: unknown): number | undefined {
17
+ const index = (ctx as { deliveryPartIndex?: unknown }).deliveryPartIndex;
18
+ return typeof index === "number" ? index : undefined;
19
+ }
8
20
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
9
21
  import {
10
22
  createMessageReceiptFromOutboundResults,
@@ -38,6 +50,7 @@ import {
38
50
  sendRelayText,
39
51
  } from "./outbound.js";
40
52
  import { runRelayPollLoop } from "./poll-loop.js";
53
+ import { markRespondingBeforeAttempt } from "./responding.js";
41
54
  import { getRelayRuntime } from "./runtime.js";
42
55
  import { relaySenderIsAllowed, resolveRelayAllowedSenderIds } from "./security.js";
43
56
  import type { RelayCoreConfig, ResolvedRelayAccount } from "./types.js";
@@ -163,12 +176,13 @@ const relayMessageAdapter = defineChannelMessageAdapter({
163
176
  conversationId: ctx.to,
164
177
  text: ctx.text,
165
178
  replyToId: ctx.replyToId ?? null,
166
- // Stable per (queueId, partIndex): internal retries replay the same
167
- // key, so the server-side idempotent commit makes duplicates
168
- // impossible by contract.
179
+ // Stable per (queueId, part): internal retries replay the same key,
180
+ // so the server-side idempotent commit makes duplicates impossible by
181
+ // contract. On a core with no part index the text names the part.
169
182
  idempotencyKey: deriveRelayIdempotencyKey({
170
183
  deliveryQueueId: ctx.deliveryQueueId,
171
- deliveryPartIndex: ctx.deliveryPartIndex,
184
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
185
+ partText: ctx.text,
172
186
  }),
173
187
  ...(ctx.signal ? { signal: ctx.signal } : {}),
174
188
  });
@@ -273,7 +287,13 @@ async function dispatchRelayInbound(params: {
273
287
  // Admission, runtime resolution, route/session lookup, envelope building,
274
288
  // and context finalization above are replay-safe. The durable attempt starts
275
289
  // immediately before OpenClaw can invoke the agent or its tools.
276
- await params.markAttempt();
290
+ await markRespondingBeforeAttempt({
291
+ client: params.client,
292
+ conversationId: facts.conversationId,
293
+ messageId: facts.messageId,
294
+ label: "OpenClaw",
295
+ markAttempt: params.markAttempt,
296
+ });
277
297
  await runtime.channel.inbound.dispatchReply({
278
298
  cfg: params.cfg,
279
299
  channel: RELAY_CHANNEL_ID,
@@ -479,11 +499,6 @@ async function startRelayAccount(ctx: ChannelGatewayContext<ResolvedRelayAccount
479
499
  allowedSenderIds,
480
500
  markAttempt,
481
501
  });
482
- // Read watermark after the turn is handled: read implies delivered;
483
- // best effort — a failed receipt must not replay the event.
484
- await client
485
- .markRead({ conversationId: facts.conversationId, messageId: facts.messageId })
486
- .catch((error) => log(`[relay] markRead failed: ${String(error)}`));
487
502
  },
488
503
  });
489
504
  } catch (error) {
@@ -659,7 +674,8 @@ export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> = createCha
659
674
  // this invocation so two intentional identical sends remain two.
660
675
  idempotencyKey: deriveRelayIdempotencyKey({
661
676
  deliveryQueueId: ctx.deliveryQueueId,
662
- deliveryPartIndex: ctx.deliveryPartIndex,
677
+ deliveryPartIndex: deliveryPartIndexOf(ctx),
678
+ partText: text,
663
679
  }),
664
680
  });
665
681
  return { messageId: result.messageId };
package/src/client.ts CHANGED
@@ -1,7 +1,6 @@
1
- // Thin Relay REST client for the OpenClaw channel plugin. Bespoke fetch until
2
- // the Relay SDK ships. Owns the abort-aware long poll, idempotent
3
- // sends, typing, and read watermarks. No SDK imports so unit tests run
4
- // without an OpenClaw runtime.
1
+ // Thin Relay REST client boundary for the standalone OpenClaw channel plugin.
2
+ // Owns the abort-aware long poll, idempotent sends, typing, responding, and
3
+ // read watermarks without loading an OpenClaw runtime in unit tests.
5
4
  import { isIP } from "node:net";
6
5
  import type {
7
6
  RelayAgentProfile,
@@ -146,6 +145,12 @@ export type RelayClient = {
146
145
  label?: string;
147
146
  signal?: AbortSignal;
148
147
  }) => Promise<void>;
148
+ setResponding: (params: {
149
+ conversationId: string;
150
+ messageId: string;
151
+ label?: string;
152
+ signal?: AbortSignal;
153
+ }) => Promise<void>;
149
154
  markRead: (params: {
150
155
  conversationId: string;
151
156
  messageId: string;
@@ -301,6 +306,18 @@ export function createRelayClient(options: RelayClientOptions): RelayClient {
301
306
  });
302
307
  },
303
308
 
309
+ setResponding: async (params) => {
310
+ await request({
311
+ method: "POST",
312
+ path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
313
+ body: {
314
+ message_id: params.messageId,
315
+ ...(params.label ? { label: params.label } : {}),
316
+ },
317
+ signal: params.signal,
318
+ });
319
+ },
320
+
304
321
  markRead: async (params) => {
305
322
  await request({
306
323
  method: "POST",
package/src/outbound.ts CHANGED
@@ -19,18 +19,41 @@ const IDEMPOTENCY_KEY_MAX = 255;
19
19
 
20
20
  /**
21
21
  * Idempotency key for one logical send. When core supplies a durable delivery
22
- * queue id, the key is a stable function of (queueId, partIndex) so internal
22
+ * queue id, the key is a stable function of (queueId, part) so internal
23
23
  * retries and reconciliation replay the exact same key. Without a queue id a
24
24
  * fresh key is minted: identical intentional sends must remain distinct.
25
+ *
26
+ * The part term names WHICH piece of one delivery this is. Core supplies
27
+ * `deliveryPartIndex` from 2026.7.2-beta.5 onward and that is authoritative.
28
+ * Older cores do not: they call `enqueueDelivery` once, mint ONE queue id, and
29
+ * then hand the channel each chunk of a long reply under it. Defaulting the
30
+ * missing index to 0 would key every chunk to `...:0`, so the server would
31
+ * replay the first chunk for each of the rest and the person would receive a
32
+ * long answer truncated to its opening chunk with no error anywhere.
33
+ *
34
+ * So when core cannot say which part this is, the part term is a digest of the
35
+ * part's own text. Sibling chunks differ, so each commits; a retry reproduces
36
+ * the same text, so it replays. This is not the content-in-the-key mistake
37
+ * that defeats conflict detection: the digest stands in FOR the position core
38
+ * did not give us, it does not replace it. Two byte-identical chunks in one
39
+ * delivery do collapse to one message, which is the residual cost of an
40
+ * unindexed core and is bounded to a repeat the reader would see twice.
25
41
  */
26
42
  export function deriveRelayIdempotencyKey(params: {
27
43
  deliveryQueueId?: string;
28
44
  deliveryPartIndex?: number;
45
+ /** Part text, used only when core supplies no `deliveryPartIndex`. */
46
+ partText?: string;
29
47
  random?: () => string;
30
48
  }): string {
31
49
  const queueId = params.deliveryQueueId?.trim();
50
+ const part = params.deliveryPartIndex ?? (
51
+ params.partText === undefined
52
+ ? 0
53
+ : `t${createHash("sha256").update(params.partText).digest("hex").slice(0, 16)}`
54
+ );
32
55
  const key = queueId
33
- ? `relay-send:${queueId}:${params.deliveryPartIndex ?? 0}`
56
+ ? `relay-send:${queueId}:${part}`
34
57
  : `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
35
58
  // Server accepts 8-255 chars; the prefix guarantees the minimum.
36
59
  if (key.length <= IDEMPOTENCY_KEY_MAX) {
@@ -0,0 +1,21 @@
1
+ import type { RelayClient } from "./client.js";
2
+
3
+ /**
4
+ * The receipt must commit before OpenClaw can run an agent or tool. A rejected
5
+ * receipt leaves the durable attempt marker untouched, so the poll loop can
6
+ * replay the event safely instead of hiding the failure after execution.
7
+ */
8
+ export async function markRespondingBeforeAttempt(params: {
9
+ client: RelayClient;
10
+ conversationId: string;
11
+ messageId: string;
12
+ label: string;
13
+ markAttempt: () => Promise<void>;
14
+ }): Promise<void> {
15
+ await params.client.setResponding({
16
+ conversationId: params.conversationId,
17
+ messageId: params.messageId,
18
+ label: params.label,
19
+ });
20
+ await params.markAttempt();
21
+ }