@relaymessenger/openclaw-plugin 0.2.0 → 0.3.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 +8 -4
- package/dist/src/channel.js +18 -5
- package/dist/src/outbound.js +21 -2
- package/package.json +5 -5
- package/src/channel.ts +19 -5
- package/src/outbound.ts +25 -2
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";
|
|
@@ -131,12 +142,13 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
131
142
|
conversationId: ctx.to,
|
|
132
143
|
text: ctx.text,
|
|
133
144
|
replyToId: ctx.replyToId ?? null,
|
|
134
|
-
// Stable per (queueId,
|
|
135
|
-
//
|
|
136
|
-
//
|
|
145
|
+
// Stable per (queueId, part): internal retries replay the same key,
|
|
146
|
+
// so the server-side idempotent commit makes duplicates impossible by
|
|
147
|
+
// contract. On a core with no part index the text names the part.
|
|
137
148
|
idempotencyKey: deriveRelayIdempotencyKey({
|
|
138
149
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
139
|
-
deliveryPartIndex: ctx
|
|
150
|
+
deliveryPartIndex: deliveryPartIndexOf(ctx),
|
|
151
|
+
partText: ctx.text,
|
|
140
152
|
}),
|
|
141
153
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
142
154
|
});
|
|
@@ -596,7 +608,8 @@ export const relayChannelPlugin = createChatChannelPlugin({
|
|
|
596
608
|
// this invocation so two intentional identical sends remain two.
|
|
597
609
|
idempotencyKey: deriveRelayIdempotencyKey({
|
|
598
610
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
599
|
-
deliveryPartIndex: ctx
|
|
611
|
+
deliveryPartIndex: deliveryPartIndexOf(ctx),
|
|
612
|
+
partText: text,
|
|
600
613
|
}),
|
|
601
614
|
});
|
|
602
615
|
return { messageId: result.messageId };
|
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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relaymessenger/openclaw-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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,
|
|
@@ -163,12 +175,13 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
163
175
|
conversationId: ctx.to,
|
|
164
176
|
text: ctx.text,
|
|
165
177
|
replyToId: ctx.replyToId ?? null,
|
|
166
|
-
// Stable per (queueId,
|
|
167
|
-
//
|
|
168
|
-
//
|
|
178
|
+
// Stable per (queueId, part): internal retries replay the same key,
|
|
179
|
+
// so the server-side idempotent commit makes duplicates impossible by
|
|
180
|
+
// contract. On a core with no part index the text names the part.
|
|
169
181
|
idempotencyKey: deriveRelayIdempotencyKey({
|
|
170
182
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
171
|
-
deliveryPartIndex: ctx
|
|
183
|
+
deliveryPartIndex: deliveryPartIndexOf(ctx),
|
|
184
|
+
partText: ctx.text,
|
|
172
185
|
}),
|
|
173
186
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
174
187
|
});
|
|
@@ -659,7 +672,8 @@ export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> = createCha
|
|
|
659
672
|
// this invocation so two intentional identical sends remain two.
|
|
660
673
|
idempotencyKey: deriveRelayIdempotencyKey({
|
|
661
674
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
662
|
-
deliveryPartIndex: ctx
|
|
675
|
+
deliveryPartIndex: deliveryPartIndexOf(ctx),
|
|
676
|
+
partText: text,
|
|
663
677
|
}),
|
|
664
678
|
});
|
|
665
679
|
return { messageId: result.messageId };
|
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) {
|