@shadowob/openclaw-shadowob 1.1.1 → 1.1.3
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/dist/chunk-NBNZ7NVR.js +175 -0
- package/dist/chunk-PEV3R2R7.js +2018 -0
- package/dist/index.js +1011 -4
- package/dist/monitor-AE3LRQYD.js +16 -0
- package/dist/setup-entry.js +3 -3
- package/openclaw.plugin.json +118 -0
- package/package.json +4 -3
- package/skills/shadowob/SKILL.md +37 -57
- package/dist/chunk-JGLINAN5.js +0 -1064
- package/dist/chunk-LUNQKMJD.js +0 -585
- package/dist/monitor-L5CUPMSN.js +0 -6
|
@@ -0,0 +1,2018 @@
|
|
|
1
|
+
// src/monitor.ts
|
|
2
|
+
import { ShadowClient as ShadowClient3, ShadowSocket as ShadowSocket2 } from "@shadowob/sdk";
|
|
3
|
+
|
|
4
|
+
// src/monitor/channel-message.ts
|
|
5
|
+
import { ShadowClient as ShadowClient2 } from "@shadowob/sdk";
|
|
6
|
+
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
7
|
+
|
|
8
|
+
// src/mentions.ts
|
|
9
|
+
function isMentionMetadata(value) {
|
|
10
|
+
return !!value && typeof value === "object";
|
|
11
|
+
}
|
|
12
|
+
function getShadowMessageMentions(message) {
|
|
13
|
+
const metadata = message.metadata;
|
|
14
|
+
if (!isMentionMetadata(metadata) || !Array.isArray(metadata.mentions)) return [];
|
|
15
|
+
return metadata.mentions.filter((mention) => mention && typeof mention.token === "string");
|
|
16
|
+
}
|
|
17
|
+
function mentionTargetsBot(params) {
|
|
18
|
+
const botUsername = params.botUsername.toLowerCase();
|
|
19
|
+
return params.mentions.some((mention) => {
|
|
20
|
+
if (mention.kind !== "user" && mention.kind !== "buddy") return false;
|
|
21
|
+
if (mention.userId === params.botUserId || mention.targetId === params.botUserId) return true;
|
|
22
|
+
return mention.username?.toLowerCase() === botUsername;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function formatShadowMentionsForAgent(mentions) {
|
|
26
|
+
if (mentions.length === 0) return "";
|
|
27
|
+
const lines = mentions.map((mention) => {
|
|
28
|
+
const label = mention.label || mention.sourceToken || mention.token;
|
|
29
|
+
if (mention.kind === "channel") {
|
|
30
|
+
return `- ${label} [channel] channelId=${mention.channelId ?? mention.targetId} serverId=${mention.serverId ?? ""} server=${mention.serverName ?? ""}`;
|
|
31
|
+
}
|
|
32
|
+
if (mention.kind === "server") {
|
|
33
|
+
return `- ${label} [server] serverId=${mention.serverId ?? mention.targetId} slug=${mention.serverSlug ?? ""}`;
|
|
34
|
+
}
|
|
35
|
+
if (mention.kind === "user" || mention.kind === "buddy") {
|
|
36
|
+
return `- ${label} [${mention.kind}] userId=${mention.userId ?? mention.targetId} username=${mention.username ?? ""}`;
|
|
37
|
+
}
|
|
38
|
+
return `- ${label} [${mention.kind}] serverId=${mention.serverId ?? mention.targetId}`;
|
|
39
|
+
});
|
|
40
|
+
return [
|
|
41
|
+
"Shadow mentions:",
|
|
42
|
+
...lines,
|
|
43
|
+
"To mention a Shadow entity in a reply, write its visible handle (for example @username or #channel); Shadow will resolve it before delivery."
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
function mentionContextFields(mentions) {
|
|
47
|
+
if (mentions.length === 0) return {};
|
|
48
|
+
return {
|
|
49
|
+
Mentions: mentions,
|
|
50
|
+
MentionSummary: mentions.map(
|
|
51
|
+
(mention) => `${mention.label || mention.sourceToken || mention.token} (${mention.kind})`
|
|
52
|
+
).join(", "),
|
|
53
|
+
MentionedUsers: mentions.filter((mention) => mention.kind === "user" || mention.kind === "buddy").map((mention) => ({
|
|
54
|
+
userId: mention.userId ?? mention.targetId,
|
|
55
|
+
username: mention.username,
|
|
56
|
+
displayName: mention.displayName,
|
|
57
|
+
isBot: mention.isBot
|
|
58
|
+
})),
|
|
59
|
+
MentionedChannels: mentions.filter((mention) => mention.kind === "channel").map((mention) => ({
|
|
60
|
+
channelId: mention.channelId ?? mention.targetId,
|
|
61
|
+
channelName: mention.channelName,
|
|
62
|
+
serverId: mention.serverId,
|
|
63
|
+
serverName: mention.serverName
|
|
64
|
+
})),
|
|
65
|
+
MentionedServers: mentions.filter((mention) => mention.kind === "server").map((mention) => ({
|
|
66
|
+
serverId: mention.serverId ?? mention.targetId,
|
|
67
|
+
serverSlug: mention.serverSlug,
|
|
68
|
+
serverName: mention.serverName
|
|
69
|
+
}))
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function resolveOutboundMentions(params) {
|
|
73
|
+
if (!params.channelId) return void 0;
|
|
74
|
+
if (!/[@#]|<[@#!][^>\s]+>/u.test(params.content)) return void 0;
|
|
75
|
+
try {
|
|
76
|
+
const resolved = await params.client.resolveMentions({
|
|
77
|
+
channelId: params.channelId,
|
|
78
|
+
content: params.content
|
|
79
|
+
});
|
|
80
|
+
return resolved.mentions.length > 0 ? resolved.mentions : void 0;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
params.runtime?.error?.(`[mention] Failed to resolve outbound mentions: ${String(err)}`);
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/monitor/commerce-context.ts
|
|
88
|
+
function isResolvedId(value) {
|
|
89
|
+
return Boolean(value && value.trim() && !value.includes("${env:"));
|
|
90
|
+
}
|
|
91
|
+
function listResolvedCommerceOffers(account) {
|
|
92
|
+
return (account.commerceOffers ?? []).filter((offer) => isResolvedId(offer.offerId));
|
|
93
|
+
}
|
|
94
|
+
function buildCommerceContextForAgent(account) {
|
|
95
|
+
const offers = listResolvedCommerceOffers(account);
|
|
96
|
+
if (offers.length === 0) return "";
|
|
97
|
+
return [
|
|
98
|
+
"Shadow commerce offers available to this Buddy:",
|
|
99
|
+
...offers.map((offer) => {
|
|
100
|
+
const label = offer.name ?? offer.seedId ?? offer.offerId;
|
|
101
|
+
const summary = offer.summary ? ` \u2014 ${offer.summary}` : "";
|
|
102
|
+
const fileHint = offer.fileId ? ` paid file ${offer.fileId}` : "";
|
|
103
|
+
return `- ${label}${summary}. CommerceOfferId: ${offer.offerId}.${fileHint}`;
|
|
104
|
+
}),
|
|
105
|
+
'To sell an offer, use the Shadow message tool with action "send", the current target, a natural sales message, and commerceOfferId set to the CommerceOfferId above.'
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
async function buildCommerceViewerContextForAgent(params) {
|
|
109
|
+
if (!params.viewerUserId) return "";
|
|
110
|
+
const offers = listResolvedCommerceOffers(params.account);
|
|
111
|
+
if (offers.length === 0) return "";
|
|
112
|
+
const lines = [];
|
|
113
|
+
const commerceClient = params.client;
|
|
114
|
+
if (!commerceClient.getCommerceOfferCheckoutPreview) return "";
|
|
115
|
+
for (const offer of offers) {
|
|
116
|
+
try {
|
|
117
|
+
const preview = await commerceClient.getCommerceOfferCheckoutPreview(offer.offerId, {
|
|
118
|
+
viewerUserId: params.viewerUserId
|
|
119
|
+
});
|
|
120
|
+
const label = offer.name ?? offer.seedId ?? offer.offerId;
|
|
121
|
+
lines.push(
|
|
122
|
+
`- ${label}: viewerState=${preview.viewerState}; nextAction=${preview.nextAction}.`
|
|
123
|
+
);
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (lines.length === 0) return "";
|
|
128
|
+
return [
|
|
129
|
+
"Current viewer commerce state for the user you are speaking with:",
|
|
130
|
+
...lines,
|
|
131
|
+
"If viewerState is active, do not ask them to buy again; help them open or use the unlocked content instead."
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
function commerceContextFields(account) {
|
|
135
|
+
const offers = listResolvedCommerceOffers(account);
|
|
136
|
+
if (offers.length === 0) return {};
|
|
137
|
+
return {
|
|
138
|
+
CommerceOffers: offers,
|
|
139
|
+
CommerceOfferIds: offers.map((offer) => offer.offerId).join(",")
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/monitor/interactive-response.ts
|
|
144
|
+
async function buildInteractiveResponseContext(params) {
|
|
145
|
+
const response = params.message.metadata?.interactiveResponse;
|
|
146
|
+
if (!response?.sourceMessageId) return { text: "", fields: {} };
|
|
147
|
+
let source = null;
|
|
148
|
+
try {
|
|
149
|
+
source = await params.client.getMessage(response.sourceMessageId);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
params.runtime.error?.(
|
|
152
|
+
`[interactive] Failed to load source message ${response.sourceMessageId}: ${String(err)}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const sourceInteractive = source?.metadata?.interactive;
|
|
156
|
+
const sourceSlashCommand = source?.metadata?.slashCommand;
|
|
157
|
+
const sourceCommandName = sourceSlashCommand && typeof sourceSlashCommand === "object" && !Array.isArray(sourceSlashCommand) ? sourceSlashCommand.name : void 0;
|
|
158
|
+
const sourceCommand = typeof sourceCommandName === "string" ? params.slashCommands?.find(
|
|
159
|
+
(command) => command.name.toLowerCase() === sourceCommandName.toLowerCase()
|
|
160
|
+
) : void 0;
|
|
161
|
+
const sourcePrompt = sourceInteractive && typeof sourceInteractive === "object" && !Array.isArray(sourceInteractive) ? sourceInteractive.prompt : void 0;
|
|
162
|
+
const responsePrompt = sourceInteractive && typeof sourceInteractive === "object" && !Array.isArray(sourceInteractive) ? sourceInteractive.responsePrompt : void 0;
|
|
163
|
+
const lines = [
|
|
164
|
+
"Shadow interactive response received.",
|
|
165
|
+
`Source message: ${source?.content ?? "(unavailable)"}`,
|
|
166
|
+
typeof sourcePrompt === "string" && sourcePrompt.trim() ? `Source prompt: ${sourcePrompt.trim()}` : "",
|
|
167
|
+
typeof responsePrompt === "string" && responsePrompt.trim() ? `Follow-up instruction: ${responsePrompt.trim()}` : "",
|
|
168
|
+
sourceCommand?.body ? `Source slash command definition:
|
|
169
|
+
${sourceCommand.body}` : "",
|
|
170
|
+
`Action: ${response.actionId ?? "(unknown)"}`,
|
|
171
|
+
response.values ? `Submitted values:
|
|
172
|
+
${JSON.stringify(response.values, null, 2)}` : ""
|
|
173
|
+
].filter(Boolean);
|
|
174
|
+
return {
|
|
175
|
+
text: lines.join("\n\n"),
|
|
176
|
+
fields: {
|
|
177
|
+
InteractiveResponse: response,
|
|
178
|
+
...source ? { InteractiveSourceMessage: source.content } : {},
|
|
179
|
+
...sourceInteractive ? { InteractiveSourceBlock: sourceInteractive } : {},
|
|
180
|
+
...sourceCommand ? { InteractiveSourceSlashCommand: sourceCommand } : {}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/monitor/media.ts
|
|
186
|
+
import nodeCrypto from "crypto";
|
|
187
|
+
import fsPromises from "fs/promises";
|
|
188
|
+
import nodePath2 from "path";
|
|
189
|
+
import { ShadowClient } from "@shadowob/sdk";
|
|
190
|
+
|
|
191
|
+
// src/monitor/paths.ts
|
|
192
|
+
import nodeOs from "os";
|
|
193
|
+
import nodePath from "path";
|
|
194
|
+
async function getDataDir() {
|
|
195
|
+
const dataDir = process.env.OPENCLAW_DATA_DIR;
|
|
196
|
+
return dataDir || nodePath.join(nodeOs.homedir(), ".openclaw");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/monitor/media.ts
|
|
200
|
+
function isShadowResolvableMediaUrl(url) {
|
|
201
|
+
if (url.startsWith("/")) {
|
|
202
|
+
return url.includes("/uploads/") || url.startsWith("/api/media/signed/");
|
|
203
|
+
}
|
|
204
|
+
return url.startsWith("http");
|
|
205
|
+
}
|
|
206
|
+
function inferMimeType(filename, headerType) {
|
|
207
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
208
|
+
const map = {
|
|
209
|
+
jpg: "image/jpeg",
|
|
210
|
+
jpeg: "image/jpeg",
|
|
211
|
+
png: "image/png",
|
|
212
|
+
gif: "image/gif",
|
|
213
|
+
webp: "image/webp",
|
|
214
|
+
svg: "image/svg+xml",
|
|
215
|
+
mp4: "video/mp4",
|
|
216
|
+
webm: "video/webm",
|
|
217
|
+
mp3: "audio/mpeg",
|
|
218
|
+
wav: "audio/wav",
|
|
219
|
+
ogg: "audio/ogg",
|
|
220
|
+
pdf: "application/pdf"
|
|
221
|
+
};
|
|
222
|
+
return map[ext] ?? headerType ?? "application/octet-stream";
|
|
223
|
+
}
|
|
224
|
+
async function resolveShadowInboundMediaContext(params) {
|
|
225
|
+
const { account, message, rawBody, runtime } = params;
|
|
226
|
+
const attachmentUrls = (message.attachments ?? []).map((a) => a.url).filter(Boolean);
|
|
227
|
+
const markdownMediaRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
|
228
|
+
const markdownUrls = [];
|
|
229
|
+
for (const mdMatch of rawBody.matchAll(markdownMediaRegex)) {
|
|
230
|
+
const url = mdMatch[1];
|
|
231
|
+
if (isShadowResolvableMediaUrl(url)) {
|
|
232
|
+
markdownUrls.push(url);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const allRawUrls = [.../* @__PURE__ */ new Set([...attachmentUrls, ...markdownUrls])];
|
|
236
|
+
if (allRawUrls.length === 0) {
|
|
237
|
+
return { cleanBody: rawBody, fields: {} };
|
|
238
|
+
}
|
|
239
|
+
const mediaClient = new ShadowClient(account.serverUrl, account.token);
|
|
240
|
+
const dataDir = await getDataDir();
|
|
241
|
+
const mediaDir = nodePath2.join(dataDir, "media", "inbound");
|
|
242
|
+
await fsPromises.mkdir(mediaDir, { recursive: true });
|
|
243
|
+
const localMediaPaths = [];
|
|
244
|
+
const localMediaTypes = [];
|
|
245
|
+
const resolvedMediaUrls = [];
|
|
246
|
+
for (const rawUrl of allRawUrls) {
|
|
247
|
+
try {
|
|
248
|
+
const downloaded = await mediaClient.downloadFile(rawUrl);
|
|
249
|
+
const uuid = nodeCrypto.randomUUID();
|
|
250
|
+
const ext = nodePath2.extname(downloaded.filename) || ".bin";
|
|
251
|
+
const safeBase = downloaded.filename.replace(/[^a-zA-Z0-9._\u4e00-\u9fff-]/g, "_").slice(0, 100);
|
|
252
|
+
const localFilename = `${safeBase}---${uuid}${ext.startsWith(".") ? "" : "."}${ext}`;
|
|
253
|
+
const localPath = nodePath2.join(mediaDir, localFilename);
|
|
254
|
+
await fsPromises.writeFile(localPath, new Uint8Array(downloaded.buffer));
|
|
255
|
+
localMediaPaths.push(localPath);
|
|
256
|
+
localMediaTypes.push(inferMimeType(downloaded.filename, downloaded.contentType));
|
|
257
|
+
const baseUrl = account.serverUrl.replace(/\/$/, "");
|
|
258
|
+
resolvedMediaUrls.push(rawUrl.startsWith("/") ? `${baseUrl}${rawUrl}` : rawUrl);
|
|
259
|
+
runtime.log?.(
|
|
260
|
+
`[media] Downloaded ${rawUrl} \u2192 ${localPath} (${downloaded.buffer.byteLength} bytes)`
|
|
261
|
+
);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
runtime.error?.(`[media] Failed to download ${rawUrl}: ${String(err)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (localMediaPaths.length === 0) {
|
|
267
|
+
return { cleanBody: rawBody, fields: {} };
|
|
268
|
+
}
|
|
269
|
+
const cleanBody = rawBody.replace(/!?\[[^\]]*\]\((?:[^)]*\/uploads\/[^)]+|[^)]*\/api\/media\/signed\/[^)]+)\)/g, "").replace(/\n{2,}/g, "\n").trim() || "[Media attached]";
|
|
270
|
+
return {
|
|
271
|
+
cleanBody,
|
|
272
|
+
fields: {
|
|
273
|
+
MediaPath: localMediaPaths[0],
|
|
274
|
+
MediaPaths: localMediaPaths,
|
|
275
|
+
MediaUrl: resolvedMediaUrls[0],
|
|
276
|
+
MediaUrls: resolvedMediaUrls,
|
|
277
|
+
MediaType: localMediaTypes[0],
|
|
278
|
+
MediaTypes: localMediaTypes
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/monitor/preflight.ts
|
|
284
|
+
function escapeRegex(value) {
|
|
285
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
286
|
+
}
|
|
287
|
+
function evaluateShadowMessagePreflight(params) {
|
|
288
|
+
const { message, botUserId, botUsername, channelPolicies, runtime } = params;
|
|
289
|
+
const senderLabel = message.author?.username ?? message.authorId;
|
|
290
|
+
if (message.authorId === botUserId) {
|
|
291
|
+
return { ok: false, reason: `[msg] Skipping own message ${message.id}` };
|
|
292
|
+
}
|
|
293
|
+
const policy = channelPolicies.get(message.channelId);
|
|
294
|
+
const policyConfig = policy?.config;
|
|
295
|
+
const structuredMentions = getShadowMessageMentions(message);
|
|
296
|
+
let isProcessingBuddyMessage = false;
|
|
297
|
+
if (message.author?.isBot) {
|
|
298
|
+
if (!policyConfig?.replyToBuddy) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
reason: `[msg] Skipping bot message from ${senderLabel} (replyToBuddy=false) (${message.id})`
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const maxDepth = policyConfig.maxBuddyChainDepth ?? 3;
|
|
305
|
+
const chainMeta = message.metadata?.agentChain;
|
|
306
|
+
if (chainMeta) {
|
|
307
|
+
if (chainMeta.depth >= maxDepth) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
reason: `[msg] Buddy chain depth ${chainMeta.depth} >= max ${maxDepth}, stopping loop (${message.id})`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (chainMeta.participants?.includes(botUserId)) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
reason: `[msg] Already in buddy chain [${chainMeta.participants.join(", ")}], skipping to prevent loop (${message.id})`
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const senderAgentId = message.author?.id;
|
|
320
|
+
if (senderAgentId && policyConfig.buddyBlacklist?.includes(senderAgentId)) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
reason: `[msg] Sender agent ${senderAgentId} is in blacklist, skipping (${message.id})`
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (senderAgentId && policyConfig.buddyWhitelist?.length && !policyConfig.buddyWhitelist.includes(senderAgentId)) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
reason: `[msg] Sender agent ${senderAgentId} not in whitelist, skipping (${message.id})`
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
isProcessingBuddyMessage = true;
|
|
334
|
+
runtime.log?.(
|
|
335
|
+
`[msg] Processing bot message from ${senderLabel} (replyToBuddy=true) (${message.id})`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (policy && !policy.listen) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
reason: `[msg] Policy blocks listen for channel ${message.channelId}, skipping`
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (policy && !policy.reply) {
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
reason: `[msg] Policy blocks reply for channel ${message.channelId}, skipping (${message.id})`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
let wasMentionedExplicitly = false;
|
|
351
|
+
if (policy?.mentionOnly) {
|
|
352
|
+
const mentionRegex = new RegExp(`@${escapeRegex(botUsername)}(?:\\s|$)`, "i");
|
|
353
|
+
wasMentionedExplicitly = mentionTargetsBot({ mentions: structuredMentions, botUserId, botUsername }) || mentionRegex.test(message.content);
|
|
354
|
+
if (!wasMentionedExplicitly) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: `[msg] mentionOnly policy \u2014 no @${botUsername} mention found, skipping (${message.id})`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
runtime.log?.(
|
|
361
|
+
`[msg] mentionOnly policy \u2014 @${botUsername} mentioned, processing (${message.id})`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (policyConfig?.replyToUsers?.length) {
|
|
365
|
+
const allowedUsers = policyConfig.replyToUsers.map((u) => u.toLowerCase());
|
|
366
|
+
const senderUser = (message.author?.username ?? "").toLowerCase();
|
|
367
|
+
if (!allowedUsers.includes(senderUser)) {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
reason: `[msg] replyToUsers policy \u2014 sender "${senderUser}" not in allowed list, skipping (${message.id})`
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (policyConfig?.keywords?.length) {
|
|
375
|
+
const lowerContent = message.content.toLowerCase();
|
|
376
|
+
const matched = policyConfig.keywords.some((kw) => lowerContent.includes(kw.toLowerCase()));
|
|
377
|
+
if (!matched) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
reason: `[msg] keywords policy \u2014 no matching keyword found, skipping (${message.id})`
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
runtime.log?.(`[msg] keywords policy \u2014 keyword matched, processing (${message.id})`);
|
|
384
|
+
}
|
|
385
|
+
const smartReplyEnabled = policyConfig?.smartReply !== false;
|
|
386
|
+
if (smartReplyEnabled && !isProcessingBuddyMessage && !wasMentionedExplicitly) {
|
|
387
|
+
const mentionPattern = /@([a-zA-Z0-9_\-\u4e00-\u9fa5]+)/g;
|
|
388
|
+
const userMentions = structuredMentions.filter(
|
|
389
|
+
(mention) => mention.kind === "user" || mention.kind === "buddy"
|
|
390
|
+
);
|
|
391
|
+
const allMentions = structuredMentions.length > 0 ? [] : message.content.match(mentionPattern) || [];
|
|
392
|
+
const structuredSelfMentioned = mentionTargetsBot({
|
|
393
|
+
mentions: userMentions,
|
|
394
|
+
botUserId,
|
|
395
|
+
botUsername
|
|
396
|
+
});
|
|
397
|
+
if (userMentions.length > 0 && !structuredSelfMentioned) {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
reason: `[msg] Smart reply: message mentions other users but not @${botUsername}, skipping (${message.id})`
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const mentionsWithoutSelf = userMentions.length > 0 ? [] : allMentions.filter((m) => {
|
|
404
|
+
const mentionedUser = m.slice(1).toLowerCase();
|
|
405
|
+
return mentionedUser !== botUsername.toLowerCase();
|
|
406
|
+
});
|
|
407
|
+
if (userMentions.length === 0 && allMentions.length > 0 && mentionsWithoutSelf.length === allMentions.length) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
reason: `[msg] Smart reply: message @mentions others (${allMentions.join(", ")}) but not @${botUsername}, skipping (${message.id})`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const replyToData = message.replyTo;
|
|
414
|
+
if (replyToData?.authorId && replyToData.authorId !== botUserId) {
|
|
415
|
+
const selfMentioned = allMentions.some((m) => {
|
|
416
|
+
const mentionedUser = m.slice(1).toLowerCase();
|
|
417
|
+
return mentionedUser === botUsername.toLowerCase();
|
|
418
|
+
}) || structuredSelfMentioned;
|
|
419
|
+
if (!selfMentioned) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
reason: `[msg] Smart reply: message is a reply to another user (${replyToData.authorId}), not this Buddy, skipping (${message.id})`
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
ok: true,
|
|
429
|
+
senderLabel,
|
|
430
|
+
policy,
|
|
431
|
+
policyConfig,
|
|
432
|
+
isProcessingBuddyMessage,
|
|
433
|
+
wasMentionedExplicitly
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/monitor/reply-delivery.ts
|
|
438
|
+
import { randomUUID } from "crypto";
|
|
439
|
+
var DELIVERY_RETRY_DELAYS_MS = [500, 1e3, 2e3];
|
|
440
|
+
function sleep(ms) {
|
|
441
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
442
|
+
}
|
|
443
|
+
function isRetryableDeliveryError(err) {
|
|
444
|
+
const message = String(err);
|
|
445
|
+
return message.includes("fetch failed") || message.includes("ECONNRESET") || message.includes("ETIMEDOUT") || message.includes("AbortError") || /failed \((408|429|5\d\d)\)/.test(message);
|
|
446
|
+
}
|
|
447
|
+
function replyMetadata(params) {
|
|
448
|
+
return {
|
|
449
|
+
...params.agentChain ? { agentChain: params.agentChain } : {},
|
|
450
|
+
shadowDelivery: {
|
|
451
|
+
id: params.deliveryId,
|
|
452
|
+
source: "openclaw-shadowob",
|
|
453
|
+
replyToId: params.replyToId
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function messageDeliveryId(message) {
|
|
458
|
+
const metadata = message.metadata;
|
|
459
|
+
if (!metadata || typeof metadata !== "object") return null;
|
|
460
|
+
const delivery = metadata.shadowDelivery;
|
|
461
|
+
if (!delivery || typeof delivery !== "object") return null;
|
|
462
|
+
const id = delivery.id;
|
|
463
|
+
return typeof id === "string" ? id : null;
|
|
464
|
+
}
|
|
465
|
+
async function findDeliveredChannelMessage(params) {
|
|
466
|
+
const messages = (await params.client.getMessages(params.channelId, 20)).messages;
|
|
467
|
+
return messages.find((message) => messageDeliveryId(message) === params.deliveryId) ?? null;
|
|
468
|
+
}
|
|
469
|
+
async function withDeliveryRetry(params) {
|
|
470
|
+
for (let attempt = 0; attempt <= DELIVERY_RETRY_DELAYS_MS.length; attempt++) {
|
|
471
|
+
try {
|
|
472
|
+
return await params.operation();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
const recovered = await params.recover?.().catch((recoverErr) => {
|
|
475
|
+
params.runtime.error?.(
|
|
476
|
+
`[${params.label}] Delivery recovery check failed: ${String(recoverErr)}`
|
|
477
|
+
);
|
|
478
|
+
return null;
|
|
479
|
+
});
|
|
480
|
+
if (recovered) {
|
|
481
|
+
params.runtime.log?.(`[${params.label}] Recovered delivered message after retryable error`);
|
|
482
|
+
return recovered;
|
|
483
|
+
}
|
|
484
|
+
const delay3 = DELIVERY_RETRY_DELAYS_MS[attempt];
|
|
485
|
+
if (!delay3 || !isRetryableDeliveryError(err)) throw err;
|
|
486
|
+
params.runtime.error?.(
|
|
487
|
+
`[${params.label}] Delivery attempt ${attempt + 1} failed: ${String(err)}; retrying in ${delay3}ms`
|
|
488
|
+
);
|
|
489
|
+
await sleep(delay3);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
throw new Error(`[${params.label}] Delivery retry exhausted`);
|
|
493
|
+
}
|
|
494
|
+
async function deliverShadowReply(params) {
|
|
495
|
+
const { payload, channelId, replyToId, client, runtime, agentChain, agentId, botUserId } = params;
|
|
496
|
+
try {
|
|
497
|
+
if (!payload.text && !(payload.mediaUrl || payload.mediaUrls?.length)) {
|
|
498
|
+
runtime.error?.("[reply] No text or media in reply payload");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const text = payload.text ?? "";
|
|
502
|
+
runtime.log?.(`[reply] Sending reply to channel ${channelId}: "${text.slice(0, 80)}"`);
|
|
503
|
+
const mediaUrls = [payload.mediaUrl, ...payload.mediaUrls ?? []].filter(Boolean);
|
|
504
|
+
const newAgentChain = agentId ? {
|
|
505
|
+
agentId,
|
|
506
|
+
depth: (agentChain?.depth ?? 0) + 1,
|
|
507
|
+
participants: [...agentChain?.participants ?? [], botUserId].filter(
|
|
508
|
+
Boolean
|
|
509
|
+
),
|
|
510
|
+
startedAt: agentChain?.startedAt ?? Date.now(),
|
|
511
|
+
rootMessageId: agentChain?.rootMessageId ?? replyToId
|
|
512
|
+
} : void 0;
|
|
513
|
+
let sentMessage = null;
|
|
514
|
+
if (text || mediaUrls.length > 0) {
|
|
515
|
+
const contentToSend = text || "\u200B";
|
|
516
|
+
const deliveryId = randomUUID();
|
|
517
|
+
const metadata = replyMetadata({
|
|
518
|
+
deliveryId,
|
|
519
|
+
agentChain: newAgentChain,
|
|
520
|
+
replyToId
|
|
521
|
+
});
|
|
522
|
+
const mentions = await resolveOutboundMentions({
|
|
523
|
+
client,
|
|
524
|
+
channelId,
|
|
525
|
+
content: contentToSend,
|
|
526
|
+
runtime
|
|
527
|
+
});
|
|
528
|
+
sentMessage = await withDeliveryRetry({
|
|
529
|
+
label: "reply",
|
|
530
|
+
runtime,
|
|
531
|
+
operation: () => client.sendMessage(channelId, contentToSend, {
|
|
532
|
+
replyToId,
|
|
533
|
+
metadata,
|
|
534
|
+
...mentions ? { mentions } : {}
|
|
535
|
+
}),
|
|
536
|
+
recover: () => findDeliveredChannelMessage({ client, channelId, deliveryId })
|
|
537
|
+
});
|
|
538
|
+
runtime.log?.(
|
|
539
|
+
`[reply] Message created (${sentMessage.id})${text ? "" : " [media-only placeholder]"}${newAgentChain ? ` [chain depth: ${newAgentChain.depth}]` : ""}`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
if (mediaUrls.length > 0) {
|
|
543
|
+
const messageId = sentMessage?.id;
|
|
544
|
+
let fallbackReplyToId = sentMessage?.id ?? replyToId;
|
|
545
|
+
for (const mediaUrl of mediaUrls) {
|
|
546
|
+
runtime.log?.(`[reply] Uploading media: ${mediaUrl}`);
|
|
547
|
+
try {
|
|
548
|
+
await withDeliveryRetry({
|
|
549
|
+
label: "reply-media",
|
|
550
|
+
runtime,
|
|
551
|
+
operation: () => client.uploadMediaFromUrl(mediaUrl, messageId)
|
|
552
|
+
});
|
|
553
|
+
runtime.log?.("[reply] Media uploaded successfully");
|
|
554
|
+
} catch (err) {
|
|
555
|
+
runtime.error?.(
|
|
556
|
+
`[reply] Media upload failed for ${mediaUrl}; sending URL fallback: ${String(err)}`
|
|
557
|
+
);
|
|
558
|
+
const deliveryId = randomUUID();
|
|
559
|
+
const metadata = replyMetadata({
|
|
560
|
+
deliveryId,
|
|
561
|
+
agentChain: newAgentChain,
|
|
562
|
+
replyToId: fallbackReplyToId
|
|
563
|
+
});
|
|
564
|
+
const mentions = await resolveOutboundMentions({
|
|
565
|
+
client,
|
|
566
|
+
channelId,
|
|
567
|
+
content: mediaUrl,
|
|
568
|
+
runtime
|
|
569
|
+
});
|
|
570
|
+
const fallbackMessage = await withDeliveryRetry({
|
|
571
|
+
label: "reply-media-fallback",
|
|
572
|
+
runtime,
|
|
573
|
+
operation: () => client.sendMessage(channelId, mediaUrl, {
|
|
574
|
+
replyToId: fallbackReplyToId,
|
|
575
|
+
metadata,
|
|
576
|
+
...mentions ? { mentions } : {}
|
|
577
|
+
}),
|
|
578
|
+
recover: () => findDeliveredChannelMessage({ client, channelId, deliveryId })
|
|
579
|
+
});
|
|
580
|
+
fallbackReplyToId = fallbackMessage.id;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
runtime.log?.("[reply] Reply delivered successfully");
|
|
585
|
+
} catch (err) {
|
|
586
|
+
runtime.error?.(`[reply] Failed to send reply: ${String(err)}`);
|
|
587
|
+
throw err;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/monitor/session.ts
|
|
592
|
+
function resolveSessionStore(cfg) {
|
|
593
|
+
const raw = cfg.session?.store;
|
|
594
|
+
if (typeof raw === "string") return raw;
|
|
595
|
+
if (raw && typeof raw === "object") {
|
|
596
|
+
const pathValue = raw.path;
|
|
597
|
+
if (typeof pathValue === "string") return pathValue;
|
|
598
|
+
}
|
|
599
|
+
return void 0;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/monitor/slash-commands.ts
|
|
603
|
+
import fsPromises2 from "fs/promises";
|
|
604
|
+
var SLASH_COMMAND_RE = /^\/([a-zA-Z][a-zA-Z0-9._-]{0,63})(?:\s+([\s\S]*))?$/;
|
|
605
|
+
function normalizeSlashCommandName(value) {
|
|
606
|
+
if (typeof value !== "string") return null;
|
|
607
|
+
const name = value.trim().replace(/^\/+/, "");
|
|
608
|
+
return /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(name) ? name : null;
|
|
609
|
+
}
|
|
610
|
+
function isRecord(value) {
|
|
611
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
612
|
+
}
|
|
613
|
+
function readString(value, max = 2e3) {
|
|
614
|
+
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : void 0;
|
|
615
|
+
}
|
|
616
|
+
function normalizeInteractionItems(value, max) {
|
|
617
|
+
if (!Array.isArray(value)) return void 0;
|
|
618
|
+
const items = value.filter(isRecord).map((item, index) => {
|
|
619
|
+
const label = readString(item.label, 120) ?? readString(item.value, 120) ?? `Option ${index + 1}`;
|
|
620
|
+
const id = readString(item.id, 80) ?? readString(item.value, 80) ?? label;
|
|
621
|
+
const itemValue = readString(item.value, 2048);
|
|
622
|
+
const rawStyle = readString(item.style, 40);
|
|
623
|
+
const style = rawStyle === "primary" || rawStyle === "secondary" || rawStyle === "destructive" ? rawStyle : void 0;
|
|
624
|
+
return {
|
|
625
|
+
id,
|
|
626
|
+
label,
|
|
627
|
+
...itemValue ? { value: itemValue } : {},
|
|
628
|
+
...style ? { style } : {}
|
|
629
|
+
};
|
|
630
|
+
}).filter((item) => item.id && item.label);
|
|
631
|
+
return items.length > 0 ? items.slice(0, max) : void 0;
|
|
632
|
+
}
|
|
633
|
+
function normalizeSlashInteraction(value) {
|
|
634
|
+
if (!isRecord(value)) return void 0;
|
|
635
|
+
const kind = readString(value.kind, 20);
|
|
636
|
+
if (kind !== "buttons" && kind !== "select" && kind !== "form" && kind !== "approval") {
|
|
637
|
+
return void 0;
|
|
638
|
+
}
|
|
639
|
+
const interaction = { kind };
|
|
640
|
+
const id = readString(value.id, 120);
|
|
641
|
+
const prompt = readString(value.prompt);
|
|
642
|
+
const submitLabel = readString(value.submitLabel, 40);
|
|
643
|
+
const responsePrompt = readString(value.responsePrompt);
|
|
644
|
+
const approvalCommentLabel = readString(value.approvalCommentLabel, 120);
|
|
645
|
+
if (id) interaction.id = id;
|
|
646
|
+
if (prompt) interaction.prompt = prompt;
|
|
647
|
+
if (submitLabel) interaction.submitLabel = submitLabel;
|
|
648
|
+
if (responsePrompt) interaction.responsePrompt = responsePrompt;
|
|
649
|
+
if (approvalCommentLabel) interaction.approvalCommentLabel = approvalCommentLabel;
|
|
650
|
+
if (typeof value.oneShot === "boolean") interaction.oneShot = value.oneShot;
|
|
651
|
+
const buttons = normalizeInteractionItems(value.buttons, 8);
|
|
652
|
+
const options = normalizeInteractionItems(value.options, 20)?.map((option) => ({
|
|
653
|
+
id: option.id,
|
|
654
|
+
label: option.label,
|
|
655
|
+
value: option.value ?? option.id
|
|
656
|
+
}));
|
|
657
|
+
if (buttons) interaction.buttons = buttons;
|
|
658
|
+
if (options) interaction.options = options;
|
|
659
|
+
if (Array.isArray(value.fields)) {
|
|
660
|
+
const fields = value.fields.filter(isRecord).flatMap((field, index) => {
|
|
661
|
+
const fieldKind = readString(field.kind, 20) ?? readString(field.type, 20) ?? "text";
|
|
662
|
+
if (!["text", "textarea", "number", "checkbox", "select"].includes(fieldKind)) return [];
|
|
663
|
+
const normalizedField = {
|
|
664
|
+
id: readString(field.id, 80) ?? readString(field.name, 80) ?? `field_${index + 1}`,
|
|
665
|
+
kind: fieldKind,
|
|
666
|
+
label: readString(field.label, 120) ?? readString(field.name, 120) ?? `Field ${index + 1}`,
|
|
667
|
+
...readString(field.placeholder, 200) ? { placeholder: readString(field.placeholder, 200) } : {},
|
|
668
|
+
...readString(field.defaultValue, 2048) ? { defaultValue: readString(field.defaultValue, 2048) } : {},
|
|
669
|
+
...typeof field.required === "boolean" ? { required: field.required } : {},
|
|
670
|
+
...typeof field.maxLength === "number" ? { maxLength: field.maxLength } : {},
|
|
671
|
+
...typeof field.min === "number" ? { min: field.min } : {},
|
|
672
|
+
...typeof field.max === "number" ? { max: field.max } : {}
|
|
673
|
+
};
|
|
674
|
+
const fieldOptions = normalizeInteractionItems(field.options, 20)?.map((option) => ({
|
|
675
|
+
id: option.id,
|
|
676
|
+
label: option.label,
|
|
677
|
+
value: option.value ?? option.id
|
|
678
|
+
}));
|
|
679
|
+
return [{ ...normalizedField, ...fieldOptions ? { options: fieldOptions } : {} }];
|
|
680
|
+
});
|
|
681
|
+
if (fields.length > 0) interaction.fields = fields.slice(0, 12);
|
|
682
|
+
}
|
|
683
|
+
return interaction;
|
|
684
|
+
}
|
|
685
|
+
function normalizeShadowSlashCommands(input) {
|
|
686
|
+
if (!Array.isArray(input)) return [];
|
|
687
|
+
const seen = /* @__PURE__ */ new Set();
|
|
688
|
+
const commands = [];
|
|
689
|
+
for (const item of input) {
|
|
690
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
691
|
+
const record = item;
|
|
692
|
+
const name = normalizeSlashCommandName(record.name);
|
|
693
|
+
if (!name) continue;
|
|
694
|
+
const key = name.toLowerCase();
|
|
695
|
+
if (seen.has(key)) continue;
|
|
696
|
+
seen.add(key);
|
|
697
|
+
const aliases = Array.isArray(record.aliases) ? [
|
|
698
|
+
...new Set(
|
|
699
|
+
record.aliases.map(normalizeSlashCommandName).filter((alias) => Boolean(alias))
|
|
700
|
+
)
|
|
701
|
+
].filter((alias) => alias.toLowerCase() !== key) : void 0;
|
|
702
|
+
const interaction = normalizeSlashInteraction(record.interaction);
|
|
703
|
+
commands.push({
|
|
704
|
+
name,
|
|
705
|
+
...typeof record.description === "string" && record.description.trim() ? { description: record.description.trim().slice(0, 240) } : {},
|
|
706
|
+
...aliases && aliases.length > 0 ? { aliases } : {},
|
|
707
|
+
...typeof record.packId === "string" && record.packId.trim() ? { packId: record.packId.trim().slice(0, 80) } : {},
|
|
708
|
+
...typeof record.sourcePath === "string" && record.sourcePath.trim() ? { sourcePath: record.sourcePath.trim().slice(0, 500) } : {},
|
|
709
|
+
...typeof record.body === "string" && record.body.trim() ? { body: record.body.trim().slice(0, 2e4) } : {},
|
|
710
|
+
...interaction ? { interaction } : {}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return commands.slice(0, 200);
|
|
714
|
+
}
|
|
715
|
+
function toPublicSlashCommands(commands) {
|
|
716
|
+
return commands.map(({ body: _body, ...command }) => command);
|
|
717
|
+
}
|
|
718
|
+
async function loadLocalSlashCommands(runtime) {
|
|
719
|
+
const indexPath = process.env.SHADOW_SLASH_COMMANDS_PATH;
|
|
720
|
+
if (!indexPath) return [];
|
|
721
|
+
try {
|
|
722
|
+
const raw = await fsPromises2.readFile(indexPath, "utf-8");
|
|
723
|
+
const commands = normalizeShadowSlashCommands(JSON.parse(raw));
|
|
724
|
+
runtime.log?.(`[slash] Loaded ${commands.length} command(s) from ${indexPath}`);
|
|
725
|
+
return commands;
|
|
726
|
+
} catch (err) {
|
|
727
|
+
runtime.error?.(`[slash] Failed to load command index: ${String(err)}`);
|
|
728
|
+
return [];
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async function registerAgentSlashCommands(params) {
|
|
732
|
+
const baseUrl = params.account.serverUrl.replace(/\/api\/?$/, "").replace(/\/$/, "");
|
|
733
|
+
const response = await fetch(`${baseUrl}/api/agents/${params.agentId}/slash-commands`, {
|
|
734
|
+
method: "PUT",
|
|
735
|
+
headers: {
|
|
736
|
+
Authorization: `Bearer ${params.account.token}`,
|
|
737
|
+
"Content-Type": "application/json"
|
|
738
|
+
},
|
|
739
|
+
body: JSON.stringify({ commands: toPublicSlashCommands(params.commands) })
|
|
740
|
+
});
|
|
741
|
+
if (!response.ok) {
|
|
742
|
+
const errorText = await response.text().catch(() => "");
|
|
743
|
+
throw new Error(`Shadow slash command registry failed (${response.status}): ${errorText}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function matchShadowSlashCommand(content, commands) {
|
|
747
|
+
const match = content.trim().match(SLASH_COMMAND_RE);
|
|
748
|
+
if (!match) return null;
|
|
749
|
+
const invokedName = match[1];
|
|
750
|
+
const args = match[2]?.trim() ?? "";
|
|
751
|
+
const invokedKey = invokedName.toLowerCase();
|
|
752
|
+
const command = commands.find((candidate) => {
|
|
753
|
+
if (candidate.name.toLowerCase() === invokedKey) return true;
|
|
754
|
+
return (candidate.aliases ?? []).some((alias) => alias.toLowerCase() === invokedKey);
|
|
755
|
+
});
|
|
756
|
+
return command ? { command, invokedName, args } : null;
|
|
757
|
+
}
|
|
758
|
+
function formatSlashCommandPrompt(originalBody, match) {
|
|
759
|
+
const chunks = [
|
|
760
|
+
`Slash command /${match.command.name} was invoked.`,
|
|
761
|
+
match.command.description ? `Description: ${match.command.description}` : "",
|
|
762
|
+
match.command.packId ? `Pack: ${match.command.packId}` : "",
|
|
763
|
+
`Arguments:
|
|
764
|
+
${match.args || "(none)"}`,
|
|
765
|
+
match.command.body ? `Command definition:
|
|
766
|
+
${match.command.body}` : "",
|
|
767
|
+
`Original message:
|
|
768
|
+
${originalBody}`
|
|
769
|
+
].filter(Boolean);
|
|
770
|
+
return chunks.join("\n\n");
|
|
771
|
+
}
|
|
772
|
+
function buildAgentChainMetadata(params) {
|
|
773
|
+
if (!params.agentId) return void 0;
|
|
774
|
+
return {
|
|
775
|
+
agentId: params.agentId,
|
|
776
|
+
depth: (params.prior?.depth ?? 0) + 1,
|
|
777
|
+
participants: [...params.prior?.participants ?? [], params.botUserId].filter(Boolean),
|
|
778
|
+
startedAt: params.prior?.startedAt ?? Date.now(),
|
|
779
|
+
rootMessageId: params.prior?.rootMessageId ?? params.rootMessageId
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function buildSlashCommandInteractiveBlock(match, messageId) {
|
|
783
|
+
const interaction = match.command.interaction;
|
|
784
|
+
if (!interaction) return void 0;
|
|
785
|
+
return {
|
|
786
|
+
...interaction,
|
|
787
|
+
id: interaction.id && interaction.id.trim() ? `${interaction.id}:${messageId}` : `slash:${match.command.packId ?? "pack"}:${match.command.name}:${messageId}`
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
async function sendSlashCommandInteractivePrompt(params) {
|
|
791
|
+
const block = buildSlashCommandInteractiveBlock(params.match, params.messageId);
|
|
792
|
+
if (!block) return false;
|
|
793
|
+
const content = block.prompt ?? `/${params.match.command.name} needs input before the Buddy can continue.`;
|
|
794
|
+
const agentChain = buildAgentChainMetadata({
|
|
795
|
+
agentId: params.agentId,
|
|
796
|
+
botUserId: params.botUserId,
|
|
797
|
+
rootMessageId: params.messageId,
|
|
798
|
+
prior: params.agentChain
|
|
799
|
+
});
|
|
800
|
+
await params.client.sendMessage(params.channelId, content, {
|
|
801
|
+
replyToId: params.messageId,
|
|
802
|
+
threadId: params.threadId,
|
|
803
|
+
metadata: {
|
|
804
|
+
...agentChain ? { agentChain } : {},
|
|
805
|
+
interactive: block,
|
|
806
|
+
slashCommand: {
|
|
807
|
+
name: params.match.command.name,
|
|
808
|
+
invokedName: params.match.invokedName,
|
|
809
|
+
args: params.match.args,
|
|
810
|
+
packId: params.match.command.packId
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
params.runtime.log?.(
|
|
815
|
+
`[slash] Sent interactive prompt for /${params.match.command.name} (${block.kind})`
|
|
816
|
+
);
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/monitor/thread-bindings.ts
|
|
821
|
+
import fsPromises4 from "fs/promises";
|
|
822
|
+
import nodePath4 from "path";
|
|
823
|
+
|
|
824
|
+
// src/monitor/state.ts
|
|
825
|
+
import fsPromises3 from "fs/promises";
|
|
826
|
+
import nodePath3 from "path";
|
|
827
|
+
async function getSessionCachePath(accountId) {
|
|
828
|
+
const dataDir = await getDataDir();
|
|
829
|
+
return nodePath3.join(dataDir, "shadow", `session-cache-${accountId}.json`);
|
|
830
|
+
}
|
|
831
|
+
function safeCacheKey(value) {
|
|
832
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
833
|
+
}
|
|
834
|
+
async function saveSessionCache(accountId, data) {
|
|
835
|
+
try {
|
|
836
|
+
const cachePath = await getSessionCachePath(accountId);
|
|
837
|
+
await fsPromises3.mkdir(nodePath3.dirname(cachePath), { recursive: true });
|
|
838
|
+
await fsPromises3.writeFile(cachePath, JSON.stringify(data), "utf-8");
|
|
839
|
+
} catch {
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async function loadSessionCache(accountId) {
|
|
843
|
+
try {
|
|
844
|
+
const cachePath = await getSessionCachePath(accountId);
|
|
845
|
+
const raw = await fsPromises3.readFile(cachePath, "utf-8");
|
|
846
|
+
return JSON.parse(raw);
|
|
847
|
+
} catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
async function getMessageWatermarksPath(accountId) {
|
|
852
|
+
const dataDir = await getDataDir();
|
|
853
|
+
return nodePath3.join(dataDir, "shadow", `message-watermarks-${safeCacheKey(accountId)}.json`);
|
|
854
|
+
}
|
|
855
|
+
async function loadMessageWatermarks(accountId) {
|
|
856
|
+
try {
|
|
857
|
+
const raw = await fsPromises3.readFile(await getMessageWatermarksPath(accountId), "utf-8");
|
|
858
|
+
const parsed = JSON.parse(raw);
|
|
859
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
860
|
+
const watermarks = {};
|
|
861
|
+
for (const [channelId, value] of Object.entries(parsed)) {
|
|
862
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
863
|
+
const record = value;
|
|
864
|
+
if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
watermarks[channelId] = {
|
|
868
|
+
createdAt: record.createdAt,
|
|
869
|
+
...typeof record.messageId === "string" ? { messageId: record.messageId } : {}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
return watermarks;
|
|
873
|
+
} catch {
|
|
874
|
+
return {};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async function saveMessageWatermarks(accountId, watermarks) {
|
|
878
|
+
try {
|
|
879
|
+
const cachePath = await getMessageWatermarksPath(accountId);
|
|
880
|
+
await fsPromises3.mkdir(nodePath3.dirname(cachePath), { recursive: true });
|
|
881
|
+
await fsPromises3.writeFile(cachePath, JSON.stringify(watermarks), "utf-8");
|
|
882
|
+
} catch {
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function getMessageCreatedMs(message) {
|
|
886
|
+
const createdMs = Date.parse(message.createdAt);
|
|
887
|
+
return Number.isFinite(createdMs) ? createdMs : null;
|
|
888
|
+
}
|
|
889
|
+
function updateMessageWatermark(watermarks, message) {
|
|
890
|
+
const createdMs = getMessageCreatedMs(message);
|
|
891
|
+
if (createdMs === null) return false;
|
|
892
|
+
const current = watermarks[message.channelId];
|
|
893
|
+
const currentMs = current ? Date.parse(current.createdAt) : Number.NaN;
|
|
894
|
+
if (Number.isFinite(currentMs) && createdMs < currentMs) return false;
|
|
895
|
+
if (current?.messageId === message.id && current.createdAt === message.createdAt) return false;
|
|
896
|
+
watermarks[message.channelId] = { createdAt: message.createdAt, messageId: message.id };
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
async function appendMonitorLog(accountId, level, message) {
|
|
900
|
+
try {
|
|
901
|
+
const dataDir = await getDataDir();
|
|
902
|
+
const logDir = nodePath3.join(dataDir, "shadow");
|
|
903
|
+
await fsPromises3.mkdir(logDir, { recursive: true });
|
|
904
|
+
const line = JSON.stringify({
|
|
905
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
906
|
+
level,
|
|
907
|
+
message
|
|
908
|
+
});
|
|
909
|
+
await fsPromises3.appendFile(
|
|
910
|
+
nodePath3.join(logDir, `monitor-${safeCacheKey(accountId)}.log`),
|
|
911
|
+
`${line}
|
|
912
|
+
`,
|
|
913
|
+
"utf-8"
|
|
914
|
+
);
|
|
915
|
+
} catch {
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/monitor/thread-bindings.ts
|
|
920
|
+
async function getThreadBindingPath(accountId) {
|
|
921
|
+
const dataDir = await getDataDir();
|
|
922
|
+
return nodePath4.join(dataDir, "shadow", `thread-bindings-${safeCacheKey(accountId)}.json`);
|
|
923
|
+
}
|
|
924
|
+
async function loadShadowThreadBindings(accountId) {
|
|
925
|
+
try {
|
|
926
|
+
const raw = await fsPromises4.readFile(await getThreadBindingPath(accountId), "utf-8");
|
|
927
|
+
const parsed = JSON.parse(raw);
|
|
928
|
+
if (!Array.isArray(parsed.bindings)) return [];
|
|
929
|
+
return parsed.bindings.filter((binding) => {
|
|
930
|
+
return typeof binding.accountId === "string" && typeof binding.agentId === "string" && typeof binding.sessionKey === "string" && typeof binding.channelId === "string" && typeof binding.updatedAt === "string";
|
|
931
|
+
});
|
|
932
|
+
} catch {
|
|
933
|
+
return [];
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async function saveShadowThreadBindings(accountId, bindings) {
|
|
937
|
+
const path = await getThreadBindingPath(accountId);
|
|
938
|
+
await fsPromises4.mkdir(nodePath4.dirname(path), { recursive: true });
|
|
939
|
+
await fsPromises4.writeFile(path, `${JSON.stringify({ bindings }, null, 2)}
|
|
940
|
+
`, "utf-8");
|
|
941
|
+
}
|
|
942
|
+
async function upsertShadowThreadBinding(params) {
|
|
943
|
+
const bindings = await loadShadowThreadBindings(params.accountId);
|
|
944
|
+
const key = `${params.agentId}:${params.sessionKey}`;
|
|
945
|
+
const next = {
|
|
946
|
+
accountId: params.accountId,
|
|
947
|
+
agentId: params.agentId,
|
|
948
|
+
sessionKey: params.sessionKey,
|
|
949
|
+
channelId: params.channelId,
|
|
950
|
+
...params.threadId ? { threadId: params.threadId } : {},
|
|
951
|
+
...params.messageId ? { messageId: params.messageId } : {},
|
|
952
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
953
|
+
};
|
|
954
|
+
const filtered = bindings.filter((binding) => `${binding.agentId}:${binding.sessionKey}` !== key);
|
|
955
|
+
const recent = [next, ...filtered].slice(0, 500);
|
|
956
|
+
await saveShadowThreadBindings(params.accountId, recent);
|
|
957
|
+
return next;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/monitor/typing.ts
|
|
961
|
+
import {
|
|
962
|
+
createTypingCallbacks
|
|
963
|
+
} from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
964
|
+
|
|
965
|
+
// src/monitor/usage-reporting.ts
|
|
966
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
967
|
+
import { homedir } from "os";
|
|
968
|
+
import { join } from "path";
|
|
969
|
+
function delay(ms) {
|
|
970
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
971
|
+
}
|
|
972
|
+
function isSafeOpenClawAgentId(agentId) {
|
|
973
|
+
return !agentId.includes("/") && !agentId.includes("\\") && !agentId.includes("..");
|
|
974
|
+
}
|
|
975
|
+
function stateDir() {
|
|
976
|
+
return process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
|
|
977
|
+
}
|
|
978
|
+
function asRecord(value) {
|
|
979
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
980
|
+
}
|
|
981
|
+
function asString(value) {
|
|
982
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
983
|
+
}
|
|
984
|
+
function asNumber(value) {
|
|
985
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
986
|
+
}
|
|
987
|
+
function parseLine(line) {
|
|
988
|
+
try {
|
|
989
|
+
return asRecord(JSON.parse(line));
|
|
990
|
+
} catch {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function readUsageNumber(usage, keys) {
|
|
995
|
+
for (const key of keys) {
|
|
996
|
+
const value = asNumber(usage[key]);
|
|
997
|
+
if (value !== null) return Math.round(value);
|
|
998
|
+
}
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
function artifactFromLine(line, sessionKey) {
|
|
1002
|
+
if (line.type !== "trace.artifacts" || line.sessionKey !== sessionKey) return null;
|
|
1003
|
+
const data = asRecord(line.data);
|
|
1004
|
+
const usage = asRecord(data?.usage);
|
|
1005
|
+
if (!usage) return null;
|
|
1006
|
+
const sessionId = asString(line.sessionId);
|
|
1007
|
+
const generatedAt = asString(line.ts) ?? asString(data?.capturedAt);
|
|
1008
|
+
const provider = asString(line.provider) ?? "openclaw";
|
|
1009
|
+
if (!sessionId || !generatedAt) return null;
|
|
1010
|
+
return {
|
|
1011
|
+
sessionId,
|
|
1012
|
+
sessionKey,
|
|
1013
|
+
runId: asString(line.runId),
|
|
1014
|
+
generatedAt,
|
|
1015
|
+
provider,
|
|
1016
|
+
model: asString(line.modelId),
|
|
1017
|
+
modelApi: asString(line.modelApi),
|
|
1018
|
+
inputTokens: readUsageNumber(usage, ["input", "inputTokens", "promptTokens"]),
|
|
1019
|
+
outputTokens: readUsageNumber(usage, ["output", "outputTokens", "completionTokens"]),
|
|
1020
|
+
cacheReadTokens: readUsageNumber(usage, ["cacheRead", "cacheReadTokens"]),
|
|
1021
|
+
cacheWriteTokens: readUsageNumber(usage, ["cacheWrite", "cacheWriteTokens"]),
|
|
1022
|
+
totalTokens: readUsageNumber(usage, ["total", "totalTokens", "total_tokens"])
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
async function listTrajectoryFiles(openClawAgentId) {
|
|
1026
|
+
if (!isSafeOpenClawAgentId(openClawAgentId)) return [];
|
|
1027
|
+
const sessionsDir = join(stateDir(), "agents", openClawAgentId, "sessions");
|
|
1028
|
+
const entries = await readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
|
1029
|
+
const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".trajectory.jsonl")).map((entry) => join(sessionsDir, entry.name));
|
|
1030
|
+
const withStats = await Promise.all(
|
|
1031
|
+
files.map(async (file) => ({
|
|
1032
|
+
file,
|
|
1033
|
+
mtimeMs: (await stat(file).catch(() => null))?.mtimeMs ?? 0
|
|
1034
|
+
}))
|
|
1035
|
+
);
|
|
1036
|
+
return withStats.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, 8).map((entry) => entry.file);
|
|
1037
|
+
}
|
|
1038
|
+
async function readLatestArtifact(params) {
|
|
1039
|
+
const files = await listTrajectoryFiles(params.openClawAgentId);
|
|
1040
|
+
for (const file of files) {
|
|
1041
|
+
const text = await readFile(file, "utf8").catch(() => "");
|
|
1042
|
+
if (!text) continue;
|
|
1043
|
+
const lines = text.split("\n").filter(Boolean);
|
|
1044
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1045
|
+
const line = parseLine(lines[index]);
|
|
1046
|
+
if (!line) continue;
|
|
1047
|
+
const artifact = artifactFromLine(line, params.sessionKey);
|
|
1048
|
+
if (!artifact) continue;
|
|
1049
|
+
if (Date.parse(artifact.generatedAt) + 1e3 < params.sinceMs) continue;
|
|
1050
|
+
return artifact;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
function costFromAssistantMessage(message) {
|
|
1056
|
+
const usage = asRecord(message.usage);
|
|
1057
|
+
const cost = asRecord(usage?.cost);
|
|
1058
|
+
return asNumber(cost?.total);
|
|
1059
|
+
}
|
|
1060
|
+
async function readAssistantCosts(openClawAgentId, sessionId) {
|
|
1061
|
+
if (!isSafeOpenClawAgentId(openClawAgentId)) return { totalUsd: null };
|
|
1062
|
+
const sessionFile = join(stateDir(), "agents", openClawAgentId, "sessions", `${sessionId}.jsonl`);
|
|
1063
|
+
const text = await readFile(sessionFile, "utf8").catch(() => "");
|
|
1064
|
+
if (!text) return { totalUsd: null };
|
|
1065
|
+
let totalUsd = 0;
|
|
1066
|
+
let foundCost = false;
|
|
1067
|
+
let sawAssistant = false;
|
|
1068
|
+
const lines = text.split("\n").filter(Boolean);
|
|
1069
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1070
|
+
const line = parseLine(lines[index]);
|
|
1071
|
+
const message = asRecord(line?.message);
|
|
1072
|
+
if (!message) continue;
|
|
1073
|
+
const role = message?.role;
|
|
1074
|
+
if (role === "user" && sawAssistant) break;
|
|
1075
|
+
if (role !== "assistant") continue;
|
|
1076
|
+
sawAssistant = true;
|
|
1077
|
+
const cost = costFromAssistantMessage(message);
|
|
1078
|
+
if (cost === null) continue;
|
|
1079
|
+
foundCost = true;
|
|
1080
|
+
totalUsd += cost;
|
|
1081
|
+
}
|
|
1082
|
+
return { totalUsd: foundCost ? totalUsd : null };
|
|
1083
|
+
}
|
|
1084
|
+
function buildSnapshot(artifact, costs) {
|
|
1085
|
+
return {
|
|
1086
|
+
source: "openclaw-trajectory",
|
|
1087
|
+
model: artifact.model,
|
|
1088
|
+
totalUsd: costs.totalUsd,
|
|
1089
|
+
inputTokens: artifact.inputTokens,
|
|
1090
|
+
outputTokens: artifact.outputTokens,
|
|
1091
|
+
cacheReadTokens: artifact.cacheReadTokens,
|
|
1092
|
+
cacheWriteTokens: artifact.cacheWriteTokens,
|
|
1093
|
+
totalTokens: artifact.totalTokens,
|
|
1094
|
+
providers: [
|
|
1095
|
+
{
|
|
1096
|
+
provider: artifact.provider,
|
|
1097
|
+
amountUsd: costs.totalUsd,
|
|
1098
|
+
usageLabel: artifact.model,
|
|
1099
|
+
inputTokens: artifact.inputTokens,
|
|
1100
|
+
outputTokens: artifact.outputTokens,
|
|
1101
|
+
totalTokens: artifact.totalTokens,
|
|
1102
|
+
raw: artifact.modelApi
|
|
1103
|
+
}
|
|
1104
|
+
],
|
|
1105
|
+
raw: {
|
|
1106
|
+
sessionId: artifact.sessionId,
|
|
1107
|
+
sessionKey: artifact.sessionKey,
|
|
1108
|
+
runId: artifact.runId,
|
|
1109
|
+
provider: artifact.provider,
|
|
1110
|
+
model: artifact.model,
|
|
1111
|
+
modelApi: artifact.modelApi,
|
|
1112
|
+
cacheReadTokens: artifact.cacheReadTokens,
|
|
1113
|
+
cacheWriteTokens: artifact.cacheWriteTokens
|
|
1114
|
+
},
|
|
1115
|
+
generatedAt: artifact.generatedAt
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async function reportShadowUsageSnapshot(params) {
|
|
1119
|
+
if (!params.shadowAgentId || !params.sessionKey) return;
|
|
1120
|
+
let artifact = null;
|
|
1121
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
1122
|
+
artifact = await readLatestArtifact({
|
|
1123
|
+
openClawAgentId: params.openClawAgentId,
|
|
1124
|
+
sessionKey: params.sessionKey,
|
|
1125
|
+
sinceMs: params.sinceMs
|
|
1126
|
+
});
|
|
1127
|
+
if (artifact) break;
|
|
1128
|
+
await delay(250);
|
|
1129
|
+
}
|
|
1130
|
+
if (!artifact) {
|
|
1131
|
+
params.runtime.log?.("[usage] No fresh OpenClaw usage artifact found for this dispatch");
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const costs = await readAssistantCosts(params.openClawAgentId, artifact.sessionId);
|
|
1135
|
+
await params.client.reportAgentUsageSnapshot(params.shadowAgentId, buildSnapshot(artifact, costs));
|
|
1136
|
+
params.runtime.log?.(
|
|
1137
|
+
`[usage] Reported ${artifact.totalTokens ?? 0} token(s) for Shadow agent ${params.shadowAgentId}`
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/monitor/channel-message.ts
|
|
1142
|
+
function buildChannelContextForAgent(info, channelId) {
|
|
1143
|
+
if (!info) return `Shadow channel id: ${channelId}`;
|
|
1144
|
+
return [
|
|
1145
|
+
`Shadow server: ${info.serverName}`,
|
|
1146
|
+
`Shadow server slug: ${info.serverSlug}`,
|
|
1147
|
+
`Shadow channel: #${info.channelName}`,
|
|
1148
|
+
`Shadow channel id: ${channelId}`
|
|
1149
|
+
].join("\n");
|
|
1150
|
+
}
|
|
1151
|
+
async function processShadowMessage(params) {
|
|
1152
|
+
const {
|
|
1153
|
+
message,
|
|
1154
|
+
account,
|
|
1155
|
+
accountId,
|
|
1156
|
+
config,
|
|
1157
|
+
runtime,
|
|
1158
|
+
core,
|
|
1159
|
+
botUserId,
|
|
1160
|
+
botUsername,
|
|
1161
|
+
agentId,
|
|
1162
|
+
channelPolicies,
|
|
1163
|
+
channelServerMap,
|
|
1164
|
+
slashCommands,
|
|
1165
|
+
socket
|
|
1166
|
+
} = params;
|
|
1167
|
+
const cfg = config;
|
|
1168
|
+
const preflight = evaluateShadowMessagePreflight({
|
|
1169
|
+
message,
|
|
1170
|
+
botUserId,
|
|
1171
|
+
botUsername,
|
|
1172
|
+
channelPolicies,
|
|
1173
|
+
runtime
|
|
1174
|
+
});
|
|
1175
|
+
if (!preflight.ok) {
|
|
1176
|
+
runtime.log?.(preflight.reason);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const { senderLabel } = preflight;
|
|
1180
|
+
const channelId = message.channelId;
|
|
1181
|
+
runtime.log?.(
|
|
1182
|
+
`[msg] Processing message from ${senderLabel}: "${message.content.slice(0, 80)}" (${message.id})`
|
|
1183
|
+
);
|
|
1184
|
+
const senderName = message.author?.displayName ?? message.author?.username ?? "Unknown";
|
|
1185
|
+
const senderUsername = message.author?.username ?? "";
|
|
1186
|
+
const senderId = message.authorId;
|
|
1187
|
+
const rawBody = message.content;
|
|
1188
|
+
const chatType = message.threadId ? "thread" : "channel";
|
|
1189
|
+
const peerId = message.threadId ? `${channelId}:thread:${message.threadId}` : channelId;
|
|
1190
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
1191
|
+
cfg,
|
|
1192
|
+
channel: "shadowob",
|
|
1193
|
+
accountId,
|
|
1194
|
+
peer: { kind: "group", id: peerId }
|
|
1195
|
+
});
|
|
1196
|
+
runtime.log?.(`[routing] Resolved agent: ${route.agentId} (account ${accountId})`);
|
|
1197
|
+
const mediaClient = new ShadowClient2(account.serverUrl, account.token);
|
|
1198
|
+
const mediaContext = await resolveShadowInboundMediaContext({
|
|
1199
|
+
account,
|
|
1200
|
+
message,
|
|
1201
|
+
rawBody,
|
|
1202
|
+
runtime
|
|
1203
|
+
});
|
|
1204
|
+
const cleanBody = mediaContext.cleanBody;
|
|
1205
|
+
const interactiveResponseContext = await buildInteractiveResponseContext({
|
|
1206
|
+
message,
|
|
1207
|
+
client: mediaClient,
|
|
1208
|
+
runtime,
|
|
1209
|
+
slashCommands
|
|
1210
|
+
});
|
|
1211
|
+
const slashCommandMatch = matchShadowSlashCommand(cleanBody, slashCommands);
|
|
1212
|
+
if (slashCommandMatch) {
|
|
1213
|
+
runtime.log?.(
|
|
1214
|
+
`[slash] Matched /${slashCommandMatch.invokedName} -> /${slashCommandMatch.command.name}`
|
|
1215
|
+
);
|
|
1216
|
+
} else if (cleanBody.trim().startsWith("/")) {
|
|
1217
|
+
runtime.log?.(`[slash] Unknown slash command in message ${message.id}; treating as text`);
|
|
1218
|
+
}
|
|
1219
|
+
const triggerChain = message.metadata?.agentChain;
|
|
1220
|
+
if (slashCommandMatch?.command.interaction && !slashCommandMatch.args.trim() && !interactiveResponseContext.text) {
|
|
1221
|
+
await sendSlashCommandInteractivePrompt({
|
|
1222
|
+
match: slashCommandMatch,
|
|
1223
|
+
messageId: message.id,
|
|
1224
|
+
channelId,
|
|
1225
|
+
threadId: message.threadId ?? void 0,
|
|
1226
|
+
client: mediaClient,
|
|
1227
|
+
runtime,
|
|
1228
|
+
agentId,
|
|
1229
|
+
botUserId,
|
|
1230
|
+
agentChain: triggerChain
|
|
1231
|
+
});
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const baseBodyForAgent = slashCommandMatch ? formatSlashCommandPrompt(cleanBody, slashCommandMatch) : cleanBody;
|
|
1235
|
+
const structuredMentions = getShadowMessageMentions(message);
|
|
1236
|
+
const mentionContext = formatShadowMentionsForAgent(structuredMentions);
|
|
1237
|
+
const serverInfo = channelServerMap.get(channelId);
|
|
1238
|
+
const channelLabel = serverInfo ? `#${serverInfo.channelName}` : `channel:${channelId}`;
|
|
1239
|
+
const conversationLabel = serverInfo ? `${serverInfo.serverName} ${channelLabel}` : peerId;
|
|
1240
|
+
const messageBodyForAgent = interactiveResponseContext.text ? `${interactiveResponseContext.text}
|
|
1241
|
+
|
|
1242
|
+
User message:
|
|
1243
|
+
${baseBodyForAgent}` : baseBodyForAgent;
|
|
1244
|
+
const client = new ShadowClient2(account.serverUrl, account.token);
|
|
1245
|
+
const viewerCommerceContext = await buildCommerceViewerContextForAgent({
|
|
1246
|
+
account,
|
|
1247
|
+
client,
|
|
1248
|
+
viewerUserId: senderId
|
|
1249
|
+
});
|
|
1250
|
+
const bodyForAgent = [
|
|
1251
|
+
buildChannelContextForAgent(serverInfo, channelId),
|
|
1252
|
+
buildCommerceContextForAgent(account),
|
|
1253
|
+
viewerCommerceContext,
|
|
1254
|
+
mentionContext,
|
|
1255
|
+
messageBodyForAgent
|
|
1256
|
+
].filter(Boolean).join("\n\n");
|
|
1257
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
1258
|
+
channel: serverInfo ? `Shadow ${channelLabel}` : "Shadow",
|
|
1259
|
+
from: senderName,
|
|
1260
|
+
timestamp: new Date(message.createdAt).getTime(),
|
|
1261
|
+
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
1262
|
+
body: bodyForAgent
|
|
1263
|
+
});
|
|
1264
|
+
const escapedBotUsername = botUsername.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1265
|
+
const mentionRegex = new RegExp(`@${escapedBotUsername}(?:\\s|$)`, "i");
|
|
1266
|
+
const wasMentioned = mentionTargetsBot({ mentions: structuredMentions, botUserId, botUsername }) || mentionRegex.test(message.content);
|
|
1267
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
1268
|
+
Body: body,
|
|
1269
|
+
BodyForAgent: bodyForAgent,
|
|
1270
|
+
RawBody: rawBody,
|
|
1271
|
+
CommandBody: slashCommandMatch?.args ?? cleanBody,
|
|
1272
|
+
From: `shadowob:user:${senderId}`,
|
|
1273
|
+
To: `shadowob:channel:${channelId}`,
|
|
1274
|
+
SessionKey: route.sessionKey,
|
|
1275
|
+
AccountId: route.accountId,
|
|
1276
|
+
ChatType: chatType,
|
|
1277
|
+
ConversationLabel: conversationLabel,
|
|
1278
|
+
SenderName: senderName,
|
|
1279
|
+
SenderId: senderId,
|
|
1280
|
+
SenderUsername: senderUsername,
|
|
1281
|
+
Provider: "shadowob",
|
|
1282
|
+
Surface: "shadowob",
|
|
1283
|
+
MessageSid: message.id,
|
|
1284
|
+
WasMentioned: wasMentioned,
|
|
1285
|
+
...mentionContextFields(structuredMentions),
|
|
1286
|
+
OriginatingChannel: "shadowob",
|
|
1287
|
+
OriginatingTo: `shadowob:channel:${channelId}`,
|
|
1288
|
+
...serverInfo ? {
|
|
1289
|
+
ServerId: serverInfo.serverId,
|
|
1290
|
+
ServerSlug: serverInfo.serverSlug,
|
|
1291
|
+
ServerName: serverInfo.serverName,
|
|
1292
|
+
ChannelName: serverInfo.channelName,
|
|
1293
|
+
ChannelLabel: channelLabel
|
|
1294
|
+
} : {},
|
|
1295
|
+
BotUserId: botUserId,
|
|
1296
|
+
BotUsername: botUsername,
|
|
1297
|
+
AgentId: route.agentId,
|
|
1298
|
+
ChannelId: channelId,
|
|
1299
|
+
...slashCommandMatch ? {
|
|
1300
|
+
SlashCommand: `/${slashCommandMatch.command.name}`,
|
|
1301
|
+
SlashCommandName: slashCommandMatch.command.name,
|
|
1302
|
+
SlashCommandInvokedName: slashCommandMatch.invokedName,
|
|
1303
|
+
SlashCommandArgs: slashCommandMatch.args,
|
|
1304
|
+
...slashCommandMatch.command.description ? { SlashCommandDescription: slashCommandMatch.command.description } : {},
|
|
1305
|
+
...slashCommandMatch.command.packId ? { SlashCommandPackId: slashCommandMatch.command.packId } : {},
|
|
1306
|
+
...slashCommandMatch.command.sourcePath ? { SlashCommandSourcePath: slashCommandMatch.command.sourcePath } : {},
|
|
1307
|
+
...slashCommandMatch.command.body ? { SlashCommandDefinition: slashCommandMatch.command.body } : {}
|
|
1308
|
+
} : {},
|
|
1309
|
+
...account.buddyName ? { BuddyName: account.buddyName } : {},
|
|
1310
|
+
...account.buddyId ? { BuddyId: account.buddyId } : {},
|
|
1311
|
+
...account.buddyDescription ? { BuddyDescription: account.buddyDescription } : {},
|
|
1312
|
+
...commerceContextFields(account),
|
|
1313
|
+
...message.threadId ? { ThreadId: message.threadId } : {},
|
|
1314
|
+
...message.replyToId ? { ReplyToId: message.replyToId } : {},
|
|
1315
|
+
...interactiveResponseContext.fields,
|
|
1316
|
+
...mediaContext.fields
|
|
1317
|
+
});
|
|
1318
|
+
const storePath = core.channel.session.resolveStorePath(resolveSessionStore(cfg), {
|
|
1319
|
+
agentId: route.agentId
|
|
1320
|
+
});
|
|
1321
|
+
await core.channel.session.recordInboundSession({
|
|
1322
|
+
storePath,
|
|
1323
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
1324
|
+
ctx: ctxPayload,
|
|
1325
|
+
onRecordError: (err) => {
|
|
1326
|
+
runtime.error?.(`Failed updating session meta: ${String(err)}`);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
const bindingSessionKey = typeof ctxPayload.SessionKey === "string" ? ctxPayload.SessionKey : route.sessionKey;
|
|
1330
|
+
if (route.agentId && bindingSessionKey) {
|
|
1331
|
+
await upsertShadowThreadBinding({
|
|
1332
|
+
accountId,
|
|
1333
|
+
agentId: route.agentId,
|
|
1334
|
+
sessionKey: bindingSessionKey,
|
|
1335
|
+
channelId,
|
|
1336
|
+
...message.threadId ? { threadId: message.threadId } : {},
|
|
1337
|
+
messageId: message.id
|
|
1338
|
+
}).catch((err) => {
|
|
1339
|
+
runtime.error?.(`[session] Failed updating thread binding: ${String(err)}`);
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
runtime.log?.(`[msg] Dispatching to AI pipeline for message ${message.id}`);
|
|
1343
|
+
const typingCbs = createTypingCallbacks({
|
|
1344
|
+
start: async () => {
|
|
1345
|
+
socket.sendTyping(channelId);
|
|
1346
|
+
},
|
|
1347
|
+
stop: async () => {
|
|
1348
|
+
socket.sendTyping(channelId, false);
|
|
1349
|
+
},
|
|
1350
|
+
onStartError: (err) => {
|
|
1351
|
+
runtime.error?.(`[typing] Failed to send typing indicator: ${String(err)}`);
|
|
1352
|
+
},
|
|
1353
|
+
onStopError: (err) => {
|
|
1354
|
+
runtime.error?.(`[typing] Failed to clear typing indicator: ${String(err)}`);
|
|
1355
|
+
},
|
|
1356
|
+
maxDurationMs: 12e4
|
|
1357
|
+
});
|
|
1358
|
+
socket.updateActivity(channelId, "thinking");
|
|
1359
|
+
const activityTimeout = setTimeout(() => {
|
|
1360
|
+
runtime.log?.(`[activity] Clearing stale activity for message ${message.id}`);
|
|
1361
|
+
socket.updateActivity(channelId, null);
|
|
1362
|
+
}, 12e4);
|
|
1363
|
+
try {
|
|
1364
|
+
const dispatchAgentId = route.agentId || agentId;
|
|
1365
|
+
if (!dispatchAgentId) {
|
|
1366
|
+
runtime.error?.(`[msg] Cannot dispatch ${message.id}: no OpenClaw agent resolved`);
|
|
1367
|
+
socket.updateActivity(channelId, null);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
const replyPipeline = createChannelReplyPipeline({
|
|
1371
|
+
cfg,
|
|
1372
|
+
agentId: dispatchAgentId,
|
|
1373
|
+
channel: "shadowob",
|
|
1374
|
+
accountId,
|
|
1375
|
+
typingCallbacks: typingCbs
|
|
1376
|
+
});
|
|
1377
|
+
const dispatchStartedAt = Date.now();
|
|
1378
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1379
|
+
ctx: ctxPayload,
|
|
1380
|
+
cfg,
|
|
1381
|
+
replyOptions: {
|
|
1382
|
+
sourceReplyDeliveryMode: "automatic"
|
|
1383
|
+
},
|
|
1384
|
+
dispatcherOptions: {
|
|
1385
|
+
...replyPipeline,
|
|
1386
|
+
deliver: async (payload) => {
|
|
1387
|
+
socket.updateActivity(channelId, "working");
|
|
1388
|
+
await deliverShadowReply({
|
|
1389
|
+
payload,
|
|
1390
|
+
channelId,
|
|
1391
|
+
threadId: message.threadId ?? void 0,
|
|
1392
|
+
replyToId: message.id,
|
|
1393
|
+
client,
|
|
1394
|
+
runtime,
|
|
1395
|
+
agentChain: triggerChain,
|
|
1396
|
+
agentId: dispatchAgentId,
|
|
1397
|
+
botUserId
|
|
1398
|
+
});
|
|
1399
|
+
},
|
|
1400
|
+
onError: (err, info) => {
|
|
1401
|
+
runtime.error?.(
|
|
1402
|
+
`[msg] Reply delivery failed for ${message.id} (${info.kind}): ${String(err)}`
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
await reportShadowUsageSnapshot({
|
|
1408
|
+
client,
|
|
1409
|
+
shadowAgentId: agentId,
|
|
1410
|
+
openClawAgentId: dispatchAgentId,
|
|
1411
|
+
sessionKey: bindingSessionKey,
|
|
1412
|
+
runtime,
|
|
1413
|
+
sinceMs: dispatchStartedAt
|
|
1414
|
+
}).catch((err) => {
|
|
1415
|
+
runtime.error?.(`[usage] Failed to report usage snapshot for ${message.id}: ${String(err)}`);
|
|
1416
|
+
});
|
|
1417
|
+
socket.updateActivity(channelId, "ready");
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
runtime.error?.(`[msg] AI dispatch failed for message ${message.id}: ${String(err)}`);
|
|
1420
|
+
socket.updateActivity(channelId, null);
|
|
1421
|
+
throw err;
|
|
1422
|
+
} finally {
|
|
1423
|
+
clearTimeout(activityTimeout);
|
|
1424
|
+
typingCbs.onCleanup?.();
|
|
1425
|
+
setTimeout(() => {
|
|
1426
|
+
socket.updateActivity(channelId, null);
|
|
1427
|
+
}, 3e3);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// src/runtime.ts
|
|
1432
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
1433
|
+
var store = createPluginRuntimeStore(
|
|
1434
|
+
"Shadow runtime not initialized \u2014 plugin not registered yet"
|
|
1435
|
+
);
|
|
1436
|
+
var setShadowRuntime = store.setRuntime;
|
|
1437
|
+
var getShadowRuntime = store.getRuntime;
|
|
1438
|
+
var tryGetShadowRuntime = store.tryGetRuntime;
|
|
1439
|
+
|
|
1440
|
+
// src/monitor.ts
|
|
1441
|
+
function resolveShadowAgentIdFromConfig(config, accountId) {
|
|
1442
|
+
const cfg = config;
|
|
1443
|
+
const routeBinding = cfg.bindings?.find((binding) => {
|
|
1444
|
+
return binding.match?.channel === "shadowob" && binding.match.accountId === accountId;
|
|
1445
|
+
});
|
|
1446
|
+
if (typeof routeBinding?.agentId === "string" && routeBinding.agentId.trim()) {
|
|
1447
|
+
return routeBinding.agentId;
|
|
1448
|
+
}
|
|
1449
|
+
const defaultAgent = cfg.agents?.list?.find((agent) => agent.default) ?? cfg.agents?.list?.[0];
|
|
1450
|
+
return typeof defaultAgent?.id === "string" && defaultAgent.id.trim() ? defaultAgent.id : null;
|
|
1451
|
+
}
|
|
1452
|
+
var RECENT_MESSAGE_CATCHUP_WINDOW_MS = 30 * 60 * 1e3;
|
|
1453
|
+
var MAX_TRACKED_MESSAGE_IDS = 1e3;
|
|
1454
|
+
var SHADOW_API_RETRY_ATTEMPTS = 5;
|
|
1455
|
+
var SHADOW_API_RETRY_DELAY_MS = 750;
|
|
1456
|
+
function getMessageCreatedMs2(message) {
|
|
1457
|
+
const createdMs = Date.parse(message.createdAt);
|
|
1458
|
+
return Number.isFinite(createdMs) ? createdMs : null;
|
|
1459
|
+
}
|
|
1460
|
+
function delay2(ms, abortSignal) {
|
|
1461
|
+
if (!abortSignal) return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1462
|
+
if (abortSignal.aborted) return Promise.resolve();
|
|
1463
|
+
const signal = abortSignal;
|
|
1464
|
+
return new Promise((resolve) => {
|
|
1465
|
+
const timeout = setTimeout(done, ms);
|
|
1466
|
+
function done() {
|
|
1467
|
+
clearTimeout(timeout);
|
|
1468
|
+
signal.removeEventListener("abort", done);
|
|
1469
|
+
resolve();
|
|
1470
|
+
}
|
|
1471
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
async function runShadowApiOperation(label, operation, options) {
|
|
1475
|
+
const attempts = options.attempts ?? SHADOW_API_RETRY_ATTEMPTS;
|
|
1476
|
+
const delayMs = options.delayMs ?? SHADOW_API_RETRY_DELAY_MS;
|
|
1477
|
+
let lastError;
|
|
1478
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1479
|
+
if (options.abortSignal?.aborted) {
|
|
1480
|
+
throw lastError ?? new Error(`${label} aborted`);
|
|
1481
|
+
}
|
|
1482
|
+
try {
|
|
1483
|
+
return await operation();
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
lastError = err;
|
|
1486
|
+
if (attempt >= attempts) break;
|
|
1487
|
+
const waitMs = delayMs * attempt;
|
|
1488
|
+
options.runtime.error?.(
|
|
1489
|
+
`[shadow-api] ${label} failed (attempt ${attempt}/${attempts}): ${String(
|
|
1490
|
+
err
|
|
1491
|
+
)}; retrying in ${waitMs}ms`
|
|
1492
|
+
);
|
|
1493
|
+
await delay2(waitMs, options.abortSignal);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
throw lastError;
|
|
1497
|
+
}
|
|
1498
|
+
function shouldCatchUpShadowMessage(message, options) {
|
|
1499
|
+
if (message.authorId === options.botUserId) return false;
|
|
1500
|
+
if (options.processedMessageIds?.has(message.id)) return false;
|
|
1501
|
+
const createdMs = getMessageCreatedMs2(message);
|
|
1502
|
+
if (createdMs === null) return false;
|
|
1503
|
+
const watermark = options.watermarks?.[message.channelId];
|
|
1504
|
+
const watermarkMs = watermark ? Date.parse(watermark.createdAt) : Number.NaN;
|
|
1505
|
+
if (Number.isFinite(watermarkMs)) {
|
|
1506
|
+
return createdMs > watermarkMs;
|
|
1507
|
+
}
|
|
1508
|
+
return createdMs >= options.startedAtMs - (options.catchupWindowMs ?? RECENT_MESSAGE_CATCHUP_WINDOW_MS);
|
|
1509
|
+
}
|
|
1510
|
+
async function monitorShadowProvider(options) {
|
|
1511
|
+
const { account, accountId, config, abortSignal } = options;
|
|
1512
|
+
const runtime = {
|
|
1513
|
+
log: (msg) => {
|
|
1514
|
+
options.runtime.log?.(msg);
|
|
1515
|
+
void appendMonitorLog(accountId, "info", msg);
|
|
1516
|
+
},
|
|
1517
|
+
error: (msg) => {
|
|
1518
|
+
options.runtime.error?.(msg);
|
|
1519
|
+
void appendMonitorLog(accountId, "error", msg);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
const core = options.channelRuntime ? { channel: options.channelRuntime } : getShadowRuntime();
|
|
1523
|
+
let stopped = false;
|
|
1524
|
+
const monitorStartedAtMs = Date.now();
|
|
1525
|
+
const client = new ShadowClient3(account.serverUrl, account.token);
|
|
1526
|
+
const me = await client.getMe();
|
|
1527
|
+
const botUserId = me.id;
|
|
1528
|
+
runtime.log?.(`Shadow bot connected as ${me.username} (${botUserId})`);
|
|
1529
|
+
const agentId = account.agentId ?? me.agentId ?? resolveShadowAgentIdFromConfig(config, accountId);
|
|
1530
|
+
if (!agentId) {
|
|
1531
|
+
runtime.error?.(
|
|
1532
|
+
"[config] Cannot resolve agentId \u2014 heartbeat and remote config will be unavailable"
|
|
1533
|
+
);
|
|
1534
|
+
} else {
|
|
1535
|
+
runtime.log?.(`[config] Resolved agentId: ${agentId}`);
|
|
1536
|
+
}
|
|
1537
|
+
const slashCommands = await loadLocalSlashCommands(runtime);
|
|
1538
|
+
if (agentId) {
|
|
1539
|
+
try {
|
|
1540
|
+
await runShadowApiOperation(
|
|
1541
|
+
"register slash commands",
|
|
1542
|
+
() => registerAgentSlashCommands({ account, agentId, commands: slashCommands }),
|
|
1543
|
+
{ runtime, abortSignal }
|
|
1544
|
+
);
|
|
1545
|
+
runtime.log?.(`[slash] Registered ${slashCommands.length} slash command(s) with Shadow`);
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
runtime.error?.(`[slash] Failed to register slash commands: ${String(err)}`);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
let remoteConfig = null;
|
|
1551
|
+
const channelPolicies = /* @__PURE__ */ new Map();
|
|
1552
|
+
const channelServerMap = /* @__PURE__ */ new Map();
|
|
1553
|
+
const allChannelIds = [];
|
|
1554
|
+
const messageWatermarks = await loadMessageWatermarks(accountId);
|
|
1555
|
+
const processedMessageIds = /* @__PURE__ */ new Set();
|
|
1556
|
+
const catchupInFlight = /* @__PURE__ */ new Map();
|
|
1557
|
+
const rememberProcessedMessage = (messageId) => {
|
|
1558
|
+
processedMessageIds.add(messageId);
|
|
1559
|
+
if (processedMessageIds.size > MAX_TRACKED_MESSAGE_IDS) {
|
|
1560
|
+
const first = processedMessageIds.values().next().value;
|
|
1561
|
+
if (first) processedMessageIds.delete(first);
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
const rememberChannelContext = (channel, server) => {
|
|
1565
|
+
if (channel.kind === "dm" || !channel.serverId) return;
|
|
1566
|
+
channelServerMap.set(channel.id, {
|
|
1567
|
+
serverId: channel.serverId,
|
|
1568
|
+
serverSlug: server?.slug ?? channel.serverId,
|
|
1569
|
+
serverName: server?.name ?? channel.serverId,
|
|
1570
|
+
channelName: channel.name
|
|
1571
|
+
});
|
|
1572
|
+
};
|
|
1573
|
+
const resolveChannelContext = async (channelId, reason) => {
|
|
1574
|
+
if (channelServerMap.has(channelId)) return true;
|
|
1575
|
+
try {
|
|
1576
|
+
const channel = await runShadowApiOperation(
|
|
1577
|
+
`fetch channel context (${reason})`,
|
|
1578
|
+
() => client.getChannel(channelId),
|
|
1579
|
+
{ runtime, abortSignal }
|
|
1580
|
+
);
|
|
1581
|
+
if (channel.kind === "dm" || !channel.serverId) {
|
|
1582
|
+
runtime.log?.(`[config] Resolved direct channel context: ${channelId}`);
|
|
1583
|
+
return true;
|
|
1584
|
+
}
|
|
1585
|
+
const serverId = channel.serverId;
|
|
1586
|
+
let server = null;
|
|
1587
|
+
try {
|
|
1588
|
+
server = await runShadowApiOperation(
|
|
1589
|
+
`fetch server context for channel ${channelId}`,
|
|
1590
|
+
() => client.getServer(serverId),
|
|
1591
|
+
{ runtime, abortSignal }
|
|
1592
|
+
);
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
runtime.error?.(`[config] Failed to fetch server context for ${channelId}: ${String(err)}`);
|
|
1595
|
+
}
|
|
1596
|
+
rememberChannelContext(channel, server);
|
|
1597
|
+
const serverLabel = server?.name ?? channel.serverId;
|
|
1598
|
+
runtime.log?.(`[config] Resolved channel context: ${serverLabel} #${channel.name}`);
|
|
1599
|
+
return true;
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
runtime.error?.(`[config] Failed to resolve channel context for ${channelId}: ${String(err)}`);
|
|
1602
|
+
return false;
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
if (agentId) {
|
|
1606
|
+
try {
|
|
1607
|
+
remoteConfig = await runShadowApiOperation(
|
|
1608
|
+
"fetch remote config",
|
|
1609
|
+
() => client.getAgentConfig(agentId),
|
|
1610
|
+
{ runtime, abortSignal }
|
|
1611
|
+
);
|
|
1612
|
+
runtime.log?.(`[config] Fetched remote config: ${remoteConfig.servers.length} server(s)`);
|
|
1613
|
+
for (const server of remoteConfig.servers) {
|
|
1614
|
+
runtime.log?.(
|
|
1615
|
+
`[config] Server "${server.name}" (${server.id}) \u2014 ${server.channels.length} channel(s)`
|
|
1616
|
+
);
|
|
1617
|
+
for (const ch of server.channels) {
|
|
1618
|
+
channelPolicies.set(ch.id, ch.policy);
|
|
1619
|
+
channelServerMap.set(ch.id, {
|
|
1620
|
+
serverId: server.id,
|
|
1621
|
+
serverSlug: server.slug ?? server.id,
|
|
1622
|
+
serverName: server.name,
|
|
1623
|
+
channelName: ch.name
|
|
1624
|
+
});
|
|
1625
|
+
if (ch.policy.listen) {
|
|
1626
|
+
allChannelIds.push(ch.id);
|
|
1627
|
+
runtime.log?.(
|
|
1628
|
+
`[config] \u2713 #${ch.name} (${ch.id}) \u2014 listen=true reply=${ch.policy.reply} mentionOnly=${ch.policy.mentionOnly}`
|
|
1629
|
+
);
|
|
1630
|
+
} else {
|
|
1631
|
+
runtime.log?.(`[config] \u2717 #${ch.name} (${ch.id}) \u2014 listen=false, skipping`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
runtime.log?.(
|
|
1636
|
+
`[config] Monitoring ${allChannelIds.length} channel(s) across ${remoteConfig.servers.length} server(s)`
|
|
1637
|
+
);
|
|
1638
|
+
void saveSessionCache(accountId, { remoteConfig, botUserId, botUsername: me.username });
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
runtime.error?.(`[config] Failed to fetch remote config: ${String(err)}`);
|
|
1641
|
+
const cached = await loadSessionCache(accountId);
|
|
1642
|
+
if (cached) {
|
|
1643
|
+
runtime.log?.("[config] Loaded session from cache \u2014 using cached config");
|
|
1644
|
+
remoteConfig = cached.remoteConfig;
|
|
1645
|
+
for (const server of remoteConfig.servers) {
|
|
1646
|
+
for (const ch of server.channels) {
|
|
1647
|
+
channelPolicies.set(ch.id, ch.policy);
|
|
1648
|
+
channelServerMap.set(ch.id, {
|
|
1649
|
+
serverId: server.id,
|
|
1650
|
+
serverSlug: server.slug ?? server.id,
|
|
1651
|
+
serverName: server.name,
|
|
1652
|
+
channelName: ch.name
|
|
1653
|
+
});
|
|
1654
|
+
if (ch.policy.listen) allChannelIds.push(ch.id);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
runtime.log?.(`[config] Restored ${allChannelIds.length} channel(s) from cache`);
|
|
1658
|
+
} else {
|
|
1659
|
+
runtime.log?.("[config] No cached session \u2014 falling back to monitoring no channels");
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
try {
|
|
1664
|
+
const directChannels = await runShadowApiOperation(
|
|
1665
|
+
"fetch direct channels",
|
|
1666
|
+
() => client.listDirectChannels(),
|
|
1667
|
+
{ runtime, abortSignal }
|
|
1668
|
+
);
|
|
1669
|
+
for (const ch of directChannels) {
|
|
1670
|
+
if (!allChannelIds.includes(ch.id)) allChannelIds.push(ch.id);
|
|
1671
|
+
if (!channelPolicies.has(ch.id)) {
|
|
1672
|
+
channelPolicies.set(ch.id, {
|
|
1673
|
+
listen: true,
|
|
1674
|
+
reply: true,
|
|
1675
|
+
mentionOnly: false,
|
|
1676
|
+
config: {}
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
runtime.log?.(`[config] Monitoring ${directChannels.length} direct channel(s)`);
|
|
1681
|
+
} catch (err) {
|
|
1682
|
+
runtime.error?.(`[config] Failed to fetch direct channels: ${String(err)}`);
|
|
1683
|
+
}
|
|
1684
|
+
let heartbeatInterval = null;
|
|
1685
|
+
if (agentId) {
|
|
1686
|
+
const sendHeartbeat = async () => {
|
|
1687
|
+
try {
|
|
1688
|
+
await client.sendHeartbeat(agentId);
|
|
1689
|
+
runtime.log?.("[heartbeat] Heartbeat sent");
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
runtime.error?.(`[heartbeat] Heartbeat failed: ${String(err)}`);
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
void sendHeartbeat();
|
|
1695
|
+
heartbeatInterval = setInterval(sendHeartbeat, 3e4);
|
|
1696
|
+
}
|
|
1697
|
+
runtime.log?.(`[ws] Connecting to Shadow WebSocket at ${account.serverUrl}`);
|
|
1698
|
+
const socket = new ShadowSocket2({
|
|
1699
|
+
serverUrl: account.serverUrl,
|
|
1700
|
+
token: account.token,
|
|
1701
|
+
transports: ["websocket", "polling"]
|
|
1702
|
+
});
|
|
1703
|
+
const processChannelMessageWithRetry = async (message, source, attempt = 0) => {
|
|
1704
|
+
try {
|
|
1705
|
+
await resolveChannelContext(message.channelId, `${source} message`);
|
|
1706
|
+
await processShadowMessage({
|
|
1707
|
+
message,
|
|
1708
|
+
account,
|
|
1709
|
+
accountId,
|
|
1710
|
+
config,
|
|
1711
|
+
runtime,
|
|
1712
|
+
core,
|
|
1713
|
+
botUserId,
|
|
1714
|
+
botUsername: me.username,
|
|
1715
|
+
agentId,
|
|
1716
|
+
channelPolicies,
|
|
1717
|
+
channelServerMap,
|
|
1718
|
+
slashCommands,
|
|
1719
|
+
socket
|
|
1720
|
+
});
|
|
1721
|
+
if (updateMessageWatermark(messageWatermarks, message)) {
|
|
1722
|
+
void saveMessageWatermarks(accountId, messageWatermarks);
|
|
1723
|
+
}
|
|
1724
|
+
} catch (err) {
|
|
1725
|
+
const MAX_RETRIES = 2;
|
|
1726
|
+
runtime.error?.(
|
|
1727
|
+
`[${source}] Message processing failed (attempt ${attempt + 1}): ${String(err)}`
|
|
1728
|
+
);
|
|
1729
|
+
if (attempt < MAX_RETRIES) {
|
|
1730
|
+
await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
|
|
1731
|
+
return processChannelMessageWithRetry(message, source, attempt + 1);
|
|
1732
|
+
}
|
|
1733
|
+
runtime.error?.(
|
|
1734
|
+
`[${source}] Message permanently failed after ${MAX_RETRIES + 1} attempts: ${message.id}`
|
|
1735
|
+
);
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
const catchUpChannel = async (channelId, reason) => {
|
|
1739
|
+
try {
|
|
1740
|
+
const result = await client.getMessages(channelId, 50);
|
|
1741
|
+
if (!messageWatermarks[channelId]) {
|
|
1742
|
+
const latestBotMessage = [...result.messages].reverse().find((message) => message.authorId === botUserId);
|
|
1743
|
+
if (latestBotMessage && updateMessageWatermark(messageWatermarks, latestBotMessage)) {
|
|
1744
|
+
void saveMessageWatermarks(accountId, messageWatermarks);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
const candidates = result.messages.filter(
|
|
1748
|
+
(message) => shouldCatchUpShadowMessage(message, {
|
|
1749
|
+
botUserId,
|
|
1750
|
+
processedMessageIds,
|
|
1751
|
+
startedAtMs: monitorStartedAtMs,
|
|
1752
|
+
watermarks: messageWatermarks
|
|
1753
|
+
})
|
|
1754
|
+
);
|
|
1755
|
+
if (candidates.length > 0) {
|
|
1756
|
+
runtime.log?.(
|
|
1757
|
+
`[catchup] Replaying ${candidates.length} missed message(s) in channel ${channelId} (${reason})`
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
for (const message of candidates) {
|
|
1761
|
+
rememberProcessedMessage(message.id);
|
|
1762
|
+
await processChannelMessageWithRetry(message, "catchup");
|
|
1763
|
+
}
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
runtime.error?.(`[catchup] Failed for channel ${channelId}: ${String(err)}`);
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
const enqueueChannelCatchup = (channelId, reason) => {
|
|
1769
|
+
if (catchupInFlight.has(channelId)) return;
|
|
1770
|
+
const task = catchUpChannel(channelId, reason).finally(() => {
|
|
1771
|
+
catchupInFlight.delete(channelId);
|
|
1772
|
+
});
|
|
1773
|
+
catchupInFlight.set(channelId, task);
|
|
1774
|
+
};
|
|
1775
|
+
socket.onConnect(() => {
|
|
1776
|
+
runtime.log?.(`[ws] Connected (sid=${socket.raw.id})`);
|
|
1777
|
+
if (allChannelIds.length === 0) {
|
|
1778
|
+
runtime.log?.("[ws] No channels to join \u2014 allChannelIds is empty");
|
|
1779
|
+
runtime.log?.("[ws] Shadow channel monitor ready (no channels configured)");
|
|
1780
|
+
}
|
|
1781
|
+
for (const chId of allChannelIds) {
|
|
1782
|
+
runtime.log?.(`[ws] Emitting channel:join for ${chId}`);
|
|
1783
|
+
socket.joinChannel(chId).then((ack) => {
|
|
1784
|
+
if (ack?.ok) {
|
|
1785
|
+
runtime.log?.(`[ws] \u2713 Joined channel room ${chId} (server confirmed)`);
|
|
1786
|
+
runtime.log?.("[ws] Shadow channel monitor ready");
|
|
1787
|
+
} else {
|
|
1788
|
+
runtime.log?.(`[ws] channel:join for ${chId} \u2014 no ack received (older server?)`);
|
|
1789
|
+
runtime.log?.("[ws] Shadow channel monitor ready");
|
|
1790
|
+
}
|
|
1791
|
+
enqueueChannelCatchup(chId, "connect");
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
runtime.log?.(
|
|
1795
|
+
`[ws] Emitted channel:join for ${allChannelIds.length} channel(s), listening for messages`
|
|
1796
|
+
);
|
|
1797
|
+
});
|
|
1798
|
+
socket.onConnectError((err) => {
|
|
1799
|
+
runtime.error?.(`[ws] Connection error: ${err.message}`);
|
|
1800
|
+
});
|
|
1801
|
+
socket.onDisconnect((reason) => {
|
|
1802
|
+
runtime.log?.(`[ws] Disconnected: ${reason}`);
|
|
1803
|
+
});
|
|
1804
|
+
socket.raw.io.on("reconnect", (attempt) => {
|
|
1805
|
+
runtime.log?.(`[ws] Reconnected after ${attempt} attempt(s)`);
|
|
1806
|
+
});
|
|
1807
|
+
socket.raw.io.on("reconnect_attempt", (attempt) => {
|
|
1808
|
+
runtime.log?.(`[ws] Reconnect attempt #${attempt}`);
|
|
1809
|
+
});
|
|
1810
|
+
socket.on("server:joined", async (data) => {
|
|
1811
|
+
if (!agentId) return;
|
|
1812
|
+
runtime.log?.(`[ws] Received server:joined for server ${data.serverId} \u2014 refreshing channels`);
|
|
1813
|
+
try {
|
|
1814
|
+
const updatedConfig = await runShadowApiOperation(
|
|
1815
|
+
"refresh remote config",
|
|
1816
|
+
() => client.getAgentConfig(agentId),
|
|
1817
|
+
{ runtime, abortSignal }
|
|
1818
|
+
);
|
|
1819
|
+
runtime.log?.(`[config] Refreshed config: ${updatedConfig.servers.length} server(s)`);
|
|
1820
|
+
for (const server of updatedConfig.servers) {
|
|
1821
|
+
for (const ch of server.channels) {
|
|
1822
|
+
channelServerMap.set(ch.id, {
|
|
1823
|
+
serverId: server.id,
|
|
1824
|
+
serverSlug: server.slug ?? server.id,
|
|
1825
|
+
serverName: server.name,
|
|
1826
|
+
channelName: ch.name
|
|
1827
|
+
});
|
|
1828
|
+
if (!channelPolicies.has(ch.id)) {
|
|
1829
|
+
channelPolicies.set(ch.id, ch.policy);
|
|
1830
|
+
if (ch.policy.listen) {
|
|
1831
|
+
allChannelIds.push(ch.id);
|
|
1832
|
+
runtime.log?.(`[config] New channel: #${ch.name} (${ch.id}) \u2014 joining`);
|
|
1833
|
+
socket.joinChannel(ch.id).then((ack) => {
|
|
1834
|
+
if (ack?.ok) {
|
|
1835
|
+
runtime.log?.(`[ws] \u2713 Joined new channel room ${ch.id}`);
|
|
1836
|
+
runtime.log?.("[ws] Shadow channel monitor ready");
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
} else {
|
|
1841
|
+
channelPolicies.set(ch.id, ch.policy);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
remoteConfig = updatedConfig;
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
runtime.error?.(`[config] Failed to refresh config after server:joined: ${String(err)}`);
|
|
1848
|
+
}
|
|
1849
|
+
});
|
|
1850
|
+
socket.on(
|
|
1851
|
+
"channel:created",
|
|
1852
|
+
async (data) => {
|
|
1853
|
+
runtime.log?.(
|
|
1854
|
+
`[ws] Received channel:created: #${data.name} (${data.id}) in server ${data.serverId} \u2014 ignoring (bot must be explicitly added)`
|
|
1855
|
+
);
|
|
1856
|
+
channelServerMap.set(data.id, {
|
|
1857
|
+
serverId: data.serverId,
|
|
1858
|
+
serverSlug: data.serverId,
|
|
1859
|
+
serverName: data.serverId,
|
|
1860
|
+
channelName: data.name
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
);
|
|
1864
|
+
socket.on(
|
|
1865
|
+
"agent:policy-changed",
|
|
1866
|
+
(data) => {
|
|
1867
|
+
if (data.agentId !== agentId) return;
|
|
1868
|
+
if (!data.channelId) return;
|
|
1869
|
+
const mentionOnly = data.mentionOnly ?? false;
|
|
1870
|
+
runtime.log?.(
|
|
1871
|
+
`[ws] Received agent:policy-changed for channel ${data.channelId}: mentionOnly=${mentionOnly}, reply=${data.reply}, config=${JSON.stringify(data.config ?? {})}`
|
|
1872
|
+
);
|
|
1873
|
+
const existing = channelPolicies.get(data.channelId);
|
|
1874
|
+
if (existing) {
|
|
1875
|
+
channelPolicies.set(data.channelId, {
|
|
1876
|
+
...existing,
|
|
1877
|
+
mentionOnly,
|
|
1878
|
+
reply: data.reply ?? existing.reply,
|
|
1879
|
+
config: data.config ?? existing.config
|
|
1880
|
+
});
|
|
1881
|
+
} else {
|
|
1882
|
+
channelPolicies.set(data.channelId, {
|
|
1883
|
+
listen: true,
|
|
1884
|
+
reply: data.reply ?? true,
|
|
1885
|
+
mentionOnly,
|
|
1886
|
+
config: data.config ?? {}
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
);
|
|
1891
|
+
socket.on("channel:member-added", (data) => {
|
|
1892
|
+
runtime.log?.(
|
|
1893
|
+
`[ws] Received channel:member-added: channel ${data.channelId} in server ${data.serverId}`
|
|
1894
|
+
);
|
|
1895
|
+
const refreshChannelConfig = async () => {
|
|
1896
|
+
if (!agentId) return false;
|
|
1897
|
+
try {
|
|
1898
|
+
const updatedConfig = await runShadowApiOperation(
|
|
1899
|
+
"refresh remote config after channel member add",
|
|
1900
|
+
() => client.getAgentConfig(agentId),
|
|
1901
|
+
{ runtime, abortSignal }
|
|
1902
|
+
);
|
|
1903
|
+
remoteConfig = updatedConfig;
|
|
1904
|
+
for (const server2 of updatedConfig.servers) {
|
|
1905
|
+
for (const ch of server2.channels) {
|
|
1906
|
+
channelServerMap.set(ch.id, {
|
|
1907
|
+
serverId: server2.id,
|
|
1908
|
+
serverSlug: server2.slug ?? server2.id,
|
|
1909
|
+
serverName: server2.name,
|
|
1910
|
+
channelName: ch.name
|
|
1911
|
+
});
|
|
1912
|
+
channelPolicies.set(ch.id, ch.policy);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const server = updatedConfig.servers.find(
|
|
1916
|
+
(candidate) => candidate.channels.some((ch) => ch.id === data.channelId)
|
|
1917
|
+
);
|
|
1918
|
+
const channel = server?.channels.find((ch) => ch.id === data.channelId);
|
|
1919
|
+
if (channel) {
|
|
1920
|
+
runtime.log?.(
|
|
1921
|
+
`[config] Refreshed new channel context: #${channel.name} (${data.channelId})`
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
return true;
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
runtime.error?.(
|
|
1927
|
+
`[config] Failed to refresh config after channel:member-added: ${String(err)}`
|
|
1928
|
+
);
|
|
1929
|
+
return false;
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
void (async () => {
|
|
1933
|
+
const refreshed = await refreshChannelConfig();
|
|
1934
|
+
if (!refreshed) {
|
|
1935
|
+
await resolveChannelContext(data.channelId, "member-added");
|
|
1936
|
+
}
|
|
1937
|
+
if (!refreshed && !channelPolicies.has(data.channelId)) {
|
|
1938
|
+
const defaultPolicy = {
|
|
1939
|
+
listen: true,
|
|
1940
|
+
reply: true,
|
|
1941
|
+
mentionOnly: false,
|
|
1942
|
+
config: {}
|
|
1943
|
+
};
|
|
1944
|
+
channelPolicies.set(data.channelId, defaultPolicy);
|
|
1945
|
+
}
|
|
1946
|
+
if (!allChannelIds.includes(data.channelId)) {
|
|
1947
|
+
allChannelIds.push(data.channelId);
|
|
1948
|
+
}
|
|
1949
|
+
socket.joinChannel(data.channelId).then((ack) => {
|
|
1950
|
+
if (ack?.ok) {
|
|
1951
|
+
runtime.log?.(`[ws] \u2713 Joined channel room ${data.channelId} after member-added`);
|
|
1952
|
+
runtime.log?.("[ws] Shadow channel monitor ready");
|
|
1953
|
+
}
|
|
1954
|
+
enqueueChannelCatchup(data.channelId, "member-added");
|
|
1955
|
+
});
|
|
1956
|
+
})();
|
|
1957
|
+
});
|
|
1958
|
+
socket.on("channel:member-removed", (data) => {
|
|
1959
|
+
runtime.log?.(
|
|
1960
|
+
`[ws] Received channel:member-removed: channel ${data.channelId} in server ${data.serverId}`
|
|
1961
|
+
);
|
|
1962
|
+
channelPolicies.delete(data.channelId);
|
|
1963
|
+
const idx = allChannelIds.indexOf(data.channelId);
|
|
1964
|
+
if (idx !== -1) allChannelIds.splice(idx, 1);
|
|
1965
|
+
socket.leaveChannel(data.channelId);
|
|
1966
|
+
runtime.log?.(`[ws] Left channel room ${data.channelId} after member-removed`);
|
|
1967
|
+
});
|
|
1968
|
+
socket.on("message:new", (message) => {
|
|
1969
|
+
const senderLabel = message.author?.username ?? message.authorId;
|
|
1970
|
+
runtime.log?.(
|
|
1971
|
+
`[ws] \u2190 message:new from ${senderLabel} in channel ${message.channelId}: "${message.content?.slice(0, 60)}" (${message.id})`
|
|
1972
|
+
);
|
|
1973
|
+
if (processedMessageIds.has(message.id)) {
|
|
1974
|
+
runtime.log?.(`[ws] Skipping duplicate message:new ${message.id}`);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
rememberProcessedMessage(message.id);
|
|
1978
|
+
if (stopped) {
|
|
1979
|
+
runtime.log?.("[ws] Monitor stopped, ignoring message");
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
if (allChannelIds.length > 0 && !allChannelIds.includes(message.channelId)) {
|
|
1983
|
+
runtime.log?.(`[ws] Message from unmonitored channel ${message.channelId}, ignoring`);
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
void processChannelMessageWithRetry(message, "ws");
|
|
1987
|
+
});
|
|
1988
|
+
socket.connect();
|
|
1989
|
+
const stop = () => {
|
|
1990
|
+
runtime.log?.("[lifecycle] Stopping Shadow monitor...");
|
|
1991
|
+
stopped = true;
|
|
1992
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
1993
|
+
socket.disconnect();
|
|
1994
|
+
runtime.log?.("[lifecycle] Shadow monitor stopped");
|
|
1995
|
+
};
|
|
1996
|
+
abortSignal.addEventListener("abort", stop, { once: true });
|
|
1997
|
+
await new Promise((resolve) => {
|
|
1998
|
+
if (abortSignal.aborted) {
|
|
1999
|
+
resolve();
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
2003
|
+
});
|
|
2004
|
+
return { stop };
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
export {
|
|
2008
|
+
resolveOutboundMentions,
|
|
2009
|
+
normalizeShadowSlashCommands,
|
|
2010
|
+
matchShadowSlashCommand,
|
|
2011
|
+
formatSlashCommandPrompt,
|
|
2012
|
+
setShadowRuntime,
|
|
2013
|
+
getShadowRuntime,
|
|
2014
|
+
tryGetShadowRuntime,
|
|
2015
|
+
resolveShadowAgentIdFromConfig,
|
|
2016
|
+
shouldCatchUpShadowMessage,
|
|
2017
|
+
monitorShadowProvider
|
|
2018
|
+
};
|