@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -533
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -85
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -111
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -646
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -122
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -149
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -162
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -13
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/invocations.js +0 -47
  38. package/dist/src/lifecycle.js +0 -35
  39. package/dist/src/poll-loop.js +0 -137
  40. package/dist/src/responding.js +0 -36
  41. package/dist/src/security.js +0 -26
  42. package/dist/src/state-files.js +0 -243
  43. package/dist/src/vendor/relay-sdk/client.js +0 -163
  44. package/dist/src/vendor/relay-sdk/errors.js +0 -45
  45. package/dist/src/vendor/relay-sdk/types.js +0 -2
  46. package/dist/src/vendor/relay-sdk/url.js +0 -39
  47. package/src/account-lock.ts +0 -108
  48. package/src/client.ts +0 -51
  49. package/src/cursor-store.ts +0 -186
  50. package/src/inbound-dedupe.ts +0 -241
  51. package/src/invocations.ts +0 -58
  52. package/src/lifecycle.ts +0 -42
  53. package/src/poll-loop.ts +0 -173
  54. package/src/responding.ts +0 -52
  55. package/src/security.ts +0 -36
  56. package/src/state-files.ts +0 -298
  57. package/src/vendor/relay-sdk/README.md +0 -28
  58. package/src/vendor/relay-sdk/client.ts +0 -293
  59. package/src/vendor/relay-sdk/errors.ts +0 -61
  60. package/src/vendor/relay-sdk/types.ts +0 -82
  61. package/src/vendor/relay-sdk/url.ts +0 -43
package/src/channel.ts CHANGED
@@ -1,28 +1,17 @@
1
- // Relay channel plugin assembly: config/multi-account resolution,
2
- // gateway long-poll lifecycle, durable message adapter, and inbound dispatch
3
- // wiring. Transport logic lives in client/poll-loop/inbound/outbound modules;
4
- // this file owns the OpenClaw adapter surfaces.
5
- import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
6
- import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
7
- import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
8
-
9
- /**
10
- * Read core's part index without requiring it to exist. Cores before
11
- * 2026.7.2-beta.5 have no `deliveryPartIndex` in their outbound context at all,
12
- * so naming the field directly would not typecheck against them. Reading it
13
- * through a widened shape keeps one source compiling on every supported core;
14
- * `deriveRelayIdempotencyKey` handles the undefined case.
15
- */
16
- function deliveryPartIndexOf(ctx: unknown): number | undefined {
17
- const index = (ctx as { deliveryPartIndex?: unknown }).deliveryPartIndex;
18
- return typeof index === "number" ? index : undefined;
19
- }
20
- import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
1
+ import type {
2
+ MessageSendResponse,
3
+ } from "@relaymessenger/sdk";
4
+ import {
5
+ createChatChannelPlugin,
6
+ type ChannelPlugin,
7
+ type OpenClawConfig,
8
+ } from "openclaw/plugin-sdk/channel-core";
21
9
  import {
22
10
  createMessageReceiptFromOutboundResults,
23
11
  defineChannelMessageAdapter,
12
+ type ChannelMessageUnknownSendContext,
13
+ type ChannelMessageUnknownSendReconciliationResult,
24
14
  } from "openclaw/plugin-sdk/channel-outbound";
25
- import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
26
15
  import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
27
16
  import {
28
17
  DEFAULT_ACCOUNT_ID,
@@ -30,31 +19,21 @@ import {
30
19
  resolveDefaultRelayAccountId,
31
20
  resolveRelayAccount,
32
21
  } from "./accounts.js";
33
- import { RelayAccountLock } from "./account-lock.js";
34
22
  import {
35
- createRelayClient,
36
- isAbortError,
37
- isRelayWebhookConflict,
38
- RelayApiError,
39
- } from "./client.js";
40
- import type { RelayClient } from "./client.js";
41
- import { createRelayCursorStore, openRelayCursorStateStore } from "./cursor-store.js";
42
- import { createRelayInboundDedupeGuard, createRelayInboundDeduper } from "./inbound-dedupe.js";
43
- import { buildRelayInboundFacts } from "./inbound.js";
44
- import type { RelayInboundFacts } from "./inbound.js";
45
- import { relayInvocationFor, rememberRelayInvocation } from "./invocations.js";
46
- import { createRelayAccountLifecycleRegistry } from "./lifecycle.js";
23
+ startRelayAccount,
24
+ stopRelayAccount,
25
+ } from "./gateway.js";
47
26
  import {
27
+ classifyUnknownRelaySend,
28
+ createRelaySdkClient,
48
29
  deriveRelayIdempotencyKey,
49
30
  RELAY_TEXT_CHUNK_LIMIT,
50
- reconcileRelayUnknownSend,
51
31
  sendRelayText,
52
32
  } from "./outbound.js";
53
- import { runRelayPollLoop } from "./poll-loop.js";
54
- import { markRespondingBeforeAttempt } from "./responding.js";
55
- import { getRelayRuntime } from "./runtime.js";
56
- import { relaySenderIsAllowed, resolveRelayAllowedSenderIds } from "./security.js";
57
- import type { RelayCoreConfig, ResolvedRelayAccount } from "./types.js";
33
+ import type {
34
+ RelayCoreConfig,
35
+ ResolvedRelayAccount,
36
+ } from "./types.js";
58
37
 
59
38
  export const RELAY_CHANNEL_ID = "relay" as const;
60
39
 
@@ -62,659 +41,305 @@ const relayMeta = {
62
41
  id: RELAY_CHANNEL_ID,
63
42
  label: "Relay",
64
43
  selectionLabel: "Relay",
65
- detailLabel: "Relay",
44
+ detailLabel: "Relay Messenger",
66
45
  docsPath: "https://docs.relayapp.im/integrations/openclaw",
67
- blurb: "Text your OpenClaw like a friend.",
46
+ docsLabel: "Relay OpenClaw",
47
+ blurb: "Message your OpenClaw through Relay.",
68
48
  systemImage: "message",
69
- // Relay renders plain text plus typed parts; no markdown dialect, so core
70
- // strips formatting instead of leaking `**`.
71
49
  markdownCapable: false,
50
+ order: 70,
72
51
  };
73
52
 
74
- function relayClientForAccount(account: ResolvedRelayAccount): RelayClient {
75
- return createRelayClient({ baseUrl: account.baseUrl, token: account.token });
53
+ function requireAccount(
54
+ cfg: RelayCoreConfig,
55
+ accountId?: string | null | undefined,
56
+ ): ResolvedRelayAccount {
57
+ const account = resolveRelayAccount({ cfg, accountId });
58
+ if (!account.configured) {
59
+ throw new Error(
60
+ `relay: account "${account.accountId}" has no Relay Agent Token`,
61
+ );
62
+ }
63
+ return account;
76
64
  }
77
65
 
78
- // ---------------------------------------------------------------------------
79
- // Outbound: durable message adapter.
80
- // ---------------------------------------------------------------------------
66
+ function receipt(
67
+ messages: readonly MessageSendResponse[],
68
+ replyToId?: string | null | undefined,
69
+ ) {
70
+ return createMessageReceiptFromOutboundResults({
71
+ results: messages.map((result) => ({
72
+ channel: RELAY_CHANNEL_ID,
73
+ messageId: result.message.id,
74
+ chatId: result.chat_id,
75
+ conversationId: result.chat_id,
76
+ })),
77
+ ...(replyToId ? { replyToId } : {}),
78
+ kind: "text",
79
+ });
80
+ }
81
81
 
82
- /**
83
- * Reconciliation can only prove sends whose idempotency key it can rebuild
84
- * exactly: one payload, one text, short enough that the renderer produced a
85
- * single platform send (partIndex 0). Anything else (multi-payload,
86
- * chunk-split, media) returns null so core keeps the intent unresolved
87
- * instead of replaying a body that differs from the original.
88
- */
89
- function resolveSingleReconcilableText(ctx: {
90
- payloads: ReadonlyArray<unknown>;
91
- renderedBatchPlan?: { textCount: number; mediaCount: number; payloadCount: number };
92
- }): string | null {
93
- if (ctx.payloads.length !== 1) {
82
+ function reconciliationText(
83
+ ctx: ChannelMessageUnknownSendContext,
84
+ ): string | null {
85
+ if (ctx.payloads.length !== 1) return null;
86
+ if (
87
+ ctx.renderedBatchPlan &&
88
+ (ctx.renderedBatchPlan.payloadCount !== 1 ||
89
+ ctx.renderedBatchPlan.mediaCount > 0)
90
+ ) {
94
91
  return null;
95
92
  }
93
+ const planned = ctx.renderedBatchPlan?.items[0]?.text;
96
94
  const payload = ctx.payloads[0];
97
- if (!payload || typeof payload !== "object" || !("text" in payload)) {
98
- return null;
99
- }
100
- const text = (payload as { text?: unknown }).text;
101
- if (typeof text !== "string" || !text.trim()) {
102
- return null;
103
- }
104
- if (text.length > RELAY_TEXT_CHUNK_LIMIT) {
105
- return null;
106
- }
107
- const plan = ctx.renderedBatchPlan;
108
- if (plan && (plan.payloadCount !== 1 || plan.textCount > 1 || plan.mediaCount > 0)) {
109
- return null;
95
+ const text = planned ?? payload?.text;
96
+ return typeof text === "string" && text.trim() ? text : null;
97
+ }
98
+
99
+ async function reconcileRelayUnknownSend(
100
+ ctx: ChannelMessageUnknownSendContext,
101
+ ): Promise<ChannelMessageUnknownSendReconciliationResult | null> {
102
+ const text = reconciliationText(ctx);
103
+ if (text === null) return null;
104
+
105
+ const account = requireAccount(
106
+ ctx.cfg as RelayCoreConfig,
107
+ ctx.accountId,
108
+ );
109
+ const relay = createRelaySdkClient(account);
110
+ const responses: MessageSendResponse[] = [];
111
+ const effectiveReplyToId =
112
+ ctx.effectiveReplyToId !== undefined
113
+ ? ctx.effectiveReplyToId
114
+ : ctx.replyToId;
115
+ try {
116
+ const chunks = chunkText(text, RELAY_TEXT_CHUNK_LIMIT);
117
+ for (const [index, chunk] of chunks.entries()) {
118
+ responses.push(
119
+ await sendRelayText({
120
+ relay,
121
+ chatId: ctx.to,
122
+ text: chunk,
123
+ replyToId: effectiveReplyToId,
124
+ idempotencyKey: deriveRelayIdempotencyKey({
125
+ deliveryQueueId: ctx.queueId,
126
+ deliveryPartIndex: index,
127
+ }),
128
+ }),
129
+ );
130
+ }
131
+ const first = responses[0];
132
+ if (!first) {
133
+ return {
134
+ status: "unresolved",
135
+ error: "relay: reconciliation produced no Message",
136
+ retryable: false,
137
+ };
138
+ }
139
+ return {
140
+ status: "sent",
141
+ messageId: first.message.id,
142
+ receipt: receipt(responses, effectiveReplyToId),
143
+ };
144
+ } catch (error) {
145
+ return classifyUnknownRelaySend(error);
110
146
  }
111
- return text;
112
147
  }
113
148
 
114
- const relayMessageAdapter = defineChannelMessageAdapter({
149
+ export const relayMessageAdapter = defineChannelMessageAdapter({
115
150
  id: RELAY_CHANNEL_ID,
116
151
  durableFinal: {
152
+ automaticUnknownSendReconciliation: true,
117
153
  capabilities: {
118
154
  text: true,
119
155
  replyTo: true,
120
- // Plain per-send adapter functions: core's message-sending hooks run
121
- // around every send, which the default durable requirement derivation
122
- // demands (capabilities.ts requires it unless explicitly waived).
123
156
  messageSendingHooks: true,
124
157
  reconcileUnknownSend: true,
125
158
  },
126
- // Only single-part text sends: that is what replaying one idempotency key
127
- // actually proves (multi-chunk sends have per-part keys and stay with the
128
- // normal retry path).
129
159
  reconcileUnknownSendKinds: { text: true },
130
- reconcileUnknownSend: async (ctx) => {
131
- const account = resolveRelayAccount({
132
- cfg: ctx.cfg as RelayCoreConfig,
133
- accountId: ctx.accountId,
134
- });
135
- if (!account.configured) {
136
- return { status: "unresolved", error: "relay account not configured", retryable: false };
137
- }
138
- const text = resolveSingleReconcilableText(ctx);
139
- if (text === null) {
140
- return null;
141
- }
142
- const invocationId = relayInvocationFor({
143
- accountId: account.accountId,
144
- conversationId: ctx.to,
145
- });
146
- const verdict = await reconcileRelayUnknownSend({
147
- client: relayClientForAccount(account),
148
- conversationId: ctx.to,
149
- text,
150
- replyToId: ctx.effectiveReplyToId ?? ctx.replyToId ?? null,
151
- ...(invocationId ? { invocationId } : {}),
152
- idempotencyKey: deriveRelayIdempotencyKey({ deliveryQueueId: ctx.queueId }),
153
- });
154
- if (verdict.status === "sent") {
155
- return {
156
- status: "sent",
157
- messageId: verdict.messageId,
158
- // The 202 is an array: name every message the send committed.
159
- receipt: createMessageReceiptFromOutboundResults({
160
- results: verdict.messages.map((message) => ({
161
- channel: RELAY_CHANNEL_ID,
162
- messageId: message.id,
163
- })),
164
- kind: "text",
165
- }),
166
- };
167
- }
168
- return verdict;
169
- },
160
+ reconcileUnknownSend: reconcileRelayUnknownSend,
170
161
  },
171
162
  send: {
172
163
  text: async (ctx) => {
173
- const account = resolveRelayAccount({
174
- cfg: ctx.cfg as RelayCoreConfig,
175
- accountId: ctx.accountId,
176
- });
177
- if (!account.configured) {
178
- throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
179
- }
180
- // A group reply must name the invocation it answers. Core's send context
181
- // has no field for it, so the turn parks it in the invocation registry
182
- // under (accountId, conversationId) and it is read back here.
183
- const invocationId = relayInvocationFor({
184
- accountId: account.accountId,
185
- conversationId: ctx.to,
186
- });
187
- const result = await sendRelayText({
188
- client: relayClientForAccount(account),
189
- conversationId: ctx.to,
164
+ const account = requireAccount(
165
+ ctx.cfg as RelayCoreConfig,
166
+ ctx.accountId,
167
+ );
168
+ const response = await sendRelayText({
169
+ relay: createRelaySdkClient(account),
170
+ chatId: ctx.to,
190
171
  text: ctx.text,
191
- replyToId: ctx.replyToId ?? null,
192
- ...(invocationId ? { invocationId } : {}),
193
- // Stable per (queueId, part): internal retries replay the same key,
194
- // so the server-side idempotent commit makes duplicates impossible by
195
- // contract. On a core with no part index the text names the part.
172
+ replyToId: ctx.replyToId,
196
173
  idempotencyKey: deriveRelayIdempotencyKey({
197
174
  deliveryQueueId: ctx.deliveryQueueId,
198
- deliveryPartIndex: deliveryPartIndexOf(ctx),
199
- partText: ctx.text,
175
+ deliveryPartIndex: ctx.deliveryPartIndex,
200
176
  }),
201
177
  ...(ctx.signal ? { signal: ctx.signal } : {}),
178
+ ...(ctx.onPlatformSendDispatch
179
+ ? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
180
+ : {}),
202
181
  });
203
182
  return {
204
- messageId: result.messageId,
205
- // The 202 is an array: name every message the send committed.
206
- receipt: createMessageReceiptFromOutboundResults({
207
- results: result.messages.map((message) => ({
208
- channel: RELAY_CHANNEL_ID,
209
- messageId: message.id,
210
- })),
211
- replyToId: ctx.replyToId ?? undefined,
212
- kind: "text",
213
- }),
183
+ messageId: response.message.id,
184
+ receipt: receipt([response], ctx.replyToId),
214
185
  };
215
186
  },
216
187
  },
217
- receive: {
218
- // Cursor acks after a durable at-most-once attempt marker is written.
219
- defaultAckPolicy: "after_agent_dispatch",
220
- supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
221
- },
222
188
  });
223
189
 
224
- // ---------------------------------------------------------------------------
225
- // Inbound dispatch — qa-channel-shaped runtime wiring.
226
- // ---------------------------------------------------------------------------
227
-
228
- async function dispatchRelayInbound(params: {
229
- cfg: OpenClawConfig;
230
- account: ResolvedRelayAccount;
231
- facts: RelayInboundFacts;
232
- client: RelayClient;
233
- allowedSenderIds: readonly string[];
234
- markAttempt: () => Promise<void>;
235
- warn?: (line: string) => void;
236
- }): Promise<void> {
237
- const { account, facts } = params;
238
- // Public Relay agents are discoverable, so contact membership is not an
239
- // authorization boundary. Only the API-pinned owner and explicit operator
240
- // allowlist entries may start an OpenClaw turn.
241
- const dmPolicy = "allowlist" as const;
242
- const allowFrom = [...params.allowedSenderIds];
243
- const access = await resolveStableChannelMessageIngress({
244
- channelId: RELAY_CHANNEL_ID,
245
- accountId: account.accountId,
246
- identity: { key: "sender", entryIdPrefix: "relay-entry" },
247
- subject: { stableId: facts.senderId },
248
- conversation: { kind: "direct", id: facts.conversationId },
249
- dmPolicy,
250
- allowFrom,
251
- });
252
- if (access.ingress.admission !== "dispatch") {
253
- return;
254
- }
255
- const runtime = getRelayRuntime();
256
- const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
257
- cfg: params.cfg,
258
- channel: RELAY_CHANNEL_ID,
259
- accountId: account.accountId,
260
- peer: { kind: "direct", id: facts.conversationId },
261
- runtime: runtime.channel,
262
- sessionStore: (params.cfg as RelayCoreConfig).session?.store,
263
- });
264
- const commandAuthorized = relaySenderIsAllowed(params.allowedSenderIds, facts.senderId);
265
- const { storePath, body } = buildEnvelope({
266
- channel: relayMeta.label,
267
- from: facts.senderId,
268
- ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
269
- body: facts.text,
270
- });
271
- const ctxPayload = runtime.channel.reply.finalizeInboundContext({
272
- Body: body,
273
- BodyForAgent: facts.text,
274
- RawBody: facts.text,
275
- CommandBody: facts.text,
276
- From: facts.conversationId,
277
- To: facts.conversationId,
278
- SessionKey: route.sessionKey,
279
- AccountId: route.accountId ?? account.accountId,
280
- ChatType: "direct",
281
- ConversationLabel: facts.conversationId,
282
- SenderId: facts.senderId,
283
- SenderName: facts.senderId,
284
- Provider: RELAY_CHANNEL_ID,
285
- Surface: RELAY_CHANNEL_ID,
286
- MessageSid: facts.messageId,
287
- MessageSidFull: facts.messageId,
288
- ...(facts.replyToId ? { ReplyToId: facts.replyToId } : {}),
289
- ...(facts.timestamp ? { Timestamp: facts.timestamp } : {}),
290
- OriginatingChannel: RELAY_CHANNEL_ID,
291
- OriginatingTo: facts.conversationId,
292
- CommandAuthorized: commandAuthorized,
293
- });
294
- // A consumed inbound message with a silently lost reply is the worst
295
- // outcome. Delivery failures are surfaced, but the inbound attempt marker
296
- // prevents replaying an agent turn whose tools may already have run.
297
- let deliveryError: unknown;
298
- let fallbackDeliveryIndex = 0;
299
- const recordDeliveryError = (error: unknown) => {
300
- deliveryError ??= error;
301
- };
302
- // Park the group invocation for the life of the turn. Core's durable send
303
- // adapter is a separate entry point with no inbound context, so this is how
304
- // the reply learns which invocation it answers.
305
- const releaseInvocation = facts.invocationId
306
- ? rememberRelayInvocation({
307
- accountId: account.accountId,
308
- conversationId: facts.conversationId,
309
- invocationId: facts.invocationId,
310
- })
311
- : () => {};
312
- try {
313
- // Admission, runtime resolution, route/session lookup, envelope building,
314
- // and context finalization above are replay-safe. The durable attempt starts
315
- // immediately before OpenClaw can invoke the agent or its tools.
316
- await markRespondingBeforeAttempt({
317
- client: params.client,
318
- facts,
319
- label: "OpenClaw",
320
- markAttempt: params.markAttempt,
321
- ...(params.warn ? { onReceiptFailure: params.warn } : {}),
322
- });
323
- await runtime.channel.inbound.dispatchReply({
324
- cfg: params.cfg,
325
- channel: RELAY_CHANNEL_ID,
326
- accountId: account.accountId,
327
- agentId: route.agentId,
328
- routeSessionKey: route.sessionKey,
329
- storePath,
330
- ctxPayload,
331
- recordInboundSession: runtime.channel.session.recordInboundSession,
332
- dispatchReplyWithBufferedBlockDispatcher:
333
- runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
334
- delivery: {
335
- // Final replies go through the durable message adapter: core renders
336
- // and chunks them (chunker + textChunkLimit) and tracks the send as a
337
- // durable queue intent. Requiring reconcileUnknownSend forces
338
- // `durability: "required"`, so single-payload finals carry a stable
339
- // deliveryQueueId into send.text (stable idempotency key + exact
340
- // replay), and multi-chunk finals get core's queue-level crash
341
- // recovery. Replies land as plain messages, not quotes
342
- // (`replyToId: null`).
343
- durable: {
344
- to: facts.conversationId,
345
- replyToId: null,
346
- requiredCapabilities: { reconcileUnknownSend: true },
347
- },
348
- // Fallback for payloads the durable path does not carry (non-final
349
- // visible blocks). The event id + block/chunk ordinals identify each
350
- // logical send: retries reuse it while identical intentional blocks and
351
- // chunks remain distinct.
352
- deliver: async (payload) => {
353
- const text =
354
- payload && typeof payload === "object" && "text" in payload
355
- ? ((payload as { text?: string }).text ?? "")
356
- : "";
357
- if (!text.trim()) {
358
- return;
359
- }
360
- const logicalBlockId = `${facts.eventId}:block:${fallbackDeliveryIndex}`;
361
- fallbackDeliveryIndex += 1;
362
- try {
363
- let chunkIndex = 0;
364
- for (const chunk of chunkText(text, RELAY_TEXT_CHUNK_LIMIT)) {
365
- await sendRelayText({
366
- client: params.client,
367
- conversationId: facts.conversationId,
368
- text: chunk,
369
- ...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
370
- idempotencyKey: deriveRelayIdempotencyKey({
371
- deliveryQueueId: logicalBlockId,
372
- deliveryPartIndex: chunkIndex,
373
- }),
374
- });
375
- chunkIndex += 1;
376
- }
377
- } catch (error) {
378
- recordDeliveryError(error);
379
- throw error;
380
- }
190
+ export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> =
191
+ createChatChannelPlugin({
192
+ base: {
193
+ id: RELAY_CHANNEL_ID,
194
+ meta: relayMeta,
195
+ capabilities: {
196
+ chatTypes: ["direct", "group"],
197
+ reply: true,
198
+ threads: false,
199
+ media: false,
200
+ reactions: false,
201
+ edit: false,
202
+ unsend: false,
203
+ effects: false,
204
+ blockStreaming: false,
205
+ },
206
+ reload: { configPrefixes: ["channels.relay"] },
207
+ setup: {
208
+ applyAccountConfig: ({ cfg, accountId, input }) => {
209
+ const core = cfg as RelayCoreConfig;
210
+ const section = { ...core.channels?.relay };
211
+ const patch = input as Record<string, unknown>;
212
+ const relay =
213
+ !accountId || accountId === DEFAULT_ACCOUNT_ID
214
+ ? { ...section, ...patch }
215
+ : {
216
+ ...section,
217
+ accounts: {
218
+ ...section.accounts,
219
+ [accountId]: {
220
+ ...section.accounts?.[accountId],
221
+ ...patch,
222
+ },
223
+ },
224
+ };
225
+ return {
226
+ ...cfg,
227
+ channels: {
228
+ ...core.channels,
229
+ relay,
230
+ },
231
+ } as OpenClawConfig;
381
232
  },
382
- onError: recordDeliveryError,
383
233
  },
384
- replyPipeline: {},
385
- });
386
- if (deliveryError) {
387
- throw deliveryError instanceof Error
388
- ? deliveryError
389
- : new Error(`relay reply delivery failed: ${String(deliveryError)}`);
390
- }
391
- } finally {
392
- releaseInvocation();
393
- }
394
- }
395
-
396
- // ---------------------------------------------------------------------------
397
- // Gateway lifecycle.
398
- // ---------------------------------------------------------------------------
399
-
400
- /**
401
- * One long-poll consumer per agent token: two configured
402
- * accounts sharing a token would otherwise fight over the server's consumer
403
- * slot in an endless 409 loop. Keyed by (baseUrl, agentId) from getMe.
404
- */
405
- const runningRelayAgentAccounts = new Map<string, string>();
406
- const relayAccountLifecycles = createRelayAccountLifecycleRegistry();
407
-
408
- export function relayAgentAccountKey(baseUrl: string, agentId: string): string {
409
- return `${baseUrl}\0${agentId}`;
410
- }
411
-
412
- async function startRelayAccount(ctx: ChannelGatewayContext<ResolvedRelayAccount>): Promise<void> {
413
- const account = ctx.account;
414
- if (!account.configured) {
415
- throw new Error(
416
- `Relay is not configured for account "${account.accountId}" (set channels.relay.token or ${account.accountId === DEFAULT_ACCOUNT_ID ? "RELAY_AGENT_TOKEN" : `channels.relay.accounts.${account.accountId}.token`}).`,
417
- );
418
- }
419
- const log = (line: string) => ctx.log?.info?.(line);
420
- const warn = (line: string) => ctx.log?.warn?.(line);
421
- const client = relayClientForAccount(account);
422
- const lifecycle = relayAccountLifecycles.acquire(account.accountId, ctx.abortSignal);
423
- const abortSignal = lifecycle.signal;
424
- let agentKey: string | undefined;
425
- let accountLock: RelayAccountLock | undefined;
426
-
427
- const markTerminalDisconnect = (error: Error) => {
428
- // Operator action required: flag terminalDisconnect so the supervisor
429
- // does not auto-restart (server-channels.ts:718).
430
- ctx.setStatus({
431
- accountId: account.accountId,
432
- running: false,
433
- connected: false,
434
- terminalDisconnect: true,
435
- lastError: error.message,
436
- });
437
- };
438
-
439
- try {
440
- const me = await client.getMe({ signal: abortSignal });
441
- const allowedSenderIds = resolveRelayAllowedSenderIds({
442
- profile: me,
443
- allowFrom: account.config.allowFrom,
444
- });
445
- if (allowedSenderIds.length === 0) {
446
- const error = new Error(
447
- `relay: account "${account.accountId}" has no owner pin. ` +
448
- "The Relay API did not return owner_user_id and channels.relay.allowFrom is empty.",
449
- );
450
- markTerminalDisconnect(error);
451
- throw error;
452
- }
453
-
454
- // Two accounts configured with the same token would fight over the
455
- // server's single consumer slot forever; keep the second one down until
456
- // the operator fixes the config. account.baseUrl is already canonical.
457
- agentKey = relayAgentAccountKey(account.baseUrl, me.id);
458
- const owner = runningRelayAgentAccounts.get(agentKey);
459
- if (owner !== undefined) {
460
- const error = new Error(
461
- `relay: agent ${me.id} is already polled by account "${owner}"; account "${account.accountId}" appears to reuse the same Agent Token. Give each account its own token.`,
462
- );
463
- markTerminalDisconnect(error);
464
- throw error;
465
- }
466
- accountLock = new RelayAccountLock(account.baseUrl, me.id, account.accountId);
467
- try {
468
- accountLock.acquire();
469
- } catch (error) {
470
- const lockError = error instanceof Error ? error : new Error(String(error));
471
- markTerminalDisconnect(lockError);
472
- throw lockError;
473
- }
474
- runningRelayAgentAccounts.set(agentKey, account.accountId);
475
-
476
- ctx.setStatus({
477
- accountId: account.accountId,
478
- running: true,
479
- connected: true,
480
- configured: true,
481
- enabled: account.enabled,
482
- });
483
-
484
- const cursorStore = createRelayCursorStore({
485
- store: openRelayCursorStateStore(warn),
486
- baseUrl: account.baseUrl,
487
- agentId: me.id,
488
- onPersistError: (error) => warn(`[relay] cursor persistence failed: ${String(error)}`),
489
- });
490
- await cursorStore.load();
491
- const deduper = createRelayInboundDeduper({
492
- guard: createRelayInboundDedupeGuard({
493
- onDiskError: (error) => warn(`[relay] inbound dedupe persistence failed: ${String(error)}`),
494
- }),
495
- baseUrl: account.baseUrl,
496
- agentId: me.id,
497
- });
498
-
499
- await runRelayPollLoop({
500
- client,
501
- cursorStore,
502
- deduper,
503
- abortSignal,
504
- timeoutSeconds: account.pollTimeoutSeconds,
505
- limit: 100,
506
- log,
507
- // Receipts, reactions, and echoes are acked without a dedupe row or a
508
- // dispatch: reaction.* is observe-only at v1, delivered/read
509
- // are bookkeeping.
510
- shouldProcess: (event) => buildRelayInboundFacts(event, { agentId: me.id }) !== null,
511
- onBatch: () => {
512
- ctx.setStatus({
513
- accountId: account.accountId,
514
- running: true,
515
- connected: true,
516
- lastInboundAt: Date.now(),
517
- });
234
+ config: {
235
+ listAccountIds: (cfg) =>
236
+ listRelayAccountIds(cfg as RelayCoreConfig),
237
+ resolveAccount: (cfg, accountId) =>
238
+ resolveRelayAccount({
239
+ cfg: cfg as RelayCoreConfig,
240
+ accountId,
241
+ }),
242
+ defaultAccountId: (cfg) =>
243
+ resolveDefaultRelayAccountId(cfg as RelayCoreConfig),
244
+ isConfigured: (account) => account.configured,
245
+ inspectAccount: (cfg, accountId) => {
246
+ const account = resolveRelayAccount({
247
+ cfg: cfg as RelayCoreConfig,
248
+ accountId,
249
+ });
250
+ return {
251
+ enabled: account.enabled,
252
+ configured: account.configured,
253
+ tokenStatus: account.configured ? "available" : "missing",
254
+ baseUrl: account.baseUrl,
255
+ };
256
+ },
257
+ resolveAllowFrom: ({ cfg, accountId }) =>
258
+ (() => {
259
+ const account = resolveRelayAccount({
260
+ cfg: cfg as RelayCoreConfig,
261
+ accountId,
262
+ });
263
+ return account.allowFrom.length > 0
264
+ ? account.allowFrom
265
+ : ["*"];
266
+ })(),
518
267
  },
519
- handleEvent: async (event, markAttempt) => {
520
- const facts = buildRelayInboundFacts(event, { agentId: me.id });
521
- if (!facts) {
522
- return;
523
- }
524
- await dispatchRelayInbound({
525
- cfg: ctx.cfg,
526
- account,
527
- facts,
528
- client,
529
- allowedSenderIds,
530
- markAttempt,
531
- warn,
532
- });
268
+ messaging: {
269
+ targetPrefixes: ["relay"],
270
+ normalizeTarget: (target) => {
271
+ const normalized = target.trim().replace(/^relay:/iu, "");
272
+ return normalized || undefined;
273
+ },
274
+ targetResolver: {
275
+ looksLikeId: (raw) =>
276
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
277
+ raw.trim().replace(/^relay:/iu, ""),
278
+ ),
279
+ hint: "<Relay Chat ID>",
280
+ },
533
281
  },
534
- });
535
- } catch (error) {
536
- if (abortSignal.aborted || isAbortError(error)) {
537
- return;
538
- }
539
- // Named as `kind === "auth"`, not `error.terminal`. The SDK client counts
540
- // every non-retryable kind as terminal, which would swallow the 409 cases
541
- // below — including `terminated_by_other_consumer`, whose whole point is
542
- // to fall through to the supervisor's restart arbitration.
543
- if (error instanceof RelayApiError && error.kind === "auth") {
544
- markTerminalDisconnect(error);
545
- } else if (isRelayWebhookConflict(error)) {
546
- // Webhook XOR: long polling stays 409 until the operator
547
- // disables the webhook endpoint — restarting cannot fix it.
548
- // `terminated_by_other_consumer` intentionally falls through to the
549
- // supervisor's normal restart/backoff arbitration.
550
- markTerminalDisconnect(error);
551
- }
552
- throw error;
553
- } finally {
554
- if (agentKey && runningRelayAgentAccounts.get(agentKey) === account.accountId) {
555
- runningRelayAgentAccounts.delete(agentKey);
556
- }
557
- accountLock?.release();
558
- lifecycle.release();
559
- ctx.setStatus({
560
- accountId: account.accountId,
561
- running: false,
562
- connected: false,
563
- });
564
- }
565
- }
566
-
567
- async function stopRelayAccount(
568
- ctx: ChannelGatewayContext<ResolvedRelayAccount>,
569
- ): Promise<void> {
570
- relayAccountLifecycles.stop(ctx.accountId);
571
- ctx.setStatus({
572
- accountId: ctx.accountId,
573
- running: false,
574
- connected: false,
575
- });
576
- ctx.log?.info?.(`[relay] stopped account "${ctx.accountId}"`);
577
- }
578
-
579
- // ---------------------------------------------------------------------------
580
- // Plugin object.
581
- // ---------------------------------------------------------------------------
582
-
583
- export const relayChannelPlugin: ChannelPlugin<ResolvedRelayAccount> = createChatChannelPlugin({
584
- base: {
585
- id: RELAY_CHANNEL_ID,
586
- meta: relayMeta,
587
- capabilities: {
588
- // v1: direct conversations only; media flips on when the agent
589
- // attachment path ships. Reactions are observe-only.
590
- chatTypes: ["direct"],
591
- reply: true,
592
- threads: false,
593
- media: false,
594
- reactions: false,
595
- },
596
- reload: { configPrefixes: ["channels.relay"] },
597
- setup: {
598
- applyAccountConfig: ({ cfg, accountId, input }) => {
599
- const coreCfg = cfg as RelayCoreConfig;
600
- const channelSection = { ...coreCfg.channels?.relay };
601
- const patch = input as Record<string, unknown>;
602
- const next =
603
- !accountId || accountId === DEFAULT_ACCOUNT_ID
604
- ? { ...channelSection, ...patch }
605
- : {
606
- ...channelSection,
607
- accounts: {
608
- ...channelSection.accounts,
609
- [accountId]: {
610
- ...channelSection.accounts?.[accountId],
611
- ...patch,
612
- },
613
- },
614
- };
615
- return {
616
- ...cfg,
617
- channels: {
618
- ...coreCfg.channels,
619
- relay: next,
620
- },
621
- } as OpenClawConfig;
282
+ gateway: {
283
+ startAccount: startRelayAccount,
284
+ stopAccount: stopRelayAccount,
622
285
  },
623
- },
624
- config: {
625
- listAccountIds: (cfg) => listRelayAccountIds(cfg as RelayCoreConfig),
626
- resolveAccount: (cfg, accountId) =>
627
- resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId }),
628
- defaultAccountId: (cfg) => resolveDefaultRelayAccountId(cfg as RelayCoreConfig),
629
- isConfigured: (account) => account.configured,
630
- inspectAccount: (cfg, accountId) => {
631
- const account = resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId });
632
- return {
633
- enabled: account.enabled,
634
- configured: account.configured,
635
- tokenStatus: account.configured ? "available" : "missing",
636
- baseUrl: account.baseUrl,
637
- };
286
+ heartbeat: {
287
+ sendTyping: async ({ cfg, to, accountId }) => {
288
+ const account = requireAccount(
289
+ cfg as RelayCoreConfig,
290
+ accountId,
291
+ );
292
+ await createRelaySdkClient(account).chats.startTyping(to);
293
+ },
294
+ clearTyping: async ({ cfg, to, accountId }) => {
295
+ const account = requireAccount(
296
+ cfg as RelayCoreConfig,
297
+ accountId,
298
+ );
299
+ await createRelaySdkClient(account).chats.stopTyping(to);
300
+ },
638
301
  },
639
- resolveAllowFrom: ({ cfg, accountId }) =>
640
- resolveRelayAllowedSenderIds({
641
- profile: {},
642
- allowFrom: resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId }).config
643
- .allowFrom,
644
- }),
302
+ message: relayMessageAdapter,
645
303
  },
646
- messaging: {
647
- targetResolver: {
648
- looksLikeId: (raw) => /^cnv_[A-Za-z0-9]+$/.test(raw.trim()),
649
- hint: "<cnv_…> (Relay conversation id from message.received)",
304
+ security: {
305
+ dm: {
306
+ channelKey: RELAY_CHANNEL_ID,
307
+ resolvePolicy: (account) =>
308
+ account.allowFrom.length > 0 ? "allowlist" : "open",
309
+ resolveAllowFrom: (account) =>
310
+ account.allowFrom.length > 0 ? account.allowFrom : ["*"],
311
+ defaultPolicy: "open",
650
312
  },
651
313
  },
652
- gateway: {
653
- startAccount: startRelayAccount,
654
- stopAccount: stopRelayAccount,
655
- },
656
- heartbeat: {
657
- // Ephemeral typing indicator: POST typing start/stop.
658
- sendTyping: async ({ cfg, to, accountId }) => {
659
- const account = resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId });
660
- if (!account.configured) {
661
- return;
662
- }
663
- await relayClientForAccount(account).setTyping({ conversationId: to, started: true });
314
+ outbound: {
315
+ base: {
316
+ deliveryMode: "direct",
317
+ chunker: (text, limit) => chunkText(text, limit),
318
+ chunkerMode: "text",
319
+ textChunkLimit: RELAY_TEXT_CHUNK_LIMIT,
664
320
  },
665
- clearTyping: async ({ cfg, to, accountId }) => {
666
- const account = resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId });
667
- if (!account.configured) {
668
- return;
669
- }
670
- await relayClientForAccount(account).setTyping({ conversationId: to, started: false });
671
- },
672
- },
673
- message: relayMessageAdapter,
674
- },
675
- security: {
676
- dm: {
677
- channelKey: RELAY_CHANNEL_ID,
678
- resolvePolicy: () => "allowlist",
679
- resolveAllowFrom: (account) =>
680
- resolveRelayAllowedSenderIds({ profile: {}, allowFrom: account.config.allowFrom }),
681
- defaultPolicy: "allowlist",
682
- },
683
- },
684
- outbound: {
685
- base: {
686
- deliveryMode: "direct",
687
- // Core's renderer splits long replies before the adapter sees them
688
- // without a chunker the plan falls back to one oversized
689
- // unit, which the server 422s at its 8 KiB per-part cap.
690
- chunker: (text, limit) => chunkText(text, limit),
691
- chunkerMode: "text",
692
- textChunkLimit: RELAY_TEXT_CHUNK_LIMIT,
693
- },
694
- attachedResults: {
695
- channel: RELAY_CHANNEL_ID,
696
- sendText: async (ctx) => {
697
- const { cfg, to, text, accountId, replyToId } = ctx;
698
- const account = resolveRelayAccount({ cfg: cfg as RelayCoreConfig, accountId });
699
- if (!account.configured) {
700
- throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
701
- }
702
- const normalizedReplyToId = replyToId == null ? null : String(replyToId);
703
- const result = await sendRelayText({
704
- client: relayClientForAccount(account),
705
- conversationId: to,
706
- text,
707
- replyToId: normalizedReplyToId,
708
- // Stable when core supplies a logical queue id; otherwise fresh for
709
- // this invocation so two intentional identical sends remain two.
710
- idempotencyKey: deriveRelayIdempotencyKey({
711
- deliveryQueueId: ctx.deliveryQueueId,
712
- deliveryPartIndex: deliveryPartIndexOf(ctx),
713
- partText: text,
714
- }),
715
- });
716
- return { messageId: result.messageId };
321
+ attachedResults: {
322
+ channel: RELAY_CHANNEL_ID,
323
+ sendText: async (ctx) => {
324
+ const account = requireAccount(
325
+ ctx.cfg as RelayCoreConfig,
326
+ ctx.accountId,
327
+ );
328
+ const response = await sendRelayText({
329
+ relay: createRelaySdkClient(account),
330
+ chatId: ctx.to,
331
+ text: ctx.text,
332
+ replyToId: ctx.replyToId,
333
+ idempotencyKey: deriveRelayIdempotencyKey({
334
+ deliveryQueueId: ctx.deliveryQueueId,
335
+ deliveryPartIndex: ctx.deliveryPartIndex,
336
+ }),
337
+ ...(ctx.onPlatformSendDispatch
338
+ ? { onPlatformSendDispatch: ctx.onPlatformSendDispatch }
339
+ : {}),
340
+ });
341
+ return { messageId: response.message.id };
342
+ },
717
343
  },
718
344
  },
719
- },
720
- });
345
+ });