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