@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 +8 -4
- package/dist/src/channel.js +26 -11
- package/dist/src/client.js +14 -4
- package/dist/src/outbound.js +21 -2
- package/dist/src/responding.js +13 -0
- package/package.json +5 -5
- package/src/channel.ts +27 -11
- package/src/client.ts +21 -4
- package/src/outbound.ts +25 -2
- package/src/responding.ts +21 -0
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
package/dist/src/channel.js
CHANGED
|
@@ -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,
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
|
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
|
|
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
|
|
613
|
+
deliveryPartIndex: deliveryPartIndexOf(ctx),
|
|
614
|
+
partText: text,
|
|
600
615
|
}),
|
|
601
616
|
});
|
|
602
617
|
return { messageId: result.messageId };
|
package/dist/src/client.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Thin Relay REST client for the OpenClaw channel plugin.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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",
|
package/dist/src/outbound.js
CHANGED
|
@@ -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,
|
|
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}:${
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
60
|
+
"pluginApi": ">=2026.7.1-2"
|
|
61
61
|
},
|
|
62
62
|
"install": {
|
|
63
63
|
"localPath": ".",
|
|
64
64
|
"defaultChoice": "local",
|
|
65
|
-
"minHostVersion": ">=2026.7.2
|
|
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,
|
|
167
|
-
//
|
|
168
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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,
|
|
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}:${
|
|
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
|
+
}
|