@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
@@ -0,0 +1,324 @@
1
+ import type {
2
+ Message,
3
+ Relay,
4
+ RelayWebhookEvent,
5
+ } from "@relaymessenger/sdk";
6
+ import {
7
+ buildChannelInboundEventContext,
8
+ resolveChannelInboundRouteEnvelope,
9
+ } from "openclaw/plugin-sdk/channel-inbound";
10
+ import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
11
+ import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
12
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
13
+ import { buildRelayInboundFacts } from "./inbound.js";
14
+ import type { RelayIngressLifecycle } from "./ingress.js";
15
+ import type { PluginRuntime } from "./runtime.js";
16
+ import type {
17
+ RelayCoreConfig,
18
+ RelayInboundFacts,
19
+ ResolvedRelayAccount,
20
+ } from "./types.js";
21
+
22
+ export type RelayTurnActivation =
23
+ | {
24
+ kind: "direct";
25
+ wasMentioned: false;
26
+ implicitMentionKinds: [];
27
+ }
28
+ | {
29
+ kind: "mention";
30
+ wasMentioned: true;
31
+ implicitMentionKinds: [];
32
+ }
33
+ | {
34
+ kind: "reply";
35
+ wasMentioned: false;
36
+ implicitMentionKinds: ["reply_to_bot"];
37
+ };
38
+
39
+ type RelayReplyLookup = Pick<Relay, "messages">;
40
+
41
+ function isReplyToAgentMessage(
42
+ message: Message,
43
+ chatId: string,
44
+ ): boolean {
45
+ return message.chat_id === chatId && message.is_from_me === true;
46
+ }
47
+
48
+ /**
49
+ * Relay's structured mention and reply facts are authoritative. In
50
+ * particular, this does not infer an activation from visible `@handle` text.
51
+ */
52
+ export async function resolveRelayTurnActivation(params: {
53
+ facts: RelayInboundFacts;
54
+ relay: RelayReplyLookup;
55
+ }): Promise<RelayTurnActivation | null> {
56
+ if (params.facts.chatType === "direct") {
57
+ return {
58
+ kind: "direct",
59
+ wasMentioned: false,
60
+ implicitMentionKinds: [],
61
+ };
62
+ }
63
+
64
+ const ownerHandle = params.facts.ownerHandle;
65
+ if (
66
+ ownerHandle?.kind === "agent" &&
67
+ params.facts.mentionHandles.includes(ownerHandle.handle)
68
+ ) {
69
+ return {
70
+ kind: "mention",
71
+ wasMentioned: true,
72
+ implicitMentionKinds: [],
73
+ };
74
+ }
75
+
76
+ if (!params.facts.replyToId) return null;
77
+ const replyTarget = await params.relay.messages.retrieve(
78
+ params.facts.replyToId,
79
+ );
80
+ if (!isReplyToAgentMessage(replyTarget, params.facts.chatId)) return null;
81
+ return {
82
+ kind: "reply",
83
+ wasMentioned: false,
84
+ implicitMentionKinds: ["reply_to_bot"],
85
+ };
86
+ }
87
+
88
+ export async function dispatchRelayEvent(params: {
89
+ event: RelayWebhookEvent;
90
+ lifecycle: RelayIngressLifecycle;
91
+ account: ResolvedRelayAccount;
92
+ cfg: RelayCoreConfig;
93
+ relay: Pick<Relay, "chats" | "messages">;
94
+ runtime: PluginRuntime;
95
+ warn?: (message: string) => void;
96
+ }): Promise<void> {
97
+ const facts = buildRelayInboundFacts(params.event);
98
+ if (!facts) {
99
+ params.warn?.(
100
+ `relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`,
101
+ );
102
+ return;
103
+ }
104
+
105
+ const activation = await resolveRelayTurnActivation({
106
+ facts,
107
+ relay: params.relay,
108
+ });
109
+ if (!activation) {
110
+ params.warn?.(
111
+ `relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`,
112
+ );
113
+ return;
114
+ }
115
+
116
+ const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
117
+ cfg: params.cfg as OpenClawConfig,
118
+ channel: "relay",
119
+ accountId: params.account.accountId,
120
+ peer: {
121
+ kind: facts.chatType,
122
+ id: facts.chatId,
123
+ },
124
+ });
125
+ const restricted = params.account.allowFrom.length > 0;
126
+ const effectiveAllowFrom = restricted
127
+ ? params.account.allowFrom
128
+ : ["*"];
129
+ const access = await resolveStableChannelMessageIngress({
130
+ channelId: "relay",
131
+ accountId: params.account.accountId,
132
+ identity: {
133
+ key: "contactId",
134
+ kind: "stable-id",
135
+ entryIdPrefix: "relay-contact",
136
+ aliases: [
137
+ {
138
+ key: "handle",
139
+ kind: "username",
140
+ normalize: (value) => value.trim().replace(/^@/u, "").toLowerCase(),
141
+ dangerous: true,
142
+ },
143
+ ],
144
+ },
145
+ subject: {
146
+ stableId: facts.contactId,
147
+ aliases: { handle: facts.handle },
148
+ },
149
+ conversation: {
150
+ kind: facts.chatType,
151
+ id: facts.chatId,
152
+ title: facts.chatType === "direct" ? facts.displayName : facts.chatId,
153
+ },
154
+ contextBinding: {
155
+ agentId: route.agentId,
156
+ sessionKey: route.sessionKey,
157
+ messageId: facts.messageId,
158
+ nativeChannelId: facts.chatId,
159
+ inboundEventKind: "user_request",
160
+ },
161
+ dmPolicy: restricted ? "allowlist" : "open",
162
+ groupPolicy: restricted ? "allowlist" : "open",
163
+ policy: {
164
+ groupAllowFromFallbackToAllowFrom: true,
165
+ ...(facts.chatType === "group"
166
+ ? {
167
+ activation: {
168
+ requireMention: true,
169
+ allowTextCommands: false,
170
+ implicitMentions: {
171
+ replyToBot: true,
172
+ quotedBot: false,
173
+ threadParticipation: false,
174
+ },
175
+ allowedImplicitMentionKinds: ["reply_to_bot"],
176
+ },
177
+ }
178
+ : {}),
179
+ },
180
+ ...(facts.chatType === "group"
181
+ ? {
182
+ mentionFacts: {
183
+ canDetectMention: true,
184
+ wasMentioned: activation.wasMentioned,
185
+ hasAnyMention: facts.mentionHandles.length > 0,
186
+ implicitMentionKinds: activation.implicitMentionKinds,
187
+ },
188
+ }
189
+ : {}),
190
+ allowFrom: effectiveAllowFrom,
191
+ groupAllowFrom: effectiveAllowFrom,
192
+ });
193
+ if (access.ingress.admission !== "dispatch") {
194
+ params.warn?.(
195
+ `relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`,
196
+ );
197
+ return;
198
+ }
199
+
200
+ const body = buildEnvelope({
201
+ channel: "Relay",
202
+ from: `${facts.displayName} (@${facts.handle})`,
203
+ ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
204
+ body: facts.text,
205
+ });
206
+ const ctxPayload = buildChannelInboundEventContext({
207
+ channel: "relay",
208
+ accountId: route.accountId ?? params.account.accountId,
209
+ messageId: facts.messageId,
210
+ messageIdFull: facts.messageId,
211
+ ...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
212
+ from: facts.chatId,
213
+ sender: {
214
+ id: facts.contactId,
215
+ name: facts.displayName,
216
+ username: facts.handle,
217
+ },
218
+ conversation: {
219
+ kind: facts.chatType,
220
+ id: facts.chatId,
221
+ label: facts.chatType === "group" ? facts.chatId : facts.displayName,
222
+ nativeChannelId: facts.chatId,
223
+ },
224
+ route: {
225
+ agentId: route.agentId,
226
+ accountId: route.accountId,
227
+ routeSessionKey: route.sessionKey,
228
+ dispatchSessionKey: route.sessionKey,
229
+ ...(route.dmScope ? { dmScope: route.dmScope } : {}),
230
+ },
231
+ reply: {
232
+ to: facts.chatId,
233
+ originatingTo: facts.chatId,
234
+ ...(facts.replyToId ? { replyToId: facts.replyToId } : {}),
235
+ },
236
+ message: {
237
+ inboundEventKind: "user_request",
238
+ body,
239
+ bodyForAgent: facts.text,
240
+ rawBody: facts.text,
241
+ commandBody: facts.text,
242
+ },
243
+ channelIngress: access,
244
+ access: {
245
+ commands: {
246
+ authorized: access.senderAccess.allowed,
247
+ },
248
+ mentions: {
249
+ canDetectMention: facts.chatType === "group",
250
+ wasMentioned: activation.wasMentioned,
251
+ hasAnyMention: facts.mentionHandles.length > 0,
252
+ explicitlyMentionedBot: activation.kind === "mention",
253
+ implicitMentionKinds: activation.implicitMentionKinds,
254
+ requireMention: facts.chatType === "group",
255
+ effectiveWasMentioned: facts.chatType === "group",
256
+ },
257
+ },
258
+ });
259
+
260
+ await Promise.allSettled([
261
+ params.relay.chats.markAsRead(facts.chatId),
262
+ params.relay.chats.startTyping(facts.chatId),
263
+ ]).then((results) => {
264
+ for (const result of results) {
265
+ if (result.status === "rejected") {
266
+ params.warn?.(`relay: pre-dispatch Chat state failed: ${String(result.reason)}`);
267
+ }
268
+ }
269
+ });
270
+
271
+ let deliveryError: unknown;
272
+ try {
273
+ await params.runtime.channel.inbound.dispatch({
274
+ cfg: params.cfg as OpenClawConfig,
275
+ channel: "relay",
276
+ accountId: params.account.accountId,
277
+ route: {
278
+ agentId: route.agentId,
279
+ sessionKey: route.sessionKey,
280
+ ...(route.dmScope ? { dmScope: route.dmScope } : {}),
281
+ },
282
+ ctxPayload,
283
+ delivery: {
284
+ durable: {
285
+ to: facts.chatId,
286
+ replyToId: null,
287
+ requiredCapabilities: { reconcileUnknownSend: true },
288
+ },
289
+ deliver: async (_payload, info) => {
290
+ if (info.kind === "final") {
291
+ throw new Error(
292
+ "relay: durable final Message delivery was unavailable",
293
+ );
294
+ }
295
+ return { visibleReplySent: false };
296
+ },
297
+ onError: (error) => {
298
+ deliveryError ??= error;
299
+ },
300
+ },
301
+ replyPipeline: {},
302
+ replyOptions: {
303
+ ...bindIngressLifecycleToReplyOptions(params.lifecycle),
304
+ disableBlockStreaming: true,
305
+ },
306
+ record: {
307
+ onRecordError: (error) => {
308
+ throw error instanceof Error
309
+ ? error
310
+ : new Error(`relay: session record failed: ${String(error)}`);
311
+ },
312
+ },
313
+ });
314
+ if (deliveryError) {
315
+ throw deliveryError instanceof Error
316
+ ? deliveryError
317
+ : new Error(`relay: reply delivery failed: ${String(deliveryError)}`);
318
+ }
319
+ } finally {
320
+ await params.relay.chats.stopTyping(facts.chatId).catch((error: unknown) => {
321
+ params.warn?.(`relay: stop typing failed: ${String(error)}`);
322
+ });
323
+ }
324
+ }
@@ -0,0 +1,47 @@
1
+ import type {
2
+ Chat,
3
+ Message,
4
+ Relay,
5
+ WebSocketFullSyncContext,
6
+ } from "@relaymessenger/sdk";
7
+ import type {
8
+ RelaySnapshot,
9
+ } from "./types.js";
10
+ import type { RelayStateStore } from "./state.js";
11
+
12
+ type RelaySnapshotClient = Pick<Relay, "chats">;
13
+
14
+ export async function readCompleteRelaySnapshot(params: {
15
+ relay: RelaySnapshotClient;
16
+ context: WebSocketFullSyncContext;
17
+ }): Promise<RelaySnapshot> {
18
+ const chats: Array<{ chat: Chat; messages: Message[] }> = [];
19
+ const firstChatPage = await params.relay.chats.listChats({ limit: 100 });
20
+ for await (const chat of firstChatPage) {
21
+ const messages: Message[] = [];
22
+ const firstMessagePage = await params.relay.chats.messages.list(
23
+ chat.id,
24
+ { limit: 100 },
25
+ );
26
+ for await (const message of firstMessagePage) {
27
+ messages.push(message);
28
+ }
29
+ chats.push({ chat, messages });
30
+ }
31
+ return {
32
+ version: 1,
33
+ throughSequence: params.context.throughSequence,
34
+ reason: params.context.reason,
35
+ completedAt: new Date().toISOString(),
36
+ chats,
37
+ };
38
+ }
39
+
40
+ export async function commitRelayFullSync(params: {
41
+ relay: RelaySnapshotClient;
42
+ state: RelayStateStore;
43
+ context: WebSocketFullSyncContext;
44
+ }): Promise<void> {
45
+ const snapshot = await readCompleteRelaySnapshot(params);
46
+ await params.state.replaceSnapshot(snapshot);
47
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,216 @@
1
+ import {
2
+ type Relay,
3
+ RelayWebhookConfiguredError,
4
+ type RelayWebhookEvent,
5
+ } from "@relaymessenger/sdk";
6
+ import { createHash } from "node:crypto";
7
+ import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
8
+ import type { ChannelIngressQueue } from "openclaw/plugin-sdk/channel-outbound";
9
+ import { dispatchRelayEvent } from "./dispatch.js";
10
+ import { commitRelayFullSync } from "./full-sync.js";
11
+ import { createRelayIngressMonitor } from "./ingress.js";
12
+ import { createRelaySdkClient } from "./outbound.js";
13
+ import { getRelayRuntime } from "./runtime.js";
14
+ import {
15
+ openRelayStateStore,
16
+ type RelayStateStore,
17
+ } from "./state.js";
18
+ import type {
19
+ RelayCoreConfig,
20
+ RelayIngressPayload,
21
+ ResolvedRelayAccount,
22
+ } from "./types.js";
23
+
24
+ const runningCredentials = new Map<string, string>();
25
+ const accountControllers = new Map<string, AbortController>();
26
+
27
+ function credentialKey(account: ResolvedRelayAccount): string {
28
+ return createHash("sha256")
29
+ .update(`${account.baseUrl}\0${account.token}`)
30
+ .digest("hex");
31
+ }
32
+
33
+ function openIngressQueue(params: {
34
+ transportId: string;
35
+ state: RelayStateStore;
36
+ warn: (message: string) => void;
37
+ }): ChannelIngressQueue<RelayIngressPayload> {
38
+ try {
39
+ return getRelayRuntime().state.openChannelIngressQueue<RelayIngressPayload>({
40
+ accountId: params.transportId,
41
+ });
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ if (!message.includes("only available for trusted plugins")) throw error;
45
+ params.warn(
46
+ "relay: OpenClaw trusted ingress state is unavailable for this install; using the plugin's private SQLite queue",
47
+ );
48
+ return params.state.ingressQueue;
49
+ }
50
+ }
51
+
52
+ export async function assertRelayWebSocketAvailable(params: {
53
+ relay: Pick<Relay, "webhookSubscriptions">;
54
+ accountId: string;
55
+ signal?: AbortSignal;
56
+ }): Promise<void> {
57
+ const { subscriptions } = await params.relay.webhookSubscriptions.list(
58
+ params.signal ? { signal: params.signal } : undefined,
59
+ );
60
+ if (subscriptions.length > 0) {
61
+ throw new RelayWebhookConfiguredError(
62
+ `relay: account "${params.accountId}" has saved Webhook subscriptions; delete them before using OpenClaw WebSocket delivery`,
63
+ );
64
+ }
65
+ }
66
+
67
+ export async function startRelayAccount(
68
+ ctx: ChannelGatewayContext<ResolvedRelayAccount>,
69
+ ): Promise<void> {
70
+ const account = ctx.account;
71
+ if (!account.configured) {
72
+ throw new Error(
73
+ `relay: account "${account.accountId}" is missing a Relay Agent Token`,
74
+ );
75
+ }
76
+ const runtime = getRelayRuntime();
77
+ const controller = new AbortController();
78
+ const abortSignal = AbortSignal.any([ctx.abortSignal, controller.signal]);
79
+ const key = credentialKey(account);
80
+ const transportId = `transport-${key}`;
81
+ const existing = runningCredentials.get(key);
82
+ if (existing) {
83
+ throw new Error(
84
+ `relay: account "${account.accountId}" reuses the Agent Token already active in account "${existing}"`,
85
+ );
86
+ }
87
+ runningCredentials.set(key, account.accountId);
88
+ accountControllers.set(account.accountId, controller);
89
+
90
+ const warn = (message: string) => ctx.log?.warn?.(message);
91
+ const state = openRelayStateStore({
92
+ stateDir: runtime.state.resolveStateDir(),
93
+ // Bind pending events and FULL-sync state to the authenticated transport,
94
+ // not a mutable OpenClaw account label. Token rotation cannot dispatch old
95
+ // rows through a different Relay Contact, and account renames keep state.
96
+ accountId: transportId,
97
+ });
98
+ const relay = createRelaySdkClient(account);
99
+ const ingress = createRelayIngressMonitor({
100
+ queue: openIngressQueue({
101
+ transportId,
102
+ state,
103
+ warn,
104
+ }),
105
+ abortSignal,
106
+ onError: (error) =>
107
+ ctx.log?.error?.(`relay: ingress drain failed: ${String(error)}`),
108
+ dispatch: async (event, lifecycle) => {
109
+ ctx.setStatus({
110
+ accountId: account.accountId,
111
+ running: true,
112
+ connected: true,
113
+ lastInboundAt: Date.now(),
114
+ });
115
+ await dispatchRelayEvent({
116
+ event,
117
+ lifecycle,
118
+ account,
119
+ cfg: ctx.cfg as RelayCoreConfig,
120
+ relay,
121
+ runtime,
122
+ warn,
123
+ });
124
+ },
125
+ });
126
+
127
+ ctx.setStatus({
128
+ accountId: account.accountId,
129
+ running: true,
130
+ connected: false,
131
+ lifecycle: "starting",
132
+ configured: true,
133
+ enabled: account.enabled,
134
+ });
135
+
136
+ try {
137
+ await assertRelayWebSocketAvailable({
138
+ relay,
139
+ accountId: account.accountId,
140
+ signal: abortSignal,
141
+ });
142
+
143
+ ingress.start();
144
+ ctx.setStatus({
145
+ accountId: account.accountId,
146
+ running: true,
147
+ connected: true,
148
+ lifecycle: "ready",
149
+ lastError: null,
150
+ });
151
+ await relay.websocket.run({
152
+ signal: abortSignal,
153
+ onEvent: async (
154
+ event: RelayWebhookEvent,
155
+ ) => {
156
+ const admission = await ingress.receive(event);
157
+ if (admission.kind === "invalid") {
158
+ throw new Error(admission.message);
159
+ }
160
+ },
161
+ onFullSync: async (context) => {
162
+ await commitRelayFullSync({ relay, state, context });
163
+ },
164
+ onError: (error) => {
165
+ ctx.log?.warn?.(`relay: WebSocket reconnecting after ${String(error)}`);
166
+ ctx.setStatus({
167
+ accountId: account.accountId,
168
+ running: true,
169
+ connected: false,
170
+ lifecycle: "recovering",
171
+ lastError: error instanceof Error ? error.message : String(error),
172
+ });
173
+ },
174
+ });
175
+ } catch (error) {
176
+ if (abortSignal.aborted) return;
177
+ if (error instanceof RelayWebhookConfiguredError) {
178
+ ctx.setStatus({
179
+ accountId: account.accountId,
180
+ running: false,
181
+ connected: false,
182
+ terminalDisconnect: true,
183
+ lastError: error.message,
184
+ });
185
+ }
186
+ throw error;
187
+ } finally {
188
+ await ingress.stop();
189
+ if (runningCredentials.get(key) === account.accountId) {
190
+ runningCredentials.delete(key);
191
+ }
192
+ if (accountControllers.get(account.accountId) === controller) {
193
+ accountControllers.delete(account.accountId);
194
+ }
195
+ ctx.setStatus({
196
+ accountId: account.accountId,
197
+ running: false,
198
+ connected: false,
199
+ lifecycle: "stopped",
200
+ });
201
+ }
202
+ }
203
+
204
+ export async function stopRelayAccount(
205
+ ctx: ChannelGatewayContext<ResolvedRelayAccount>,
206
+ ): Promise<void> {
207
+ accountControllers.get(ctx.accountId)?.abort(
208
+ new Error(`relay: account "${ctx.accountId}" stopped`),
209
+ );
210
+ ctx.setStatus({
211
+ accountId: ctx.accountId,
212
+ running: false,
213
+ connected: false,
214
+ lifecycle: "stopped",
215
+ });
216
+ }