@relaymessenger/openclaw-plugin 0.4.7-staging.3 → 0.4.7-staging.31

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
@@ -12,6 +12,28 @@ 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 after the question.
18
+ `BodyForAgent` carries structured response and rich-message JSON; `RawBody` and
19
+ `CommandBody` retain readable text. Stable values are not executable commands.
20
+
21
+ New human reply text is literal `• ` + each selected source label joined with
22
+ `\n`, followed by `selection_response` metadata in source-option order. Dispatch
23
+ with `selected_values` and the explicit source target, never label parsing.
24
+ Exact legacy comma-joined text remains a server compatibility input. The person
25
+ checks any number of options and submits them once; checking sends nothing, and
26
+ a person answers a given selection once. iOS may draw a checkmark in place of
27
+ each bullet and repeat the prompt's title, as presentation only.
28
+
29
+ ## Payment
30
+
31
+ The agent ends the final answer with a `payment` JSON fence holding the
32
+ payment request's fields (`description`, `category`, and `amount` with
33
+ `currency`, or `mode: "subscription"` with `price_id`). The plugin creates the
34
+ request with its own Relay token, on the card's own idempotency key; the words
35
+ go first and the payment card follows as its own Message.
36
+
15
37
  ## Install
16
38
 
17
39
  ```bash
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "relayServer": {
3
3
  "repository": "RelayMessenger/Relay-Server",
4
- "commit": "78d7991c7b8a615302ab30d727503779755df3ab",
4
+ "commit": "486fb8b70680bafd52532d478e4067e714901774",
5
5
  "openapiPath": "contracts/developer/openapi.yaml",
6
- "sha256": "1d790da998a8dfc26cffc098def76d85b8275af7a343148df8e78c486cd02849"
6
+ "sha256": "eac6c797d7f4a63ed2bdcd3d9bc0b0a0f5b4b889c6c0b2f731ac0738f7c0eb69"
7
7
  },
8
8
  "relaySdk": {
9
9
  "package": "@relaymessenger/sdk",
10
- "version": "0.3.6-staging.3",
11
- "integrity": "sha512-yT5/q4alxyFPPmrOh3MqVk9i+55ZPyiLFPX4K4QGeqQsI8/qNt5GCaNVBGewD/rq6Zlw7sfeDqR9P/9leMfPwg==",
12
- "operationsSha256": "a7c3367841e1f7803ff4a040586e30a6347d80548999015503f18d8e32f47b40",
13
- "workspaceOpenapiSha256": "1d790da998a8dfc26cffc098def76d85b8275af7a343148df8e78c486cd02849",
10
+ "version": "0.3.6-staging.33",
11
+ "integrity": "sha512-sYj/2OEqse7EkVvy9pPRlgocNuN46LVHVtB1kg4ro+56QNMML132kn6to8B3lUoO2SBRwG/u1gN45nTjaetWlw==",
12
+ "operationsSha256": "dc65744bdac51c47863b72536ae7ac7914a72d882f58f8ccc7d1cd3e5a54bec6",
13
+ "workspaceOpenapiSha256": "eac6c797d7f4a63ed2bdcd3d9bc0b0a0f5b4b889c6c0b2f731ac0738f7c0eb69",
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,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 { 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";
@@ -177,7 +178,9 @@ export async function dispatchRelayEvent(params) {
177
178
  message: {
178
179
  inboundEventKind: "user_request",
179
180
  body,
180
- bodyForAgent: facts.text,
181
+ bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage),
182
+ `${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`,
183
+ ].filter(Boolean).join("\n\n"),
181
184
  rawBody: facts.text,
182
185
  commandBody: facts.text,
183
186
  },
@@ -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,10 @@ 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);
56
59
  return {
60
+ ...(selection ? { selection } : {}),
61
+ ...(richMessage ? { richMessage } : {}),
57
62
  eventId: event.event_id,
58
63
  messageId: event.data.id,
59
64
  chatId: event.data.chat.id,
@@ -70,9 +75,11 @@ export function buildRelayInboundFacts(event) {
70
75
  ...(event.data.reply_to?.message_id
71
76
  ? {
72
77
  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,
78
+ // The answer quotes the person's message, the one it answers (a bot's
79
+ // reply_to in Telegram and Discord names the person's message). A
80
+ // tap's reply_to names the agent's buttons part, which no reply may
81
+ // target, so this is also what keeps a tap answerable.
82
+ replyAnchorId: event.data.id,
76
83
  }
77
84
  : {}),
78
85
  ...(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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/openclaw-plugin",
3
- "version": "0.4.7-staging.3",
3
+ "version": "0.4.7-staging.31",
4
4
  "description": "Native Relay channel plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,11 +37,11 @@
37
37
  "prepack": "npm run build"
38
38
  },
39
39
  "dependencies": {
40
- "@relaymessenger/sdk": "0.3.6-staging.3"
40
+ "@relaymessenger/sdk": "0.3.6-staging.33"
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,
@@ -243,7 +244,9 @@ export async function dispatchRelayEvent(params: {
243
244
  message: {
244
245
  inboundEventKind: "user_request",
245
246
  body,
246
- bodyForAgent: facts.text,
247
+ bodyForAgent: [facts.text, selectionReplyContext(facts.selection, facts.richMessage),
248
+ `${BUTTONS_BLOCK_INSTRUCTION} ${LINK_LINE_INSTRUCTION} ${BUTTONS_GUIDANCE} ${SELECTION_BLOCK_INSTRUCTION} ${SELECTION_GUIDANCE} ${PAYMENT_BLOCK_INSTRUCTION} ${PAYMENT_GUIDANCE}`,
249
+ ].filter(Boolean).join("\n\n"),
247
250
  rawBody: facts.text,
248
251
  commandBody: facts.text,
249
252
  },
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,10 @@ 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);
78
81
  return {
82
+ ...(selection ? { selection } : {}),
83
+ ...(richMessage ? { richMessage } : {}),
79
84
  eventId: event.event_id,
80
85
  messageId: event.data.id,
81
86
  chatId: event.data.chat.id,
@@ -93,9 +98,11 @@ export function buildRelayInboundFacts(
93
98
  ...(event.data.reply_to?.message_id
94
99
  ? {
95
100
  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,
101
+ // The answer quotes the person's message, the one it answers (a bot's
102
+ // reply_to in Telegram and Discord names the person's message). A
103
+ // tap's reply_to names the agent's buttons part, which no reply may
104
+ // target, so this is also what keeps a tap answerable.
105
+ replyAnchorId: event.data.id,
99
106
  }
100
107
  : {}),
101
108
  ...(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/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;
@@ -73,10 +78,9 @@ export type RelayInboundFacts = {
73
78
  ownerHandle?: ChatHandle;
74
79
  replyToId?: string;
75
80
  /**
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.
81
+ * The Message an outbound reply should quote when the person's Message
82
+ * was itself a reply: the person's Message. A tap's reply_to names the
83
+ * agent's buttons part, which no reply may target.
80
84
  */
81
85
  replyAnchorId?: string;
82
86
  timestamp?: number;