@relaymessenger/openclaw-plugin 0.4.7-staging.9 → 0.4.7

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": "17ad8d0c1d1d420e88a008eeaa3f3e910cb01972",
4
+ "commit": "9448e92fb7465bdf30bad37475e5f3017460799b",
5
5
  "openapiPath": "contracts/developer/openapi.yaml",
6
- "sha256": "c97bee2a79fac1866326a7df4398bb8b9750b824cf501dfe226130c18275c171"
6
+ "sha256": "61bd07d26328a493fa3aca1ceef9bf1c43321d31fb3ba3c6e10f7d353b48218b"
7
7
  },
8
8
  "relaySdk": {
9
9
  "package": "@relaymessenger/sdk",
10
- "version": "0.3.6-staging.8",
11
- "integrity": "sha512-Jj96lQtPRgn3y7BWBgAoestpSrA5JLoo91LUFPdUVj5jfbFwhOUzS3+GSp+1yd85c+r1pYjCAxh5mb5tiujmlw==",
12
- "operationsSha256": "04e5e1f1f626a2b0607fb0d091f12a4205a9e298fdb6f56e5bfcc446c9fe5ee3",
13
- "workspaceOpenapiSha256": "c97bee2a79fac1866326a7df4398bb8b9750b824cf501dfe226130c18275c171",
10
+ "version": "0.3.6",
11
+ "integrity": "sha512-npGKYHveASvFDQ5DEq3ZYEjqwJDrDJhXfxELHEWPUYY2mylwJiXX4J/5RjQ76c5oS+bG2jOy8YXKhgr0TIgMTQ==",
12
+ "operationsSha256": "b118ea1dcd729270b1453afad2b69f5d12e85fc24682b59305f9e691e9a6eae6",
13
+ "workspaceOpenapiSha256": "61bd07d26328a493fa3aca1ceef9bf1c43321d31fb3ba3c6e10f7d353b48218b",
14
14
  "usedOperations": [
15
15
  {
16
16
  "method": "GET",
@@ -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: buttons block left as text: ${error}`),
252
+ onButtonsError: (error) => ctx.log?.warn?.(`relay: component block left as text: ${error}`),
253
253
  ...(ctx.onPlatformSendDispatch
254
254
  ? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
255
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,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
- ...((facts.replyAnchorId ?? facts.replyToId)
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
@@ -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":
@@ -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
- 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)
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
- replyAnchorId: event.data.id,
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 } : {}),
@@ -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.7-staging.9",
3
+ "version": "0.4.7",
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.8"
40
+ "@relaymessenger/sdk": "0.3.6"
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
@@ -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: buttons block left as text: ${error}`,
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 { 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,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
- ...((facts.replyAnchorId ?? facts.replyToId)
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
- 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;
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
- replyAnchorId: event.data.id,
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
  };