comfy-pr 0.2.27 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +258 -157
  2. package/bot/IdleWaiter.ts +31 -0
  3. package/bot/RestartManager.spec.ts +163 -0
  4. package/bot/RestartManager.ts +190 -0
  5. package/bot/WorkingTasksManager.spec.ts +154 -0
  6. package/bot/cli.ts +1219 -0
  7. package/bot/codesearch-ai-wip.ts +351 -0
  8. package/bot/error-collector.ts +133 -0
  9. package/bot/index.ts +14 -0
  10. package/bot/restart-example.ts +99 -0
  11. package/bot/slack-bolt.ts +688 -0
  12. package/bot/slack-bot.ts +1499 -0
  13. package/bot/state.ts +19 -0
  14. package/bot/templateLoader.test.ts +84 -0
  15. package/bot/templateLoader.ts +92 -0
  16. package/next.config.ts +26 -0
  17. package/package.json +164 -41
  18. package/post-summary.ts +44 -0
  19. package/src/Authors.ts +2 -2
  20. package/src/CMNodes.ts +3 -3
  21. package/src/CNRepos.ts +30 -7
  22. package/src/CRNodes.ts +11 -9
  23. package/src/CRPulls.ts +3 -0
  24. package/src/EmailTasks.ts +27 -9
  25. package/src/FORK_OWNER.ts +3 -1
  26. package/src/FollowRules.ts +3 -3
  27. package/src/GithubIssueComments.ts +1 -1
  28. package/src/Totals.ts +9 -8
  29. package/src/WorkerInstances.ts +31 -8
  30. package/src/addCommentAction.ts +19 -11
  31. package/src/analyzePullsStatus.ts +39 -17
  32. package/src/analyzeTotals.ts +19 -11
  33. package/src/bypassRepos.ts +6 -5
  34. package/src/checkPRsFailures.ts +13 -8
  35. package/src/cli.test.ts +2 -0
  36. package/src/cli.ts +1 -1
  37. package/src/constants.ts +2 -0
  38. package/src/createComfyRegistryPRsFromCandidates.ts +20 -12
  39. package/src/createComfyRegistryPullRequests.ts +35 -10
  40. package/src/createGithubForkForRepo.ts +36 -11
  41. package/src/createGithubPullRequest.ts +83 -33
  42. package/src/createIssueComment.ts +4 -4
  43. package/src/fetchComfyRegistryNodes.ts +34 -4
  44. package/src/fetchCurrentGeoInfo.ts +3 -1
  45. package/src/fetchRelatedPullWithComments.ts +1 -1
  46. package/src/fetchRepoDescriptionMap.ts +1 -4
  47. package/src/followRuleSchema.ts +10 -4
  48. package/src/getBranchWorkingDir.ts +3 -7
  49. package/src/getRepoWorkingDir.ts +2 -3
  50. package/src/gh.spec.ts +571 -0
  51. package/src/ghData.ts +13 -0
  52. package/src/ghPageFlow.ts +33 -0
  53. package/src/ghSubscriber.ts +76 -0
  54. package/src/ghUser.ts +28 -4
  55. package/src/index.ts +13 -4
  56. package/src/initializeFollowRules.ts +11 -4
  57. package/src/logger.spec.ts +95 -0
  58. package/src/logger.ts +46 -0
  59. package/src/makeGpl3LicenseBranch.ts +66 -0
  60. package/src/makePublishBranch.ts +21 -15
  61. package/src/makeTomlBranch.ts +48 -14
  62. package/src/makeUpdateTomlLicenseBranch.ts +40 -44
  63. package/src/matchRelatedPulls.ts +9 -4
  64. package/src/normalizeGithubUrl.spec.ts +131 -0
  65. package/src/normalizeGithubUrl.ts +64 -0
  66. package/src/parseIssueUrl.spec.ts +95 -0
  67. package/src/parseIssueUrl.ts +17 -3
  68. package/src/parseOwnerRepo.spec.ts +147 -0
  69. package/src/parseOwnerRepo.ts +8 -5
  70. package/src/parsePullsState.ts +2 -2
  71. package/src/parseTitleBodyOfMarkdown.ts +1 -1
  72. package/src/pickRepoInfo.ts +37 -0
  73. package/src/postSlackMessage.ts +5 -1
  74. package/src/preload.ts +30 -27
  75. package/src/readTemplateTitle.ts +1 -3
  76. package/src/sendEmailAction.ts +14 -10
  77. package/src/sendGmail.ts +3 -3
  78. package/src/updateAuthorsForGithub.ts +53 -35
  79. package/src/updateAuthorsFromCNRepo.ts +12 -5
  80. package/src/updateCMNodesDuplicationWarnings.ts +14 -9
  81. package/src/updateCMRepos.ts +1 -1
  82. package/src/updateCNRepos.ts +14 -14
  83. package/src/updateCNReposCRPullsComments.ts +7 -5
  84. package/src/updateCNReposInfo.ts +49 -40
  85. package/src/updateCNReposPRCandidate.ts +3 -3
  86. package/src/updateCNReposPulls.ts +9 -5
  87. package/src/updateCNReposPullsDashboard.ts +11 -9
  88. package/src/updateCNReposRelatedPulls.ts +3 -3
  89. package/src/updateCRNodes.ts +24 -5
  90. package/src/updateCRRepos.ts +1 -1
  91. package/src/updateComfyTotals.ts +7 -5
  92. package/src/updateFollowRuleSet.ts +17 -9
  93. package/src/updateOutdatedPullsTemplates.ts +61 -48
  94. package/src/updateSlackMessages.ts +8 -4
  95. package/tailwind.config.ts +7 -1
  96. package/next-env.d.ts +0 -5
  97. package/src/GIT_USEREMAIL.ts +0 -5
  98. package/src/GIT_USERNAME.ts +0 -9
  99. package/src/clone_modify_push_Branches.ts +0 -21
  100. package/src/tomlFillDescription.ts +0 -17
@@ -0,0 +1,1499 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * ComfyPR Bot
5
+ *
6
+ * Slack
7
+
8
+ */
9
+ import { slack } from "@/lib";
10
+ import { yaml } from "@/src/utils/yaml";
11
+ import { SocketModeClient } from "@slack/socket-mode";
12
+ import {} from "@slack/bolt";
13
+ import DIE from "@snomiao/die";
14
+ import { compareBy } from "comparing";
15
+ import { mkdir } from "fs/promises";
16
+ import sflow from "sflow";
17
+ import winston from "winston";
18
+ import zChatCompletion from "../lib/zChat";
19
+ import z from "zod";
20
+ import { IdleWaiter } from "./IdleWaiter";
21
+ import { RestartManager } from "./RestartManager";
22
+ import { parseSlackMessageToMarkdown } from "@/lib/slack/parseSlackMessageToMarkdown";
23
+ import { slackTsToISO } from "@/lib/slack/slackTsToISO";
24
+ import { safeSlackPostMessage, safeSlackUpdateMessage } from "@/lib/slack/safeSlackMessage";
25
+ import { slackMessageUrlParse } from "@/app/tasks/gh-design/slackMessageUrlParse";
26
+ import minimist from "minimist";
27
+ import { loadClaudeMd, loadSkills } from "./templateLoader";
28
+ import { appendFile } from "fs/promises";
29
+ import fsp from "fs/promises";
30
+ import { mdFmt } from "@/app/tasks/gh-desktop-release-notification/upsertSlackMessage";
31
+ import { getSlackChannelName } from "@/lib/slack";
32
+ import { SlackBotState } from "./state";
33
+ import { ErrorCollector } from "./error-collector";
34
+ import { query, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
35
+
36
+ export const SLACK_ORG_DOMAIN_NAME = "comfy-organization";
37
+ // Configure winston logger
38
+ const logDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
39
+ const logger = winston.createLogger({
40
+ level: process.env.VERBOSE ? "debug" : process.env.LOG_LEVEL || "info",
41
+ format: winston.format.combine(
42
+ winston.format.timestamp(),
43
+ winston.format.errors({ stack: true }),
44
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
45
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
46
+ return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr ? "\n" + metaStr : ""}`;
47
+ }),
48
+ ),
49
+ transports: [
50
+ new winston.transports.Console({
51
+ format: winston.format.combine(
52
+ winston.format.colorize(),
53
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
54
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
55
+ return `[${timestamp}] ${level}: ${message}${metaStr ? "\n" + metaStr : ""}`;
56
+ }),
57
+ ),
58
+ }),
59
+ new winston.transports.File({
60
+ filename: `./.logs/bot-${logDate}.log`,
61
+ level: "debug",
62
+ format: winston.format.combine(
63
+ winston.format.timestamp(),
64
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
65
+ const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : "";
66
+ return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr ? "\n" + metaStr : ""}`;
67
+ }),
68
+ ),
69
+ }),
70
+ ],
71
+ });
72
+
73
+ const TaskInputFlows = new Map<string, TransformStream<string, string>>();
74
+ // https://comfy-pr-bot.pages.dev/
75
+ // Slack block type definition
76
+ const zSlackBlock = z
77
+ .object({
78
+ type: z.string(),
79
+ block_id: z.string().optional(),
80
+ elements: z.array(z.unknown()).optional(),
81
+ })
82
+ .passthrough();
83
+
84
+ // Slack attachment type definition
85
+ const zSlackAttachment = z
86
+ .object({
87
+ title: z.string().optional(),
88
+ title_link: z.string().optional(),
89
+ text: z.string().optional(),
90
+ fallback: z.string().optional(),
91
+ image_url: z.string().optional(),
92
+ from_url: z.string().optional(),
93
+ })
94
+ .passthrough();
95
+
96
+ const zAppMentionEvent = z.object({
97
+ type: z.literal("app_mention"),
98
+ user: z.string(),
99
+ ts: z.string(),
100
+ client_msg_id: z.string().optional(),
101
+ text: z.string(),
102
+ team: z.string(),
103
+ thread_ts: z.string().optional(),
104
+ parent_user_id: z.string().optional(),
105
+ blocks: z.array(zSlackBlock),
106
+ channel: z.string(),
107
+ assistant_thread: z.unknown().optional(),
108
+ attachments: z.array(zSlackAttachment).optional(),
109
+ event_ts: z.string(),
110
+ });
111
+
112
+ // Helper functions to manage current working tasks
113
+ async function addWorkingTask(event: z.infer<typeof zAppMentionEvent>) {
114
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
115
+ workingMessageEvents: [],
116
+ };
117
+ const events = workingTasks.workingMessageEvents || [];
118
+
119
+ // Check if event already exists (by ts and channel)
120
+ const exists = events.some(
121
+ (e: z.infer<typeof zAppMentionEvent>) => e.ts === event.ts && e.channel === event.channel,
122
+ );
123
+ if (!exists) {
124
+ events.push(event);
125
+ await SlackBotState.set("current-working-tasks", { workingMessageEvents: events });
126
+ logger.info(`Added task to working list: ${event.ts} (total: ${events.length})`);
127
+ }
128
+ }
129
+
130
+ async function removeWorkingTask(event: z.infer<typeof zAppMentionEvent>) {
131
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
132
+ workingMessageEvents: [],
133
+ };
134
+ const events = workingTasks.workingMessageEvents || [];
135
+
136
+ // Remove event by ts and channel
137
+ const filtered = events.filter(
138
+ (e: z.infer<typeof zAppMentionEvent>) => !(e.ts === event.ts && e.channel === event.channel),
139
+ );
140
+ await SlackBotState.set("current-working-tasks", { workingMessageEvents: filtered });
141
+ logger.info(`Removed task from working list: ${event.ts} (remaining: ${filtered.length})`);
142
+ }
143
+ const g = globalThis as typeof globalThis & { instanceId?: string; hotId?: string };
144
+ const now = new Date().toISOString();
145
+ g.instanceId ??= now;
146
+ g.hotId = now;
147
+
148
+ if (import.meta.main) {
149
+ await startSlackBot();
150
+ }
151
+
152
+ export async function startSlackBot() {
153
+ console.log("Starting ComfyPR Bot...");
154
+ const argv = minimist(process.argv.slice(2));
155
+ const port = Number(process.env.PRBOT_PORT || DIE("missing env.PRBOT_PORT"));
156
+
157
+ // Step 1: Health check (only for non-PTY launches)
158
+ const isHumanLaunched = process.stdin.isTTY;
159
+
160
+ if (!isHumanLaunched) {
161
+ // Non-PTY launch (PM2): Poll for 10 seconds to ensure port is continuously unhealthy
162
+ logger.info(`Detected non-PTY launch - polling for 10s to ensure port ${port} is unhealthy`);
163
+
164
+ const pollDuration = 10000; // 10 seconds
165
+ const pollInterval = 1000; // 1 second
166
+ const startTime = Date.now();
167
+ let healthyInstanceFound = false;
168
+
169
+ while (Date.now() - startTime < pollDuration) {
170
+ try {
171
+ const statusResp = await fetch(`http://localhost:${port}/status`, {
172
+ signal: AbortSignal.timeout(1000),
173
+ });
174
+
175
+ if (statusResp.ok) {
176
+ // Found a healthy instance - abort and exit
177
+ const statusData = await statusResp.json();
178
+ healthyInstanceFound = true;
179
+
180
+ // Try to get PID of existing process
181
+ let existingPid = "unknown";
182
+ try {
183
+ const lsofOutput = await Bun.$`lsof -ti:${port}`.text();
184
+ existingPid = lsofOutput.trim();
185
+ } catch {}
186
+
187
+ logger.info(
188
+ `Healthy instance detected (PID: ${existingPid}) - aborting launch to avoid conflict`,
189
+ );
190
+ logger.info(`Status: ${JSON.stringify(statusData)}`);
191
+ process.exit(0);
192
+ }
193
+ } catch (err) {
194
+ // Port is unhealthy/unreachable - this is expected
195
+ logger.debug(
196
+ `Health check: port ${port} is unhealthy (${Date.now() - startTime}ms elapsed)`,
197
+ );
198
+ }
199
+
200
+ await sleep(pollInterval);
201
+ }
202
+
203
+ if (!healthyInstanceFound) {
204
+ logger.info(`Port ${port} remained unhealthy for 10s - proceeding to launch`);
205
+ }
206
+ } else {
207
+ // PTY launch (human): Skip health check entirely
208
+ logger.info(`Detected PTY launch - skipping health check`);
209
+ }
210
+
211
+ // Step 2: Kill port and launch
212
+ logger.info(`Killing port ${port} and starting server`);
213
+ await Bun.$`npx -y kill-port ${port}`;
214
+
215
+ const server = Bun.serve({
216
+ port: port,
217
+ fetch: async (req: Request) => {
218
+ const url = new URL(req.url);
219
+
220
+ if (url.pathname === "/status") {
221
+ // Get current working tasks from state
222
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
223
+ workingMessageEvents: [],
224
+ };
225
+ const events = workingTasks.workingMessageEvents || [];
226
+
227
+ // Build message URLs from events
228
+ const processing_message_urls = events.map((event: z.infer<typeof zAppMentionEvent>) => {
229
+ const tsForUrl = event.ts.replace(".", "");
230
+ return `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${tsForUrl}`;
231
+ });
232
+
233
+ const status = {
234
+ status: TaskInputFlows.size === 0 ? "idle" : "busy",
235
+ processing_message_urls,
236
+ processing_message_urls_count: processing_message_urls.length,
237
+ };
238
+
239
+ return new Response(JSON.stringify(status, null, 2), {
240
+ status: 200,
241
+ headers: { "Content-Type": "application/json" },
242
+ });
243
+ }
244
+
245
+ return new Response("ComfyPR Bot is running.\n", { status: 200 });
246
+ },
247
+ });
248
+
249
+ // const missedMsg = "https://comfy-organization.slack.com/archives/C09QKKXK8RX/p1767849032076329?thread_ts=1767838632.470639&cid=C09QKKXK8RX"
250
+ // const missedMsg = "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1767893546609709?thread_ts=1767862331.962569&cid=C0A4XMHANP3"
251
+ // await spawnBotOnSlackMessageUrl(
252
+ // "https://comfy-organization.slack.com/archives/D09GGTE7S00/p1769576340892099",
253
+ // );
254
+
255
+ const msgs = await fsp
256
+ .readFile("./inbox.yaml", "utf-8")
257
+ .then((s) => yaml.parse(s))
258
+ .then((e) => z.object({ missed: z.string().array() }).parseAsync(e));
259
+ await fsp.writeFile("./inbox.yaml", "missed: []");
260
+
261
+ // clean the file
262
+ //
263
+ sflow(msgs.missed)
264
+ .forEach((url) => spawnBotOnSlackMessageUrl(url))
265
+ .run();
266
+
267
+ if (argv.continue) {
268
+ async () => {
269
+ logger.info("BOT - --continue flag detected, resuming crashed tasks...");
270
+
271
+ // Read current working tasks from state
272
+ const workingTasks = (await SlackBotState.get("current-working-tasks")) || {
273
+ workingMessageEvents: [],
274
+ };
275
+ const events = workingTasks.workingMessageEvents || [];
276
+
277
+ if (events.length === 0) {
278
+ logger.info("No working tasks to resume");
279
+ } else {
280
+ logger.info(`Found ${events.length} working task(s) to resume`);
281
+
282
+ for await (const event of events) {
283
+ if (event && event.ts) {
284
+ logger.info(
285
+ `Resuming task for event ${event.ts} in channel ${await getSlackChannelName(event.channel)}, text: ${event.text}`,
286
+ );
287
+ await spawnBotOnSlackMessageEvent(event).catch((err) => {
288
+ logger.error(`Error resuming task for event ${event.ts}`, { err });
289
+ });
290
+ }
291
+ }
292
+ }
293
+ };
294
+ }
295
+
296
+ logger.info(`Starting ComfyPR Bot... id: ${g.instanceId}, hotId: ${g.hotId}`);
297
+
298
+ // Setup smart restart manager (only restart when bot is idle)
299
+ if (!argv["no-watch"]) {
300
+ const restartManager = new RestartManager({
301
+ watchPaths: ["bot", "src", "lib"],
302
+ isIdle: () => TaskInputFlows.size === 0,
303
+ onRestart: () => {
304
+ logger.warn("🔄 Restarting bot process...");
305
+ process.exit(0);
306
+ },
307
+ idleCheckInterval: 5000,
308
+ debounceDelay: 1000,
309
+ logger: {
310
+ info: (msg, meta) => logger.info(`[RestartManager] ${msg}`, meta),
311
+ warn: (msg, meta) => logger.warn(`[RestartManager] ${msg}`, meta),
312
+ },
313
+ });
314
+ restartManager.start();
315
+ logger.info("Smart restart manager enabled (use --no-watch to disable)");
316
+ }
317
+
318
+ // Initialize Socket Mode client with app-level token
319
+ const socketModeClient = new SocketModeClient({
320
+ appToken: process.env.SLACK_SOCKET_TOKEN || DIE("missing env.SLACK_SOCKET_TOKEN"),
321
+ });
322
+
323
+ // Handle all events via events_api envelope, https://docs.slack.dev/reference/events/message
324
+ socketModeClient
325
+ .on("app_mention", async ({ event, body, ack }) => {
326
+ const parsedEvent = await zAppMentionEvent.parseAsync(event);
327
+
328
+ // Acknowledge the event as its parsed
329
+ await ack();
330
+ await spawnBotOnSlackMessageEvent(parsedEvent);
331
+ })
332
+ .on("message", async ({ event, body, ack }) => {
333
+ // bot-1 | msg: {"type":"message","user":"U04F3GHTG2X","ts":"1767100459.669809","client_msg_id":"2fed13c0-9739-4888-a4f6-b876c25f1407","text":"test","team":"T0462DJ9G3C","blocks":[{"type":"rich_text","block_id":"gB9fq","elements":[{"type":"rich_text_section","elements":[{"type":"text","text":"test"}]}]}],"channel":"C0A6Y4AU52L","event_ts":"1767100459.669809","channel_type":"channel"}
334
+ // Parse the message event
335
+ const zSlackMessage = z
336
+ .object({
337
+ type: z.literal("message"),
338
+ user: z.string().optional(),
339
+ ts: z.string().optional(),
340
+ client_msg_id: z.string().optional(),
341
+ text: z.string().optional(),
342
+ team: z.string().optional(),
343
+ thread_ts: z.string().optional(),
344
+ parent_user_id: z.string().optional(),
345
+ blocks: z.array(zSlackBlock).optional(),
346
+ channel: z.string().optional(),
347
+ channel_type: z.string().optional(),
348
+ assistant_thread: z.unknown().optional(),
349
+ attachments: z.array(zSlackAttachment).optional(),
350
+ event_ts: z.string().optional(),
351
+ bot_id: z.string().optional(),
352
+ })
353
+ .passthrough();
354
+
355
+ const messageEvent = zSlackMessage.parse(event);
356
+
357
+ logger.debug("MESSAGE EVENT", { event });
358
+ logger.debug("parsed_text: " + (await parseSlackMessageToMarkdown(messageEvent.text || "")));
359
+
360
+ await ack();
361
+
362
+ // Skip bot messages
363
+ if (messageEvent.bot_id) {
364
+ return;
365
+ }
366
+
367
+ // Get my bot user ID
368
+ const botUsername = "comfyprbot";
369
+ // TODO: fetch botUserId by botUsername or use slack api to "get my name"
370
+ const botUserId = process.env.SLACK_BOT_USER_ID || "U078499LK5K"; // ComfyPR-Bot user ID
371
+
372
+ // Check if message mentions the bot
373
+ const text = messageEvent.text || "";
374
+ const hasBotMention = text.includes(`<@${botUserId}>`);
375
+
376
+ // Handle DM messages (channel_type: "im") and treat them like app mentions
377
+ const isDM = messageEvent.channel_type === "im" || messageEvent.channel_type === "mpdm";
378
+
379
+ if (
380
+ (isDM || hasBotMention) &&
381
+ messageEvent.user &&
382
+ messageEvent.text &&
383
+ messageEvent.channel &&
384
+ messageEvent.ts &&
385
+ messageEvent.team &&
386
+ messageEvent.event_ts
387
+ ) {
388
+ const eventType = isDM ? "DM" : "BOT MENTION";
389
+ logger.debug(`${eventType} DETECTED - Processing message as app_mention`, {
390
+ channel: messageEvent.channel,
391
+ ts: messageEvent.ts,
392
+ text: text.substring(0, 100),
393
+ });
394
+
395
+ const mentionEvent: z.infer<typeof zAppMentionEvent> = {
396
+ type: "app_mention" as const,
397
+ user: messageEvent.user,
398
+ ts: messageEvent.ts,
399
+ client_msg_id: messageEvent.client_msg_id,
400
+ text: messageEvent.text,
401
+ team: messageEvent.team,
402
+ thread_ts: messageEvent.thread_ts,
403
+ parent_user_id: messageEvent.parent_user_id,
404
+ blocks: messageEvent.blocks || [],
405
+ channel: messageEvent.channel,
406
+ assistant_thread: messageEvent.assistant_thread,
407
+ attachments: messageEvent.attachments,
408
+ event_ts: messageEvent.event_ts,
409
+ };
410
+ await spawnBotOnSlackMessageEvent(mentionEvent);
411
+ }
412
+ })
413
+ .on("error", (error) => {
414
+ logger.error("Socket Mode error", { error });
415
+ })
416
+ .on("connect", () => logger.info("SOCKET - Slack connected"))
417
+ .on("disconnect", () => logger.info("SOCKET - Slack disconnected"))
418
+ .on("ready", () => logger.info("SOCKET - Ready to receive events"));
419
+
420
+ logger.info("BOT - Connecting to Slack Socket Mode...");
421
+ await socketModeClient.start();
422
+ logger.info("BOT - socketModeClient.start() returned");
423
+ return socketModeClient;
424
+ }
425
+ async function spawnBotOnSlackMessageEvent(event: z.infer<typeof zAppMentionEvent>) {
426
+ // msg dedup for same content
427
+ const eventProcessed = await SlackBotState.get(`msg-${event.ts}`);
428
+ // if (eventProcessed?.content === event.text) return;
429
+ if (+new Date() - (eventProcessed?.touchedAt ?? 0) <= 10e3) return; // debounce for 10s
430
+ await SlackBotState.set(`msg-${event.ts}`, { touchedAt: +new Date(), content: event.text });
431
+
432
+ logger.info(
433
+ await parseSlackMessageToMarkdown(
434
+ `SPAWN - Received Slack app_mention event in channel <#${event.channel}> from user <@${event.user}>`,
435
+ ),
436
+ );
437
+
438
+ // whitelist channel name #comfypr-bot, for security reason, only runs agent against @mention messages in #comfyprbot channel
439
+ // you can forward other channel messages to #comfyprbot if needed, and the bot will read the context from the original thread messages
440
+ // DMs are also allowed to spawn agents directly
441
+ const channelInfo = await slack.conversations.info({ channel: event.channel });
442
+ const channelName = channelInfo.channel?.name;
443
+ const isDM = channelInfo.channel?.is_im === true;
444
+ const isAgentChannel = isDM || channelName?.match(/^(comfypr-bot|pr-bot)\b/); //starts with comfyprbot or pr-bot, will spawn agent without requireing @mention
445
+
446
+ const user =
447
+ (await slack.users.info({ user: event.user })).user ||
448
+ DIE("failed to fetch user info of <@" + event.user + ">");
449
+ if (user?.is_restricted || user?.is_ultra_restricted) {
450
+ logger.info(`User ${event.user} is a guest user, skipping processing.`);
451
+ return;
452
+ }
453
+
454
+ const username =
455
+ user.name ||
456
+ user.id?.replace(/(.*)/, "<@$1>") ||
457
+ DIE("failed to get username of <@" + event.user + ">");
458
+
459
+ // task state
460
+ const workspaceId = event.thread_ts || event.ts;
461
+ const eventId = event.channel + "_" + event.ts;
462
+ logger.info(
463
+ `Processing Slack app_mention event in channel ${event.channel} (isAgentChannel: ${isAgentChannel}) with workspaceId: ${workspaceId}`,
464
+ );
465
+ const botWorkingDir = `/bot/slack/${sanitized(channelName || username)}/${workspaceId.replace(".", "-")}`;
466
+ const task = await SlackBotState.get(`task-${workspaceId}`);
467
+
468
+ // Allow append messages to running task
469
+
470
+ // grab 100 most nearby messages in this thread or channel
471
+ const nearbyMessagesResp = await slack.conversations.replies({
472
+ channel: event.channel,
473
+ ts: workspaceId,
474
+ limit: 100,
475
+ });
476
+ // Type definitions for Slack message components
477
+ type SlackFile = {
478
+ name?: string;
479
+ title?: string;
480
+ mimetype?: string;
481
+ size?: number;
482
+ url_private?: string;
483
+ permalink?: string;
484
+ };
485
+
486
+ type SlackReaction = {
487
+ name?: string;
488
+ count?: number;
489
+ };
490
+
491
+ const nearbyMessages = (
492
+ await sflow(nearbyMessagesResp.messages || [])
493
+ .map(async (m) => ({
494
+ username: await slack.users
495
+ .info({ user: m.user || DIE("missing user id in message") })
496
+ .then((res) => res.user?.name || "<@" + m.user + ">"),
497
+ markdown: await parseSlackMessageToMarkdown(m.text || ""),
498
+ ts: m.ts,
499
+ iso: slackTsToISO(m.ts || DIE("missing ts")),
500
+ ...(m.files &&
501
+ m.files.length > 0 && {
502
+ files: m.files.map((f: unknown) => {
503
+ const file = f as SlackFile;
504
+ return {
505
+ name: file.name,
506
+ title: file.title,
507
+ mimetype: file.mimetype,
508
+ size: file.size,
509
+ url_private: file.url_private,
510
+ permalink: file.permalink,
511
+ };
512
+ }),
513
+ }),
514
+ ...(m.attachments &&
515
+ m.attachments.length > 0 && {
516
+ attachments: await Promise.all(
517
+ m.attachments.map(async (a: unknown) => {
518
+ const attachment = a as z.infer<typeof zSlackAttachment>;
519
+ // Parse from_url to extract channel name
520
+ let from_channel: string | undefined;
521
+ if (attachment.from_url) {
522
+ try {
523
+ const parsed = slackMessageUrlParse(attachment.from_url);
524
+ const channelInfo = await slack.conversations.info({ channel: parsed.channel });
525
+ from_channel = channelInfo.channel?.name
526
+ ? `#${channelInfo.channel.name}`
527
+ : undefined;
528
+ } catch {
529
+ // Ignore parsing errors
530
+ }
531
+ }
532
+ return {
533
+ title: attachment.title,
534
+ title_link: attachment.title_link,
535
+ text: attachment.text
536
+ ? await parseSlackMessageToMarkdown(attachment.text)
537
+ : undefined,
538
+ fallback: attachment.fallback
539
+ ? await parseSlackMessageToMarkdown(attachment.fallback)
540
+ : undefined,
541
+ image_url: attachment.image_url,
542
+ from_url: attachment.from_url,
543
+ from_channel, // Add resolved channel name
544
+ };
545
+ }),
546
+ ),
547
+ }),
548
+ ...(m.reactions &&
549
+ m.reactions.length > 0 && {
550
+ reactions: m.reactions.map((r: unknown) => {
551
+ const reaction = r as SlackReaction;
552
+ return {
553
+ name: reaction.name,
554
+ count: reaction.count,
555
+ };
556
+ }),
557
+ }),
558
+ }))
559
+ .toArray()
560
+ ).toSorted(compareBy((e) => +(e.ts || 0))); // sort by ts asc
561
+
562
+ const existedTaskInputFlow = TaskInputFlows.get(workspaceId);
563
+ if (existedTaskInputFlow && false) {
564
+ // disable for now, lets use --queue to serialize tasks
565
+ // while agent still running, user sent new message in the same thread very quickly
566
+ // lets understand the user's intent and give a quick response, and then append the msg to the existing agent input flow
567
+ // const threadMessages = await pageFlow(undefined as undefined | string, async (cursor, limit = 100) => {
568
+ // const resp = await slack.conversations.replies({
569
+ // channel: event.channel,
570
+ // ts: workspaceId,
571
+ // cursor,
572
+ // limit,
573
+ // });
574
+ // return {
575
+ // data: resp.messages || [],
576
+ // next: resp.response_metadata?.next_cursor,
577
+ // };
578
+ // })
579
+ // .flat()
580
+ // .map(async (m) => ({
581
+ // ts: slackTsToISO(m.ts || DIE("missing ts")),
582
+ // username: await slack.users
583
+ // .info({ user: m.user || DIE("missing user id in message") })
584
+ // .then((res) => res.user?.name || "<@" + m.user + ">"),
585
+ // markdown: await parseSlackMessageToMarkdown(m.text || ""),
586
+ // }))
587
+ // .toArray();
588
+
589
+ // use LLM to understand the new message intent
590
+ const action = await zChatCompletion(
591
+ z.object({
592
+ user_intent: z.string(),
593
+ my_quick_respond: z.string(),
594
+ stop_existing_task: z.boolean(),
595
+ msg_to_append_to_agent: z.string(),
596
+ }),
597
+ { model: "gpt-4o" },
598
+ )`
599
+ The user sent a new message in a Slack thread where I am already assisting them with an ongoing task. The new message is as follows:
600
+ ${event.text}
601
+
602
+ The thread's recent messages are:
603
+ ${((data: string) => {
604
+ logger.debug("Thread messages:", { data });
605
+ return data;
606
+ })(
607
+ yaml.stringify(
608
+ nearbyMessages.toSorted(compareBy((e) => +(e.ts || 0))), // sort by ts asc
609
+ ),
610
+ )}
611
+
612
+ Based on the new message and the thread context,
613
+
614
+ Please analyze the new message and determine:
615
+ 1. The user's intent behind this new message.
616
+ 2. A quick response I can send to the user right away to acknowledge their new message.
617
+ 3. Whether I should append this new message to the existing task's input flow for further processing.
618
+ 4. Whether I should stop the existing task based on this new message.
619
+
620
+ Respond in JSON format with the following fields:
621
+ - user_intent: A brief description of the user's intent regarding the new message.
622
+ - my_quick_respond: A short message I can send to the user immediately.
623
+ - stop_existing_task: true or false, indicating whether to stop the existing task.
624
+ - msg_to_append_to_agent: The content of the new message to append to the existing task's input flow. Use empty string "" if not applicable.
625
+ `;
626
+ logger.info("New message intent analysis", { action });
627
+
628
+ // send quick response
629
+ const myQuickRespondMsg = await safeSlackPostMessage(slack, {
630
+ channel: event.channel,
631
+ thread_ts: event.ts,
632
+ text: action.my_quick_respond, // Fallback text for notifications
633
+ blocks: [
634
+ {
635
+ type: "markdown",
636
+ text: action.my_quick_respond,
637
+ },
638
+ ],
639
+ });
640
+
641
+ if (action.stop_existing_task) {
642
+ // stop existing task
643
+ TaskInputFlows.delete(workspaceId);
644
+ await safeSlackPostMessage(slack, {
645
+ channel: event.channel,
646
+ thread_ts: event.thread_ts || event.ts,
647
+ text: `The existing task has been stopped as per your request.`, // Fallback text for notifications
648
+ blocks: [
649
+ {
650
+ type: "markdown",
651
+ text: `The existing task has been stopped as per your request.`,
652
+ },
653
+ ],
654
+ });
655
+ await SlackBotState.set(`task-${workspaceId}`, {
656
+ ...(await SlackBotState.get(`task-${workspaceId}`)),
657
+ status: "stopped_by_user",
658
+ });
659
+
660
+ // Remove task from working list
661
+ await removeWorkingTask(event);
662
+
663
+ return "existing task stopped by user";
664
+ }
665
+ if (action.msg_to_append_to_agent && action.msg_to_append_to_agent.trim()) {
666
+ if (!existedTaskInputFlow) {
667
+ logger.warn("No existing task input flow found");
668
+ return;
669
+ }
670
+ const w = existedTaskInputFlow!.writable.getWriter();
671
+ await w.write(
672
+ await parseSlackMessageToMarkdown(
673
+ `New message from <@${event.user}> in the thread:\n${event.text}\n\nMy quick response to the user: ${action.my_quick_respond}\n\n`,
674
+ ),
675
+ );
676
+ w.releaseLock();
677
+ logger.info(`Appended new message to existing task ${workspaceId} input flow`);
678
+ return "msg appended to existing task";
679
+ }
680
+ return;
681
+ }
682
+
683
+ const taskInputFlow = new TransformStream<string, string>();
684
+ TaskInputFlows.set(workspaceId, taskInputFlow); // able to append more inputs later
685
+
686
+ // mark that msg as seeing
687
+ await SlackBotState.set(`task-${workspaceId}`, {
688
+ ...(await SlackBotState.get(`task-${workspaceId}`)),
689
+ status: "checking",
690
+ event,
691
+ startTime: Date.now(),
692
+ });
693
+ await slack.reactions
694
+ .add({ name: "eyes", channel: event.channel, timestamp: event.ts })
695
+ .catch(() => {});
696
+
697
+ // quick-intent-detect-respond by chatgpt, give quick plan/context responds before start heavy agent work
698
+ const resp = await zChatCompletion(
699
+ z.object({
700
+ user_intent: z.string(),
701
+ my_respond_before_spawn_agent: z.string(),
702
+ should_spawn_agent: z.boolean(),
703
+ }),
704
+ {
705
+ model: "gpt-4o",
706
+ },
707
+ )`
708
+ The user mentioned me with the following message in Slack: ${event.text}
709
+ Based on this message, please determine the user's intent in a concise manner.
710
+ Also, provide a brief response that I can send to the user immediately to acknowledge their request.
711
+ Finally, I will spawn an agent to help with this request if necessary.
712
+
713
+ For context, Recent messages from this thread are as follows:
714
+ ${nearbyMessages.map((m) => `- User ${m.username} said: ${JSON.stringify(m.markdown)}`).join("\n\n")}
715
+
716
+ Possible Context Repos:
717
+ - https://github.com/comfyanonymous/ComfyUI: The main ComfyUI repository containing the core application logic and features. Its a python backend to run unknown machine learning models and solves various machine learning tasks.
718
+ - https://github.com/Comfy-Org/ComfyUI_frontend: The frontend codebase for ComfyuUI, built with Vue and TypeScript.
719
+ - https://github.com/Comfy-Org/docs: Documentation for ComfyUI, including setup guides, tutorials, and API references.
720
+ - https://github.com/Comfy-Org/desktop: The desktop application for ComfyUI, providing a user-friendly interface and additional functionalities.
721
+ - https://github.com/Comfy-Org/registry: The registry.comfy.org, where users can share and discover ComfyUI custom-nodes, and extensions.
722
+ - https://github.com/Comfy-Org/workflow_templates: A collection of official shared workflow templates for ComfyUI to help users get started quickly.
723
+
724
+ - https://github.com/Comfy-Org/comfy-api: A RESTful API service for comfy-registry, it stores custom-node metadatas and user profile/billings informations.
725
+
726
+ - And also other repos under Comfy-Org organization on GitHub.
727
+
728
+ Respond in JSON format with the following fields:
729
+ - user_intent: A brief description of the user's intent. e.g. "The user is asking for help with setting up a CI/CD pipeline."
730
+ - my_respond_before_spawn_agent: A short message I can send to the user right away. e.g. "Got it, let me look into that for you."
731
+ - should_spawn_agent: true if further research needed
732
+ `;
733
+
734
+ const myResponseMessage = await mdFmt(resp.my_respond_before_spawn_agent);
735
+ // - spawn_agent?: true or false, indicating whether an agent is needed to handle this request. e.g. if the user is asking for complex tasks like searching the web, managing repositories, or interacting with other services, or need to check original thread, set this to true.
736
+ logger.info("Intent detection response", JSON.stringify({ resp }));
737
+
738
+ // upsert quick respond msg
739
+ type QuickRespondMsg = { ts: string; text: string; channel?: string; url?: string };
740
+ const quickRespondMsg = await SlackBotState.get(`task-quick-respond-msg-${eventId}`).then(
741
+ async (existing: QuickRespondMsg | undefined) => {
742
+ if (existing) {
743
+ await slack.reactions
744
+ .remove({ name: "x", channel: existing.channel!, timestamp: existing.ts! })
745
+ .catch(() => {});
746
+
747
+ // if its a DM, always create a new message
748
+ // if (isDM) {
749
+ // const newMsg = await slack.chat.postMessage({
750
+ // channel: event.channel,
751
+ // thread_ts: event.ts,
752
+ // text: myResponseMessage,
753
+ // blocks: [
754
+ // {
755
+ // type: "markdown",
756
+ // text: myResponseMessage,
757
+ // },
758
+ // ],
759
+ // });
760
+ // await State.set(`task-quick-respond-msg-${eventId}`, { ts: newMsg.ts!, text: myResponseMessage });
761
+ // return { ...newMsg, text: myResponseMessage };
762
+ // }
763
+ // actually lets always post new msg for now.
764
+ // if (true) {
765
+ // const newMsg = await slack.chat.postMessage({
766
+ // channel: event.channel,
767
+ // thread_ts: event.ts,
768
+ // text: myResponseMessage,
769
+ // blocks: [
770
+ // {
771
+ // type: "markdown",
772
+ // text: myResponseMessage,
773
+ // },
774
+ // ],
775
+ // });
776
+ // await State.set(`task-quick-respond-msg-${eventId}`, { ts: newMsg.ts!, text: myResponseMessage });
777
+ // return { ...newMsg, text: myResponseMessage };
778
+ // }
779
+
780
+ const msg = await safeSlackUpdateMessage(slack, {
781
+ channel: event.channel,
782
+ ts: existing.ts,
783
+ text: myResponseMessage, // Fallback text for notifications
784
+ blocks: [
785
+ {
786
+ type: "markdown",
787
+ text: myResponseMessage,
788
+ },
789
+ ],
790
+ });
791
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
792
+ ts: existing.ts,
793
+ text: myResponseMessage,
794
+ channel: event.channel,
795
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${existing.ts.replace(".", "")}`,
796
+ });
797
+ return { ...msg, text: myResponseMessage };
798
+ } else {
799
+ const newMsg = await safeSlackPostMessage(slack, {
800
+ channel: event.channel,
801
+ thread_ts: event.ts,
802
+ text: myResponseMessage, // Fallback text for notifications
803
+ blocks: [
804
+ {
805
+ type: "markdown",
806
+ text: myResponseMessage,
807
+ },
808
+ ],
809
+ });
810
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
811
+ ts: newMsg.ts!,
812
+ text: myResponseMessage,
813
+ channel: event.channel,
814
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${newMsg.ts!.replace(".", "")}`,
815
+ });
816
+ return { ...newMsg, text: myResponseMessage };
817
+ }
818
+ },
819
+ );
820
+
821
+ // and now, lets update quickRespondMsg freq until user is satisfied or agent finished its work
822
+
823
+ // spawn agent if needed & allowed
824
+ // if (!resp.should_spawn_agent) {
825
+ // // update status
826
+ // await slack.reactions.remove({ name: 'eyes', channel: event.channel, timestamp: event.ts, });
827
+ // await slack.reactions.add({ name: 'white_check_mark', channel: event.channel, timestamp: event.ts, });j
828
+ // await State.set(`task-${workspaceId}`, { ...await State.get(`task-${workspaceId}`), status: 'done' });
829
+ // return 'no agent spawned'
830
+ // }
831
+
832
+ // The problem not easy to solve in original thread, lets forward this message to #prbot channel, and then spawn agent using that message.
833
+ // if (!isAgentChannel) {
834
+ // // update status, remove eye, add forwarding reaction
835
+ // await slack.reactions.remove({ name: "eyes", channel: event.channel, timestamp: event.ts }).catch(() => { });
836
+ // await slack.reactions.add({ name: "arrow_right", channel: event.channel, timestamp: event.ts }).catch(() => { });
837
+
838
+ // const originalMessageUrl = `https://${event.team}.slack.com/archives/${event.channel}/p${event.ts.replace(".", "")}`;
839
+ // // forward msg to #prbot channel, mention original msg user:content, and the original msg url for agent to read
840
+ // const agentChannelId =
841
+ // (await slack.conversations.list({ types: "public_channel" })).channels?.find((c) => c.name === "pr-bot")?.id ||
842
+ // DIE("failed to find #prbot channel id");
843
+ // // this is a user facing msg to tell user we are forwarding the msg
844
+ // const text = `Forwarded message from <@${event.user}> in <#${event.channel}>:\n${await parseSlackMessageToMarkdown(event.text)}\n\nYou can view the original message here: ${originalMessageUrl}`;
845
+ // const forwardedMsg = await slack.chat.postMessage({
846
+ // channel: agentChannelId,
847
+ // text,
848
+ // });
849
+
850
+ // // mention forwarded msg in original thread says I will continue there
851
+ // await slack.chat.update({
852
+ // channel: event.channel,
853
+ // ts: quickRespondMsg.ts!,
854
+ // markdown_text: `${myResponseMessage}\n\nI have forwarded your message to <#${agentChannelId}>. I will continue the research there.`,
855
+ // });
856
+ // await State.set(`task-${workspaceId}`, { ...(await State.get(`task-${workspaceId}`)), status: "forward_to_pr_bot_channel" });
857
+
858
+ // // process the forwarded message in agent channel
859
+ // return await spawnBotOnSlackMessageEvent({
860
+ // ...event,
861
+ // channel: agentChannelId,
862
+ // ts: forwardedMsg.ts!,
863
+ // thread_ts: undefined,
864
+ // text: forwardedMsg.text || "",
865
+ // });
866
+ // }
867
+
868
+ await SlackBotState.set(`task-${workspaceId}`, {
869
+ ...(await SlackBotState.get(`task-${workspaceId}`)),
870
+ status: "thinking",
871
+ event,
872
+ });
873
+
874
+ // Add task to working list
875
+ await addWorkingTask(event);
876
+
877
+ slack.reactions
878
+ .remove({ name: "eyes", channel: event.channel, timestamp: event.ts })
879
+ .catch(() => {});
880
+ slack.reactions
881
+ .add({ name: "thinking_face", channel: event.channel, timestamp: event.ts })
882
+ .catch(() => {});
883
+
884
+ const CLAUDEMD = loadClaudeMd({
885
+ EVENT_CHANNEL: event.channel,
886
+ QUICK_RESPOND_MSG_TS: quickRespondMsg.ts!,
887
+ USERNAME: username,
888
+ NEARBY_MESSAGES_YAML: yaml.stringify(nearbyMessages),
889
+ EVENT_TEXT_JSON: JSON.stringify(await parseSlackMessageToMarkdown(event.text)),
890
+ USER_INTENT: resp.user_intent,
891
+ MY_RESPONSE_MESSAGE_JSON: JSON.stringify(myResponseMessage),
892
+ EVENT_THREAD_TS: event.thread_ts || event.ts,
893
+ });
894
+
895
+ // const taskUser = `bot-user-${workspaceId.replace(".", "-")}`;
896
+ // const taskUser = `bot-user-${workspaceId.replace(".", "-")}`;
897
+ await mkdir(botWorkingDir, { recursive: true });
898
+ // todo: create a linux user for task
899
+
900
+ // fill initial files for agent
901
+
902
+ await Bun.write(`${botWorkingDir}/CLAUDE.md`, CLAUDEMD);
903
+
904
+ // clone https://github.com/Comfy-Org/Comfy-PR/tree/sno-bot to ./repos/prbot (branch: sno-bot)
905
+ const prBotRepoDir = `${botWorkingDir}/codes/Comfy-Org/pr-bot/tree/main`;
906
+ await mkdir(prBotRepoDir, { recursive: true });
907
+ await Bun.$`git clone --branch main https://github.com/Comfy-Org/Comfy-PR ${prBotRepoDir}`.catch(
908
+ () => null,
909
+ );
910
+
911
+ // await Bun.write(`${botWorkingDir}/PROMPT.txt`, agentPrompt);
912
+
913
+ // Add Claude Skills to working dir (.claude/skills)
914
+ // Reference: https://docs.claude.ai/en/claude-code/skills
915
+ const skillsBase = `${botWorkingDir}/.claude/skills`;
916
+ await mkdir(skillsBase, { recursive: true });
917
+ const skills = loadSkills({
918
+ EVENT_CHANNEL: event.channel,
919
+ QUICK_RESPOND_MSG_TS: quickRespondMsg.ts!,
920
+ EVENT_THREAD_TS: event.thread_ts || event.ts,
921
+ });
922
+
923
+ for (const [dir, content] of Object.entries(skills)) {
924
+ const p = `${skillsBase}/${dir}`;
925
+ await mkdir(p, { recursive: true });
926
+ await Bun.write(`${p}/SKILL.md`, content);
927
+ }
928
+
929
+ // Index file to make skills easy to discover alongside CLAUDE.md
930
+ await Bun.write(
931
+ `${botWorkingDir}/SKILLS.txt`,
932
+ `
933
+ Available Skills (.claude/skills):
934
+ - slack-messaging: Communicate in Slack threads using prbot slack commands.
935
+ - slack-file-sharing: Upload and download files, share deliverables with users.
936
+ - github-pr-bot: Delegate all code changes via prbot pr command.
937
+ - code-search: Search ComfyUI code using prbot code search.
938
+ - github-issue-search: Search issues and PRs using prbot github-issue search.
939
+ - notion-search: Discover and cite internal Notion pages using prbot notion search.
940
+ - registry-search: Search custom nodes using prbot registry search.
941
+ - repo-reading: Clone and inspect Comfy-Org repos read-only, or use prbot code search.
942
+ - web-research: Pull in external context and cite sources.
943
+
944
+ Open the corresponding SKILL.md under .claude/skills/<name>/ for details.
945
+ `,
946
+ );
947
+
948
+ await Bun.write(
949
+ `${botWorkingDir}/TODO.md`,
950
+ `
951
+ # Task TODOs
952
+
953
+ - Analyze the user's request and gather necessary information.
954
+ - Search relevant documents, codebases, and resources using prbot CLI:
955
+ - Code search: prbot code search --query="<search terms>" [--repo=<owner/repo>]
956
+ - Issue search: prbot github-issue search --query="<search terms>"
957
+ - Notion search: prbot notion search --query="<search terms>"
958
+ - Registry search: prbot registry search --query="<search terms>"
959
+ - Coordinate with prbot agents for unknown coding tasks:
960
+ - prbot pr --repo=<owner/repo> --prompt="<detailed coding task>"
961
+ - For each deliverable: save to ./deliverable-<name>.md then immediately upload to Slack.
962
+ - Compile findings and provide a comprehensive response to the user.
963
+
964
+ ## GitHub Changes
965
+ - IMPORTANT: Remember to use the prbot CLI for unknown GitHub code changes:
966
+ prbot pr --repo=<owner/repo> [--branch=<branch>] --prompt="<detailed coding task>"
967
+
968
+ ## Deliverables Convention
969
+ - ALWAYS save any document, guide, report, or artifact to: ./deliverable-<name>.md
970
+ - Then IMMEDIATELY post to Slack (smart-post: short → inline message, long → file upload):
971
+ prbot slack post --channel=<channel> --file=./deliverable-<name>.md --title="<title>" --comment="<summary>" --thread=<thread_ts>
972
+ - Examples:
973
+ - ./deliverable-research-report.md
974
+ - ./deliverable-analysis.md
975
+ - ./deliverable-summary.md
976
+
977
+ ## Tool Error Recovery
978
+ When a prbot CLI command fails:
979
+ 1. Record error to ./TOOLS_ERRORS.md (command, error, context)
980
+ 2. Read the failing tool's source in ./codes/Comfy-Org/Comfy-PR/tree/sno-bot to diagnose
981
+ 3. Spawn a fix via: prbot pr --repo=Comfy-Org/Comfy-PR --prompt="Fix <tool>: <error>. Root cause: <analysis>. Fix: <change>"
982
+ 4. Workaround to complete the user's task while the fix PR is open
983
+
984
+ `,
985
+ );
986
+ await Bun.$`code ${botWorkingDir}`.catch(() => null); // open the working dir in vscode for debugging
987
+
988
+ const agentPrompt = `
989
+ the @${username} intented to ${resp.user_intent}
990
+ Please assist them with their request using all your resources available.
991
+
992
+ IMPORTANT WORKSPACE CONVENTIONS:
993
+ - Save ALL deliverables (documents, guides, reports, summaries, analysis, code snippets, etc.) to ./deliverable-<name>.md in the current workspace directory. For example: ./deliverable-draft-pr-guide.md, ./deliverable-research-report.md
994
+ - Log any tool errors or failures to ./TOOLS_ERRORS.md
995
+ - Keep deliverables self-contained and well-formatted so they can be shared directly with the user
996
+ `;
997
+
998
+ // Write PROMPT.txt so claude-yes can read the user's intent
999
+ await Bun.write(`${botWorkingDir}/PROMPT.txt`, agentPrompt);
1000
+
1001
+ logger.info(`Spawning agent in ${botWorkingDir} with prompt: ${JSON.stringify(agentPrompt)}`);
1002
+ // todo: spawn in a worker user
1003
+
1004
+ // Create dedicated log files for this task
1005
+ const taskLogDir = `${botWorkingDir}/.logs`;
1006
+ await mkdir(taskLogDir, { recursive: true });
1007
+ const agentLogPath = `${taskLogDir}/agent-output.log`;
1008
+ const statusLogPath = `${taskLogDir}/STATUS.txt`;
1009
+
1010
+ const isDebugMode = process.env.DEBUG === "true" || process.env.DEBUG === "1";
1011
+
1012
+ // Start error collector to monitor workspace for errors
1013
+ const errorLogPath = `${taskLogDir}/COLLECTED_ERRORS.md`;
1014
+ const errorCollector = new ErrorCollector({
1015
+ workspaceDir: botWorkingDir,
1016
+ outputLogPath: errorLogPath,
1017
+ onError: isDebugMode
1018
+ ? (errorPath: string, content: string) => {
1019
+ logger.warn(`Error detected in workspace: ${errorPath}`);
1020
+ logger.warn(`Error content preview: ${content.substring(0, 500)}...`);
1021
+ }
1022
+ : undefined,
1023
+ checkInterval: 10000,
1024
+ });
1025
+ await errorCollector.start();
1026
+
1027
+ // --- Claude Agent SDK ---
1028
+ logger.info(
1029
+ `Spawning agent via SDK in ${botWorkingDir} with env GH_TOKEN_COMFY_PR_BOT=[REDACTED]`,
1030
+ );
1031
+
1032
+ const sdkPrompt =
1033
+ "Please read PROMPT.txt and TODO.md in the current directory and complete all tasks listed there.";
1034
+
1035
+ const abortController = new AbortController();
1036
+
1037
+ // Handle follow-up messages: when user sends more messages in the thread,
1038
+ // pipe them to the running agent via streamInput
1039
+ let agentQuery: Query | null = null;
1040
+
1041
+ // Drain taskInputFlow into the SDK agent
1042
+ const inputDrainPromise = (async () => {
1043
+ const reader = taskInputFlow.readable.getReader();
1044
+ try {
1045
+ while (true) {
1046
+ const { done, value } = await reader.read();
1047
+ if (done) break;
1048
+ if (value && agentQuery !== null) {
1049
+ const userMsg: SDKUserMessage = {
1050
+ type: "user" as const,
1051
+ message: { role: "user" as const, content: value },
1052
+ parent_tool_use_id: null,
1053
+ session_id: "",
1054
+ };
1055
+ await (agentQuery as Query).streamInput(
1056
+ (async function* () {
1057
+ yield userMsg;
1058
+ })(),
1059
+ );
1060
+ logger.info(`Injected follow-up message into SDK agent: ${value.slice(0, 100)}`);
1061
+ }
1062
+ }
1063
+ } catch {
1064
+ // taskInputFlow closed
1065
+ }
1066
+ })();
1067
+
1068
+ // Track agent output for Slack updates
1069
+ let agentOutput = "";
1070
+ let lastSentOutput = "";
1071
+ const idleWaiter = new IdleWaiter();
1072
+ let isThinking = false;
1073
+
1074
+ // Slack update logic — extracted so it can be called from interval and finally
1075
+ let lastSlackUpdateTime = 0;
1076
+ const MIN_SLACK_UPDATE_INTERVAL_MS = 10_000; // minimum 10s between LLM-synthesized updates
1077
+ const sendSlackUpdate = async () => {
1078
+ if (agentOutput === lastSentOutput || !agentOutput) return;
1079
+
1080
+ const now = Date.now();
1081
+ if (now - lastSlackUpdateTime < MIN_SLACK_UPDATE_INTERVAL_MS) return;
1082
+ lastSlackUpdateTime = now;
1083
+
1084
+ const news = agentOutput.slice(lastSentOutput.length);
1085
+ lastSentOutput = agentOutput;
1086
+
1087
+ const my_internal_thoughts = agentOutput.split("\n").slice(-80).join("\n");
1088
+ logger.info(
1089
+ "Agent output preview: " +
1090
+ yaml.stringify({
1091
+ preview: my_internal_thoughts.slice(0, 200),
1092
+ news_preview: news.slice(0, 200),
1093
+ }),
1094
+ );
1095
+
1096
+ // GPT-4o synthesis for Slack update
1097
+ const contexts = {
1098
+ my_internal_thoughts,
1099
+ news,
1100
+ user_original_intent: resp.user_intent,
1101
+ my_response_md_original: quickRespondMsg.text || "",
1102
+ };
1103
+ const updateResponseResp = (await zChatCompletion({
1104
+ my_response_md_updated: z.string(),
1105
+ })`
1106
+ TASK: Update my my_response_md_original based on agent's my_internal_thoughts findings, and give me my_response_md_updated to post in slack.
1107
+
1108
+ RULES:
1109
+ - Do not remove unknown parts from my_response_md_original that are not mentioned in my_internal_thoughts.
1110
+ - Preserve markdown formatting in my_response_md_original.
1111
+ - If my_internal_thoughts contains new information, append it to the relevant sections in my_response_md_original.
1112
+ - If my_internal_thoughts indicates completion of a task, add a "Tasks" section at the end of my_response_md_original with - [x] mark.
1113
+ - Ensure my_response_md_updated is clear and concise.
1114
+ - Use **bold** to highlight new sections or important updates. Remove previously highlighted sections if they're no longer relevant.
1115
+ - If all information from my_internal_thoughts is already contained in my_response_md_original, return: {my_response_md_updated: "__NOTHING_CHANGED__"}
1116
+
1117
+ CRITICAL FILTERING RULES (Non-negotiable):
1118
+ - KEEP ONLY: User-facing progress, task completion status, findings relevant to user's intent, next steps
1119
+ - REMOVE: File paths, system info, debug output, error stack traces, internal process details, development notes
1120
+ - EXAMPLES OF WHAT TO REMOVE:
1121
+ - "/bot/slack/channel-id/timestamp" (internal paths)
1122
+ - "undefined/null received in chunk" (internal errors)
1123
+ - "DEBUG: ..." (debug output)
1124
+ - "✓ Created /tmp/cache/..." (internal file operations)
1125
+ - "[2026-02-20T15:10:40.123Z]" (timestamps)
1126
+
1127
+ TONE & LENGTH:
1128
+ - KEEP message very short and informative, use url links to reference documents/repos instead of pasting large contents
1129
+ - Response should be up to 16 lines maximum (agent posts long reports as .md files)
1130
+ - Focus ONLY on end-user's question or intent's helpful contents
1131
+ - Describe current progress in up to 7 words (less is better)
1132
+ - Avoid jargon; write for non-technical users when possible
1133
+
1134
+ FORMAT REQUIREMENTS:
1135
+ - Output in standard markdown format (GitHub flavored)
1136
+ - YOU CAN ONLY change/remove/add up to 1 line per update!
1137
+ - LENGTH LIMIT: Must be within 4000 characters (system will truncate if exceeding)
1138
+ - MOST IMPORTANT: Keep my_response_md_original's context and formatting mostly unchanged, only update necessary lines
1139
+
1140
+ DO NOT:
1141
+ - Ask the user questions
1142
+ - Include error details (they're logged separately for developers)
1143
+ - Show code blocks or technical configs
1144
+ - Show internal process logs or environment variables
1145
+ - Show any paths starting with "/" or "./"
1146
+
1147
+ - Here's Contexts in YAML for your respondse:
1148
+
1149
+ <task-context-yaml>
1150
+ ${yaml.stringify(contexts)}
1151
+ </task-context-yaml>
1152
+
1153
+ `) as { my_response_md_updated: string };
1154
+
1155
+ // Log raw response
1156
+ await appendFile(
1157
+ ".logs/my_response_md_updated.jsonl",
1158
+ JSON.stringify({
1159
+ timestamp: new Date().toISOString(),
1160
+ workspaceId,
1161
+ stage: "raw_from_claude",
1162
+ my_response_md_updated_raw: updateResponseResp.my_response_md_updated,
1163
+ my_internal_thoughts_preview: my_internal_thoughts.slice(0, 500),
1164
+ my_response_md_original: quickRespondMsg.text || "",
1165
+ }) + "\n",
1166
+ ).catch(() => {});
1167
+
1168
+ const updated_response_full = await mdFmt(
1169
+ updateResponseResp.my_response_md_updated
1170
+ .trim()
1171
+ .replace(/^__NOTHING_CHANGED__$/m, quickRespondMsg.text || ""),
1172
+ );
1173
+
1174
+ // Truncate to 4000 chars from the middle
1175
+ const my_response_md_updated =
1176
+ updated_response_full.length > 4000
1177
+ ? updated_response_full.slice(0, 2000) +
1178
+ "\n\n...TRUNCATED...\n\n" +
1179
+ updated_response_full.slice(-2000)
1180
+ : updated_response_full;
1181
+
1182
+ await appendFile(
1183
+ ".logs/my_response_md_updated.jsonl",
1184
+ JSON.stringify({
1185
+ timestamp: new Date().toISOString(),
1186
+ workspaceId,
1187
+ stage: "final_processed",
1188
+ my_response_md_updated_final: my_response_md_updated,
1189
+ was_truncated: updated_response_full.length > 4000,
1190
+ original_length: updated_response_full.length,
1191
+ }) + "\n",
1192
+ ).catch(() => {});
1193
+
1194
+ if (quickRespondMsg.ts && quickRespondMsg.channel) {
1195
+ await safeSlackUpdateMessage(slack, {
1196
+ channel: quickRespondMsg.channel,
1197
+ ts: quickRespondMsg.ts,
1198
+ text: my_response_md_updated,
1199
+ blocks: [{ type: "markdown", text: my_response_md_updated }],
1200
+ });
1201
+ quickRespondMsg.text = my_response_md_updated;
1202
+ await SlackBotState.set(`task-quick-respond-msg-${eventId}`, {
1203
+ ts: quickRespondMsg.ts,
1204
+ text: quickRespondMsg.text,
1205
+ channel: event.channel,
1206
+ url: `https://${SLACK_ORG_DOMAIN_NAME}.slack.com/archives/${event.channel}/p${quickRespondMsg.ts.replace(".", "")}`,
1207
+ });
1208
+ }
1209
+ };
1210
+
1211
+ // Periodic Slack update interval
1212
+ const slackUpdateInterval = setInterval(sendSlackUpdate, 10e3);
1213
+
1214
+ // Run the agent
1215
+ let exitCode: number | null = 0;
1216
+ try {
1217
+ agentQuery = query({
1218
+ prompt: sdkPrompt,
1219
+ options: {
1220
+ cwd: botWorkingDir,
1221
+ permissionMode: "bypassPermissions",
1222
+ allowDangerouslySkipPermissions: true,
1223
+ settingSources: ["project"], // loads CLAUDE.md from cwd
1224
+ maxTurns: 200,
1225
+ persistSession: false,
1226
+ abortController,
1227
+ env: {
1228
+ ...process.env,
1229
+ GH_TOKEN: process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1230
+ GITHUB_TOKEN:
1231
+ process.env.GH_TOKEN_COMFY_PR_BOT || DIE("missing GH_TOKEN_COMFY_PR_BOT env"),
1232
+ },
1233
+ stderr: (data: string) => {
1234
+ logger.warn(`[agent stderr]: ${data}`);
1235
+ },
1236
+ },
1237
+ });
1238
+
1239
+ await Bun.write(
1240
+ statusLogPath,
1241
+ `Started: ${new Date().toISOString()}\nStatus: Running (SDK)\nLog: ${agentLogPath}\n`,
1242
+ );
1243
+
1244
+ for await (const message of agentQuery) {
1245
+ // Loading indicator management
1246
+ idleWaiter.ping();
1247
+ if (!isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1248
+ isThinking = true;
1249
+ const msgChannel = quickRespondMsg.channel;
1250
+ const msgTs = quickRespondMsg.ts;
1251
+ slack.reactions
1252
+ .add({ name: "loading", channel: msgChannel, timestamp: msgTs })
1253
+ .catch(() => {});
1254
+ idleWaiter.wait(5e3).finally(async () => {
1255
+ await slack.reactions
1256
+ .remove({ name: "loading", channel: msgChannel, timestamp: msgTs })
1257
+ .catch(() => {});
1258
+ isThinking = false;
1259
+ });
1260
+ }
1261
+
1262
+ // Process SDK messages
1263
+ if (message.type === "assistant") {
1264
+ const textBlocks = (message.message.content as Array<{ type: string; text?: string }>)
1265
+ .filter(
1266
+ (block): block is { type: "text"; text: string } =>
1267
+ block.type === "text" && typeof block.text === "string",
1268
+ )
1269
+ .map((block) => block.text);
1270
+ if (textBlocks.length > 0) {
1271
+ const text = textBlocks.join("\n");
1272
+ agentOutput += text + "\n";
1273
+ await appendFile(agentLogPath, text + "\n").catch(() => {});
1274
+ logger.debug(`Agent assistant text (${text.length} chars): ${text.slice(0, 200)}`);
1275
+ }
1276
+ } else if (message.type === "result") {
1277
+ if (message.subtype === "success") {
1278
+ exitCode = 0;
1279
+ logger.info(
1280
+ `Agent completed successfully. Turns: ${message.num_turns}, Cost: $${message.total_cost_usd.toFixed(4)}, Duration: ${(message.duration_ms / 1000).toFixed(1)}s`,
1281
+ );
1282
+ // Append final result to output for last Slack update
1283
+ if ("result" in message && message.result) {
1284
+ agentOutput += "\n" + message.result;
1285
+ }
1286
+ } else {
1287
+ exitCode = 1;
1288
+ const errors = "errors" in message ? (message as { errors: string[] }).errors : [];
1289
+ logger.error(
1290
+ `Agent failed (${message.subtype}). Turns: ${message.num_turns}, Errors: ${errors.join(", ")}`,
1291
+ );
1292
+ }
1293
+ await appendFile(
1294
+ agentLogPath,
1295
+ `\n--- Result: ${message.subtype} | Turns: ${message.num_turns} | Cost: $${message.total_cost_usd.toFixed(4)} ---\n`,
1296
+ ).catch(() => {});
1297
+ } else {
1298
+ // Log other message types for debugging
1299
+ logger.debug(
1300
+ `SDK message: ${message.type}${"subtype" in message ? `.${(message as { subtype: string }).subtype}` : ""}`,
1301
+ );
1302
+ }
1303
+ }
1304
+ } catch (err) {
1305
+ exitCode = 1;
1306
+ logger.error("Agent SDK error:", { err });
1307
+ } finally {
1308
+ clearInterval(slackUpdateInterval);
1309
+ // Remove loading icon if still showing
1310
+ if (isThinking && quickRespondMsg.ts && quickRespondMsg.channel) {
1311
+ await slack.reactions
1312
+ .remove({
1313
+ name: "loading",
1314
+ channel: quickRespondMsg.channel,
1315
+ timestamp: quickRespondMsg.ts,
1316
+ })
1317
+ .catch(() => {});
1318
+ }
1319
+ // Send one final Slack update with complete output
1320
+ lastSlackUpdateTime = 0; // bypass throttle for final update
1321
+ await sendSlackUpdate().catch((err) => logger.error("Final Slack update error:", { err }));
1322
+ // Cancel input drain
1323
+ abortController.abort();
1324
+ }
1325
+
1326
+ TaskInputFlows.delete(workspaceId);
1327
+
1328
+ // Stop error collector
1329
+ errorCollector.stop();
1330
+
1331
+ // Final status
1332
+ const finalStatus = exitCode === 0 ? "Completed Successfully" : `Failed (exit code ${exitCode})`;
1333
+ await Bun.write(
1334
+ statusLogPath,
1335
+ `Status: ${finalStatus}\nEnded: ${new Date().toISOString()}\nErrors: ${errorLogPath}\n`,
1336
+ ).catch(() => {});
1337
+
1338
+ if (exitCode !== 0) {
1339
+ logger.error(`claude-yes process for task ${workspaceId} exited with code ${exitCode}`);
1340
+ // those error tasks will got retry after a restart
1341
+ // update my slack message reactions shows a cross mark and update it appending a error happened and say will retry later
1342
+ await slack.reactions
1343
+ .remove({ name: "thinking_face", channel: event.channel, timestamp: event.ts })
1344
+ .catch(() => {});
1345
+ if (quickRespondMsg.ts && quickRespondMsg.channel) {
1346
+ await slack.reactions
1347
+ .add({ name: "x", channel: quickRespondMsg.channel, timestamp: quickRespondMsg.ts })
1348
+ .catch(() => {});
1349
+ const errorText = await mdFmt(
1350
+ (quickRespondMsg.text || "") +
1351
+ `\n\n:warning: An error occurred while processing this request <@snomiao>, I will try it again later`,
1352
+ );
1353
+ await safeSlackUpdateMessage(slack, {
1354
+ channel: event.channel,
1355
+ ts: quickRespondMsg.ts,
1356
+ text: errorText, // Fallback text for notifications
1357
+ blocks: [
1358
+ {
1359
+ type: "markdown",
1360
+ text: errorText,
1361
+ },
1362
+ ],
1363
+ });
1364
+ }
1365
+ }
1366
+
1367
+ // claude exited as no more inputs/outputs for a while, update the status message
1368
+ await slack.reactions
1369
+ .remove({ name: "thinking_face", channel: event.channel, timestamp: event.ts })
1370
+ .catch(() => {});
1371
+ await slack.reactions
1372
+ .add({ name: "white_check_mark", channel: event.channel, timestamp: event.ts })
1373
+ .catch(() => {});
1374
+ const taskState = await SlackBotState.get(`task-${workspaceId}`);
1375
+ const endTime = Date.now();
1376
+ const responseDuration = taskState?.startTime ? endTime - taskState.startTime : undefined;
1377
+
1378
+ await SlackBotState.set(`task-${workspaceId}`, {
1379
+ ...taskState,
1380
+ status: "done",
1381
+ endTime,
1382
+ responseDuration,
1383
+ });
1384
+
1385
+ // Remove task from working list
1386
+ await removeWorkingTask(event);
1387
+ }
1388
+
1389
+ function sleep(ms: number) {
1390
+ return new Promise((resolve) => setTimeout(resolve, ms));
1391
+ }
1392
+
1393
+ async function getSlackMessageFromUrl(url: string) {
1394
+ const { ts, channel } = slackMessageUrlParse(url);
1395
+ const page = await slack.conversations.history({
1396
+ channel,
1397
+ limit: 1,
1398
+ inclusive: true,
1399
+ latest: ts,
1400
+ });
1401
+ return page.messages?.[0] || DIE("not found");
1402
+ }
1403
+
1404
+ function commonPrefix(...args: string[]): string {
1405
+ if (args.length === 0) return "";
1406
+ let prefix = args[0];
1407
+ for (let i = 1; i < args.length; i++) {
1408
+ let j = 0;
1409
+ while (j < prefix.length && j < args[i].length && prefix[j] === args[i][j]) {
1410
+ j++;
1411
+ }
1412
+ prefix = prefix.slice(0, j);
1413
+ if (prefix === "") break;
1414
+ }
1415
+ return prefix;
1416
+ }
1417
+
1418
+ /**
1419
+ * Clean terminal output by removing ANSI codes, debug info, and system paths
1420
+ * This ensures Claude only sees user-meaningful progress information
1421
+ */
1422
+ function cleanTerminalOutput(text: string): string {
1423
+ // Remove ANSI color codes and escape sequences
1424
+ text = text.replace(/\x1b\[[0-9;]*m/g, "");
1425
+ text = text.replace(/\x1b\[[^m]*m/g, "");
1426
+ text = text.replace(/\u0007/g, ""); // Bell character
1427
+ text = text.replace(/\r/g, ""); // Carriage returns
1428
+
1429
+ // Remove box drawing characters (Claude Code banner)
1430
+ text = text.replace(/[▐▛▜▘▝█▌▙▟▞▚░▒▓│┃├┤┬┴┼─═║╔╗╚╝╠╣╦╩╬]/g, "");
1431
+
1432
+ // Filter lines to remove debug noise
1433
+ const lines = text.split("\n").filter((line) => {
1434
+ const trimmed = line.trim();
1435
+
1436
+ // Skip empty or whitespace-only lines
1437
+ if (!trimmed) return true;
1438
+
1439
+ // Skip timestamp-prefixed log lines (multiple formats)
1440
+ // Format 1: [2026-02-20T15:10:40.123Z]
1441
+ if (/^\[[\d\-T:.Z]+\]/.test(trimmed)) return false;
1442
+ // Format 2: 2026-02-20 15:42:09 [info]:
1443
+ if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[/.test(trimmed)) return false;
1444
+
1445
+ // Skip warning lines
1446
+ if (/^⚠|^Warning:|^WARN:|^\[warn\]/i.test(trimmed)) return false;
1447
+
1448
+ // Skip debug/verbose/trace/info prefixed lines
1449
+ if (/^(DEBUG|VERBOSE|TRACE|INFO):/i.test(trimmed)) return false;
1450
+ if (/\[(debug|verbose|trace|info)\]:/i.test(trimmed)) return false;
1451
+
1452
+ // Skip claude-yes specific output
1453
+ if (/\[claude-yes\]|claude-yes|Spawned claude|PID \d+/i.test(trimmed)) return false;
1454
+ if (/Claude Code v\d|Opus \d|Claude Max/i.test(trimmed)) return false;
1455
+
1456
+ // Skip lines containing system paths anywhere
1457
+ if (/\/bot\/slack\/|\/codes\/|\.logs\/|\/repos\/|\/tmp\//i.test(trimmed)) return false;
1458
+
1459
+ // Skip undefined/null error indicators
1460
+ if (/received undefined\/null|undefined\/null/i.test(trimmed)) return false;
1461
+
1462
+ // Skip deprecation warnings
1463
+ if (/deprecated|--exit-on-idle|-e are deprecated/i.test(trimmed)) return false;
1464
+
1465
+ // Skip pure terminal control output or lines that are mostly special chars
1466
+ if (/^(\s*|cursor\s+|bell|bel|\x07)$/i.test(trimmed)) return false;
1467
+
1468
+ // Skip lines that are mostly whitespace or contain only special characters
1469
+ if (/^[\s\u2000-\u206F\u2500-\u257F]*$/.test(trimmed)) return false;
1470
+
1471
+ return true;
1472
+ });
1473
+
1474
+ return lines.join("\n").trim();
1475
+ }
1476
+ function sanitized(name: string) {
1477
+ return name.replace(/[^a-zA-Z0-9-_]/g, "_").slice(0, 50);
1478
+ }
1479
+
1480
+ export async function spawnBotOnSlackMessageUrl(url: string) {
1481
+ const { team, channel, ts } = await slackMessageUrlParse(url);
1482
+ const event = await slack.conversations
1483
+ .replies({
1484
+ channel: channel,
1485
+ ts: ts,
1486
+ limit: 1,
1487
+ })
1488
+ .then((res) => res.messages?.[0] || DIE("failed to fetch message from slack"));
1489
+ logger.info("Processing missed message " + JSON.stringify({ url, event }));
1490
+ // Parse the event to ensure it matches the expected type
1491
+ const mentionEvent = zAppMentionEvent.parse({
1492
+ ...event,
1493
+ type: "app_mention",
1494
+ user: event.user || "",
1495
+ channel: channel,
1496
+ event_ts: event.ts || ts,
1497
+ });
1498
+ await spawnBotOnSlackMessageEvent(mentionEvent);
1499
+ }