@relaymessenger/openclaw-plugin 0.3.3 → 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.
- package/LICENSE +1 -1
- package/README.md +159 -124
- package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
- package/contracts/relay-v1.lock.json +77 -0
- package/dist/index.js +2 -2
- package/dist/setup-entry.js +1 -2
- package/dist/src/accounts.js +63 -34
- package/dist/src/channel.js +144 -498
- package/dist/src/dispatch.js +257 -0
- package/dist/src/full-sync.js +24 -0
- package/dist/src/gateway.js +171 -0
- package/dist/src/inbound.js +54 -80
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -109
- package/dist/src/runtime.js +2 -3
- package/dist/src/state.js +492 -0
- package/dist/src/types.js +1 -3
- package/index.ts +1 -2
- package/openclaw.plugin.json +15 -18
- package/package.json +113 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -611
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -111
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -142
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -148
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -229
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -125
- package/dist/src/responding.js +0 -13
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -167
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -330
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -161
- package/src/responding.ts +0 -21
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -212
package/src/dispatch.ts
ADDED
|
@@ -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
|
+
}
|
package/src/full-sync.ts
ADDED
|
@@ -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
|
+
}
|