@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.
@@ -1,1064 +0,0 @@
1
- // src/monitor.ts
2
- import nodeCrypto from "crypto";
3
- import fsPromises from "fs/promises";
4
- import nodeOs from "os";
5
- import nodePath from "path";
6
- import { ShadowClient, ShadowSocket } from "@shadowob/sdk";
7
-
8
- // src/runtime.ts
9
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
10
- var store = createPluginRuntimeStore(
11
- "Shadow runtime not initialized \u2014 plugin not registered yet"
12
- );
13
- var setShadowRuntime = store.setRuntime;
14
- var getShadowRuntime = store.getRuntime;
15
- var tryGetShadowRuntime = store.tryGetRuntime;
16
-
17
- // src/monitor.ts
18
- async function getDataDir() {
19
- const dataDir = process.env.OPENCLAW_DATA_DIR;
20
- return dataDir || nodePath.join(nodeOs.homedir(), ".openclaw");
21
- }
22
- function resolveSessionStore(cfg) {
23
- const raw = cfg.session?.store;
24
- if (typeof raw === "string") return raw;
25
- if (raw && typeof raw === "object") {
26
- const pathValue = raw.path;
27
- if (typeof pathValue === "string") return pathValue;
28
- }
29
- return void 0;
30
- }
31
- function createTypingCallbacks(params) {
32
- const {
33
- start,
34
- stop,
35
- onStartError,
36
- onStopError,
37
- keepaliveIntervalMs = 2e3,
38
- maxDurationMs = 12e4
39
- } = params;
40
- let keepaliveTimer = null;
41
- let maxDurationTimer = null;
42
- const cleanup = () => {
43
- if (keepaliveTimer) {
44
- clearInterval(keepaliveTimer);
45
- keepaliveTimer = null;
46
- }
47
- if (maxDurationTimer) {
48
- clearTimeout(maxDurationTimer);
49
- maxDurationTimer = null;
50
- }
51
- };
52
- return {
53
- onReplyStart: async () => {
54
- try {
55
- await start();
56
- } catch (err) {
57
- onStartError(err);
58
- return;
59
- }
60
- keepaliveTimer = setInterval(async () => {
61
- try {
62
- await start();
63
- } catch (err) {
64
- onStartError(err);
65
- }
66
- }, keepaliveIntervalMs);
67
- maxDurationTimer = setTimeout(() => {
68
- cleanup();
69
- stop?.().catch((err) => onStopError?.(err));
70
- }, maxDurationMs);
71
- },
72
- onIdle: () => {
73
- cleanup();
74
- },
75
- onCleanup: () => {
76
- cleanup();
77
- stop?.().catch((err) => onStopError?.(err));
78
- }
79
- };
80
- }
81
- async function processShadowMessage(params) {
82
- const {
83
- message,
84
- account,
85
- accountId,
86
- config,
87
- runtime,
88
- core,
89
- botUserId,
90
- botUsername,
91
- agentId,
92
- channelPolicies,
93
- channelServerMap,
94
- socket
95
- } = params;
96
- const cfg = config;
97
- const senderLabel = message.author?.username ?? message.authorId;
98
- if (message.authorId === botUserId) {
99
- runtime.log?.(`[msg] Skipping own message ${message.id}`);
100
- return;
101
- }
102
- let isProcessingBuddyMessage = false;
103
- if (message.author?.isBot) {
104
- const policy2 = channelPolicies.get(message.channelId);
105
- const policyConfig2 = policy2?.config;
106
- if (!policyConfig2?.replyToBuddy) {
107
- runtime.log?.(
108
- `[msg] Skipping bot message from ${senderLabel} (replyToBuddy=false) (${message.id})`
109
- );
110
- return;
111
- }
112
- const maxDepth = policyConfig2.maxBuddyChainDepth ?? 3;
113
- const chainMeta = message.metadata?.agentChain;
114
- if (chainMeta) {
115
- if (chainMeta.depth >= maxDepth) {
116
- runtime.log?.(
117
- `[msg] Buddy chain depth ${chainMeta.depth} >= max ${maxDepth}, stopping loop (${message.id})`
118
- );
119
- return;
120
- }
121
- if (chainMeta.participants?.includes(botUserId)) {
122
- runtime.log?.(
123
- `[msg] Already in buddy chain [${chainMeta.participants.join(", ")}], skipping to prevent loop (${message.id})`
124
- );
125
- return;
126
- }
127
- const senderAgentId = message.author?.id;
128
- if (senderAgentId && policyConfig2.buddyBlacklist?.includes(senderAgentId)) {
129
- runtime.log?.(
130
- `[msg] Sender agent ${senderAgentId} is in blacklist, skipping (${message.id})`
131
- );
132
- return;
133
- }
134
- if (senderAgentId && policyConfig2.buddyWhitelist?.length && !policyConfig2.buddyWhitelist.includes(senderAgentId)) {
135
- runtime.log?.(
136
- `[msg] Sender agent ${senderAgentId} not in whitelist, skipping (${message.id})`
137
- );
138
- return;
139
- }
140
- }
141
- isProcessingBuddyMessage = true;
142
- runtime.log?.(
143
- `[msg] Processing bot message from ${senderLabel} (replyToBuddy=true) (${message.id})`
144
- );
145
- }
146
- const channelId = message.channelId;
147
- const policy = channelPolicies.get(channelId);
148
- if (policy && !policy.listen) {
149
- runtime.log?.(`[msg] Policy blocks listen for channel ${channelId}, skipping`);
150
- return;
151
- }
152
- if (policy && !policy.reply) {
153
- runtime.log?.(`[msg] Policy blocks reply for channel ${channelId}, skipping (${message.id})`);
154
- return;
155
- }
156
- let wasMentionedExplicitly = false;
157
- if (policy?.mentionOnly) {
158
- const escapedUsername = botUsername.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
- const mentionRegex2 = new RegExp(`@${escapedUsername}(?:\\s|$)`, "i");
160
- wasMentionedExplicitly = mentionRegex2.test(message.content);
161
- if (!wasMentionedExplicitly) {
162
- runtime.log?.(
163
- `[msg] mentionOnly policy \u2014 no @${botUsername} mention found, skipping (${message.id})`
164
- );
165
- return;
166
- }
167
- runtime.log?.(
168
- `[msg] mentionOnly policy \u2014 @${botUsername} mentioned, processing (${message.id})`
169
- );
170
- }
171
- const policyConfig = policy?.config;
172
- if (policyConfig?.replyToUsers?.length) {
173
- const allowedUsers = policyConfig.replyToUsers.map((u) => u.toLowerCase());
174
- const senderUser = (message.author?.username ?? "").toLowerCase();
175
- if (!allowedUsers.includes(senderUser)) {
176
- runtime.log?.(
177
- `[msg] replyToUsers policy \u2014 sender "${senderUser}" not in allowed list, skipping (${message.id})`
178
- );
179
- return;
180
- }
181
- }
182
- if (policyConfig?.keywords?.length) {
183
- const lowerContent = message.content.toLowerCase();
184
- const matched = policyConfig.keywords.some((kw) => lowerContent.includes(kw.toLowerCase()));
185
- if (!matched) {
186
- runtime.log?.(`[msg] keywords policy \u2014 no matching keyword found, skipping (${message.id})`);
187
- return;
188
- }
189
- runtime.log?.(`[msg] keywords policy \u2014 keyword matched, processing (${message.id})`);
190
- }
191
- const smartReplyEnabled = policyConfig?.smartReply !== false;
192
- if (smartReplyEnabled && !isProcessingBuddyMessage && !wasMentionedExplicitly) {
193
- const mentionPattern = /@([a-zA-Z0-9_\-\u4e00-\u9fa5]+)/g;
194
- const allMentions = message.content.match(mentionPattern) || [];
195
- const mentionsWithoutSelf = allMentions.filter((m) => {
196
- const mentionedUser = m.slice(1).toLowerCase();
197
- return mentionedUser !== botUsername.toLowerCase();
198
- });
199
- if (allMentions.length > 0 && mentionsWithoutSelf.length === allMentions.length) {
200
- runtime.log?.(
201
- `[msg] Smart reply: message @mentions others (${allMentions.join(", ")}) but not @${botUsername}, skipping (${message.id})`
202
- );
203
- return;
204
- }
205
- const replyToData = message.replyTo;
206
- if (replyToData?.authorId && replyToData.authorId !== botUserId) {
207
- const selfMentioned = allMentions.some((m) => {
208
- const mentionedUser = m.slice(1).toLowerCase();
209
- return mentionedUser === botUsername.toLowerCase();
210
- });
211
- if (!selfMentioned) {
212
- runtime.log?.(
213
- `[msg] Smart reply: message is a reply to another user (${replyToData.authorId}), not this Buddy, skipping (${message.id})`
214
- );
215
- return;
216
- }
217
- }
218
- }
219
- runtime.log?.(
220
- `[msg] Processing message from ${senderLabel}: "${message.content.slice(0, 80)}" (${message.id})`
221
- );
222
- const senderName = message.author?.displayName ?? message.author?.username ?? "Unknown";
223
- const senderUsername = message.author?.username ?? "";
224
- const senderId = message.authorId;
225
- const rawBody = message.content;
226
- const chatType = message.threadId ? "thread" : "channel";
227
- const peerId = message.threadId ? `${channelId}:thread:${message.threadId}` : channelId;
228
- const route = core.channel.routing.resolveAgentRoute({
229
- cfg,
230
- channel: "shadowob",
231
- accountId,
232
- peer: { kind: "group", id: peerId }
233
- });
234
- runtime.log?.(`[routing] Resolved agent: ${route.agentId} (account ${accountId})`);
235
- const body = core.channel.reply.formatAgentEnvelope({
236
- channel: "Shadow",
237
- from: senderName,
238
- timestamp: new Date(message.createdAt).getTime(),
239
- envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
240
- body: rawBody
241
- });
242
- const attachmentUrls = (message.attachments ?? []).map((a) => a.url).filter(Boolean);
243
- const markdownMediaRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
244
- const markdownUrls = [];
245
- for (const mdMatch of rawBody.matchAll(markdownMediaRegex)) {
246
- const url = mdMatch[1];
247
- if (url.startsWith("/") && url.includes("/uploads/")) {
248
- markdownUrls.push(url);
249
- } else if (url.startsWith("http")) {
250
- markdownUrls.push(url);
251
- }
252
- }
253
- const allRawUrls = [.../* @__PURE__ */ new Set([...attachmentUrls, ...markdownUrls])];
254
- const mediaClient = new ShadowClient(account.serverUrl, account.token);
255
- const localMediaPaths = [];
256
- const localMediaTypes = [];
257
- const resolvedMediaUrls = [];
258
- const inferMimeType = (filename, headerType) => {
259
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
260
- const map = {
261
- jpg: "image/jpeg",
262
- jpeg: "image/jpeg",
263
- png: "image/png",
264
- gif: "image/gif",
265
- webp: "image/webp",
266
- svg: "image/svg+xml",
267
- mp4: "video/mp4",
268
- webm: "video/webm",
269
- mp3: "audio/mpeg",
270
- wav: "audio/wav",
271
- ogg: "audio/ogg",
272
- pdf: "application/pdf"
273
- };
274
- return map[ext] ?? headerType ?? "application/octet-stream";
275
- };
276
- if (allRawUrls.length > 0) {
277
- const dataDir = await getDataDir();
278
- const mediaDir = nodePath.join(dataDir, "media", "inbound");
279
- await fsPromises.mkdir(mediaDir, { recursive: true });
280
- for (const rawUrl of allRawUrls) {
281
- try {
282
- const downloaded = await mediaClient.downloadFile(rawUrl);
283
- const uuid = nodeCrypto.randomUUID();
284
- const ext = nodePath.extname(downloaded.filename) || ".bin";
285
- const safeBase = downloaded.filename.replace(/[^a-zA-Z0-9._\u4e00-\u9fff-]/g, "_").slice(0, 100);
286
- const localFilename = `${safeBase}---${uuid}${ext.startsWith(".") ? "" : "."}${ext}`;
287
- const localPath = nodePath.join(mediaDir, localFilename);
288
- await fsPromises.writeFile(localPath, new Uint8Array(downloaded.buffer));
289
- localMediaPaths.push(localPath);
290
- localMediaTypes.push(inferMimeType(downloaded.filename, downloaded.contentType));
291
- const baseUrl = account.serverUrl.replace(/\/$/, "");
292
- resolvedMediaUrls.push(rawUrl.startsWith("/") ? `${baseUrl}${rawUrl}` : rawUrl);
293
- runtime.log?.(
294
- `[media] Downloaded ${rawUrl} \u2192 ${localPath} (${downloaded.buffer.byteLength} bytes)`
295
- );
296
- } catch (err) {
297
- runtime.error?.(`[media] Failed to download ${rawUrl}: ${String(err)}`);
298
- }
299
- }
300
- }
301
- const mediaCtx = {};
302
- if (localMediaPaths.length > 0) {
303
- mediaCtx.MediaPath = localMediaPaths[0];
304
- mediaCtx.MediaPaths = localMediaPaths;
305
- mediaCtx.MediaUrl = resolvedMediaUrls[0];
306
- mediaCtx.MediaUrls = resolvedMediaUrls;
307
- mediaCtx.MediaType = localMediaTypes[0];
308
- mediaCtx.MediaTypes = localMediaTypes;
309
- }
310
- let cleanBody = rawBody;
311
- if (localMediaPaths.length > 0) {
312
- cleanBody = rawBody.replace(/!?\[[^\]]*\]\([^)]*\/uploads\/[^)]+\)/g, "").replace(/\n{2,}/g, "\n").trim();
313
- if (!cleanBody) cleanBody = "[Media attached]";
314
- }
315
- const serverInfo = channelServerMap.get(channelId);
316
- const escapedBotUsername = botUsername.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
317
- const mentionRegex = new RegExp(`@${escapedBotUsername}(?:\\s|$)`, "i");
318
- const wasMentioned = mentionRegex.test(message.content);
319
- const triggerChain = message.metadata?.agentChain;
320
- const ctxPayload = core.channel.reply.finalizeInboundContext({
321
- Body: body,
322
- BodyForAgent: cleanBody,
323
- RawBody: rawBody,
324
- CommandBody: cleanBody,
325
- From: `shadowob:user:${senderId}`,
326
- To: `shadowob:channel:${channelId}`,
327
- SessionKey: route.sessionKey,
328
- AccountId: route.accountId,
329
- ChatType: chatType,
330
- ConversationLabel: peerId,
331
- SenderName: senderName,
332
- SenderId: senderId,
333
- SenderUsername: senderUsername,
334
- Provider: "shadowob",
335
- Surface: "shadowob",
336
- MessageSid: message.id,
337
- WasMentioned: wasMentioned,
338
- OriginatingChannel: "shadowob",
339
- OriginatingTo: `shadowob:channel:${channelId}`,
340
- ...serverInfo ? {
341
- ServerId: serverInfo.serverId,
342
- ServerSlug: serverInfo.serverSlug,
343
- ServerName: serverInfo.serverName,
344
- ChannelName: serverInfo.channelName
345
- } : {},
346
- BotUserId: botUserId,
347
- BotUsername: botUsername,
348
- AgentId: route.agentId,
349
- ChannelId: channelId,
350
- ...message.threadId ? { ThreadId: message.threadId } : {},
351
- ...message.replyToId ? { ReplyToId: message.replyToId } : {},
352
- ...mediaCtx
353
- });
354
- const storePath = core.channel.session.resolveStorePath(resolveSessionStore(cfg), {
355
- agentId: route.agentId
356
- });
357
- await core.channel.session.recordInboundSession({
358
- storePath,
359
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
360
- ctx: ctxPayload,
361
- onRecordError: (err) => {
362
- runtime.error?.(`Failed updating session meta: ${String(err)}`);
363
- }
364
- });
365
- if (policy && !policy.reply) {
366
- runtime.log?.(`[msg] Policy blocks reply for channel ${channelId}, skipping dispatch`);
367
- return;
368
- }
369
- runtime.log?.(`[msg] Dispatching to AI pipeline for message ${message.id}`);
370
- const client = new ShadowClient(account.serverUrl, account.token);
371
- const typingCbs = createTypingCallbacks({
372
- start: async () => {
373
- socket.sendTyping(channelId);
374
- },
375
- onStartError: (err) => {
376
- runtime.error?.(`[typing] Failed to send typing indicator: ${String(err)}`);
377
- }
378
- });
379
- socket.updateActivity(channelId, "thinking");
380
- typingCbs.onReplyStart().catch(() => {
381
- });
382
- try {
383
- if (core.channel.reply.createReplyDispatcherWithTyping) {
384
- const { markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({
385
- typingCallbacks: typingCbs,
386
- deliver: async (payload) => {
387
- socket.updateActivity(channelId, "working");
388
- await deliverShadowReply({
389
- payload,
390
- channelId,
391
- threadId: message.threadId ?? void 0,
392
- replyToId: message.id,
393
- client,
394
- runtime,
395
- agentChain: triggerChain,
396
- agentId,
397
- botUserId
398
- });
399
- }
400
- });
401
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
402
- ctx: ctxPayload,
403
- cfg,
404
- dispatcherOptions: {
405
- deliver: async (payload) => {
406
- socket.updateActivity(channelId, "working");
407
- await deliverShadowReply({
408
- payload,
409
- channelId,
410
- threadId: message.threadId ?? void 0,
411
- replyToId: message.id,
412
- client,
413
- runtime,
414
- agentChain: triggerChain,
415
- agentId,
416
- botUserId
417
- });
418
- }
419
- }
420
- });
421
- markDispatchIdle();
422
- markRunComplete();
423
- } else {
424
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
425
- ctx: ctxPayload,
426
- cfg,
427
- dispatcherOptions: {
428
- deliver: async (payload) => {
429
- socket.updateActivity(channelId, "working");
430
- socket.sendTyping(channelId);
431
- await deliverShadowReply({
432
- payload,
433
- channelId,
434
- threadId: message.threadId ?? void 0,
435
- replyToId: message.id,
436
- client,
437
- runtime,
438
- agentChain: triggerChain,
439
- agentId,
440
- botUserId
441
- });
442
- }
443
- }
444
- });
445
- }
446
- socket.updateActivity(channelId, "ready");
447
- } catch (err) {
448
- runtime.error?.(`[msg] AI dispatch failed for message ${message.id}: ${String(err)}`);
449
- socket.updateActivity(channelId, null);
450
- throw err;
451
- } finally {
452
- typingCbs.onCleanup?.();
453
- setTimeout(() => {
454
- socket.updateActivity(channelId, null);
455
- }, 3e3);
456
- }
457
- }
458
- async function deliverShadowReply(params) {
459
- const {
460
- payload,
461
- channelId,
462
- threadId,
463
- replyToId,
464
- client,
465
- runtime,
466
- agentChain,
467
- agentId,
468
- botUserId
469
- } = params;
470
- try {
471
- if (!payload.text && !(payload.mediaUrl || payload.mediaUrls?.length)) {
472
- runtime.error?.("[reply] No text or media in reply payload");
473
- return;
474
- }
475
- const text = payload.text ?? "";
476
- runtime.log?.(`[reply] Sending reply to channel ${channelId}: "${text.slice(0, 80)}"`);
477
- const mediaUrls = [payload.mediaUrl, ...payload.mediaUrls ?? []].filter(Boolean);
478
- const newAgentChain = agentId ? {
479
- agentId,
480
- depth: (agentChain?.depth ?? 0) + 1,
481
- participants: [...agentChain?.participants ?? [], botUserId].filter(
482
- Boolean
483
- ),
484
- startedAt: agentChain?.startedAt ?? Date.now(),
485
- rootMessageId: agentChain?.rootMessageId ?? replyToId
486
- } : void 0;
487
- let sentMessage = null;
488
- if (text || mediaUrls.length > 0) {
489
- const contentToSend = text || "\u200B";
490
- if (threadId) {
491
- sentMessage = await client.sendToThread(threadId, contentToSend);
492
- } else {
493
- sentMessage = await client.sendMessage(channelId, contentToSend, {
494
- replyToId,
495
- metadata: newAgentChain ? { agentChain: newAgentChain } : void 0
496
- });
497
- }
498
- runtime.log?.(
499
- `[reply] Message created (${sentMessage.id})${text ? "" : " [media-only placeholder]"}${newAgentChain ? ` [chain depth: ${newAgentChain.depth}]` : ""}`
500
- );
501
- }
502
- if (mediaUrls.length > 0) {
503
- const messageId = sentMessage?.id;
504
- for (const mediaUrl of mediaUrls) {
505
- try {
506
- runtime.log?.(`[reply] Uploading media: ${mediaUrl}`);
507
- await client.uploadMediaFromUrl(mediaUrl, messageId);
508
- runtime.log?.(`[reply] Media uploaded successfully`);
509
- } catch (err) {
510
- runtime.error?.(`[reply] Failed to upload media ${mediaUrl}: ${String(err)}`);
511
- }
512
- }
513
- }
514
- runtime.log?.(`[reply] Reply delivered successfully`);
515
- } catch (err) {
516
- runtime.error?.(`[reply] Failed to send reply: ${String(err)}`);
517
- }
518
- }
519
- async function processShadowDmMessage(params) {
520
- const {
521
- dmMessage,
522
- account,
523
- accountId,
524
- config,
525
- runtime,
526
- core,
527
- botUserId,
528
- botUsername,
529
- shadowAgentId,
530
- socket
531
- } = params;
532
- const cfg = config;
533
- const senderLabel = dmMessage.author?.username ?? dmMessage.senderId;
534
- if (dmMessage.senderId === botUserId || dmMessage.authorId === botUserId) {
535
- runtime.log?.(`[dm] Skipping own DM message ${dmMessage.id}`);
536
- return;
537
- }
538
- if (dmMessage.author?.isBot) {
539
- runtime.log?.(`[dm] Skipping bot DM from ${senderLabel} (${dmMessage.id})`);
540
- return;
541
- }
542
- runtime.log?.(
543
- `[dm] Processing DM from ${senderLabel}: "${dmMessage.content.slice(0, 80)}" (${dmMessage.id})`
544
- );
545
- const senderName = dmMessage.author?.displayName ?? dmMessage.author?.username ?? "Unknown";
546
- const senderUsername = dmMessage.author?.username ?? "";
547
- const senderId = dmMessage.senderId;
548
- const rawBody = dmMessage.content;
549
- const dmChannelId = dmMessage.dmChannelId;
550
- const attachments = dmMessage.attachments ?? [];
551
- let bodyWithAttachments = rawBody;
552
- if (attachments.length > 0) {
553
- const attachmentLines = attachments.map(
554
- (a) => `[Attachment: ${a.filename} (${a.contentType}): ${a.url}]`
555
- );
556
- bodyWithAttachments = rawBody ? `${rawBody}
557
- ${attachmentLines.join("\n")}` : attachmentLines.join("\n");
558
- }
559
- const peerId = `dm:${dmChannelId}`;
560
- const route = core.channel.routing.resolveAgentRoute({
561
- cfg,
562
- channel: "shadowob",
563
- accountId,
564
- peer: { kind: "direct", id: peerId }
565
- });
566
- runtime.log?.(`[routing] DM resolved agent: ${route.agentId} (account ${accountId})`);
567
- const body = core.channel.reply.formatAgentEnvelope({
568
- channel: "Shadow DM",
569
- from: senderName,
570
- timestamp: new Date(dmMessage.createdAt).getTime(),
571
- envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
572
- body: bodyWithAttachments
573
- });
574
- const ctxPayload = core.channel.reply.finalizeInboundContext({
575
- Body: body,
576
- BodyForAgent: bodyWithAttachments,
577
- RawBody: rawBody,
578
- CommandBody: rawBody,
579
- From: `shadowob:user:${senderId}`,
580
- To: `shadowob:dm:${dmChannelId}`,
581
- SessionKey: route.sessionKey,
582
- AccountId: route.accountId,
583
- ChatType: "dm",
584
- ConversationLabel: peerId,
585
- SenderName: senderName,
586
- SenderId: senderId,
587
- SenderUsername: senderUsername,
588
- Provider: "shadowob",
589
- Surface: "shadowob",
590
- MessageSid: dmMessage.id,
591
- WasMentioned: true,
592
- OriginatingChannel: "shadowob",
593
- OriginatingTo: `shadowob:dm:${dmChannelId}`,
594
- BotUserId: botUserId,
595
- BotUsername: botUsername,
596
- AgentId: route.agentId,
597
- ChannelId: dmChannelId
598
- });
599
- const storePath = core.channel.session.resolveStorePath(resolveSessionStore(cfg), {
600
- agentId: route.agentId
601
- });
602
- await core.channel.session.recordInboundSession({
603
- storePath,
604
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
605
- ctx: ctxPayload,
606
- onRecordError: (err) => {
607
- runtime.error?.(`Failed updating DM session meta: ${String(err)}`);
608
- }
609
- });
610
- runtime.log?.(`[dm] Dispatching to AI pipeline for DM message ${dmMessage.id}`);
611
- const client = new ShadowClient(account.serverUrl, account.token);
612
- const triggerChain = dmMessage.metadata?.agentChain;
613
- const typingCbs = createTypingCallbacks({
614
- start: async () => {
615
- socket.sendDmTyping(dmChannelId);
616
- },
617
- onStartError: (err) => {
618
- runtime.error?.(`[dm-typing] Failed to send typing indicator: ${String(err)}`);
619
- }
620
- });
621
- typingCbs.onReplyStart().catch(() => {
622
- });
623
- try {
624
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
625
- ctx: ctxPayload,
626
- cfg,
627
- dispatcherOptions: {
628
- deliver: async (payload) => {
629
- socket.sendDmTyping(dmChannelId);
630
- await deliverShadowDmReply({
631
- payload,
632
- dmChannelId,
633
- replyToId: dmMessage.id,
634
- client,
635
- runtime,
636
- agentChain: triggerChain,
637
- agentId: shadowAgentId,
638
- botUserId
639
- });
640
- }
641
- }
642
- });
643
- } catch (err) {
644
- runtime.error?.(`[dm] AI dispatch failed for DM message ${dmMessage.id}: ${String(err)}`);
645
- throw err;
646
- } finally {
647
- typingCbs.onCleanup?.();
648
- }
649
- }
650
- async function deliverShadowDmReply(params) {
651
- const { payload, dmChannelId, replyToId, client, runtime, agentChain, agentId, botUserId } = params;
652
- try {
653
- if (!payload.text && !(payload.mediaUrl || payload.mediaUrls?.length)) {
654
- runtime.error?.("[dm-reply] No text or media in DM reply payload");
655
- return;
656
- }
657
- const text = payload.text ?? "";
658
- runtime.log?.(`[dm-reply] Sending DM reply to channel ${dmChannelId}: "${text.slice(0, 80)}"`);
659
- const mediaUrls = [payload.mediaUrl, ...payload.mediaUrls ?? []].filter(Boolean);
660
- const newAgentChain = agentId ? {
661
- agentId,
662
- depth: (agentChain?.depth ?? 0) + 1,
663
- participants: [...agentChain?.participants ?? [], botUserId].filter(
664
- Boolean
665
- ),
666
- startedAt: agentChain?.startedAt ?? Date.now(),
667
- rootMessageId: agentChain?.rootMessageId ?? replyToId
668
- } : void 0;
669
- let sentMessage = null;
670
- if (text || mediaUrls.length > 0) {
671
- const contentToSend = text || "\u200B";
672
- sentMessage = await client.sendDmMessage(dmChannelId, contentToSend, {
673
- replyToId,
674
- metadata: newAgentChain ? { agentChain: newAgentChain } : void 0
675
- });
676
- runtime.log?.(
677
- `[dm-reply] DM message created (${sentMessage.id})${text ? "" : " [media-only placeholder]"}${newAgentChain ? ` [chain depth: ${newAgentChain.depth}]` : ""}`
678
- );
679
- }
680
- if (mediaUrls.length > 0) {
681
- const messageId = sentMessage?.id;
682
- for (const mediaUrl of mediaUrls) {
683
- try {
684
- runtime.log?.(`[dm-reply] Uploading media: ${mediaUrl}`);
685
- await client.uploadMediaFromUrl(mediaUrl, messageId);
686
- runtime.log?.(`[dm-reply] Media uploaded successfully`);
687
- } catch (err) {
688
- runtime.error?.(`[dm-reply] Failed to upload media ${mediaUrl}: ${String(err)}`);
689
- }
690
- }
691
- }
692
- runtime.log?.(`[dm-reply] DM reply delivered successfully`);
693
- } catch (err) {
694
- runtime.error?.(`[dm-reply] Failed to send DM reply: ${String(err)}`);
695
- }
696
- }
697
- async function getSessionCachePath(accountId) {
698
- const dataDir = await getDataDir();
699
- return nodePath.join(dataDir, "shadow", `session-cache-${accountId}.json`);
700
- }
701
- async function saveSessionCache(accountId, data) {
702
- try {
703
- const cachePath = await getSessionCachePath(accountId);
704
- await fsPromises.mkdir(nodePath.dirname(cachePath), { recursive: true });
705
- await fsPromises.writeFile(cachePath, JSON.stringify(data), "utf-8");
706
- } catch {
707
- }
708
- }
709
- async function loadSessionCache(accountId) {
710
- try {
711
- const cachePath = await getSessionCachePath(accountId);
712
- const raw = await fsPromises.readFile(cachePath, "utf-8");
713
- return JSON.parse(raw);
714
- } catch {
715
- return null;
716
- }
717
- }
718
- async function monitorShadowProvider(options) {
719
- const { account, accountId, config, runtime, abortSignal } = options;
720
- const core = getShadowRuntime();
721
- let stopped = false;
722
- const client = new ShadowClient(account.serverUrl, account.token);
723
- const me = await client.getMe();
724
- const botUserId = me.id;
725
- runtime.log?.(`Shadow bot connected as ${me.username} (${botUserId})`);
726
- const agentId = account.agentId ?? me.agentId ?? null;
727
- if (!agentId) {
728
- runtime.error?.(
729
- "[config] Cannot resolve agentId \u2014 heartbeat and remote config will be unavailable"
730
- );
731
- } else {
732
- runtime.log?.(`[config] Resolved agentId: ${agentId}`);
733
- }
734
- let remoteConfig = null;
735
- const channelPolicies = /* @__PURE__ */ new Map();
736
- const channelServerMap = /* @__PURE__ */ new Map();
737
- const allChannelIds = [];
738
- if (agentId) {
739
- try {
740
- remoteConfig = await client.getAgentConfig(agentId);
741
- runtime.log?.(`[config] Fetched remote config: ${remoteConfig.servers.length} server(s)`);
742
- for (const server of remoteConfig.servers) {
743
- runtime.log?.(
744
- `[config] Server "${server.name}" (${server.id}) \u2014 ${server.channels.length} channel(s)`
745
- );
746
- for (const ch of server.channels) {
747
- channelPolicies.set(ch.id, ch.policy);
748
- channelServerMap.set(ch.id, {
749
- serverId: server.id,
750
- serverSlug: server.slug ?? server.id,
751
- serverName: server.name,
752
- channelName: ch.name
753
- });
754
- if (ch.policy.listen) {
755
- allChannelIds.push(ch.id);
756
- runtime.log?.(
757
- `[config] \u2713 #${ch.name} (${ch.id}) \u2014 listen=true reply=${ch.policy.reply} mentionOnly=${ch.policy.mentionOnly}`
758
- );
759
- } else {
760
- runtime.log?.(`[config] \u2717 #${ch.name} (${ch.id}) \u2014 listen=false, skipping`);
761
- }
762
- }
763
- }
764
- runtime.log?.(
765
- `[config] Monitoring ${allChannelIds.length} channel(s) across ${remoteConfig.servers.length} server(s)`
766
- );
767
- void saveSessionCache(accountId, { remoteConfig, botUserId, botUsername: me.username });
768
- } catch (err) {
769
- runtime.error?.(`[config] Failed to fetch remote config: ${String(err)}`);
770
- const cached = await loadSessionCache(accountId);
771
- if (cached) {
772
- runtime.log?.("[config] Loaded session from cache \u2014 using cached config");
773
- remoteConfig = cached.remoteConfig;
774
- for (const server of remoteConfig.servers) {
775
- for (const ch of server.channels) {
776
- channelPolicies.set(ch.id, ch.policy);
777
- channelServerMap.set(ch.id, {
778
- serverId: server.id,
779
- serverSlug: server.slug ?? server.id,
780
- serverName: server.name,
781
- channelName: ch.name
782
- });
783
- if (ch.policy.listen) allChannelIds.push(ch.id);
784
- }
785
- }
786
- runtime.log?.(`[config] Restored ${allChannelIds.length} channel(s) from cache`);
787
- } else {
788
- runtime.log?.("[config] No cached session \u2014 falling back to monitoring no channels");
789
- }
790
- }
791
- }
792
- let heartbeatInterval = null;
793
- if (agentId) {
794
- const sendHeartbeat = async () => {
795
- try {
796
- await client.sendHeartbeat(agentId);
797
- runtime.log?.("[heartbeat] Heartbeat sent");
798
- } catch (err) {
799
- runtime.error?.(`[heartbeat] Heartbeat failed: ${String(err)}`);
800
- }
801
- };
802
- void sendHeartbeat();
803
- heartbeatInterval = setInterval(sendHeartbeat, 3e4);
804
- }
805
- runtime.log?.(`[ws] Connecting to Shadow WebSocket at ${account.serverUrl}`);
806
- const socket = new ShadowSocket({
807
- serverUrl: account.serverUrl,
808
- token: account.token,
809
- transports: ["websocket", "polling"]
810
- });
811
- socket.onConnect(() => {
812
- runtime.log?.(`[ws] Connected (sid=${socket.raw.id})`);
813
- if (allChannelIds.length === 0) {
814
- runtime.log?.("[ws] No channels to join \u2014 allChannelIds is empty");
815
- }
816
- for (const chId of allChannelIds) {
817
- runtime.log?.(`[ws] Emitting channel:join for ${chId}`);
818
- socket.joinChannel(chId).then((ack) => {
819
- if (ack?.ok) runtime.log?.(`[ws] \u2713 Joined channel room ${chId} (server confirmed)`);
820
- else runtime.log?.(`[ws] channel:join for ${chId} \u2014 no ack received (older server?)`);
821
- });
822
- }
823
- runtime.log?.(
824
- `[ws] Emitted channel:join for ${allChannelIds.length} channel(s), listening for messages`
825
- );
826
- (async () => {
827
- try {
828
- const dmChannels = await client.listDmChannels();
829
- for (const ch of dmChannels) {
830
- socket.joinDmChannel(ch.id);
831
- runtime.log?.(`[ws] Joined DM room dm:${ch.id}`);
832
- }
833
- runtime.log?.(`[ws] Joined ${dmChannels.length} DM channel room(s)`);
834
- } catch (err) {
835
- runtime.error?.(`[ws] Failed to join DM rooms: ${String(err)}`);
836
- }
837
- })();
838
- });
839
- socket.onConnectError((err) => {
840
- runtime.error?.(`[ws] Connection error: ${err.message}`);
841
- });
842
- socket.onDisconnect((reason) => {
843
- runtime.log?.(`[ws] Disconnected: ${reason}`);
844
- });
845
- socket.raw.io.on("reconnect", (attempt) => {
846
- runtime.log?.(`[ws] Reconnected after ${attempt} attempt(s)`);
847
- });
848
- socket.raw.io.on("reconnect_attempt", (attempt) => {
849
- runtime.log?.(`[ws] Reconnect attempt #${attempt}`);
850
- });
851
- socket.on("server:joined", async (data) => {
852
- if (!agentId) return;
853
- runtime.log?.(`[ws] Received server:joined for server ${data.serverId} \u2014 refreshing channels`);
854
- try {
855
- const updatedConfig = await client.getAgentConfig(agentId);
856
- runtime.log?.(`[config] Refreshed config: ${updatedConfig.servers.length} server(s)`);
857
- for (const server of updatedConfig.servers) {
858
- for (const ch of server.channels) {
859
- channelServerMap.set(ch.id, {
860
- serverId: server.id,
861
- serverSlug: server.slug ?? server.id,
862
- serverName: server.name,
863
- channelName: ch.name
864
- });
865
- if (!channelPolicies.has(ch.id)) {
866
- channelPolicies.set(ch.id, ch.policy);
867
- if (ch.policy.listen) {
868
- allChannelIds.push(ch.id);
869
- runtime.log?.(`[config] New channel: #${ch.name} (${ch.id}) \u2014 joining`);
870
- socket.joinChannel(ch.id).then((ack) => {
871
- if (ack?.ok) runtime.log?.(`[ws] \u2713 Joined new channel room ${ch.id}`);
872
- });
873
- }
874
- } else {
875
- channelPolicies.set(ch.id, ch.policy);
876
- }
877
- }
878
- }
879
- remoteConfig = updatedConfig;
880
- } catch (err) {
881
- runtime.error?.(`[config] Failed to refresh config after server:joined: ${String(err)}`);
882
- }
883
- });
884
- socket.on(
885
- "channel:created",
886
- async (data) => {
887
- runtime.log?.(
888
- `[ws] Received channel:created: #${data.name} (${data.id}) in server ${data.serverId} \u2014 ignoring (bot must be explicitly added)`
889
- );
890
- }
891
- );
892
- socket.on(
893
- "agent:policy-changed",
894
- (data) => {
895
- if (data.agentId !== agentId) return;
896
- if (!data.channelId) return;
897
- const mentionOnly = data.mentionOnly ?? false;
898
- runtime.log?.(
899
- `[ws] Received agent:policy-changed for channel ${data.channelId}: mentionOnly=${mentionOnly}, reply=${data.reply}, config=${JSON.stringify(data.config ?? {})}`
900
- );
901
- const existing = channelPolicies.get(data.channelId);
902
- if (existing) {
903
- channelPolicies.set(data.channelId, {
904
- ...existing,
905
- mentionOnly,
906
- reply: data.reply ?? existing.reply,
907
- config: data.config ?? existing.config
908
- });
909
- } else {
910
- channelPolicies.set(data.channelId, {
911
- listen: true,
912
- reply: data.reply ?? true,
913
- mentionOnly,
914
- config: data.config ?? {}
915
- });
916
- }
917
- }
918
- );
919
- socket.on("channel:member-added", (data) => {
920
- runtime.log?.(
921
- `[ws] Received channel:member-added: channel ${data.channelId} in server ${data.serverId}`
922
- );
923
- if (!channelPolicies.has(data.channelId)) {
924
- const defaultPolicy = {
925
- listen: true,
926
- reply: true,
927
- mentionOnly: false,
928
- config: {}
929
- };
930
- channelPolicies.set(data.channelId, defaultPolicy);
931
- allChannelIds.push(data.channelId);
932
- }
933
- socket.joinChannel(data.channelId).then((ack) => {
934
- if (ack?.ok) runtime.log?.(`[ws] \u2713 Joined channel room ${data.channelId} after member-added`);
935
- });
936
- });
937
- socket.on("channel:member-removed", (data) => {
938
- runtime.log?.(
939
- `[ws] Received channel:member-removed: channel ${data.channelId} in server ${data.serverId}`
940
- );
941
- channelPolicies.delete(data.channelId);
942
- const idx = allChannelIds.indexOf(data.channelId);
943
- if (idx !== -1) allChannelIds.splice(idx, 1);
944
- socket.leaveChannel(data.channelId);
945
- runtime.log?.(`[ws] Left channel room ${data.channelId} after member-removed`);
946
- });
947
- const processedDmIds = /* @__PURE__ */ new Set();
948
- socket.on(
949
- "dm:message:new",
950
- (dmMessage) => {
951
- if (processedDmIds.has(dmMessage.id)) {
952
- runtime.log?.(`[ws] Skipping duplicate dm:message:new ${dmMessage.id}`);
953
- return;
954
- }
955
- processedDmIds.add(dmMessage.id);
956
- if (processedDmIds.size > 500) {
957
- const first = processedDmIds.values().next().value;
958
- if (first) processedDmIds.delete(first);
959
- }
960
- const senderLabel = dmMessage.author?.username ?? dmMessage.senderId;
961
- runtime.log?.(
962
- `[ws] \u2190 dm:message:new from ${senderLabel} in DM ${dmMessage.dmChannelId}: "${dmMessage.content?.slice(0, 60)}" (${dmMessage.id})`
963
- );
964
- if (stopped) {
965
- runtime.log?.("[ws] Monitor stopped, ignoring DM message");
966
- return;
967
- }
968
- const processWithRetry = async (attempt = 0) => {
969
- try {
970
- await processShadowDmMessage({
971
- dmMessage,
972
- account,
973
- accountId,
974
- config,
975
- runtime,
976
- core,
977
- botUserId,
978
- botUsername: me.username,
979
- shadowAgentId: agentId,
980
- socket
981
- });
982
- } catch (err) {
983
- const MAX_RETRIES = 2;
984
- runtime.error?.(`[ws] DM processing failed (attempt ${attempt + 1}): ${String(err)}`);
985
- if (attempt < MAX_RETRIES) {
986
- await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
987
- return processWithRetry(attempt + 1);
988
- }
989
- runtime.error?.(
990
- `[ws] DM permanently failed after ${MAX_RETRIES + 1} attempts: ${dmMessage.id}`
991
- );
992
- }
993
- };
994
- void processWithRetry();
995
- }
996
- );
997
- socket.on("message:new", (message) => {
998
- const senderLabel = message.author?.username ?? message.authorId;
999
- runtime.log?.(
1000
- `[ws] \u2190 message:new from ${senderLabel} in channel ${message.channelId}: "${message.content?.slice(0, 60)}" (${message.id})`
1001
- );
1002
- if (stopped) {
1003
- runtime.log?.("[ws] Monitor stopped, ignoring message");
1004
- return;
1005
- }
1006
- if (allChannelIds.length > 0 && !allChannelIds.includes(message.channelId)) {
1007
- runtime.log?.(`[ws] Message from unmonitored channel ${message.channelId}, ignoring`);
1008
- return;
1009
- }
1010
- const processWithRetry = async (attempt = 0) => {
1011
- try {
1012
- await processShadowMessage({
1013
- message,
1014
- account,
1015
- accountId,
1016
- config,
1017
- runtime,
1018
- core,
1019
- botUserId,
1020
- botUsername: me.username,
1021
- agentId,
1022
- channelPolicies,
1023
- channelServerMap,
1024
- socket
1025
- });
1026
- } catch (err) {
1027
- const MAX_RETRIES = 2;
1028
- runtime.error?.(`[ws] Message processing failed (attempt ${attempt + 1}): ${String(err)}`);
1029
- if (attempt < MAX_RETRIES) {
1030
- await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
1031
- return processWithRetry(attempt + 1);
1032
- }
1033
- runtime.error?.(
1034
- `[ws] Message permanently failed after ${MAX_RETRIES + 1} attempts: ${message.id}`
1035
- );
1036
- }
1037
- };
1038
- void processWithRetry();
1039
- });
1040
- socket.connect();
1041
- const stop = () => {
1042
- runtime.log?.("[lifecycle] Stopping Shadow monitor...");
1043
- stopped = true;
1044
- if (heartbeatInterval) clearInterval(heartbeatInterval);
1045
- socket.disconnect();
1046
- runtime.log?.("[lifecycle] Shadow monitor stopped");
1047
- };
1048
- abortSignal.addEventListener("abort", stop, { once: true });
1049
- await new Promise((resolve) => {
1050
- if (abortSignal.aborted) {
1051
- resolve();
1052
- return;
1053
- }
1054
- abortSignal.addEventListener("abort", () => resolve(), { once: true });
1055
- });
1056
- return { stop };
1057
- }
1058
-
1059
- export {
1060
- setShadowRuntime,
1061
- getShadowRuntime,
1062
- tryGetShadowRuntime,
1063
- monitorShadowProvider
1064
- };