@relaymessenger/openclaw-plugin 0.4.7-staging.9 → 0.4.8-staging.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 +50 -5
- package/contracts/relay-v1.lock.json +6 -6
- package/dist/src/channel.js +1 -1
- package/dist/src/dispatch.js +116 -9
- package/dist/src/gateway.js +3 -0
- package/dist/src/inbound.js +28 -3
- package/dist/src/outbound.js +17 -3
- package/dist/src/turns.js +69 -0
- package/package.json +6 -6
- package/src/channel.ts +1 -1
- package/src/dispatch.ts +136 -11
- package/src/gateway.ts +3 -0
- package/src/inbound.ts +28 -3
- package/src/outbound.ts +16 -3
- package/src/turns.ts +94 -0
- package/src/types.ts +14 -0
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": "9448e92fb7465bdf30bad37475e5f3017460799b",
|
|
5
5
|
"openapiPath": "contracts/developer/openapi.yaml",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "61bd07d26328a493fa3aca1ceef9bf1c43321d31fb3ba3c6e10f7d353b48218b"
|
|
7
7
|
},
|
|
8
8
|
"relaySdk": {
|
|
9
9
|
"package": "@relaymessenger/sdk",
|
|
10
|
-
"version": "0.3.
|
|
11
|
-
"integrity": "sha512-
|
|
12
|
-
"operationsSha256": "
|
|
13
|
-
"workspaceOpenapiSha256": "
|
|
10
|
+
"version": "0.3.7-staging.0",
|
|
11
|
+
"integrity": "sha512-Is7/fIILiWFMnv3lhhzr22bwxsjZf117oZ/TgsbcGKK3+xArwAFOuE6LIZun7h3UNcPZ+ArzTZlEOKZGFSx3RA==",
|
|
12
|
+
"operationsSha256": "b118ea1dcd729270b1453afad2b69f5d12e85fc24682b59305f9e691e9a6eae6",
|
|
13
|
+
"workspaceOpenapiSha256": "61bd07d26328a493fa3aca1ceef9bf1c43321d31fb3ba3c6e10f7d353b48218b",
|
|
14
14
|
"usedOperations": [
|
|
15
15
|
{
|
|
16
16
|
"method": "GET",
|
package/dist/src/channel.js
CHANGED
|
@@ -249,7 +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:
|
|
252
|
+
onButtonsError: (error) => ctx.log?.warn?.(`relay: component block left as text: ${error}`),
|
|
253
253
|
...(ctx.onPlatformSendDispatch
|
|
254
254
|
? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
|
|
255
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,90 @@ 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: a multipart target is narrowed to the swiped part, the rule
|
|
64
|
+
* Relay's iOS app uses to draw the quote (the SDK's `replyTargetParts`); a
|
|
65
|
+
* tap or a selection answer names a part with no words, so it keeps the
|
|
66
|
+
* whole Message.
|
|
67
|
+
*/
|
|
68
|
+
export function relayReplyQuote(facts, target) {
|
|
69
|
+
if (!target || target.chat_id !== facts.chatId)
|
|
70
|
+
return undefined;
|
|
71
|
+
const parts = target.parts ?? [];
|
|
72
|
+
const swiped = parts.length > 1 && facts.replyToPartIndex !== undefined
|
|
73
|
+
? parts[facts.replyToPartIndex]
|
|
74
|
+
: undefined;
|
|
75
|
+
const body = renderRelayMessageParts(swiped && swiped.type !== "buttons" && swiped.type !== "selection" ? [swiped] : parts);
|
|
76
|
+
const sender = target.from_handle?.display_name?.trim()
|
|
77
|
+
|| target.from_handle?.handle
|
|
78
|
+
|| target.from
|
|
79
|
+
|| undefined;
|
|
80
|
+
return {
|
|
81
|
+
id: target.id,
|
|
82
|
+
...(body ? { body } : {}),
|
|
83
|
+
...(sender ? { sender } : {}),
|
|
84
|
+
senderAllowed: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether the part a reply names may itself be replied to. A tap names the
|
|
89
|
+
* agent's buttons part and a selection answer its selection part; no reply may
|
|
90
|
+
* point at those (Relay v1 ReplyTo.part_index), so a person's tap keeps its
|
|
91
|
+
* own Message as the reply target.
|
|
92
|
+
*/
|
|
93
|
+
function repliable(target, partIndex) {
|
|
94
|
+
const part = target?.parts?.[partIndex ?? 0];
|
|
95
|
+
return part !== undefined && part.type !== "buttons" && part.type !== "selection";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The answer to another agent names the Message it answers: the model's own
|
|
99
|
+
* reply target when it chose one, else the agent's Message. Where no reply may
|
|
100
|
+
* point (a Message opening with buttons or a selection), OpenClaw's implicit
|
|
101
|
+
* current-message reply is removed.
|
|
102
|
+
*/
|
|
103
|
+
export function agentReplyPayload(payload, facts) {
|
|
104
|
+
if (facts.agentReplyLink) {
|
|
105
|
+
return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
|
|
106
|
+
}
|
|
107
|
+
if (payload.replyToId !== facts.messageId)
|
|
108
|
+
return payload;
|
|
109
|
+
const { replyToId: _unlinked, ...rest } = payload;
|
|
110
|
+
return rest;
|
|
111
|
+
}
|
|
40
112
|
export async function dispatchRelayEvent(params) {
|
|
41
113
|
const facts = buildRelayInboundFacts(params.event);
|
|
42
114
|
if (!facts) {
|
|
43
115
|
params.warn?.(`relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`);
|
|
44
116
|
return;
|
|
45
117
|
}
|
|
118
|
+
const repliedTo = await readReplyTarget({
|
|
119
|
+
facts,
|
|
120
|
+
relay: params.relay,
|
|
121
|
+
warn: params.warn,
|
|
122
|
+
});
|
|
46
123
|
const activation = await resolveRelayTurnActivation({
|
|
47
124
|
facts,
|
|
48
125
|
relay: params.relay,
|
|
126
|
+
...(repliedTo ? { replyTarget: repliedTo } : {}),
|
|
49
127
|
});
|
|
50
128
|
if (!activation) {
|
|
51
129
|
params.warn?.(`relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`);
|
|
@@ -136,6 +214,17 @@ export async function dispatchRelayEvent(params) {
|
|
|
136
214
|
params.warn?.(`relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`);
|
|
137
215
|
return;
|
|
138
216
|
}
|
|
217
|
+
// An agent's reply target is only its own Message (agentReplyLink). A
|
|
218
|
+
// person's is the Message they replied to, OpenClaw's ReplyToId, as its
|
|
219
|
+
// Telegram channel sets `reply.replyToId` to Telegram's reply_to_message:
|
|
220
|
+
// the prompt names it beside the quote. A tap or a selection answer names a
|
|
221
|
+
// part no reply may target, so it keeps the person's own Message.
|
|
222
|
+
const quote = relayReplyQuote(facts, repliedTo);
|
|
223
|
+
const replyTarget = facts.fromAgent
|
|
224
|
+
? facts.agentReplyLink
|
|
225
|
+
: quote && repliable(repliedTo, facts.replyToPartIndex)
|
|
226
|
+
? quote.id
|
|
227
|
+
: facts.replyAnchorId ?? facts.replyToId;
|
|
139
228
|
const body = buildEnvelope({
|
|
140
229
|
channel: "Relay",
|
|
141
230
|
from: `${facts.displayName} (@${facts.handle})`,
|
|
@@ -170,17 +259,18 @@ export async function dispatchRelayEvent(params) {
|
|
|
170
259
|
reply: {
|
|
171
260
|
to: facts.chatId,
|
|
172
261
|
originatingTo: facts.chatId,
|
|
173
|
-
...(
|
|
174
|
-
? { replyToId: facts.replyAnchorId ?? facts.replyToId }
|
|
175
|
-
: {}),
|
|
262
|
+
...(replyTarget ? { replyToId: replyTarget } : {}),
|
|
176
263
|
},
|
|
177
264
|
message: {
|
|
178
265
|
inboundEventKind: "user_request",
|
|
179
266
|
body,
|
|
180
|
-
bodyForAgent: facts.text,
|
|
267
|
+
bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage),
|
|
268
|
+
`${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`,
|
|
269
|
+
].filter(Boolean).join("\n\n"),
|
|
181
270
|
rawBody: facts.text,
|
|
182
271
|
commandBody: facts.text,
|
|
183
272
|
},
|
|
273
|
+
...(quote ? { supplemental: { quote } } : {}),
|
|
184
274
|
channelIngress: access,
|
|
185
275
|
access: {
|
|
186
276
|
commands: {
|
|
@@ -197,6 +287,15 @@ export async function dispatchRelayEvent(params) {
|
|
|
197
287
|
},
|
|
198
288
|
},
|
|
199
289
|
});
|
|
290
|
+
// Another agent's Message waits for the turn running in its Chat, so it
|
|
291
|
+
// gets a turn and an answer of its own (turns.ts).
|
|
292
|
+
if (facts.fromAgent) {
|
|
293
|
+
await waitForIdleChat({
|
|
294
|
+
turns: params.turns,
|
|
295
|
+
chatId: facts.chatId,
|
|
296
|
+
lifecycle: params.lifecycle,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
200
299
|
await Promise.allSettled([
|
|
201
300
|
params.relay.chats.markAsRead(facts.chatId),
|
|
202
301
|
params.relay.chats.startTyping(facts.chatId),
|
|
@@ -209,7 +308,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
209
308
|
});
|
|
210
309
|
let deliveryError;
|
|
211
310
|
try {
|
|
212
|
-
await params.runtime.channel.inbound.dispatch({
|
|
311
|
+
await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
|
|
213
312
|
cfg: params.cfg,
|
|
214
313
|
channel: "relay",
|
|
215
314
|
accountId: params.account.accountId,
|
|
@@ -222,9 +321,17 @@ export async function dispatchRelayEvent(params) {
|
|
|
222
321
|
delivery: {
|
|
223
322
|
durable: {
|
|
224
323
|
to: facts.chatId,
|
|
225
|
-
replyToId: null,
|
|
324
|
+
replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
|
|
226
325
|
requiredCapabilities: { reconcileUnknownSend: true },
|
|
227
326
|
},
|
|
327
|
+
// Every answer to another agent names its Message, whatever
|
|
328
|
+
// `replyToMode` the operator chose; OpenClaw's own implicit
|
|
329
|
+
// current-message reply is dropped where no reply may point.
|
|
330
|
+
...(facts.fromAgent
|
|
331
|
+
? {
|
|
332
|
+
preparePayload: (payload) => agentReplyPayload(payload, facts),
|
|
333
|
+
}
|
|
334
|
+
: {}),
|
|
228
335
|
deliver: async (_payload, info) => {
|
|
229
336
|
if (info.kind === "final") {
|
|
230
337
|
throw new Error("relay: durable final Message delivery was unavailable");
|
|
@@ -247,7 +354,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
247
354
|
: new Error(`relay: session record failed: ${String(error)}`);
|
|
248
355
|
},
|
|
249
356
|
},
|
|
250
|
-
});
|
|
357
|
+
}));
|
|
251
358
|
if (deliveryError) {
|
|
252
359
|
throw deliveryError instanceof Error
|
|
253
360
|
? 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":
|
|
@@ -11,6 +12,8 @@ function renderPart(part) {
|
|
|
11
12
|
// The agent's own buttons part reads as nothing, as on the server; its
|
|
12
13
|
// question is the text beside it. A tap arrives as ordinary text.
|
|
13
14
|
case "buttons":
|
|
15
|
+
case "selection":
|
|
16
|
+
case "selection_response":
|
|
14
17
|
return undefined;
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -41,7 +44,9 @@ export function buildRelayInboundFacts(event) {
|
|
|
41
44
|
event.data.sender_handle.kind !== "agent")
|
|
42
45
|
return null;
|
|
43
46
|
const text = renderRelayMessageParts(event.data.parts);
|
|
44
|
-
|
|
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)
|
|
45
50
|
return null;
|
|
46
51
|
const mentionHandles = event.data.parts.flatMap((part) => part.type === "text" &&
|
|
47
52
|
typeof part.mention === "string" &&
|
|
@@ -50,7 +55,23 @@ export function buildRelayInboundFacts(event) {
|
|
|
50
55
|
: []);
|
|
51
56
|
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
52
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;
|
|
53
70
|
return {
|
|
71
|
+
...(selection ? { selection } : {}),
|
|
72
|
+
...(richMessage ? { richMessage } : {}),
|
|
73
|
+
fromAgent,
|
|
74
|
+
...(agentReplyLink ? { agentReplyLink } : {}),
|
|
54
75
|
eventId: event.event_id,
|
|
55
76
|
messageId: event.data.id,
|
|
56
77
|
chatId: event.data.chat.id,
|
|
@@ -67,11 +88,15 @@ export function buildRelayInboundFacts(event) {
|
|
|
67
88
|
...(event.data.reply_to?.message_id
|
|
68
89
|
? {
|
|
69
90
|
replyToId: event.data.reply_to.message_id,
|
|
91
|
+
...(event.data.reply_to.part_index === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { replyToPartIndex: event.data.reply_to.part_index }),
|
|
70
94
|
// The answer quotes the person's message, the one it answers (a bot's
|
|
71
95
|
// reply_to in Telegram and Discord names the person's message). A
|
|
72
96
|
// tap's reply_to names the agent's buttons part, which no reply may
|
|
73
|
-
// target, so this is also what keeps a tap answerable.
|
|
74
|
-
|
|
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 }),
|
|
75
100
|
}
|
|
76
101
|
: {}),
|
|
77
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 { answerMessages, indexedIdempotencyKey, 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) {
|
|
@@ -27,11 +27,25 @@ export function deriveRelayIdempotencyKey(params) {
|
|
|
27
27
|
*/
|
|
28
28
|
export async function sendRelayText(params) {
|
|
29
29
|
await params.onPlatformSendDispatch?.();
|
|
30
|
-
const { messages, error } = answerMessages(params.text);
|
|
30
|
+
const { messages, payment, error } = answerMessages(params.text);
|
|
31
31
|
if (error)
|
|
32
32
|
params.onButtonsError?.(error);
|
|
33
|
-
if (messages.length === 0)
|
|
33
|
+
if (messages.length === 0 && !payment)
|
|
34
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
|
+
}
|
|
35
49
|
let first;
|
|
36
50
|
for (const [index, parts] of messages.entries()) {
|
|
37
51
|
const response = await params.relay.chats.messages.send(params.chatId, {
|
|
@@ -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.
|
|
3
|
+
"version": "0.4.8-staging.0",
|
|
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.
|
|
40
|
+
"@relaymessenger/sdk": "0.3.7-staging.0"
|
|
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
|
@@ -331,7 +331,7 @@ export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> =
|
|
|
331
331
|
}),
|
|
332
332
|
onButtonsError: (error) =>
|
|
333
333
|
(ctx as { log?: { warn?: (message: string) => void } }).log?.warn?.(
|
|
334
|
-
`relay:
|
|
334
|
+
`relay: component block left as text: ${error}`,
|
|
335
335
|
),
|
|
336
336
|
...(ctx.onPlatformSendDispatch
|
|
337
337
|
? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
|
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,89 @@ 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: a multipart target is narrowed to the swiped part, the rule
|
|
118
|
+
* Relay's iOS app uses to draw the quote (the SDK's `replyTargetParts`); a
|
|
119
|
+
* tap or a selection answer names a part with no words, so it keeps the
|
|
120
|
+
* whole Message.
|
|
121
|
+
*/
|
|
122
|
+
export function relayReplyQuote(
|
|
123
|
+
facts: Pick<RelayInboundFacts, "chatId" | "replyToPartIndex">,
|
|
124
|
+
target: Message | undefined,
|
|
125
|
+
) {
|
|
126
|
+
if (!target || target.chat_id !== facts.chatId) return undefined;
|
|
127
|
+
const parts = target.parts ?? [];
|
|
128
|
+
const swiped = parts.length > 1 && facts.replyToPartIndex !== undefined
|
|
129
|
+
? parts[facts.replyToPartIndex]
|
|
130
|
+
: undefined;
|
|
131
|
+
const body = renderRelayMessageParts(
|
|
132
|
+
swiped && swiped.type !== "buttons" && swiped.type !== "selection" ? [swiped] : parts,
|
|
133
|
+
);
|
|
134
|
+
const sender = target.from_handle?.display_name?.trim()
|
|
135
|
+
|| target.from_handle?.handle
|
|
136
|
+
|| target.from
|
|
137
|
+
|| undefined;
|
|
138
|
+
return {
|
|
139
|
+
id: target.id,
|
|
140
|
+
...(body ? { body } : {}),
|
|
141
|
+
...(sender ? { sender } : {}),
|
|
142
|
+
senderAllowed: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether the part a reply names may itself be replied to. A tap names the
|
|
148
|
+
* agent's buttons part and a selection answer its selection part; no reply may
|
|
149
|
+
* point at those (Relay v1 ReplyTo.part_index), so a person's tap keeps its
|
|
150
|
+
* own Message as the reply target.
|
|
151
|
+
*/
|
|
152
|
+
function repliable(target: Message | undefined, partIndex: number | undefined): boolean {
|
|
153
|
+
const part = target?.parts?.[partIndex ?? 0];
|
|
154
|
+
return part !== undefined && part.type !== "buttons" && part.type !== "selection";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The answer to another agent names the Message it answers: the model's own
|
|
159
|
+
* reply target when it chose one, else the agent's Message. Where no reply may
|
|
160
|
+
* point (a Message opening with buttons or a selection), OpenClaw's implicit
|
|
161
|
+
* current-message reply is removed.
|
|
162
|
+
*/
|
|
163
|
+
export function agentReplyPayload(
|
|
164
|
+
payload: ReplyPayload,
|
|
165
|
+
facts: Pick<RelayInboundFacts, "messageId" | "agentReplyLink">,
|
|
166
|
+
): ReplyPayload {
|
|
167
|
+
if (facts.agentReplyLink) {
|
|
168
|
+
return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
|
|
169
|
+
}
|
|
170
|
+
if (payload.replyToId !== facts.messageId) return payload;
|
|
171
|
+
const { replyToId: _unlinked, ...rest } = payload;
|
|
172
|
+
return rest;
|
|
173
|
+
}
|
|
174
|
+
|
|
88
175
|
export async function dispatchRelayEvent(params: {
|
|
89
176
|
event: RelayWebhookEvent;
|
|
90
177
|
lifecycle: RelayIngressLifecycle;
|
|
@@ -92,6 +179,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
92
179
|
cfg: RelayCoreConfig;
|
|
93
180
|
relay: Pick<Relay, "chats" | "messages">;
|
|
94
181
|
runtime: PluginRuntime;
|
|
182
|
+
turns: RelayChatTurns;
|
|
95
183
|
warn?: (message: string) => void;
|
|
96
184
|
}): Promise<void> {
|
|
97
185
|
const facts = buildRelayInboundFacts(params.event);
|
|
@@ -102,9 +190,15 @@ export async function dispatchRelayEvent(params: {
|
|
|
102
190
|
return;
|
|
103
191
|
}
|
|
104
192
|
|
|
193
|
+
const repliedTo = await readReplyTarget({
|
|
194
|
+
facts,
|
|
195
|
+
relay: params.relay,
|
|
196
|
+
warn: params.warn,
|
|
197
|
+
});
|
|
105
198
|
const activation = await resolveRelayTurnActivation({
|
|
106
199
|
facts,
|
|
107
200
|
relay: params.relay,
|
|
201
|
+
...(repliedTo ? { replyTarget: repliedTo } : {}),
|
|
108
202
|
});
|
|
109
203
|
if (!activation) {
|
|
110
204
|
params.warn?.(
|
|
@@ -202,6 +296,17 @@ export async function dispatchRelayEvent(params: {
|
|
|
202
296
|
return;
|
|
203
297
|
}
|
|
204
298
|
|
|
299
|
+
// An agent's reply target is only its own Message (agentReplyLink). A
|
|
300
|
+
// person's is the Message they replied to, OpenClaw's ReplyToId, as its
|
|
301
|
+
// Telegram channel sets `reply.replyToId` to Telegram's reply_to_message:
|
|
302
|
+
// the prompt names it beside the quote. A tap or a selection answer names a
|
|
303
|
+
// part no reply may target, so it keeps the person's own Message.
|
|
304
|
+
const quote = relayReplyQuote(facts, repliedTo);
|
|
305
|
+
const replyTarget = facts.fromAgent
|
|
306
|
+
? facts.agentReplyLink
|
|
307
|
+
: quote && repliable(repliedTo, facts.replyToPartIndex)
|
|
308
|
+
? quote.id
|
|
309
|
+
: facts.replyAnchorId ?? facts.replyToId;
|
|
205
310
|
const body = buildEnvelope({
|
|
206
311
|
channel: "Relay",
|
|
207
312
|
from: `${facts.displayName} (@${facts.handle})`,
|
|
@@ -236,17 +341,18 @@ export async function dispatchRelayEvent(params: {
|
|
|
236
341
|
reply: {
|
|
237
342
|
to: facts.chatId,
|
|
238
343
|
originatingTo: facts.chatId,
|
|
239
|
-
...(
|
|
240
|
-
? { replyToId: facts.replyAnchorId ?? facts.replyToId }
|
|
241
|
-
: {}),
|
|
344
|
+
...(replyTarget ? { replyToId: replyTarget } : {}),
|
|
242
345
|
},
|
|
243
346
|
message: {
|
|
244
347
|
inboundEventKind: "user_request",
|
|
245
348
|
body,
|
|
246
|
-
bodyForAgent: facts.text,
|
|
349
|
+
bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage),
|
|
350
|
+
`${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`,
|
|
351
|
+
].filter(Boolean).join("\n\n"),
|
|
247
352
|
rawBody: facts.text,
|
|
248
353
|
commandBody: facts.text,
|
|
249
354
|
},
|
|
355
|
+
...(quote ? { supplemental: { quote } } : {}),
|
|
250
356
|
channelIngress: access,
|
|
251
357
|
access: {
|
|
252
358
|
commands: {
|
|
@@ -264,6 +370,16 @@ export async function dispatchRelayEvent(params: {
|
|
|
264
370
|
},
|
|
265
371
|
});
|
|
266
372
|
|
|
373
|
+
// Another agent's Message waits for the turn running in its Chat, so it
|
|
374
|
+
// gets a turn and an answer of its own (turns.ts).
|
|
375
|
+
if (facts.fromAgent) {
|
|
376
|
+
await waitForIdleChat({
|
|
377
|
+
turns: params.turns,
|
|
378
|
+
chatId: facts.chatId,
|
|
379
|
+
lifecycle: params.lifecycle,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
267
383
|
await Promise.allSettled([
|
|
268
384
|
params.relay.chats.markAsRead(facts.chatId),
|
|
269
385
|
params.relay.chats.startTyping(facts.chatId),
|
|
@@ -277,7 +393,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
277
393
|
|
|
278
394
|
let deliveryError: unknown;
|
|
279
395
|
try {
|
|
280
|
-
await params.runtime.channel.inbound.dispatch({
|
|
396
|
+
await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
|
|
281
397
|
cfg: params.cfg as OpenClawConfig,
|
|
282
398
|
channel: "relay",
|
|
283
399
|
accountId: params.account.accountId,
|
|
@@ -290,9 +406,18 @@ export async function dispatchRelayEvent(params: {
|
|
|
290
406
|
delivery: {
|
|
291
407
|
durable: {
|
|
292
408
|
to: facts.chatId,
|
|
293
|
-
replyToId: null,
|
|
409
|
+
replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
|
|
294
410
|
requiredCapabilities: { reconcileUnknownSend: true },
|
|
295
411
|
},
|
|
412
|
+
// Every answer to another agent names its Message, whatever
|
|
413
|
+
// `replyToMode` the operator chose; OpenClaw's own implicit
|
|
414
|
+
// current-message reply is dropped where no reply may point.
|
|
415
|
+
...(facts.fromAgent
|
|
416
|
+
? {
|
|
417
|
+
preparePayload: (payload: ReplyPayload) =>
|
|
418
|
+
agentReplyPayload(payload, facts),
|
|
419
|
+
}
|
|
420
|
+
: {}),
|
|
296
421
|
deliver: async (_payload, info) => {
|
|
297
422
|
if (info.kind === "final") {
|
|
298
423
|
throw new Error(
|
|
@@ -317,7 +442,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
317
442
|
: new Error(`relay: session record failed: ${String(error)}`);
|
|
318
443
|
},
|
|
319
444
|
},
|
|
320
|
-
});
|
|
445
|
+
}));
|
|
321
446
|
if (deliveryError) {
|
|
322
447
|
throw deliveryError instanceof Error
|
|
323
448
|
? 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,
|
|
@@ -20,6 +21,8 @@ function renderPart(part: MessagePartResponse): string | undefined {
|
|
|
20
21
|
// The agent's own buttons part reads as nothing, as on the server; its
|
|
21
22
|
// question is the text beside it. A tap arrives as ordinary text.
|
|
22
23
|
case "buttons":
|
|
24
|
+
case "selection":
|
|
25
|
+
case "selection_response":
|
|
23
26
|
return undefined;
|
|
24
27
|
}
|
|
25
28
|
}
|
|
@@ -61,7 +64,9 @@ export function buildRelayInboundFacts(
|
|
|
61
64
|
) return null;
|
|
62
65
|
|
|
63
66
|
const text = renderRelayMessageParts(event.data.parts);
|
|
64
|
-
|
|
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;
|
|
65
70
|
|
|
66
71
|
const mentionHandles = event.data.parts.flatMap((part) =>
|
|
67
72
|
part.type === "text" &&
|
|
@@ -72,7 +77,23 @@ export function buildRelayInboundFacts(
|
|
|
72
77
|
);
|
|
73
78
|
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
74
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;
|
|
75
92
|
return {
|
|
93
|
+
...(selection ? { selection } : {}),
|
|
94
|
+
...(richMessage ? { richMessage } : {}),
|
|
95
|
+
fromAgent,
|
|
96
|
+
...(agentReplyLink ? { agentReplyLink } : {}),
|
|
76
97
|
eventId: event.event_id,
|
|
77
98
|
messageId: event.data.id,
|
|
78
99
|
chatId: event.data.chat.id,
|
|
@@ -90,11 +111,15 @@ export function buildRelayInboundFacts(
|
|
|
90
111
|
...(event.data.reply_to?.message_id
|
|
91
112
|
? {
|
|
92
113
|
replyToId: event.data.reply_to.message_id,
|
|
114
|
+
...(event.data.reply_to.part_index === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: { replyToPartIndex: event.data.reply_to.part_index }),
|
|
93
117
|
// The answer quotes the person's message, the one it answers (a bot's
|
|
94
118
|
// reply_to in Telegram and Discord names the person's message). A
|
|
95
119
|
// tap's reply_to names the agent's buttons part, which no reply may
|
|
96
|
-
// target, so this is also what keeps a tap answerable.
|
|
97
|
-
|
|
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 }),
|
|
98
123
|
}
|
|
99
124
|
: {}),
|
|
100
125
|
...(Number.isFinite(timestamp) ? { timestamp } : {}),
|
package/src/outbound.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
answerMessages,
|
|
4
|
+
createPaymentPart,
|
|
4
5
|
indexedIdempotencyKey,
|
|
5
6
|
Relay,
|
|
6
7
|
RelayAPIError,
|
|
@@ -43,7 +44,7 @@ export function deriveRelayIdempotencyKey(params: {
|
|
|
43
44
|
* Message; the response is the first Message's, the one the reply anchors to.
|
|
44
45
|
*/
|
|
45
46
|
export async function sendRelayText(params: {
|
|
46
|
-
relay: Pick<Relay, "chats">;
|
|
47
|
+
relay: Pick<Relay, "chats" | "paymentRequests">;
|
|
47
48
|
chatId: string;
|
|
48
49
|
text: string;
|
|
49
50
|
replyToId?: string | null | undefined;
|
|
@@ -53,9 +54,21 @@ export async function sendRelayText(params: {
|
|
|
53
54
|
onButtonsError?: (error: string) => void;
|
|
54
55
|
}): Promise<MessageSendResponse> {
|
|
55
56
|
await params.onPlatformSendDispatch?.();
|
|
56
|
-
const { messages, error } = answerMessages(params.text);
|
|
57
|
+
const { messages, payment, error } = answerMessages(params.text);
|
|
57
58
|
if (error) params.onButtonsError?.(error);
|
|
58
|
-
if (messages.length === 0) messages.push([{ type: "text", value: params.text }]);
|
|
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
|
+
}
|
|
59
72
|
let first: MessageSendResponse | undefined;
|
|
60
73
|
for (const [index, parts] of messages.entries()) {
|
|
61
74
|
const response = await params.relay.chats.messages.send(
|
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,11 +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
83
|
* The Message an outbound reply should quote when the person's Message
|
|
77
84
|
* was itself a reply: the person's Message. A tap's reply_to names the
|
|
78
85
|
* agent's buttons part, which no reply may target.
|
|
79
86
|
*/
|
|
80
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;
|
|
81
95
|
timestamp?: number;
|
|
82
96
|
};
|