@relaymessenger/openclaw-plugin 0.4.7-staging.39 → 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.
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "relayServer": {
3
3
  "repository": "RelayMessenger/Relay-Server",
4
- "commit": "6645d5f8de90de4a9e40105d69524374a71be29c",
4
+ "commit": "65c4473526e4bbd3fedcd747ab09d218d00920ce",
5
5
  "openapiPath": "contracts/developer/openapi.yaml",
6
- "sha256": "f3fd943df25536ae40346ba0fed7dc261ee537875348d6ecdd65021fcc9853f3"
6
+ "sha256": "44d3202de07206707c8914c37029abc365c144be4245e677574210d2a1cf6f41"
7
7
  },
8
8
  "relaySdk": {
9
9
  "package": "@relaymessenger/sdk",
10
- "version": "0.3.6-staging.46",
11
- "integrity": "sha512-4OmsVlebKPTqZ/qsR07ccjBfPLkVWFmVg1XO8UEVjComqZs8EnXZqgcjB8ZAWSF6rM5EwZV5h1XbVLEKuxvbgw==",
10
+ "version": "0.3.6-staging.47",
11
+ "integrity": "sha512-CI67lgtiuYyhSwuttqXF6x07OC9fDwTIpT3eCZPiqHGxWDo6Tx/zNmAAUzEDcw9ExoGOHP2VXnIlcDFMlQrslw==",
12
12
  "operationsSha256": "3e09d345c249709d942d2efb89ed7e84934c19552767d8f4438eb086dd917e1c",
13
- "workspaceOpenapiSha256": "f3fd943df25536ae40346ba0fed7dc261ee537875348d6ecdd65021fcc9853f3",
13
+ "workspaceOpenapiSha256": "44d3202de07206707c8914c37029abc365c144be4245e677574210d2a1cf6f41",
14
14
  "usedOperations": [
15
15
  {
16
16
  "method": "GET",
@@ -2,7 +2,7 @@ import { BUTTONS_GUIDANCE, BUTTONS_BLOCK_INSTRUCTION, PAYMENT_BLOCK_INSTRUCTION,
2
2
  import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, } from "openclaw/plugin-sdk/channel-inbound";
3
3
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
4
4
  import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
5
- import { buildRelayInboundFacts } from "./inbound.js";
5
+ import { buildRelayInboundFacts, renderRelayMessageParts } from "./inbound.js";
6
6
  import { waitForIdleChat } from "./turns.js";
7
7
  function isReplyToAgentMessage(message, chatId) {
8
8
  return message.chat_id === chatId && message.is_from_me === true;
@@ -30,7 +30,8 @@ export async function resolveRelayTurnActivation(params) {
30
30
  }
31
31
  if (!params.facts.replyToId)
32
32
  return null;
33
- const replyTarget = await params.relay.messages.retrieve(params.facts.replyToId);
33
+ const replyTarget = params.replyTarget
34
+ ?? await params.relay.messages.retrieve(params.facts.replyToId);
34
35
  if (!isReplyToAgentMessage(replyTarget, params.facts.chatId))
35
36
  return null;
36
37
  return {
@@ -39,6 +40,48 @@ export async function resolveRelayTurnActivation(params) {
39
40
  implicitMentionKinds: ["reply_to_bot"],
40
41
  };
41
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
+ }
42
85
  /**
43
86
  * The answer to another agent names the Message it answers: the model's own
44
87
  * reply target when it chose one, else the agent's Message. Where no reply may
@@ -60,9 +103,15 @@ export async function dispatchRelayEvent(params) {
60
103
  params.warn?.(`relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`);
61
104
  return;
62
105
  }
106
+ const repliedTo = await readReplyTarget({
107
+ facts,
108
+ relay: params.relay,
109
+ warn: params.warn,
110
+ });
63
111
  const activation = await resolveRelayTurnActivation({
64
112
  facts,
65
113
  relay: params.relay,
114
+ ...(repliedTo ? { replyTarget: repliedTo } : {}),
66
115
  });
67
116
  if (!activation) {
68
117
  params.warn?.(`relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`);
@@ -164,6 +213,7 @@ export async function dispatchRelayEvent(params) {
164
213
  ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
165
214
  body: facts.text,
166
215
  });
216
+ const quote = relayReplyQuote(facts, repliedTo);
167
217
  const ctxPayload = buildChannelInboundEventContext({
168
218
  channel: "relay",
169
219
  accountId: route.accountId ?? params.account.accountId,
@@ -203,6 +253,7 @@ export async function dispatchRelayEvent(params) {
203
253
  rawBody: facts.text,
204
254
  commandBody: facts.text,
205
255
  },
256
+ ...(quote ? { supplemental: { quote } } : {}),
206
257
  channelIngress: access,
207
258
  access: {
208
259
  commands: {
@@ -88,6 +88,9 @@ export function buildRelayInboundFacts(event) {
88
88
  ...(event.data.reply_to?.message_id
89
89
  ? {
90
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 }),
91
94
  // The answer quotes the person's message, the one it answers (a bot's
92
95
  // reply_to in Telegram and Discord names the person's message). A
93
96
  // tap's reply_to names the agent's buttons part, which no reply may
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/openclaw-plugin",
3
- "version": "0.4.7-staging.39",
3
+ "version": "0.4.7-staging.40",
4
4
  "description": "Native Relay channel plugin for OpenClaw",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "prepack": "npm run build"
38
38
  },
39
39
  "dependencies": {
40
- "@relaymessenger/sdk": "0.3.6-staging.46"
40
+ "@relaymessenger/sdk": "0.3.6-staging.47"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.6.2",
package/src/dispatch.ts CHANGED
@@ -12,7 +12,7 @@ import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-
12
12
  import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
13
13
  import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
14
14
  import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
15
- import { buildRelayInboundFacts } from "./inbound.js";
15
+ import { buildRelayInboundFacts, renderRelayMessageParts } from "./inbound.js";
16
16
  import type { RelayIngressLifecycle } from "./ingress.js";
17
17
  import type { PluginRuntime } from "./runtime.js";
18
18
  import { type RelayChatTurns, waitForIdleChat } from "./turns.js";
@@ -55,6 +55,8 @@ function isReplyToAgentMessage(
55
55
  export async function resolveRelayTurnActivation(params: {
56
56
  facts: RelayInboundFacts;
57
57
  relay: RelayReplyLookup;
58
+ /** The replied-to Message when the caller already read it. */
59
+ replyTarget?: Message;
58
60
  }): Promise<RelayTurnActivation | null> {
59
61
  if (params.facts.chatType === "direct") {
60
62
  return {
@@ -77,9 +79,8 @@ export async function resolveRelayTurnActivation(params: {
77
79
  }
78
80
 
79
81
  if (!params.facts.replyToId) return null;
80
- const replyTarget = await params.relay.messages.retrieve(
81
- params.facts.replyToId,
82
- );
82
+ const replyTarget = params.replyTarget
83
+ ?? await params.relay.messages.retrieve(params.facts.replyToId);
83
84
  if (!isReplyToAgentMessage(replyTarget, params.facts.chatId)) return null;
84
85
  return {
85
86
  kind: "reply",
@@ -88,6 +89,56 @@ export async function resolveRelayTurnActivation(params: {
88
89
  };
89
90
  }
90
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
+
91
142
  /**
92
143
  * The answer to another agent names the Message it answers: the model's own
93
144
  * reply target when it chose one, else the agent's Message. Where no reply may
@@ -124,9 +175,15 @@ export async function dispatchRelayEvent(params: {
124
175
  return;
125
176
  }
126
177
 
178
+ const repliedTo = await readReplyTarget({
179
+ facts,
180
+ relay: params.relay,
181
+ warn: params.warn,
182
+ });
127
183
  const activation = await resolveRelayTurnActivation({
128
184
  facts,
129
185
  relay: params.relay,
186
+ ...(repliedTo ? { replyTarget: repliedTo } : {}),
130
187
  });
131
188
  if (!activation) {
132
189
  params.warn?.(
@@ -235,6 +292,7 @@ export async function dispatchRelayEvent(params: {
235
292
  ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
236
293
  body: facts.text,
237
294
  });
295
+ const quote = relayReplyQuote(facts, repliedTo);
238
296
  const ctxPayload = buildChannelInboundEventContext({
239
297
  channel: "relay",
240
298
  accountId: route.accountId ?? params.account.accountId,
@@ -274,6 +332,7 @@ export async function dispatchRelayEvent(params: {
274
332
  rawBody: facts.text,
275
333
  commandBody: facts.text,
276
334
  },
335
+ ...(quote ? { supplemental: { quote } } : {}),
277
336
  channelIngress: access,
278
337
  access: {
279
338
  commands: {
package/src/inbound.ts CHANGED
@@ -111,6 +111,9 @@ export function buildRelayInboundFacts(
111
111
  ...(event.data.reply_to?.message_id
112
112
  ? {
113
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 }),
114
117
  // The answer quotes the person's message, the one it answers (a bot's
115
118
  // reply_to in Telegram and Discord names the person's message). A
116
119
  // tap's reply_to names the agent's buttons part, which no reply may
package/src/types.ts CHANGED
@@ -77,6 +77,8 @@ export type RelayInboundFacts = {
77
77
  mentionHandles: string[];
78
78
  ownerHandle?: ChatHandle;
79
79
  replyToId?: string;
80
+ /** The part of the replied-to Message the person swiped (`reply_to.part_index`). */
81
+ replyToPartIndex?: number;
80
82
  /**
81
83
  * The Message an outbound reply should quote when the person's Message
82
84
  * was itself a reply: the person's Message. A tap's reply_to names the