@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 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
- `2026.8.1`, inspects the managed installation, starts a real OpenClaw gateway,
158
- connects to a loopback Relay WebSocket, receives one Message, and proves the
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": "ef8cedb1015e1e0d0856d61b69057f2b831f7c90",
4
+ "commit": "65c4473526e4bbd3fedcd747ab09d218d00920ce",
5
5
  "openapiPath": "contracts/developer/openapi.yaml",
6
- "sha256": "1b687dc9ffc7a6296a04b570d20fe32bed758deb2ed8f38fc48d0d44a10db2ec"
6
+ "sha256": "44d3202de07206707c8914c37029abc365c144be4245e677574210d2a1cf6f41"
7
7
  },
8
8
  "relaySdk": {
9
9
  "package": "@relaymessenger/sdk",
10
- "version": "0.3.6-staging.4",
11
- "integrity": "sha512-WkjSggp1VDo8kLTnUWb4OOED3p1fyPPrE996EGEsn6T6YTk0RPD/HlleqkIPSgaSjwJw7EBunhApwzO6a7kDKg==",
12
- "operationsSha256": "5c482165296df7d0634a0f57950936887b118e7159baf3bdabc42fce0d4fe6d8",
13
- "workspaceOpenapiSha256": "1b687dc9ffc7a6296a04b570d20fe32bed758deb2ed8f38fc48d0d44a10db2ec",
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",
@@ -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
  : {}),
@@ -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 = await params.relay.messages.retrieve(params.facts.replyToId);
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
- ...((facts.replyAnchorId ?? facts.replyToId)
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
@@ -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
  },
@@ -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
- // A tap reads as the label the person chose: the same text the server
12
- // derives for a button_reply. The agent's own buttons part reads as
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
- if (!text.trim())
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
- replyAnchorId: event.data.parts.some((part) => part.type === "button_reply")
74
- ? event.data.id
75
- : event.data.reply_to.message_id,
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 } : {}),
@@ -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
- return await params.relay.chats.messages.send(params.chatId, {
23
- message: {
24
- parts: [{ type: "text", value: params.text }],
25
- idempotency_key: params.idempotencyKey,
26
- ...(params.replyToId
27
- ? { reply_to: { message_id: params.replyToId } }
28
- : {}),
29
- },
30
- }, params.signal ? { signal: params.signal } : undefined);
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.4",
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.4"
40
+ "@relaymessenger/sdk": "0.3.6-staging.47"
41
41
  },
42
42
  "devDependencies": {
43
- "@types/node": "^26.0.0",
44
- "openclaw": "2026.8.1",
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.8.1"
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 { buildRelayInboundFacts } from "./inbound.js";
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 = await params.relay.messages.retrieve(
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
- ...((facts.replyAnchorId ?? facts.replyToId)
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
- // A tap reads as the label the person chose: the same text the server
21
- // derives for a button_reply. The agent's own buttons part reads as
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
- if (!text.trim()) return null;
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
- replyAnchorId: event.data.parts.some((part) => part.type === "button_reply")
97
- ? event.data.id
98
- : event.data.reply_to.message_id,
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
- return await params.relay.chats.messages.send(
46
- params.chatId,
47
- {
48
- message: {
49
- parts: [{ type: "text", value: params.text }],
50
- idempotency_key: params.idempotencyKey,
51
- ...(params.replyToId
52
- ? { reply_to: { message_id: params.replyToId } }
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
- params.signal ? { signal: params.signal } : undefined,
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. A tap's reply_to names the
77
- * agent's buttons part, which only a button_reply may target, so the
78
- * agent's answer quotes the tap itself; every other Message quotes what
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
  };