@relaymessenger/openclaw-plugin 0.4.7-staging.4 → 0.4.7-staging.40
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 +50 -5
- package/contracts/relay-v1.lock.json +6 -6
- package/dist/src/channel.js +1 -0
- package/dist/src/dispatch.js +99 -9
- package/dist/src/gateway.js +3 -0
- package/dist/src/inbound.js +33 -9
- package/dist/src/outbound.js +42 -10
- package/dist/src/turns.js +69 -0
- package/package.json +6 -6
- package/src/channel.ts +4 -0
- package/src/dispatch.ts +116 -11
- package/src/gateway.ts +3 -0
- package/src/inbound.ts +33 -9
- package/src/outbound.ts +45 -13
- package/src/turns.ts +94 -0
- package/src/types.ts +17 -4
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Relay for OpenClaw
|
|
2
2
|
|
|
3
3
|
`@relaymessenger/openclaw-plugin` is the native Relay channel for OpenClaw
|
|
4
|
-
`2026.8.1
|
|
4
|
+
`2026.8.1` through `2026.9.6`, the versions its gateway harness runs against.
|
|
5
5
|
|
|
6
6
|
Source is maintained in
|
|
7
7
|
[`RelayMessenger/Relay-SDK`](https://github.com/RelayMessenger/Relay-SDK/tree/main/packages/openclaw)
|
|
@@ -12,12 +12,45 @@ delivers events over its v1 WebSocket, and the plugin sends replies through
|
|
|
12
12
|
the Relay v1 REST Message API. The plugin imports `@relaymessenger/sdk`; it
|
|
13
13
|
does not contain a copied Relay client or protocol implementation.
|
|
14
14
|
|
|
15
|
+
## Selection
|
|
16
|
+
|
|
17
|
+
End the final answer with a `selection` JSON fence holding the question as
|
|
18
|
+
`title` (1 to 60 characters) and the `options`; any words outside the fence go
|
|
19
|
+
as a normal message above the card.
|
|
20
|
+
`BodyForAgent` carries structured response and rich-message JSON; `RawBody` and
|
|
21
|
+
`CommandBody` retain readable text. Stable values are not executable commands.
|
|
22
|
+
|
|
23
|
+
New human reply text is literal `• ` + each selected source label joined with
|
|
24
|
+
`\n`, followed by `selection_response` metadata in source-option order. Dispatch
|
|
25
|
+
with `selected_values` and the explicit source target, never label parsing.
|
|
26
|
+
Exact legacy comma-joined text remains a server compatibility input. The person
|
|
27
|
+
checks any number of options and submits them once; checking sends nothing, and
|
|
28
|
+
a person answers a given selection once. iOS may draw a checkmark in place of
|
|
29
|
+
each bullet and repeat the prompt's title, as presentation only.
|
|
30
|
+
|
|
31
|
+
## Payment
|
|
32
|
+
|
|
33
|
+
The agent ends the final answer with a `payment` JSON fence holding the
|
|
34
|
+
payment request's fields (`description`, `category`, and `amount` with
|
|
35
|
+
`currency`, or `mode: "subscription"` with `price_id`). The plugin creates the
|
|
36
|
+
request with its own Relay token, on the card's own idempotency key; the words
|
|
37
|
+
go first and the payment card follows as its own Message.
|
|
38
|
+
|
|
15
39
|
## Install
|
|
16
40
|
|
|
17
41
|
```bash
|
|
18
42
|
openclaw plugins install @relaymessenger/openclaw-plugin
|
|
19
43
|
```
|
|
20
44
|
|
|
45
|
+
OpenClaw asks two questions for a plugin from npm: whether you trust a source
|
|
46
|
+
outside ClawHub, and whether to accept the capabilities the plugin declares.
|
|
47
|
+
This plugin declares one capability, the `relay` channel. Where no terminal
|
|
48
|
+
can answer, pass both answers:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
openclaw plugins install @relaymessenger/openclaw-plugin --force --accept-capabilities
|
|
52
|
+
```
|
|
53
|
+
|
|
21
54
|
Configure the default account:
|
|
22
55
|
|
|
23
56
|
```json
|
|
@@ -106,6 +139,16 @@ Without `allowFrom`, any user or agent Contact whose Message Relay delivers
|
|
|
106
139
|
to this agent can start a direct turn, while the group activation rules above
|
|
107
140
|
still apply.
|
|
108
141
|
|
|
142
|
+
## Messages from another agent
|
|
143
|
+
|
|
144
|
+
Another agent's call reaches this agent as a Message, and Relay gives the
|
|
145
|
+
caller the answer whose `reply_to` names its Message. So every answer to
|
|
146
|
+
another agent names the Message it answers. When the same agent sends a second
|
|
147
|
+
Message while a turn is still running in that Chat, the plugin holds it until
|
|
148
|
+
the turn ends, then gives it a turn of its own. OpenClaw would otherwise steer
|
|
149
|
+
it into the running turn, and the second caller would get no answer. A
|
|
150
|
+
person's Messages keep OpenClaw's own queue and reply behavior.
|
|
151
|
+
|
|
109
152
|
## Durable delivery
|
|
110
153
|
|
|
111
154
|
For every WebSocket event, the plugin:
|
|
@@ -153,10 +196,12 @@ exact SHA selected from the `staging` branch, the matching
|
|
|
153
196
|
validated tarball and publishes that same digest with npm provenance; its
|
|
154
197
|
publish job is also bound to the `staging` GitHub environment.
|
|
155
198
|
|
|
156
|
-
`gateway:harness` packs the plugin, installs the tarball with OpenClaw
|
|
157
|
-
`
|
|
158
|
-
connects to a loopback Relay WebSocket, receives one Message,
|
|
159
|
-
durable ACK and idempotent REST reply.
|
|
199
|
+
`gateway:harness` packs the plugin, installs the tarball with the OpenClaw
|
|
200
|
+
version in `devDependencies`, inspects the managed installation, starts a real
|
|
201
|
+
OpenClaw gateway, connects to a loopback Relay WebSocket, receives one Message,
|
|
202
|
+
and proves the durable ACK and idempotent REST reply. Its `--overlap` run sends
|
|
203
|
+
two Messages from one agent, the second while the model still answers the
|
|
204
|
+
first, and requires two answers, each naming its own Message.
|
|
160
205
|
|
|
161
206
|
## Contract lock
|
|
162
207
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"relayServer": {
|
|
3
3
|
"repository": "RelayMessenger/Relay-Server",
|
|
4
|
-
"commit": "
|
|
4
|
+
"commit": "65c4473526e4bbd3fedcd747ab09d218d00920ce",
|
|
5
5
|
"openapiPath": "contracts/developer/openapi.yaml",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "44d3202de07206707c8914c37029abc365c144be4245e677574210d2a1cf6f41"
|
|
7
7
|
},
|
|
8
8
|
"relaySdk": {
|
|
9
9
|
"package": "@relaymessenger/sdk",
|
|
10
|
-
"version": "0.3.6-staging.
|
|
11
|
-
"integrity": "sha512-
|
|
12
|
-
"operationsSha256": "
|
|
13
|
-
"workspaceOpenapiSha256": "
|
|
10
|
+
"version": "0.3.6-staging.47",
|
|
11
|
+
"integrity": "sha512-CI67lgtiuYyhSwuttqXF6x07OC9fDwTIpT3eCZPiqHGxWDo6Tx/zNmAAUzEDcw9ExoGOHP2VXnIlcDFMlQrslw==",
|
|
12
|
+
"operationsSha256": "3e09d345c249709d942d2efb89ed7e84934c19552767d8f4438eb086dd917e1c",
|
|
13
|
+
"workspaceOpenapiSha256": "44d3202de07206707c8914c37029abc365c144be4245e677574210d2a1cf6f41",
|
|
14
14
|
"usedOperations": [
|
|
15
15
|
{
|
|
16
16
|
"method": "GET",
|
package/dist/src/channel.js
CHANGED
|
@@ -249,6 +249,7 @@ export const relayChannelPlugin = createChatChannelPlugin({
|
|
|
249
249
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
250
250
|
deliveryPartIndex: ctx.deliveryPartIndex,
|
|
251
251
|
}),
|
|
252
|
+
onButtonsError: (error) => ctx.log?.warn?.(`relay: component block left as text: ${error}`),
|
|
252
253
|
...(ctx.onPlatformSendDispatch
|
|
253
254
|
? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
|
|
254
255
|
: {}),
|
package/dist/src/dispatch.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { BUTTONS_GUIDANCE, BUTTONS_BLOCK_INSTRUCTION, PAYMENT_BLOCK_INSTRUCTION, PAYMENT_GUIDANCE, LINK_LINE_INSTRUCTION, SELECTION_GUIDANCE, SELECTION_BLOCK_INSTRUCTION, selectionReplyContext } from "@relaymessenger/sdk";
|
|
1
2
|
import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, } from "openclaw/plugin-sdk/channel-inbound";
|
|
2
3
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
3
4
|
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
|
|
4
|
-
import { buildRelayInboundFacts } from "./inbound.js";
|
|
5
|
+
import { buildRelayInboundFacts, renderRelayMessageParts } from "./inbound.js";
|
|
6
|
+
import { waitForIdleChat } from "./turns.js";
|
|
5
7
|
function isReplyToAgentMessage(message, chatId) {
|
|
6
8
|
return message.chat_id === chatId && message.is_from_me === true;
|
|
7
9
|
}
|
|
@@ -28,7 +30,8 @@ export async function resolveRelayTurnActivation(params) {
|
|
|
28
30
|
}
|
|
29
31
|
if (!params.facts.replyToId)
|
|
30
32
|
return null;
|
|
31
|
-
const replyTarget =
|
|
33
|
+
const replyTarget = params.replyTarget
|
|
34
|
+
?? await params.relay.messages.retrieve(params.facts.replyToId);
|
|
32
35
|
if (!isReplyToAgentMessage(replyTarget, params.facts.chatId))
|
|
33
36
|
return null;
|
|
34
37
|
return {
|
|
@@ -37,15 +40,78 @@ export async function resolveRelayTurnActivation(params) {
|
|
|
37
40
|
implicitMentionKinds: ["reply_to_bot"],
|
|
38
41
|
};
|
|
39
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The Message a swipe-reply answers, read once for the quote and for group
|
|
45
|
+
* activation. A failed read is logged and the turn runs without the quote;
|
|
46
|
+
* group activation then reads it itself and fails the delivery as before.
|
|
47
|
+
*/
|
|
48
|
+
async function readReplyTarget(params) {
|
|
49
|
+
if (!params.facts.replyToId)
|
|
50
|
+
return undefined;
|
|
51
|
+
try {
|
|
52
|
+
return await params.relay.messages.retrieve(params.facts.replyToId);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
params.warn?.(`relay: could not read the Message ${params.facts.replyToId} that ${params.facts.messageId} replies to: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* OpenClaw's own reply context, `supplemental.quote`, which it renders to the
|
|
61
|
+
* model as "Reply target of current user message" (id, sender, body), as its
|
|
62
|
+
* Telegram channel fills it from Telegram's `reply_to_message`. A reply names
|
|
63
|
+
* one bubble: when the target has more than one part, only the swiped part is
|
|
64
|
+
* quoted, the rule Relay's iOS app uses to draw the quote.
|
|
65
|
+
*/
|
|
66
|
+
export function relayReplyQuote(facts, target) {
|
|
67
|
+
if (!target || target.chat_id !== facts.chatId)
|
|
68
|
+
return undefined;
|
|
69
|
+
const parts = target.parts ?? [];
|
|
70
|
+
const swiped = parts.length > 1 && facts.replyToPartIndex !== undefined
|
|
71
|
+
? parts[facts.replyToPartIndex]
|
|
72
|
+
: undefined;
|
|
73
|
+
const body = renderRelayMessageParts(swiped ? [swiped] : parts);
|
|
74
|
+
const sender = target.from_handle?.display_name?.trim()
|
|
75
|
+
|| target.from_handle?.handle
|
|
76
|
+
|| target.from
|
|
77
|
+
|| undefined;
|
|
78
|
+
return {
|
|
79
|
+
id: target.id,
|
|
80
|
+
...(body ? { body } : {}),
|
|
81
|
+
...(sender ? { sender } : {}),
|
|
82
|
+
senderAllowed: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The answer to another agent names the Message it answers: the model's own
|
|
87
|
+
* reply target when it chose one, else the agent's Message. Where no reply may
|
|
88
|
+
* point (a Message opening with buttons or a selection), OpenClaw's implicit
|
|
89
|
+
* current-message reply is removed.
|
|
90
|
+
*/
|
|
91
|
+
export function agentReplyPayload(payload, facts) {
|
|
92
|
+
if (facts.agentReplyLink) {
|
|
93
|
+
return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
|
|
94
|
+
}
|
|
95
|
+
if (payload.replyToId !== facts.messageId)
|
|
96
|
+
return payload;
|
|
97
|
+
const { replyToId: _unlinked, ...rest } = payload;
|
|
98
|
+
return rest;
|
|
99
|
+
}
|
|
40
100
|
export async function dispatchRelayEvent(params) {
|
|
41
101
|
const facts = buildRelayInboundFacts(params.event);
|
|
42
102
|
if (!facts) {
|
|
43
103
|
params.warn?.(`relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`);
|
|
44
104
|
return;
|
|
45
105
|
}
|
|
106
|
+
const repliedTo = await readReplyTarget({
|
|
107
|
+
facts,
|
|
108
|
+
relay: params.relay,
|
|
109
|
+
warn: params.warn,
|
|
110
|
+
});
|
|
46
111
|
const activation = await resolveRelayTurnActivation({
|
|
47
112
|
facts,
|
|
48
113
|
relay: params.relay,
|
|
114
|
+
...(repliedTo ? { replyTarget: repliedTo } : {}),
|
|
49
115
|
});
|
|
50
116
|
if (!activation) {
|
|
51
117
|
params.warn?.(`relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`);
|
|
@@ -136,12 +202,18 @@ export async function dispatchRelayEvent(params) {
|
|
|
136
202
|
params.warn?.(`relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`);
|
|
137
203
|
return;
|
|
138
204
|
}
|
|
205
|
+
// An agent's reply target is only its own Message (agentReplyLink); a
|
|
206
|
+
// person's is the Message they replied from, as before.
|
|
207
|
+
const replyTarget = facts.fromAgent
|
|
208
|
+
? facts.agentReplyLink
|
|
209
|
+
: facts.replyAnchorId ?? facts.replyToId;
|
|
139
210
|
const body = buildEnvelope({
|
|
140
211
|
channel: "Relay",
|
|
141
212
|
from: `${facts.displayName} (@${facts.handle})`,
|
|
142
213
|
...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
|
|
143
214
|
body: facts.text,
|
|
144
215
|
});
|
|
216
|
+
const quote = relayReplyQuote(facts, repliedTo);
|
|
145
217
|
const ctxPayload = buildChannelInboundEventContext({
|
|
146
218
|
channel: "relay",
|
|
147
219
|
accountId: route.accountId ?? params.account.accountId,
|
|
@@ -170,17 +242,18 @@ export async function dispatchRelayEvent(params) {
|
|
|
170
242
|
reply: {
|
|
171
243
|
to: facts.chatId,
|
|
172
244
|
originatingTo: facts.chatId,
|
|
173
|
-
...(
|
|
174
|
-
? { replyToId: facts.replyAnchorId ?? facts.replyToId }
|
|
175
|
-
: {}),
|
|
245
|
+
...(replyTarget ? { replyToId: replyTarget } : {}),
|
|
176
246
|
},
|
|
177
247
|
message: {
|
|
178
248
|
inboundEventKind: "user_request",
|
|
179
249
|
body,
|
|
180
|
-
bodyForAgent: facts.text,
|
|
250
|
+
bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage),
|
|
251
|
+
`${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`,
|
|
252
|
+
].filter(Boolean).join("\n\n"),
|
|
181
253
|
rawBody: facts.text,
|
|
182
254
|
commandBody: facts.text,
|
|
183
255
|
},
|
|
256
|
+
...(quote ? { supplemental: { quote } } : {}),
|
|
184
257
|
channelIngress: access,
|
|
185
258
|
access: {
|
|
186
259
|
commands: {
|
|
@@ -197,6 +270,15 @@ export async function dispatchRelayEvent(params) {
|
|
|
197
270
|
},
|
|
198
271
|
},
|
|
199
272
|
});
|
|
273
|
+
// Another agent's Message waits for the turn running in its Chat, so it
|
|
274
|
+
// gets a turn and an answer of its own (turns.ts).
|
|
275
|
+
if (facts.fromAgent) {
|
|
276
|
+
await waitForIdleChat({
|
|
277
|
+
turns: params.turns,
|
|
278
|
+
chatId: facts.chatId,
|
|
279
|
+
lifecycle: params.lifecycle,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
200
282
|
await Promise.allSettled([
|
|
201
283
|
params.relay.chats.markAsRead(facts.chatId),
|
|
202
284
|
params.relay.chats.startTyping(facts.chatId),
|
|
@@ -209,7 +291,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
209
291
|
});
|
|
210
292
|
let deliveryError;
|
|
211
293
|
try {
|
|
212
|
-
await params.runtime.channel.inbound.dispatch({
|
|
294
|
+
await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
|
|
213
295
|
cfg: params.cfg,
|
|
214
296
|
channel: "relay",
|
|
215
297
|
accountId: params.account.accountId,
|
|
@@ -222,9 +304,17 @@ export async function dispatchRelayEvent(params) {
|
|
|
222
304
|
delivery: {
|
|
223
305
|
durable: {
|
|
224
306
|
to: facts.chatId,
|
|
225
|
-
replyToId: null,
|
|
307
|
+
replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
|
|
226
308
|
requiredCapabilities: { reconcileUnknownSend: true },
|
|
227
309
|
},
|
|
310
|
+
// Every answer to another agent names its Message, whatever
|
|
311
|
+
// `replyToMode` the operator chose; OpenClaw's own implicit
|
|
312
|
+
// current-message reply is dropped where no reply may point.
|
|
313
|
+
...(facts.fromAgent
|
|
314
|
+
? {
|
|
315
|
+
preparePayload: (payload) => agentReplyPayload(payload, facts),
|
|
316
|
+
}
|
|
317
|
+
: {}),
|
|
228
318
|
deliver: async (_payload, info) => {
|
|
229
319
|
if (info.kind === "final") {
|
|
230
320
|
throw new Error("relay: durable final Message delivery was unavailable");
|
|
@@ -247,7 +337,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
247
337
|
: new Error(`relay: session record failed: ${String(error)}`);
|
|
248
338
|
},
|
|
249
339
|
},
|
|
250
|
-
});
|
|
340
|
+
}));
|
|
251
341
|
if (deliveryError) {
|
|
252
342
|
throw deliveryError instanceof Error
|
|
253
343
|
? deliveryError
|
package/dist/src/gateway.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dispatchRelayEvent } from "./dispatch.js";
|
|
|
4
4
|
import { commitRelayFullSync } from "./full-sync.js";
|
|
5
5
|
import { createRelayIngressMonitor } from "./ingress.js";
|
|
6
6
|
import { createRelaySdkClient } from "./outbound.js";
|
|
7
|
+
import { createRelayChatTurns } from "./turns.js";
|
|
7
8
|
import { getRelayRuntime } from "./runtime.js";
|
|
8
9
|
import { openRelayStateStore, } from "./state.js";
|
|
9
10
|
const runningCredentials = new Map();
|
|
@@ -58,6 +59,7 @@ export async function startRelayAccount(ctx) {
|
|
|
58
59
|
accountId: transportId,
|
|
59
60
|
});
|
|
60
61
|
const relay = createRelaySdkClient(account);
|
|
62
|
+
const turns = createRelayChatTurns();
|
|
61
63
|
const ingress = createRelayIngressMonitor({
|
|
62
64
|
queue: openIngressQueue({
|
|
63
65
|
transportId,
|
|
@@ -80,6 +82,7 @@ export async function startRelayAccount(ctx) {
|
|
|
80
82
|
cfg: ctx.cfg,
|
|
81
83
|
relay,
|
|
82
84
|
runtime,
|
|
85
|
+
turns,
|
|
83
86
|
warn,
|
|
84
87
|
});
|
|
85
88
|
},
|
package/dist/src/inbound.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectionReply, selectionReplyContext } from "@relaymessenger/sdk";
|
|
1
2
|
function renderPart(part) {
|
|
2
3
|
switch (part.type) {
|
|
3
4
|
case "text":
|
|
@@ -8,12 +9,11 @@ function renderPart(part) {
|
|
|
8
9
|
return `[Attachment: ${part.filename} (${part.mime_type})] ${part.url}`;
|
|
9
10
|
case "system":
|
|
10
11
|
return part.value;
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// nothing, again as the server does; its question is the text beside it.
|
|
14
|
-
case "button_reply":
|
|
15
|
-
return part.label;
|
|
12
|
+
// The agent's own buttons part reads as nothing, as on the server; its
|
|
13
|
+
// question is the text beside it. A tap arrives as ordinary text.
|
|
16
14
|
case "buttons":
|
|
15
|
+
case "selection":
|
|
16
|
+
case "selection_response":
|
|
17
17
|
return undefined;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -44,7 +44,9 @@ export function buildRelayInboundFacts(event) {
|
|
|
44
44
|
event.data.sender_handle.kind !== "agent")
|
|
45
45
|
return null;
|
|
46
46
|
const text = renderRelayMessageParts(event.data.parts);
|
|
47
|
-
|
|
47
|
+
const message = { parts: event.data.parts, ...(event.data.reply_to ? { reply_to: event.data.reply_to } : {}) };
|
|
48
|
+
const richMessage = selectionReplyContext(undefined, message) ? message : undefined;
|
|
49
|
+
if (!text.trim() && !richMessage)
|
|
48
50
|
return null;
|
|
49
51
|
const mentionHandles = event.data.parts.flatMap((part) => part.type === "text" &&
|
|
50
52
|
typeof part.mention === "string" &&
|
|
@@ -53,7 +55,23 @@ export function buildRelayInboundFacts(event) {
|
|
|
53
55
|
: []);
|
|
54
56
|
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
55
57
|
const timestamp = Date.parse(timestampValue);
|
|
58
|
+
const selection = selectionReply(event.data.parts, event.data.reply_to);
|
|
59
|
+
const fromAgent = event.data.sender_handle.kind === "agent";
|
|
60
|
+
// Another agent's Message is named by the answer, as Relay's CLI bridges
|
|
61
|
+
// do (packages/cli/src/bridge-turn.ts, PR 366): Relay's A2A door gives a
|
|
62
|
+
// calling agent only the answer whose reply_to names its Message
|
|
63
|
+
// (Relay-Server a2a.ts replyTo). A Message that opens with buttons or a
|
|
64
|
+
// selection is not named: an agent may not reply to those parts, and a
|
|
65
|
+
// reply names part 0.
|
|
66
|
+
const opening = event.data.parts[0]?.type;
|
|
67
|
+
const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection"
|
|
68
|
+
? event.data.id
|
|
69
|
+
: undefined;
|
|
56
70
|
return {
|
|
71
|
+
...(selection ? { selection } : {}),
|
|
72
|
+
...(richMessage ? { richMessage } : {}),
|
|
73
|
+
fromAgent,
|
|
74
|
+
...(agentReplyLink ? { agentReplyLink } : {}),
|
|
57
75
|
eventId: event.event_id,
|
|
58
76
|
messageId: event.data.id,
|
|
59
77
|
chatId: event.data.chat.id,
|
|
@@ -70,9 +88,15 @@ export function buildRelayInboundFacts(event) {
|
|
|
70
88
|
...(event.data.reply_to?.message_id
|
|
71
89
|
? {
|
|
72
90
|
replyToId: event.data.reply_to.message_id,
|
|
73
|
-
|
|
74
|
-
?
|
|
75
|
-
: event.data.reply_to.
|
|
91
|
+
...(event.data.reply_to.part_index === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { replyToPartIndex: event.data.reply_to.part_index }),
|
|
94
|
+
// The answer quotes the person's message, the one it answers (a bot's
|
|
95
|
+
// reply_to in Telegram and Discord names the person's message). A
|
|
96
|
+
// tap's reply_to names the agent's buttons part, which no reply may
|
|
97
|
+
// target, so this is also what keeps a tap answerable. An agent's
|
|
98
|
+
// Message is quoted only through agentReplyLink.
|
|
99
|
+
...(fromAgent ? {} : { replyAnchorId: event.data.id }),
|
|
76
100
|
}
|
|
77
101
|
: {}),
|
|
78
102
|
...(Number.isFinite(timestamp) ? { timestamp } : {}),
|
package/dist/src/outbound.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { Relay, RelayAPIError, } from "@relaymessenger/sdk";
|
|
2
|
+
import { answerMessages, createPaymentPart, indexedIdempotencyKey, Relay, RelayAPIError, } from "@relaymessenger/sdk";
|
|
3
3
|
export const RELAY_TEXT_CHUNK_LIMIT = 10_000;
|
|
4
4
|
const IDEMPOTENCY_KEY_MAX_LENGTH = 255;
|
|
5
5
|
export function createRelaySdkClient(account) {
|
|
@@ -17,17 +17,49 @@ export function deriveRelayIdempotencyKey(params) {
|
|
|
17
17
|
? raw
|
|
18
18
|
: `relay-openclaw:sha256:${createHash("sha256").update(raw).digest("hex")}`;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* OpenClaw hands the agent's words as text, so buttons ride in them as the
|
|
22
|
+
* SDK's fenced block, lifted here into the buttons part, and a link written
|
|
23
|
+
* alone on a line goes out as its own link Message. A block that cannot be
|
|
24
|
+
* read stays in the words and is reported through `onButtonsError`. Without
|
|
25
|
+
* a block or a link line the words go exactly as OpenClaw handed them, in one
|
|
26
|
+
* Message; the response is the first Message's, the one the reply anchors to.
|
|
27
|
+
*/
|
|
20
28
|
export async function sendRelayText(params) {
|
|
21
29
|
await params.onPlatformSendDispatch?.();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const { messages, payment, error } = answerMessages(params.text);
|
|
31
|
+
if (error)
|
|
32
|
+
params.onButtonsError?.(error);
|
|
33
|
+
if (messages.length === 0 && !payment)
|
|
34
|
+
messages.push([{ type: "text", value: params.text }]);
|
|
35
|
+
if (payment) {
|
|
36
|
+
// Created with the card's own key, so a retry of this delivery returns
|
|
37
|
+
// the same request. A refusal after words went out is reported and the
|
|
38
|
+
// words stand; with nothing sent yet, it is the delivery's own error.
|
|
39
|
+
const key = indexedIdempotencyKey(params.idempotencyKey, messages.length);
|
|
40
|
+
try {
|
|
41
|
+
messages.push([await createPaymentPart(params.relay, payment, key, params.signal ? { signal: params.signal } : undefined)]);
|
|
42
|
+
}
|
|
43
|
+
catch (refusal) {
|
|
44
|
+
if (!(refusal instanceof RelayAPIError) || refusal.retryable || messages.length === 0)
|
|
45
|
+
throw refusal;
|
|
46
|
+
params.onButtonsError?.(`the payment was not sent: ${refusal.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let first;
|
|
50
|
+
for (const [index, parts] of messages.entries()) {
|
|
51
|
+
const response = await params.relay.chats.messages.send(params.chatId, {
|
|
52
|
+
message: {
|
|
53
|
+
parts,
|
|
54
|
+
idempotency_key: indexedIdempotencyKey(params.idempotencyKey, index),
|
|
55
|
+
...(index === 0 && params.replyToId
|
|
56
|
+
? { reply_to: { message_id: params.replyToId } }
|
|
57
|
+
: {}),
|
|
58
|
+
},
|
|
59
|
+
}, params.signal ? { signal: params.signal } : undefined);
|
|
60
|
+
first ??= response;
|
|
61
|
+
}
|
|
62
|
+
return first;
|
|
31
63
|
}
|
|
32
64
|
export function classifyUnknownRelaySend(error) {
|
|
33
65
|
if (!(error instanceof RelayAPIError)) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function createRelayChatTurns() {
|
|
2
|
+
const running = new Map();
|
|
3
|
+
return {
|
|
4
|
+
track(chatId, work) {
|
|
5
|
+
const turns = running.get(chatId) ?? new Set();
|
|
6
|
+
running.set(chatId, turns);
|
|
7
|
+
const turn = work();
|
|
8
|
+
turns.add(turn);
|
|
9
|
+
const settle = () => {
|
|
10
|
+
turns.delete(turn);
|
|
11
|
+
if (turns.size === 0 && running.get(chatId) === turns)
|
|
12
|
+
running.delete(chatId);
|
|
13
|
+
};
|
|
14
|
+
turn.then(settle, settle);
|
|
15
|
+
return turn;
|
|
16
|
+
},
|
|
17
|
+
busy: (chatId) => Boolean(running.get(chatId)?.size),
|
|
18
|
+
async idle(chatId, signal) {
|
|
19
|
+
for (;;) {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
const turns = running.get(chatId);
|
|
22
|
+
if (!turns?.size)
|
|
23
|
+
return;
|
|
24
|
+
const settled = Promise.allSettled([...turns]);
|
|
25
|
+
if (!signal) {
|
|
26
|
+
await settled;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
const abort = () => reject(signal.reason);
|
|
31
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
32
|
+
void settled.then(() => {
|
|
33
|
+
signal.removeEventListener("abort", abort);
|
|
34
|
+
resolve();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hold a claimed Relay event until its Chat is idle. The claim is handed off
|
|
43
|
+
* as deferred and kept alive with the drain's own heartbeat
|
|
44
|
+
* (`ChannelIngressDispatchLifecycle.onDeferred` / `onDeferredHeartbeat`,
|
|
45
|
+
* docs/plugins/sdk-channel-outbound.md "Deferred claim heartbeats"), so the
|
|
46
|
+
* adoption watchdog does not retry a Message that is only waiting its turn. On
|
|
47
|
+
* shutdown the wait rejects before adoption, and the drain keeps the event for
|
|
48
|
+
* the next start.
|
|
49
|
+
*/
|
|
50
|
+
export async function waitForIdleChat(params) {
|
|
51
|
+
const { lifecycle } = params;
|
|
52
|
+
if (!params.turns.busy(params.chatId))
|
|
53
|
+
return;
|
|
54
|
+
const signal = lifecycle.abortSignal;
|
|
55
|
+
lifecycle.onDeferred?.();
|
|
56
|
+
const interval = lifecycle.deferredHeartbeatIntervalMs;
|
|
57
|
+
const heartbeat = lifecycle.onDeferredHeartbeat && interval && interval > 0
|
|
58
|
+
? setInterval(() => lifecycle.onDeferredHeartbeat?.(), interval)
|
|
59
|
+
: undefined;
|
|
60
|
+
heartbeat?.unref?.();
|
|
61
|
+
try {
|
|
62
|
+
await params.turns.idle(params.chatId, signal);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (heartbeat)
|
|
66
|
+
clearInterval(heartbeat);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=turns.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relaymessenger/openclaw-plugin",
|
|
3
|
-
"version": "0.4.7-staging.
|
|
3
|
+
"version": "0.4.7-staging.40",
|
|
4
4
|
"description": "Native Relay channel plugin for OpenClaw",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"contract:verify": "node scripts/verify-contract-provenance.mjs",
|
|
34
34
|
"contract:test": "node --test test/*.test.mjs",
|
|
35
35
|
"pack:smoke": "node scripts/pack-smoke.mjs",
|
|
36
|
-
"gateway:harness": "node scripts/gateway-harness.mjs",
|
|
36
|
+
"gateway:harness": "node scripts/gateway-harness.mjs && node scripts/gateway-harness.mjs --overlap",
|
|
37
37
|
"prepack": "npm run build"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@relaymessenger/sdk": "0.3.6-staging.
|
|
40
|
+
"@relaymessenger/sdk": "0.3.6-staging.47"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@types/node": "^26.
|
|
44
|
-
"openclaw": "2026.
|
|
43
|
+
"@types/node": "^26.6.2",
|
|
44
|
+
"openclaw": "2026.9.5",
|
|
45
45
|
"typescript": "^7.0.2",
|
|
46
46
|
"vitest": "^4.1.10"
|
|
47
47
|
},
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"pluginApi": ">=2026.8.1 <2026.10.0"
|
|
165
165
|
},
|
|
166
166
|
"build": {
|
|
167
|
-
"openclawVersion": "2026.
|
|
167
|
+
"openclawVersion": "2026.9.5"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
}
|
package/src/channel.ts
CHANGED
|
@@ -329,6 +329,10 @@ export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> =
|
|
|
329
329
|
deliveryQueueId: ctx.deliveryQueueId,
|
|
330
330
|
deliveryPartIndex: ctx.deliveryPartIndex,
|
|
331
331
|
}),
|
|
332
|
+
onButtonsError: (error) =>
|
|
333
|
+
(ctx as { log?: { warn?: (message: string) => void } }).log?.warn?.(
|
|
334
|
+
`relay: component block left as text: ${error}`,
|
|
335
|
+
),
|
|
332
336
|
...(ctx.onPlatformSendDispatch
|
|
333
337
|
? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
|
|
334
338
|
: {}),
|
package/src/dispatch.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUTTONS_GUIDANCE, BUTTONS_BLOCK_INSTRUCTION, PAYMENT_BLOCK_INSTRUCTION, PAYMENT_GUIDANCE, LINK_LINE_INSTRUCTION, SELECTION_GUIDANCE, SELECTION_BLOCK_INSTRUCTION, selectionReplyContext } from "@relaymessenger/sdk";
|
|
1
2
|
import type {
|
|
2
3
|
Message,
|
|
3
4
|
Relay,
|
|
@@ -10,9 +11,11 @@ import {
|
|
|
10
11
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
11
12
|
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
|
|
12
13
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
13
|
-
import {
|
|
14
|
+
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
|
15
|
+
import { buildRelayInboundFacts, renderRelayMessageParts } from "./inbound.js";
|
|
14
16
|
import type { RelayIngressLifecycle } from "./ingress.js";
|
|
15
17
|
import type { PluginRuntime } from "./runtime.js";
|
|
18
|
+
import { type RelayChatTurns, waitForIdleChat } from "./turns.js";
|
|
16
19
|
import type {
|
|
17
20
|
RelayCoreConfig,
|
|
18
21
|
RelayInboundFacts,
|
|
@@ -52,6 +55,8 @@ function isReplyToAgentMessage(
|
|
|
52
55
|
export async function resolveRelayTurnActivation(params: {
|
|
53
56
|
facts: RelayInboundFacts;
|
|
54
57
|
relay: RelayReplyLookup;
|
|
58
|
+
/** The replied-to Message when the caller already read it. */
|
|
59
|
+
replyTarget?: Message;
|
|
55
60
|
}): Promise<RelayTurnActivation | null> {
|
|
56
61
|
if (params.facts.chatType === "direct") {
|
|
57
62
|
return {
|
|
@@ -74,9 +79,8 @@ export async function resolveRelayTurnActivation(params: {
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
if (!params.facts.replyToId) return null;
|
|
77
|
-
const replyTarget =
|
|
78
|
-
params.facts.replyToId
|
|
79
|
-
);
|
|
82
|
+
const replyTarget = params.replyTarget
|
|
83
|
+
?? await params.relay.messages.retrieve(params.facts.replyToId);
|
|
80
84
|
if (!isReplyToAgentMessage(replyTarget, params.facts.chatId)) return null;
|
|
81
85
|
return {
|
|
82
86
|
kind: "reply",
|
|
@@ -85,6 +89,74 @@ export async function resolveRelayTurnActivation(params: {
|
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The Message a swipe-reply answers, read once for the quote and for group
|
|
94
|
+
* activation. A failed read is logged and the turn runs without the quote;
|
|
95
|
+
* group activation then reads it itself and fails the delivery as before.
|
|
96
|
+
*/
|
|
97
|
+
async function readReplyTarget(params: {
|
|
98
|
+
facts: RelayInboundFacts;
|
|
99
|
+
relay: RelayReplyLookup;
|
|
100
|
+
warn: ((message: string) => void) | undefined;
|
|
101
|
+
}): Promise<Message | undefined> {
|
|
102
|
+
if (!params.facts.replyToId) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
return await params.relay.messages.retrieve(params.facts.replyToId);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
params.warn?.(
|
|
107
|
+
`relay: could not read the Message ${params.facts.replyToId} that ${params.facts.messageId} replies to: ${error instanceof Error ? error.message : String(error)}`,
|
|
108
|
+
);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* OpenClaw's own reply context, `supplemental.quote`, which it renders to the
|
|
115
|
+
* model as "Reply target of current user message" (id, sender, body), as its
|
|
116
|
+
* Telegram channel fills it from Telegram's `reply_to_message`. A reply names
|
|
117
|
+
* one bubble: when the target has more than one part, only the swiped part is
|
|
118
|
+
* quoted, the rule Relay's iOS app uses to draw the quote.
|
|
119
|
+
*/
|
|
120
|
+
export function relayReplyQuote(
|
|
121
|
+
facts: Pick<RelayInboundFacts, "chatId" | "replyToPartIndex">,
|
|
122
|
+
target: Message | undefined,
|
|
123
|
+
) {
|
|
124
|
+
if (!target || target.chat_id !== facts.chatId) return undefined;
|
|
125
|
+
const parts = target.parts ?? [];
|
|
126
|
+
const swiped = parts.length > 1 && facts.replyToPartIndex !== undefined
|
|
127
|
+
? parts[facts.replyToPartIndex]
|
|
128
|
+
: undefined;
|
|
129
|
+
const body = renderRelayMessageParts(swiped ? [swiped] : parts);
|
|
130
|
+
const sender = target.from_handle?.display_name?.trim()
|
|
131
|
+
|| target.from_handle?.handle
|
|
132
|
+
|| target.from
|
|
133
|
+
|| undefined;
|
|
134
|
+
return {
|
|
135
|
+
id: target.id,
|
|
136
|
+
...(body ? { body } : {}),
|
|
137
|
+
...(sender ? { sender } : {}),
|
|
138
|
+
senderAllowed: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The answer to another agent names the Message it answers: the model's own
|
|
144
|
+
* reply target when it chose one, else the agent's Message. Where no reply may
|
|
145
|
+
* point (a Message opening with buttons or a selection), OpenClaw's implicit
|
|
146
|
+
* current-message reply is removed.
|
|
147
|
+
*/
|
|
148
|
+
export function agentReplyPayload(
|
|
149
|
+
payload: ReplyPayload,
|
|
150
|
+
facts: Pick<RelayInboundFacts, "messageId" | "agentReplyLink">,
|
|
151
|
+
): ReplyPayload {
|
|
152
|
+
if (facts.agentReplyLink) {
|
|
153
|
+
return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
|
|
154
|
+
}
|
|
155
|
+
if (payload.replyToId !== facts.messageId) return payload;
|
|
156
|
+
const { replyToId: _unlinked, ...rest } = payload;
|
|
157
|
+
return rest;
|
|
158
|
+
}
|
|
159
|
+
|
|
88
160
|
export async function dispatchRelayEvent(params: {
|
|
89
161
|
event: RelayWebhookEvent;
|
|
90
162
|
lifecycle: RelayIngressLifecycle;
|
|
@@ -92,6 +164,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
92
164
|
cfg: RelayCoreConfig;
|
|
93
165
|
relay: Pick<Relay, "chats" | "messages">;
|
|
94
166
|
runtime: PluginRuntime;
|
|
167
|
+
turns: RelayChatTurns;
|
|
95
168
|
warn?: (message: string) => void;
|
|
96
169
|
}): Promise<void> {
|
|
97
170
|
const facts = buildRelayInboundFacts(params.event);
|
|
@@ -102,9 +175,15 @@ export async function dispatchRelayEvent(params: {
|
|
|
102
175
|
return;
|
|
103
176
|
}
|
|
104
177
|
|
|
178
|
+
const repliedTo = await readReplyTarget({
|
|
179
|
+
facts,
|
|
180
|
+
relay: params.relay,
|
|
181
|
+
warn: params.warn,
|
|
182
|
+
});
|
|
105
183
|
const activation = await resolveRelayTurnActivation({
|
|
106
184
|
facts,
|
|
107
185
|
relay: params.relay,
|
|
186
|
+
...(repliedTo ? { replyTarget: repliedTo } : {}),
|
|
108
187
|
});
|
|
109
188
|
if (!activation) {
|
|
110
189
|
params.warn?.(
|
|
@@ -202,12 +281,18 @@ export async function dispatchRelayEvent(params: {
|
|
|
202
281
|
return;
|
|
203
282
|
}
|
|
204
283
|
|
|
284
|
+
// An agent's reply target is only its own Message (agentReplyLink); a
|
|
285
|
+
// person's is the Message they replied from, as before.
|
|
286
|
+
const replyTarget = facts.fromAgent
|
|
287
|
+
? facts.agentReplyLink
|
|
288
|
+
: facts.replyAnchorId ?? facts.replyToId;
|
|
205
289
|
const body = buildEnvelope({
|
|
206
290
|
channel: "Relay",
|
|
207
291
|
from: `${facts.displayName} (@${facts.handle})`,
|
|
208
292
|
...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
|
|
209
293
|
body: facts.text,
|
|
210
294
|
});
|
|
295
|
+
const quote = relayReplyQuote(facts, repliedTo);
|
|
211
296
|
const ctxPayload = buildChannelInboundEventContext({
|
|
212
297
|
channel: "relay",
|
|
213
298
|
accountId: route.accountId ?? params.account.accountId,
|
|
@@ -236,17 +321,18 @@ export async function dispatchRelayEvent(params: {
|
|
|
236
321
|
reply: {
|
|
237
322
|
to: facts.chatId,
|
|
238
323
|
originatingTo: facts.chatId,
|
|
239
|
-
...(
|
|
240
|
-
? { replyToId: facts.replyAnchorId ?? facts.replyToId }
|
|
241
|
-
: {}),
|
|
324
|
+
...(replyTarget ? { replyToId: replyTarget } : {}),
|
|
242
325
|
},
|
|
243
326
|
message: {
|
|
244
327
|
inboundEventKind: "user_request",
|
|
245
328
|
body,
|
|
246
|
-
bodyForAgent: facts.text,
|
|
329
|
+
bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage),
|
|
330
|
+
`${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`,
|
|
331
|
+
].filter(Boolean).join("\n\n"),
|
|
247
332
|
rawBody: facts.text,
|
|
248
333
|
commandBody: facts.text,
|
|
249
334
|
},
|
|
335
|
+
...(quote ? { supplemental: { quote } } : {}),
|
|
250
336
|
channelIngress: access,
|
|
251
337
|
access: {
|
|
252
338
|
commands: {
|
|
@@ -264,6 +350,16 @@ export async function dispatchRelayEvent(params: {
|
|
|
264
350
|
},
|
|
265
351
|
});
|
|
266
352
|
|
|
353
|
+
// Another agent's Message waits for the turn running in its Chat, so it
|
|
354
|
+
// gets a turn and an answer of its own (turns.ts).
|
|
355
|
+
if (facts.fromAgent) {
|
|
356
|
+
await waitForIdleChat({
|
|
357
|
+
turns: params.turns,
|
|
358
|
+
chatId: facts.chatId,
|
|
359
|
+
lifecycle: params.lifecycle,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
267
363
|
await Promise.allSettled([
|
|
268
364
|
params.relay.chats.markAsRead(facts.chatId),
|
|
269
365
|
params.relay.chats.startTyping(facts.chatId),
|
|
@@ -277,7 +373,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
277
373
|
|
|
278
374
|
let deliveryError: unknown;
|
|
279
375
|
try {
|
|
280
|
-
await params.runtime.channel.inbound.dispatch({
|
|
376
|
+
await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
|
|
281
377
|
cfg: params.cfg as OpenClawConfig,
|
|
282
378
|
channel: "relay",
|
|
283
379
|
accountId: params.account.accountId,
|
|
@@ -290,9 +386,18 @@ export async function dispatchRelayEvent(params: {
|
|
|
290
386
|
delivery: {
|
|
291
387
|
durable: {
|
|
292
388
|
to: facts.chatId,
|
|
293
|
-
replyToId: null,
|
|
389
|
+
replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
|
|
294
390
|
requiredCapabilities: { reconcileUnknownSend: true },
|
|
295
391
|
},
|
|
392
|
+
// Every answer to another agent names its Message, whatever
|
|
393
|
+
// `replyToMode` the operator chose; OpenClaw's own implicit
|
|
394
|
+
// current-message reply is dropped where no reply may point.
|
|
395
|
+
...(facts.fromAgent
|
|
396
|
+
? {
|
|
397
|
+
preparePayload: (payload: ReplyPayload) =>
|
|
398
|
+
agentReplyPayload(payload, facts),
|
|
399
|
+
}
|
|
400
|
+
: {}),
|
|
296
401
|
deliver: async (_payload, info) => {
|
|
297
402
|
if (info.kind === "final") {
|
|
298
403
|
throw new Error(
|
|
@@ -317,7 +422,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
317
422
|
: new Error(`relay: session record failed: ${String(error)}`);
|
|
318
423
|
},
|
|
319
424
|
},
|
|
320
|
-
});
|
|
425
|
+
}));
|
|
321
426
|
if (deliveryError) {
|
|
322
427
|
throw deliveryError instanceof Error
|
|
323
428
|
? deliveryError
|
package/src/gateway.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { dispatchRelayEvent } from "./dispatch.js";
|
|
|
10
10
|
import { commitRelayFullSync } from "./full-sync.js";
|
|
11
11
|
import { createRelayIngressMonitor } from "./ingress.js";
|
|
12
12
|
import { createRelaySdkClient } from "./outbound.js";
|
|
13
|
+
import { createRelayChatTurns } from "./turns.js";
|
|
13
14
|
import { getRelayRuntime } from "./runtime.js";
|
|
14
15
|
import {
|
|
15
16
|
openRelayStateStore,
|
|
@@ -96,6 +97,7 @@ export async function startRelayAccount(
|
|
|
96
97
|
accountId: transportId,
|
|
97
98
|
});
|
|
98
99
|
const relay = createRelaySdkClient(account);
|
|
100
|
+
const turns = createRelayChatTurns();
|
|
99
101
|
const ingress = createRelayIngressMonitor({
|
|
100
102
|
queue: openIngressQueue({
|
|
101
103
|
transportId,
|
|
@@ -119,6 +121,7 @@ export async function startRelayAccount(
|
|
|
119
121
|
cfg: ctx.cfg as RelayCoreConfig,
|
|
120
122
|
relay,
|
|
121
123
|
runtime,
|
|
124
|
+
turns,
|
|
122
125
|
warn,
|
|
123
126
|
});
|
|
124
127
|
},
|
package/src/inbound.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectionReply, selectionReplyContext } from "@relaymessenger/sdk";
|
|
1
2
|
import type {
|
|
2
3
|
MessagePartResponse,
|
|
3
4
|
RelayWebhookEvent,
|
|
@@ -17,12 +18,11 @@ function renderPart(part: MessagePartResponse): string | undefined {
|
|
|
17
18
|
return `[Attachment: ${part.filename} (${part.mime_type})] ${part.url}`;
|
|
18
19
|
case "system":
|
|
19
20
|
return part.value;
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// nothing, again as the server does; its question is the text beside it.
|
|
23
|
-
case "button_reply":
|
|
24
|
-
return part.label;
|
|
21
|
+
// The agent's own buttons part reads as nothing, as on the server; its
|
|
22
|
+
// question is the text beside it. A tap arrives as ordinary text.
|
|
25
23
|
case "buttons":
|
|
24
|
+
case "selection":
|
|
25
|
+
case "selection_response":
|
|
26
26
|
return undefined;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -64,7 +64,9 @@ export function buildRelayInboundFacts(
|
|
|
64
64
|
) return null;
|
|
65
65
|
|
|
66
66
|
const text = renderRelayMessageParts(event.data.parts);
|
|
67
|
-
|
|
67
|
+
const message = { parts: event.data.parts, ...(event.data.reply_to ? { reply_to: event.data.reply_to } : {}) };
|
|
68
|
+
const richMessage = selectionReplyContext(undefined, message) ? message : undefined;
|
|
69
|
+
if (!text.trim() && !richMessage) return null;
|
|
68
70
|
|
|
69
71
|
const mentionHandles = event.data.parts.flatMap((part) =>
|
|
70
72
|
part.type === "text" &&
|
|
@@ -75,7 +77,23 @@ export function buildRelayInboundFacts(
|
|
|
75
77
|
);
|
|
76
78
|
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
77
79
|
const timestamp = Date.parse(timestampValue);
|
|
80
|
+
const selection = selectionReply(event.data.parts, event.data.reply_to);
|
|
81
|
+
const fromAgent = event.data.sender_handle.kind === "agent";
|
|
82
|
+
// Another agent's Message is named by the answer, as Relay's CLI bridges
|
|
83
|
+
// do (packages/cli/src/bridge-turn.ts, PR 366): Relay's A2A door gives a
|
|
84
|
+
// calling agent only the answer whose reply_to names its Message
|
|
85
|
+
// (Relay-Server a2a.ts replyTo). A Message that opens with buttons or a
|
|
86
|
+
// selection is not named: an agent may not reply to those parts, and a
|
|
87
|
+
// reply names part 0.
|
|
88
|
+
const opening = event.data.parts[0]?.type;
|
|
89
|
+
const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection"
|
|
90
|
+
? event.data.id
|
|
91
|
+
: undefined;
|
|
78
92
|
return {
|
|
93
|
+
...(selection ? { selection } : {}),
|
|
94
|
+
...(richMessage ? { richMessage } : {}),
|
|
95
|
+
fromAgent,
|
|
96
|
+
...(agentReplyLink ? { agentReplyLink } : {}),
|
|
79
97
|
eventId: event.event_id,
|
|
80
98
|
messageId: event.data.id,
|
|
81
99
|
chatId: event.data.chat.id,
|
|
@@ -93,9 +111,15 @@ export function buildRelayInboundFacts(
|
|
|
93
111
|
...(event.data.reply_to?.message_id
|
|
94
112
|
? {
|
|
95
113
|
replyToId: event.data.reply_to.message_id,
|
|
96
|
-
|
|
97
|
-
?
|
|
98
|
-
: event.data.reply_to.
|
|
114
|
+
...(event.data.reply_to.part_index === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: { replyToPartIndex: event.data.reply_to.part_index }),
|
|
117
|
+
// The answer quotes the person's message, the one it answers (a bot's
|
|
118
|
+
// reply_to in Telegram and Discord names the person's message). A
|
|
119
|
+
// tap's reply_to names the agent's buttons part, which no reply may
|
|
120
|
+
// target, so this is also what keeps a tap answerable. An agent's
|
|
121
|
+
// Message is quoted only through agentReplyLink.
|
|
122
|
+
...(fromAgent ? {} : { replyAnchorId: event.data.id }),
|
|
99
123
|
}
|
|
100
124
|
: {}),
|
|
101
125
|
...(Number.isFinite(timestamp) ? { timestamp } : {}),
|
package/src/outbound.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
|
+
answerMessages,
|
|
4
|
+
createPaymentPart,
|
|
5
|
+
indexedIdempotencyKey,
|
|
3
6
|
Relay,
|
|
4
7
|
RelayAPIError,
|
|
5
8
|
type MessageSendResponse,
|
|
@@ -32,29 +35,58 @@ export function deriveRelayIdempotencyKey(params: {
|
|
|
32
35
|
: `relay-openclaw:sha256:${createHash("sha256").update(raw).digest("hex")}`;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* OpenClaw hands the agent's words as text, so buttons ride in them as the
|
|
40
|
+
* SDK's fenced block, lifted here into the buttons part, and a link written
|
|
41
|
+
* alone on a line goes out as its own link Message. A block that cannot be
|
|
42
|
+
* read stays in the words and is reported through `onButtonsError`. Without
|
|
43
|
+
* a block or a link line the words go exactly as OpenClaw handed them, in one
|
|
44
|
+
* Message; the response is the first Message's, the one the reply anchors to.
|
|
45
|
+
*/
|
|
35
46
|
export async function sendRelayText(params: {
|
|
36
|
-
relay: Pick<Relay, "chats">;
|
|
47
|
+
relay: Pick<Relay, "chats" | "paymentRequests">;
|
|
37
48
|
chatId: string;
|
|
38
49
|
text: string;
|
|
39
50
|
replyToId?: string | null | undefined;
|
|
40
51
|
idempotencyKey: string;
|
|
41
52
|
signal?: AbortSignal;
|
|
42
53
|
onPlatformSendDispatch?: () => Promise<void>;
|
|
54
|
+
onButtonsError?: (error: string) => void;
|
|
43
55
|
}): Promise<MessageSendResponse> {
|
|
44
56
|
await params.onPlatformSendDispatch?.();
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
const { messages, payment, error } = answerMessages(params.text);
|
|
58
|
+
if (error) params.onButtonsError?.(error);
|
|
59
|
+
if (messages.length === 0 && !payment) messages.push([{ type: "text", value: params.text }]);
|
|
60
|
+
if (payment) {
|
|
61
|
+
// Created with the card's own key, so a retry of this delivery returns
|
|
62
|
+
// the same request. A refusal after words went out is reported and the
|
|
63
|
+
// words stand; with nothing sent yet, it is the delivery's own error.
|
|
64
|
+
const key = indexedIdempotencyKey(params.idempotencyKey, messages.length);
|
|
65
|
+
try {
|
|
66
|
+
messages.push([await createPaymentPart(params.relay, payment, key, params.signal ? { signal: params.signal } : undefined)]);
|
|
67
|
+
} catch (refusal) {
|
|
68
|
+
if (!(refusal instanceof RelayAPIError) || refusal.retryable || messages.length === 0) throw refusal;
|
|
69
|
+
params.onButtonsError?.(`the payment was not sent: ${refusal.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let first: MessageSendResponse | undefined;
|
|
73
|
+
for (const [index, parts] of messages.entries()) {
|
|
74
|
+
const response = await params.relay.chats.messages.send(
|
|
75
|
+
params.chatId,
|
|
76
|
+
{
|
|
77
|
+
message: {
|
|
78
|
+
parts,
|
|
79
|
+
idempotency_key: indexedIdempotencyKey(params.idempotencyKey, index),
|
|
80
|
+
...(index === 0 && params.replyToId
|
|
81
|
+
? { reply_to: { message_id: params.replyToId } }
|
|
82
|
+
: {}),
|
|
83
|
+
},
|
|
54
84
|
},
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
params.signal ? { signal: params.signal } : undefined,
|
|
86
|
+
);
|
|
87
|
+
first ??= response;
|
|
88
|
+
}
|
|
89
|
+
return first!;
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
export function classifyUnknownRelaySend(error: unknown): {
|
package/src/turns.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { RelayIngressLifecycle } from "./ingress.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The OpenClaw turns running in each Chat of one Relay account, so another
|
|
5
|
+
* agent's Message can wait for them instead of steering into them.
|
|
6
|
+
*
|
|
7
|
+
* OpenClaw steers a Message that arrives mid-turn into the running turn by
|
|
8
|
+
* default (`messages.queue.mode` "steer", docs/concepts/queue.md), and that
|
|
9
|
+
* turn's answer names the first Message. With `followup` the queued turn's
|
|
10
|
+
* answer names none. Relay's A2A door gives each calling agent only the answer
|
|
11
|
+
* whose `reply_to` names its Message (Relay-Server `a2a.ts` `replyTo`), so the
|
|
12
|
+
* second of two overlapping calls got no answer. A channel plugin "may
|
|
13
|
+
* preserve ordering ... before a message enters the session queue"
|
|
14
|
+
* (docs/concepts/messages.md, Queueing and followups); this is that ordering,
|
|
15
|
+
* the same rule Relay's CLI bridges follow (`replacesLiveTurn`, PR 366): an
|
|
16
|
+
* agent's Message waits its turn, a person's Message is left to OpenClaw.
|
|
17
|
+
*/
|
|
18
|
+
export type RelayChatTurns = {
|
|
19
|
+
/** Run one dispatch into OpenClaw, recorded as running in its Chat until it settles. */
|
|
20
|
+
track<T>(chatId: string, work: () => Promise<T>): Promise<T>;
|
|
21
|
+
/** Whether a turn runs in the Chat now. */
|
|
22
|
+
busy(chatId: string): boolean;
|
|
23
|
+
/** Resolve once no turn runs in the Chat; reject with the signal's reason. */
|
|
24
|
+
idle(chatId: string, signal?: AbortSignal): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createRelayChatTurns(): RelayChatTurns {
|
|
28
|
+
const running = new Map<string, Set<Promise<unknown>>>();
|
|
29
|
+
return {
|
|
30
|
+
track(chatId, work) {
|
|
31
|
+
const turns = running.get(chatId) ?? new Set<Promise<unknown>>();
|
|
32
|
+
running.set(chatId, turns);
|
|
33
|
+
const turn = work();
|
|
34
|
+
turns.add(turn);
|
|
35
|
+
const settle = () => {
|
|
36
|
+
turns.delete(turn);
|
|
37
|
+
if (turns.size === 0 && running.get(chatId) === turns) running.delete(chatId);
|
|
38
|
+
};
|
|
39
|
+
turn.then(settle, settle);
|
|
40
|
+
return turn;
|
|
41
|
+
},
|
|
42
|
+
busy: (chatId) => Boolean(running.get(chatId)?.size),
|
|
43
|
+
async idle(chatId, signal) {
|
|
44
|
+
for (;;) {
|
|
45
|
+
signal?.throwIfAborted();
|
|
46
|
+
const turns = running.get(chatId);
|
|
47
|
+
if (!turns?.size) return;
|
|
48
|
+
const settled = Promise.allSettled([...turns]);
|
|
49
|
+
if (!signal) {
|
|
50
|
+
await settled;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
await new Promise<void>((resolve, reject) => {
|
|
54
|
+
const abort = () => reject(signal.reason);
|
|
55
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
56
|
+
void settled.then(() => {
|
|
57
|
+
signal.removeEventListener("abort", abort);
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Hold a claimed Relay event until its Chat is idle. The claim is handed off
|
|
68
|
+
* as deferred and kept alive with the drain's own heartbeat
|
|
69
|
+
* (`ChannelIngressDispatchLifecycle.onDeferred` / `onDeferredHeartbeat`,
|
|
70
|
+
* docs/plugins/sdk-channel-outbound.md "Deferred claim heartbeats"), so the
|
|
71
|
+
* adoption watchdog does not retry a Message that is only waiting its turn. On
|
|
72
|
+
* shutdown the wait rejects before adoption, and the drain keeps the event for
|
|
73
|
+
* the next start.
|
|
74
|
+
*/
|
|
75
|
+
export async function waitForIdleChat(params: {
|
|
76
|
+
turns: RelayChatTurns;
|
|
77
|
+
chatId: string;
|
|
78
|
+
lifecycle: Partial<RelayIngressLifecycle>;
|
|
79
|
+
}): Promise<void> {
|
|
80
|
+
const { lifecycle } = params;
|
|
81
|
+
if (!params.turns.busy(params.chatId)) return;
|
|
82
|
+
const signal = lifecycle.abortSignal;
|
|
83
|
+
lifecycle.onDeferred?.();
|
|
84
|
+
const interval = lifecycle.deferredHeartbeatIntervalMs;
|
|
85
|
+
const heartbeat = lifecycle.onDeferredHeartbeat && interval && interval > 0
|
|
86
|
+
? setInterval(() => lifecycle.onDeferredHeartbeat?.(), interval)
|
|
87
|
+
: undefined;
|
|
88
|
+
heartbeat?.unref?.();
|
|
89
|
+
try {
|
|
90
|
+
await params.turns.idle(params.chatId, signal);
|
|
91
|
+
} finally {
|
|
92
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Chat,
|
|
3
|
+
SelectionReply,
|
|
4
|
+
MessagePartResponse,
|
|
5
|
+
ReplyTo,
|
|
3
6
|
ChatHandle,
|
|
4
7
|
MessageWebhookData,
|
|
5
8
|
Message,
|
|
@@ -61,6 +64,8 @@ export type RelayMessageReceivedEvent = RelayWebhookEnvelope<
|
|
|
61
64
|
>;
|
|
62
65
|
|
|
63
66
|
export type RelayInboundFacts = {
|
|
67
|
+
selection?: SelectionReply;
|
|
68
|
+
richMessage?: { parts: MessagePartResponse[]; reply_to?: ReplyTo | null };
|
|
64
69
|
eventId: string;
|
|
65
70
|
messageId: string;
|
|
66
71
|
chatId: string;
|
|
@@ -72,12 +77,20 @@ export type RelayInboundFacts = {
|
|
|
72
77
|
mentionHandles: string[];
|
|
73
78
|
ownerHandle?: ChatHandle;
|
|
74
79
|
replyToId?: string;
|
|
80
|
+
/** The part of the replied-to Message the person swiped (`reply_to.part_index`). */
|
|
81
|
+
replyToPartIndex?: number;
|
|
75
82
|
/**
|
|
76
|
-
* The Message an outbound reply should quote
|
|
77
|
-
*
|
|
78
|
-
* agent's
|
|
79
|
-
* the person quoted.
|
|
83
|
+
* The Message an outbound reply should quote when the person's Message
|
|
84
|
+
* was itself a reply: the person's Message. A tap's reply_to names the
|
|
85
|
+
* agent's buttons part, which no reply may target.
|
|
80
86
|
*/
|
|
81
87
|
replyAnchorId?: string;
|
|
88
|
+
/** Whether another agent sent the Message. */
|
|
89
|
+
fromAgent: boolean;
|
|
90
|
+
/**
|
|
91
|
+
* The Message every answer names when another agent sent it: this one,
|
|
92
|
+
* unless it opens with buttons or a selection, which no reply may target.
|
|
93
|
+
*/
|
|
94
|
+
agentReplyLink?: string;
|
|
82
95
|
timestamp?: number;
|
|
83
96
|
};
|