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