@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, } from "openclaw/plugin-sdk/channel-inbound";
|
|
2
|
+
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
3
|
+
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
|
|
4
|
+
import { buildRelayInboundFacts } from "./inbound.js";
|
|
5
|
+
function isReplyToAgentMessage(message, chatId) {
|
|
6
|
+
return message.chat_id === chatId && message.is_from_me === true;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Relay's structured mention and reply facts are authoritative. In
|
|
10
|
+
* particular, this does not infer an activation from visible `@handle` text.
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveRelayTurnActivation(params) {
|
|
13
|
+
if (params.facts.chatType === "direct") {
|
|
14
|
+
return {
|
|
15
|
+
kind: "direct",
|
|
16
|
+
wasMentioned: false,
|
|
17
|
+
implicitMentionKinds: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const ownerHandle = params.facts.ownerHandle;
|
|
21
|
+
if (ownerHandle?.kind === "agent" &&
|
|
22
|
+
params.facts.mentionHandles.includes(ownerHandle.handle)) {
|
|
23
|
+
return {
|
|
24
|
+
kind: "mention",
|
|
25
|
+
wasMentioned: true,
|
|
26
|
+
implicitMentionKinds: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (!params.facts.replyToId)
|
|
30
|
+
return null;
|
|
31
|
+
const replyTarget = await params.relay.messages.retrieve(params.facts.replyToId);
|
|
32
|
+
if (!isReplyToAgentMessage(replyTarget, params.facts.chatId))
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
kind: "reply",
|
|
36
|
+
wasMentioned: false,
|
|
37
|
+
implicitMentionKinds: ["reply_to_bot"],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function dispatchRelayEvent(params) {
|
|
41
|
+
const facts = buildRelayInboundFacts(params.event);
|
|
42
|
+
if (!facts) {
|
|
43
|
+
params.warn?.(`relay: durably accepted ${params.event.event_type} event ${params.event.event_id} without an agent turn`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const activation = await resolveRelayTurnActivation({
|
|
47
|
+
facts,
|
|
48
|
+
relay: params.relay,
|
|
49
|
+
});
|
|
50
|
+
if (!activation) {
|
|
51
|
+
params.warn?.(`relay: durably accepted unmentioned group Message ${facts.messageId} without an agent turn`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
|
|
55
|
+
cfg: params.cfg,
|
|
56
|
+
channel: "relay",
|
|
57
|
+
accountId: params.account.accountId,
|
|
58
|
+
peer: {
|
|
59
|
+
kind: facts.chatType,
|
|
60
|
+
id: facts.chatId,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const restricted = params.account.allowFrom.length > 0;
|
|
64
|
+
const effectiveAllowFrom = restricted
|
|
65
|
+
? params.account.allowFrom
|
|
66
|
+
: ["*"];
|
|
67
|
+
const access = await resolveStableChannelMessageIngress({
|
|
68
|
+
channelId: "relay",
|
|
69
|
+
accountId: params.account.accountId,
|
|
70
|
+
identity: {
|
|
71
|
+
key: "contactId",
|
|
72
|
+
kind: "stable-id",
|
|
73
|
+
entryIdPrefix: "relay-contact",
|
|
74
|
+
aliases: [
|
|
75
|
+
{
|
|
76
|
+
key: "handle",
|
|
77
|
+
kind: "username",
|
|
78
|
+
normalize: (value) => value.trim().replace(/^@/u, "").toLowerCase(),
|
|
79
|
+
dangerous: true,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
subject: {
|
|
84
|
+
stableId: facts.contactId,
|
|
85
|
+
aliases: { handle: facts.handle },
|
|
86
|
+
},
|
|
87
|
+
conversation: {
|
|
88
|
+
kind: facts.chatType,
|
|
89
|
+
id: facts.chatId,
|
|
90
|
+
title: facts.chatType === "direct" ? facts.displayName : facts.chatId,
|
|
91
|
+
},
|
|
92
|
+
contextBinding: {
|
|
93
|
+
agentId: route.agentId,
|
|
94
|
+
sessionKey: route.sessionKey,
|
|
95
|
+
messageId: facts.messageId,
|
|
96
|
+
nativeChannelId: facts.chatId,
|
|
97
|
+
inboundEventKind: "user_request",
|
|
98
|
+
},
|
|
99
|
+
dmPolicy: restricted ? "allowlist" : "open",
|
|
100
|
+
groupPolicy: restricted ? "allowlist" : "open",
|
|
101
|
+
policy: {
|
|
102
|
+
groupAllowFromFallbackToAllowFrom: true,
|
|
103
|
+
...(facts.chatType === "group"
|
|
104
|
+
? {
|
|
105
|
+
activation: {
|
|
106
|
+
requireMention: true,
|
|
107
|
+
allowTextCommands: false,
|
|
108
|
+
implicitMentions: {
|
|
109
|
+
replyToBot: true,
|
|
110
|
+
quotedBot: false,
|
|
111
|
+
threadParticipation: false,
|
|
112
|
+
},
|
|
113
|
+
allowedImplicitMentionKinds: ["reply_to_bot"],
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
},
|
|
118
|
+
...(facts.chatType === "group"
|
|
119
|
+
? {
|
|
120
|
+
mentionFacts: {
|
|
121
|
+
canDetectMention: true,
|
|
122
|
+
wasMentioned: activation.wasMentioned,
|
|
123
|
+
hasAnyMention: facts.mentionHandles.length > 0,
|
|
124
|
+
implicitMentionKinds: activation.implicitMentionKinds,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
: {}),
|
|
128
|
+
allowFrom: effectiveAllowFrom,
|
|
129
|
+
groupAllowFrom: effectiveAllowFrom,
|
|
130
|
+
});
|
|
131
|
+
if (access.ingress.admission !== "dispatch") {
|
|
132
|
+
params.warn?.(`relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const body = buildEnvelope({
|
|
136
|
+
channel: "Relay",
|
|
137
|
+
from: `${facts.displayName} (@${facts.handle})`,
|
|
138
|
+
...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
|
|
139
|
+
body: facts.text,
|
|
140
|
+
});
|
|
141
|
+
const ctxPayload = buildChannelInboundEventContext({
|
|
142
|
+
channel: "relay",
|
|
143
|
+
accountId: route.accountId ?? params.account.accountId,
|
|
144
|
+
messageId: facts.messageId,
|
|
145
|
+
messageIdFull: facts.messageId,
|
|
146
|
+
...(facts.timestamp ? { timestamp: facts.timestamp } : {}),
|
|
147
|
+
from: facts.chatId,
|
|
148
|
+
sender: {
|
|
149
|
+
id: facts.contactId,
|
|
150
|
+
name: facts.displayName,
|
|
151
|
+
username: facts.handle,
|
|
152
|
+
},
|
|
153
|
+
conversation: {
|
|
154
|
+
kind: facts.chatType,
|
|
155
|
+
id: facts.chatId,
|
|
156
|
+
label: facts.chatType === "group" ? facts.chatId : facts.displayName,
|
|
157
|
+
nativeChannelId: facts.chatId,
|
|
158
|
+
},
|
|
159
|
+
route: {
|
|
160
|
+
agentId: route.agentId,
|
|
161
|
+
accountId: route.accountId,
|
|
162
|
+
routeSessionKey: route.sessionKey,
|
|
163
|
+
dispatchSessionKey: route.sessionKey,
|
|
164
|
+
...(route.dmScope ? { dmScope: route.dmScope } : {}),
|
|
165
|
+
},
|
|
166
|
+
reply: {
|
|
167
|
+
to: facts.chatId,
|
|
168
|
+
originatingTo: facts.chatId,
|
|
169
|
+
...(facts.replyToId ? { replyToId: facts.replyToId } : {}),
|
|
170
|
+
},
|
|
171
|
+
message: {
|
|
172
|
+
inboundEventKind: "user_request",
|
|
173
|
+
body,
|
|
174
|
+
bodyForAgent: facts.text,
|
|
175
|
+
rawBody: facts.text,
|
|
176
|
+
commandBody: facts.text,
|
|
177
|
+
},
|
|
178
|
+
channelIngress: access,
|
|
179
|
+
access: {
|
|
180
|
+
commands: {
|
|
181
|
+
authorized: access.senderAccess.allowed,
|
|
182
|
+
},
|
|
183
|
+
mentions: {
|
|
184
|
+
canDetectMention: facts.chatType === "group",
|
|
185
|
+
wasMentioned: activation.wasMentioned,
|
|
186
|
+
hasAnyMention: facts.mentionHandles.length > 0,
|
|
187
|
+
explicitlyMentionedBot: activation.kind === "mention",
|
|
188
|
+
implicitMentionKinds: activation.implicitMentionKinds,
|
|
189
|
+
requireMention: facts.chatType === "group",
|
|
190
|
+
effectiveWasMentioned: facts.chatType === "group",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
await Promise.allSettled([
|
|
195
|
+
params.relay.chats.markAsRead(facts.chatId),
|
|
196
|
+
params.relay.chats.startTyping(facts.chatId),
|
|
197
|
+
]).then((results) => {
|
|
198
|
+
for (const result of results) {
|
|
199
|
+
if (result.status === "rejected") {
|
|
200
|
+
params.warn?.(`relay: pre-dispatch Chat state failed: ${String(result.reason)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
let deliveryError;
|
|
205
|
+
try {
|
|
206
|
+
await params.runtime.channel.inbound.dispatch({
|
|
207
|
+
cfg: params.cfg,
|
|
208
|
+
channel: "relay",
|
|
209
|
+
accountId: params.account.accountId,
|
|
210
|
+
route: {
|
|
211
|
+
agentId: route.agentId,
|
|
212
|
+
sessionKey: route.sessionKey,
|
|
213
|
+
...(route.dmScope ? { dmScope: route.dmScope } : {}),
|
|
214
|
+
},
|
|
215
|
+
ctxPayload,
|
|
216
|
+
delivery: {
|
|
217
|
+
durable: {
|
|
218
|
+
to: facts.chatId,
|
|
219
|
+
replyToId: null,
|
|
220
|
+
requiredCapabilities: { reconcileUnknownSend: true },
|
|
221
|
+
},
|
|
222
|
+
deliver: async (_payload, info) => {
|
|
223
|
+
if (info.kind === "final") {
|
|
224
|
+
throw new Error("relay: durable final Message delivery was unavailable");
|
|
225
|
+
}
|
|
226
|
+
return { visibleReplySent: false };
|
|
227
|
+
},
|
|
228
|
+
onError: (error) => {
|
|
229
|
+
deliveryError ??= error;
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
replyPipeline: {},
|
|
233
|
+
replyOptions: {
|
|
234
|
+
...bindIngressLifecycleToReplyOptions(params.lifecycle),
|
|
235
|
+
disableBlockStreaming: true,
|
|
236
|
+
},
|
|
237
|
+
record: {
|
|
238
|
+
onRecordError: (error) => {
|
|
239
|
+
throw error instanceof Error
|
|
240
|
+
? error
|
|
241
|
+
: new Error(`relay: session record failed: ${String(error)}`);
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
if (deliveryError) {
|
|
246
|
+
throw deliveryError instanceof Error
|
|
247
|
+
? deliveryError
|
|
248
|
+
: new Error(`relay: reply delivery failed: ${String(deliveryError)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await params.relay.chats.stopTyping(facts.chatId).catch((error) => {
|
|
253
|
+
params.warn?.(`relay: stop typing failed: ${String(error)}`);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=dispatch.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function readCompleteRelaySnapshot(params) {
|
|
2
|
+
const chats = [];
|
|
3
|
+
const firstChatPage = await params.relay.chats.listChats({ limit: 100 });
|
|
4
|
+
for await (const chat of firstChatPage) {
|
|
5
|
+
const messages = [];
|
|
6
|
+
const firstMessagePage = await params.relay.chats.messages.list(chat.id, { limit: 100 });
|
|
7
|
+
for await (const message of firstMessagePage) {
|
|
8
|
+
messages.push(message);
|
|
9
|
+
}
|
|
10
|
+
chats.push({ chat, messages });
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
version: 1,
|
|
14
|
+
throughSequence: params.context.throughSequence,
|
|
15
|
+
reason: params.context.reason,
|
|
16
|
+
completedAt: new Date().toISOString(),
|
|
17
|
+
chats,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function commitRelayFullSync(params) {
|
|
21
|
+
const snapshot = await readCompleteRelaySnapshot(params);
|
|
22
|
+
await params.state.replaceSnapshot(snapshot);
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=full-sync.js.map
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { RelayWebhookConfiguredError, } from "@relaymessenger/sdk";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dispatchRelayEvent } from "./dispatch.js";
|
|
4
|
+
import { commitRelayFullSync } from "./full-sync.js";
|
|
5
|
+
import { createRelayIngressMonitor } from "./ingress.js";
|
|
6
|
+
import { createRelaySdkClient } from "./outbound.js";
|
|
7
|
+
import { getRelayRuntime } from "./runtime.js";
|
|
8
|
+
import { openRelayStateStore, } from "./state.js";
|
|
9
|
+
const runningCredentials = new Map();
|
|
10
|
+
const accountControllers = new Map();
|
|
11
|
+
function credentialKey(account) {
|
|
12
|
+
return createHash("sha256")
|
|
13
|
+
.update(`${account.baseUrl}\0${account.token}`)
|
|
14
|
+
.digest("hex");
|
|
15
|
+
}
|
|
16
|
+
function openIngressQueue(params) {
|
|
17
|
+
try {
|
|
18
|
+
return getRelayRuntime().state.openChannelIngressQueue({
|
|
19
|
+
accountId: params.transportId,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
if (!message.includes("only available for trusted plugins"))
|
|
25
|
+
throw error;
|
|
26
|
+
params.warn("relay: OpenClaw trusted ingress state is unavailable for this install; using the plugin's private SQLite queue");
|
|
27
|
+
return params.state.ingressQueue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function assertRelayWebSocketAvailable(params) {
|
|
31
|
+
const { subscriptions } = await params.relay.webhookSubscriptions.list(params.signal ? { signal: params.signal } : undefined);
|
|
32
|
+
if (subscriptions.length > 0) {
|
|
33
|
+
throw new RelayWebhookConfiguredError(`relay: account "${params.accountId}" has saved Webhook subscriptions; delete them before using OpenClaw WebSocket delivery`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function startRelayAccount(ctx) {
|
|
37
|
+
const account = ctx.account;
|
|
38
|
+
if (!account.configured) {
|
|
39
|
+
throw new Error(`relay: account "${account.accountId}" is missing a Relay Agent Token`);
|
|
40
|
+
}
|
|
41
|
+
const runtime = getRelayRuntime();
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const abortSignal = AbortSignal.any([ctx.abortSignal, controller.signal]);
|
|
44
|
+
const key = credentialKey(account);
|
|
45
|
+
const transportId = `transport-${key}`;
|
|
46
|
+
const existing = runningCredentials.get(key);
|
|
47
|
+
if (existing) {
|
|
48
|
+
throw new Error(`relay: account "${account.accountId}" reuses the Agent Token already active in account "${existing}"`);
|
|
49
|
+
}
|
|
50
|
+
runningCredentials.set(key, account.accountId);
|
|
51
|
+
accountControllers.set(account.accountId, controller);
|
|
52
|
+
const warn = (message) => ctx.log?.warn?.(message);
|
|
53
|
+
const state = openRelayStateStore({
|
|
54
|
+
stateDir: runtime.state.resolveStateDir(),
|
|
55
|
+
// Bind pending events and FULL-sync state to the authenticated transport,
|
|
56
|
+
// not a mutable OpenClaw account label. Token rotation cannot dispatch old
|
|
57
|
+
// rows through a different Relay Contact, and account renames keep state.
|
|
58
|
+
accountId: transportId,
|
|
59
|
+
});
|
|
60
|
+
const relay = createRelaySdkClient(account);
|
|
61
|
+
const ingress = createRelayIngressMonitor({
|
|
62
|
+
queue: openIngressQueue({
|
|
63
|
+
transportId,
|
|
64
|
+
state,
|
|
65
|
+
warn,
|
|
66
|
+
}),
|
|
67
|
+
abortSignal,
|
|
68
|
+
onError: (error) => ctx.log?.error?.(`relay: ingress drain failed: ${String(error)}`),
|
|
69
|
+
dispatch: async (event, lifecycle) => {
|
|
70
|
+
ctx.setStatus({
|
|
71
|
+
accountId: account.accountId,
|
|
72
|
+
running: true,
|
|
73
|
+
connected: true,
|
|
74
|
+
lastInboundAt: Date.now(),
|
|
75
|
+
});
|
|
76
|
+
await dispatchRelayEvent({
|
|
77
|
+
event,
|
|
78
|
+
lifecycle,
|
|
79
|
+
account,
|
|
80
|
+
cfg: ctx.cfg,
|
|
81
|
+
relay,
|
|
82
|
+
runtime,
|
|
83
|
+
warn,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
ctx.setStatus({
|
|
88
|
+
accountId: account.accountId,
|
|
89
|
+
running: true,
|
|
90
|
+
connected: false,
|
|
91
|
+
lifecycle: "starting",
|
|
92
|
+
configured: true,
|
|
93
|
+
enabled: account.enabled,
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
await assertRelayWebSocketAvailable({
|
|
97
|
+
relay,
|
|
98
|
+
accountId: account.accountId,
|
|
99
|
+
signal: abortSignal,
|
|
100
|
+
});
|
|
101
|
+
ingress.start();
|
|
102
|
+
ctx.setStatus({
|
|
103
|
+
accountId: account.accountId,
|
|
104
|
+
running: true,
|
|
105
|
+
connected: true,
|
|
106
|
+
lifecycle: "ready",
|
|
107
|
+
lastError: null,
|
|
108
|
+
});
|
|
109
|
+
await relay.websocket.run({
|
|
110
|
+
signal: abortSignal,
|
|
111
|
+
onEvent: async (event) => {
|
|
112
|
+
const admission = await ingress.receive(event);
|
|
113
|
+
if (admission.kind === "invalid") {
|
|
114
|
+
throw new Error(admission.message);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
onFullSync: async (context) => {
|
|
118
|
+
await commitRelayFullSync({ relay, state, context });
|
|
119
|
+
},
|
|
120
|
+
onError: (error) => {
|
|
121
|
+
ctx.log?.warn?.(`relay: WebSocket reconnecting after ${String(error)}`);
|
|
122
|
+
ctx.setStatus({
|
|
123
|
+
accountId: account.accountId,
|
|
124
|
+
running: true,
|
|
125
|
+
connected: false,
|
|
126
|
+
lifecycle: "recovering",
|
|
127
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (abortSignal.aborted)
|
|
134
|
+
return;
|
|
135
|
+
if (error instanceof RelayWebhookConfiguredError) {
|
|
136
|
+
ctx.setStatus({
|
|
137
|
+
accountId: account.accountId,
|
|
138
|
+
running: false,
|
|
139
|
+
connected: false,
|
|
140
|
+
terminalDisconnect: true,
|
|
141
|
+
lastError: error.message,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await ingress.stop();
|
|
148
|
+
if (runningCredentials.get(key) === account.accountId) {
|
|
149
|
+
runningCredentials.delete(key);
|
|
150
|
+
}
|
|
151
|
+
if (accountControllers.get(account.accountId) === controller) {
|
|
152
|
+
accountControllers.delete(account.accountId);
|
|
153
|
+
}
|
|
154
|
+
ctx.setStatus({
|
|
155
|
+
accountId: account.accountId,
|
|
156
|
+
running: false,
|
|
157
|
+
connected: false,
|
|
158
|
+
lifecycle: "stopped",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function stopRelayAccount(ctx) {
|
|
163
|
+
accountControllers.get(ctx.accountId)?.abort(new Error(`relay: account "${ctx.accountId}" stopped`));
|
|
164
|
+
ctx.setStatus({
|
|
165
|
+
accountId: ctx.accountId,
|
|
166
|
+
running: false,
|
|
167
|
+
connected: false,
|
|
168
|
+
lifecycle: "stopped",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=gateway.js.map
|
package/dist/src/inbound.js
CHANGED
|
@@ -1,94 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
switch (
|
|
3
|
-
case "
|
|
4
|
-
return
|
|
5
|
-
case "
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
case "
|
|
10
|
-
return
|
|
11
|
-
default:
|
|
12
|
-
return "unknown";
|
|
1
|
+
function renderPart(part) {
|
|
2
|
+
switch (part.type) {
|
|
3
|
+
case "text":
|
|
4
|
+
return part.value;
|
|
5
|
+
case "link":
|
|
6
|
+
return part.value;
|
|
7
|
+
case "media":
|
|
8
|
+
return `[Attachment: ${part.filename} (${part.mime_type})] ${part.url}`;
|
|
9
|
+
case "system":
|
|
10
|
+
return part.value;
|
|
13
11
|
}
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*/
|
|
21
|
-
export function renderRelayPartsText(parts) {
|
|
22
|
-
const lines = [];
|
|
23
|
-
for (const part of parts) {
|
|
24
|
-
switch (part.type) {
|
|
25
|
-
case "text":
|
|
26
|
-
if (part.text) {
|
|
27
|
-
lines.push(part.text);
|
|
28
|
-
}
|
|
29
|
-
break;
|
|
30
|
-
case "link_preview":
|
|
31
|
-
lines.push(part.url);
|
|
32
|
-
break;
|
|
33
|
-
case "data": {
|
|
34
|
-
let rendered;
|
|
35
|
-
try {
|
|
36
|
-
rendered = JSON.stringify(part.data);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
rendered = String(part.data);
|
|
40
|
-
}
|
|
41
|
-
lines.push("```json\n" + rendered + "\n```");
|
|
42
|
-
break;
|
|
43
|
-
}
|
|
44
|
-
case "media":
|
|
45
|
-
lines.push(`[attachment] ${part.url}`);
|
|
46
|
-
break;
|
|
47
|
-
case "voice_memo":
|
|
48
|
-
lines.push(part.duration_ms
|
|
49
|
-
? `[voice memo, ${Math.round(part.duration_ms / 1000)}s] ${part.url}`
|
|
50
|
-
: `[voice memo] ${part.url}`);
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return lines.join("\n");
|
|
13
|
+
export function renderRelayMessageParts(parts) {
|
|
14
|
+
return parts
|
|
15
|
+
.map(renderPart)
|
|
16
|
+
.filter((value) => Boolean(value?.trim()))
|
|
17
|
+
.join("\n");
|
|
55
18
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
19
|
+
export function isRelayMessageReceivedEvent(event) {
|
|
20
|
+
if (event.event_type !== "message.received")
|
|
21
|
+
return false;
|
|
22
|
+
const data = event.data;
|
|
23
|
+
return (typeof data.id === "string" &&
|
|
24
|
+
typeof data.chat?.id === "string" &&
|
|
25
|
+
data.direction === "inbound" &&
|
|
26
|
+
typeof data.sender_handle?.id === "string" &&
|
|
27
|
+
Array.isArray(data.parts));
|
|
59
28
|
}
|
|
60
29
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* non-message events, or messages with no renderable content.
|
|
30
|
+
* Map the current Relay v1 Message event to OpenClaw facts. Agent-authored
|
|
31
|
+
* Messages are accepted at the transport boundary but do not start turns.
|
|
64
32
|
*/
|
|
65
|
-
export function buildRelayInboundFacts(event
|
|
66
|
-
if (
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
const message = event.data.message;
|
|
70
|
-
if (!message || !message.id || !message.conversation_id) {
|
|
33
|
+
export function buildRelayInboundFacts(event) {
|
|
34
|
+
if (!isRelayMessageReceivedEvent(event))
|
|
71
35
|
return null;
|
|
72
|
-
|
|
73
|
-
// Agent-authored messages never start a local agent turn. This drops our
|
|
74
|
-
// own event echo and prevents agent-to-agent loops even if an id is
|
|
75
|
-
// mistakenly added to the user allowlist.
|
|
76
|
-
if (message.sender.kind !== "user" || isRelayEchoMessage(message, params.agentId)) {
|
|
36
|
+
if (event.data.sender_handle.kind !== "user")
|
|
77
37
|
return null;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!text.trim()) {
|
|
38
|
+
const text = renderRelayMessageParts(event.data.parts);
|
|
39
|
+
if (!text.trim())
|
|
81
40
|
return null;
|
|
82
|
-
|
|
83
|
-
|
|
41
|
+
const mentionHandles = event.data.parts.flatMap((part) => part.type === "text" &&
|
|
42
|
+
typeof part.mention === "string" &&
|
|
43
|
+
part.mention.length > 0
|
|
44
|
+
? [part.mention]
|
|
45
|
+
: []);
|
|
46
|
+
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
47
|
+
const timestamp = Date.parse(timestampValue);
|
|
84
48
|
return {
|
|
85
49
|
eventId: event.event_id,
|
|
86
|
-
messageId:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
50
|
+
messageId: event.data.id,
|
|
51
|
+
chatId: event.data.chat.id,
|
|
52
|
+
chatType: event.data.chat.is_group === true ? "group" : "direct",
|
|
53
|
+
contactId: event.data.sender_handle.id,
|
|
54
|
+
handle: event.data.sender_handle.handle,
|
|
55
|
+
displayName: event.data.sender_handle.display_name?.trim() ||
|
|
56
|
+
event.data.sender_handle.handle,
|
|
91
57
|
text,
|
|
92
|
-
|
|
58
|
+
mentionHandles,
|
|
59
|
+
...(event.data.chat.owner_handle
|
|
60
|
+
? { ownerHandle: event.data.chat.owner_handle }
|
|
61
|
+
: {}),
|
|
62
|
+
...(event.data.reply_to?.message_id
|
|
63
|
+
? { replyToId: event.data.reply_to.message_id }
|
|
64
|
+
: {}),
|
|
65
|
+
...(Number.isFinite(timestamp) ? { timestamp } : {}),
|
|
93
66
|
};
|
|
94
67
|
}
|
|
68
|
+
//# sourceMappingURL=inbound.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createStandardRawEventIngressMonitor } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
2
|
+
import { createChannelIngressError, } from "openclaw/plugin-sdk/channel-outbound";
|
|
3
|
+
const RelayIngressPermanentError = createChannelIngressError("RelayIngressPermanentError", { withReason: true });
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function inspectRelayEvent(event) {
|
|
8
|
+
if (!isRecord(event)) {
|
|
9
|
+
throw new RelayIngressPermanentError("invalid-event", "Relay WebSocket event must be an object.");
|
|
10
|
+
}
|
|
11
|
+
const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
|
|
12
|
+
const eventType = typeof event.event_type === "string" ? event.event_type.trim() : "";
|
|
13
|
+
if (!eventId || !eventType) {
|
|
14
|
+
throw new RelayIngressPermanentError("invalid-event", "Relay WebSocket event is missing event_id or event_type.");
|
|
15
|
+
}
|
|
16
|
+
const data = isRecord(event.data) ? event.data : undefined;
|
|
17
|
+
const chat = data && isRecord(data.chat) ? data.chat : undefined;
|
|
18
|
+
const chatId = typeof chat?.id === "string"
|
|
19
|
+
? chat.id.trim()
|
|
20
|
+
: typeof data?.chat_id === "string"
|
|
21
|
+
? data.chat_id.trim()
|
|
22
|
+
: "";
|
|
23
|
+
return {
|
|
24
|
+
eventId,
|
|
25
|
+
laneKey: chatId ? `chat:${chatId}` : `event:${eventType}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function decodeRelayEvent(rawEvent, claimedId) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(rawEvent);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new RelayIngressPermanentError("invalid-event", `Relay ingress row ${claimedId} contains invalid JSON.`, { cause: error });
|
|
35
|
+
}
|
|
36
|
+
inspectRelayEvent(parsed);
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
export function createRelayIngressMonitor(options) {
|
|
40
|
+
return createStandardRawEventIngressMonitor({
|
|
41
|
+
queue: options.queue,
|
|
42
|
+
inspect: inspectRelayEvent,
|
|
43
|
+
payload: {
|
|
44
|
+
serialize: (event) => JSON.stringify(event),
|
|
45
|
+
deserialize: (rawEvent, { claim }) => decodeRelayEvent(rawEvent, claim.id),
|
|
46
|
+
createClaimError: (kind, claim) => new RelayIngressPermanentError("invalid-event", kind === "invalid-version"
|
|
47
|
+
? `Relay ingress row ${claim.id} has an invalid payload version.`
|
|
48
|
+
: `Relay ingress row ${claim.id} changed identity after admission.`),
|
|
49
|
+
},
|
|
50
|
+
deliver: async (event, lifecycle) => {
|
|
51
|
+
await options.dispatch(event, lifecycle);
|
|
52
|
+
},
|
|
53
|
+
...(options.pollIntervalMs === undefined
|
|
54
|
+
? {}
|
|
55
|
+
: { pollIntervalMs: options.pollIntervalMs }),
|
|
56
|
+
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
|
|
57
|
+
createStoppedError: () => new Error("Relay ingress monitor is stopped."),
|
|
58
|
+
onError: (error) => options.onError?.(error),
|
|
59
|
+
classifyAdmissionError: (error) => error instanceof RelayIngressPermanentError
|
|
60
|
+
? error.message
|
|
61
|
+
: undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=ingress.js.map
|