diff --git a/CLAUDE.md b/CLAUDE.md index b88255ec..9abdc5e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,55 @@ const stats = await getGhCacheStats(); - Short args: `gh.repos.get({"owner":"octocat","repo":"Hello-World"})#b3117af2` - Long args: `gh.repos.get({"owner":"octocat","descripti...bbbbbbbbbb"})#4240f076` +## ComfyPR Bot Skills + +### Overview + +The ComfyPR Bot (defined in `bot/index.ts`) is a Slack-integrated AI assistant that helps with research, documentation, and code investigation tasks. When spawned, the bot agent has access to several specialized skills. + +### Available Skills + +The bot agent prompt (lines 390-396 in `bot/index.ts`) includes the following skills: + +1. **Web Search** + - Search the web for relevant information + - Gather up-to-date information and context + +2. **GitHub Repository Access** + - Clone any repositories from https://github.com/Comfy-Org + - Inspect codebases at `./codes/Comfy-Org/[repo]/tree/[branch]` + - Read and analyze source code + +3. **Slack Integration** + - **Update messages**: `bun ../bot/slack/msg-update.ts --channel ${event.channel} --ts ${quickRespondMsg.ts} --text ""` + - **Read threads**: `bun ../bot/slack/msg-read-thread.ts --channel ${event.channel} --ts [ts]` + - Update responses frequently to provide live progress updates + +4. **Notion Documentation Search** + - Search Notion docs from Comfy-Org team: `./bot/notion-search.ts` + - Access internal documentation and knowledge base + +### Context Repositories + +The bot has knowledge of these Comfy-Org repositories: + +- **comfyanonymous/ComfyUI**: Main ComfyUI repository (Python ML backend) +- **Comfy-Org/ComfyUI_frontend**: Frontend codebase (Vue + TypeScript) +- **Comfy-Org/docs**: Documentation, setup guides, tutorials, API references +- **Comfy-Org/desktop**: Desktop application +- **Comfy-Org/registry**: registry.comfy.org for custom-nodes and extensions +- **Comfy-Org/workflow_templates**: Official shared workflow templates + +### Usage + +The bot is automatically spawned when: + +1. A user mentions the bot in a Slack channel +2. The message is in an authorized channel (`#comfypr-bot` or `#pr-bot`) +3. The bot determines that agent assistance is needed + +See `bot/README.md` for documentation on the individual skill scripts. + ## SFlow Stream Processing Library ### Overview @@ -111,7 +160,7 @@ SFlow is a powerful functional stream processing library used throughout the cod ### Implementation Details -- **Package**: `sflow@1.24.0` +- **Package**: `sflow@1.24.0` - **Author**: snomiao - **License**: MIT -- **Core Concepts**: SFlow is built around composable stream operators, lazy evaluation, and support for both synchronous and asynchronous data flows. \ No newline at end of file +- **Core Concepts**: SFlow is built around composable stream operators, lazy evaluation, and support for both synchronous and asynchronous data flows. diff --git a/bot/BUGFIX.md b/bot/BUGFIX.md new file mode 100644 index 00000000..1cdfee06 --- /dev/null +++ b/bot/BUGFIX.md @@ -0,0 +1,178 @@ +# Bug Fixes: Slack WebSocket Message Handling + +## Problem 1: App Mention Event Schema Mismatch + +The bot was not processing some `app_mention` events from Slack, logging them as `MSG_NOT_MATCHED` in `bot/msg.log`. + +## Problem 2: System Messages Not Handled + +The bot was logging Slack WebSocket system messages (like "hello" and "disconnect") as `MSG_NOT_MATCHED`, even though they are normal protocol messages. + +## Root Cause + +The Zod schema for `app_mention` events in `bot/index.ts` had two issues: + +1. **`client_msg_id` was required** - But Slack doesn't always include this field (especially for retry attempts or certain message types) +2. **`attachments` field was missing** - Events with attachments (like message unfurls) were being rejected + +## Event Analysis + +From `bot/msg.log`, the actual event structure was: + +```typescript +{ + type: 'app_mention', + user: 'U04F3GHTG2X', + ts: '1766560478.446979', + // ❌ client_msg_id: MISSING! + text: '<@U078499LK5K> do some research...', + team: 'T0462DJ9G3C', + blocks: [...], + channel: 'C0A51KF8SMU', + assistant_thread: {...}, + attachments: [...], // ❌ Not in schema! + event_ts: '1766560478.446979' +} +``` + +## Solution + +Updated the Zod schema and TypeScript interface in `bot/index.ts`: + +### Before (lines 35-48) + +```typescript +const zAppMentionEvent = z.object({ + type: z.literal("app_mention"), + user: z.string(), + ts: z.string(), + client_msg_id: z.string(), // ❌ Required + text: z.string(), + team: z.string(), + thread_ts: z.string().optional(), + parent_user_id: z.string().optional(), + blocks: z.array(z.any()), + channel: z.string(), + assistant_thread: z.any().optional(), + // ❌ attachments missing + event_ts: z.string(), +}); +``` + +### After (lines 35-49) + +```typescript +const zAppMentionEvent = z.object({ + type: z.literal("app_mention"), + user: z.string(), + ts: z.string(), + client_msg_id: z.string().optional(), // ✅ Optional + text: z.string(), + team: z.string(), + thread_ts: z.string().optional(), + parent_user_id: z.string().optional(), + blocks: z.array(z.any()), + channel: z.string(), + assistant_thread: z.any().optional(), + attachments: z.array(z.any()).optional(), // ✅ Added + event_ts: z.string(), +}); +``` + +Also updated the TypeScript interface for `processSlackAppMentionEvent` (lines 83-97) to match. + +## Verification + +Created test script `/tmp/test_schema.ts` that successfully validates the previously failing event against the updated schema: + +```bash +$ bun /tmp/test_schema.ts +✅ SUCCESS! Event matches the schema. +``` + +--- + +## Fix 2: Handle Slack WebSocket System Messages + +### Problem + +The bot was logging normal Slack WebSocket protocol messages as unmatched: + +```json +{ + "type": "hello", + "num_connections": 1, + "debug_info": { + "host": "applink-6", + "build_number": 8, + "approximate_connection_time": 18060 + }, + "connection_info": { + "app_id": "A078498JA5T" + } +} +``` + +The "hello" message is sent by Slack to acknowledge a successful WebSocket connection. + +### Root Cause + +The WebSocket message handler only tried to parse `app_mention` events and logged everything else as unmatched. + +### Solution + +Added proper handling for different Slack WebSocket message types in `bot/index.ts` (lines 65-105): + +```typescript +ws.onmessage = async ({ data }) => { + const parsed = JSON.parse(data) + let handled = false; + + // 1. Handle "hello" message (connection acknowledgment) + if (parsed.type === 'hello') { + console.log('[Slack WebSocket] Connection acknowledged:', {...}); + handled = true; + } + + // 2. Handle "disconnect" message + if (parsed.type === 'disconnect') { + console.log('[Slack WebSocket] Disconnect message:', parsed.reason); + handled = true; + } + + // 3. Handle "events_api" messages with app_mention events + if (parsed.type === 'events_api' && parsed.payload?.event) { + // Parse and process app_mention events + handled = await zAppMentionEvent.parseAsync(...)... + } + + // Log only truly unhandled messages + if (!handled) { + console.log('MSG_NOT_MATCHED: ' + JSON.stringify(data)); + } +} +``` + +### Verification + +Created test script `/tmp/test_hello_msg.ts`: + +```bash +$ bun /tmp/test_hello_msg.ts +[Slack WebSocket] Connection acknowledged: { connections: 1, host: "applink-6" } +Result: ✅ Handled +``` + +## Files Modified + +- `bot/index.ts`: + - Lines 35-49: Updated Zod schema for app_mention events + - Lines 65-105: Added WebSocket message type handlers + - Lines 117-126: Updated TypeScript interface + +## References + +- Slack Events API: https://api.slack.com/events/app_mention +- Slack WebSocket API: https://api.slack.com/apis/connections/socket +- The `client_msg_id` field is documented as optional for app_mention events +- Events with message unfurls include an `attachments` array diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 00000000..0d190b9f --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,25 @@ +FROM pytorch/pytorch:latest + +# requirements +RUN apt-get update -y && apt-get install -y git && pip install comfy-cli +RUN apt install curl -y + +WORKDIR /ComfyUI + +# install ComfyUI + Manager +RUN git clone https://github.com/comfyanonymous/ComfyUI /ComfyUI && \ + pip install -r requirements.txt && \ + cd custom_nodes && \ + git clone https://github.com/Comfy-Org/ComfyUI-Manager && \ + cd ComfyUI-Manager && \ + pip install -r requirements.txt + +# setup entrypoint script, which runs python main.py +COPY entry.sh /bin/comfyui +RUN chmod +x /bin/comfyui +ENTRYPOINT ["comfyui"] + +# default command to run ComfyUI with CPU support and latest frontend version +# CMD ["--cpu", "--listen", "0.0.0.0", "--front-end-version", "Comfy-Org/ComfyUI_frontend@latest"] + +EXPOSE 8188 \ No newline at end of file diff --git a/bot/IdleWaiter.ts b/bot/IdleWaiter.ts new file mode 100644 index 00000000..cd20c186 --- /dev/null +++ b/bot/IdleWaiter.ts @@ -0,0 +1,31 @@ +/** + * A utility class to wait for idle periods based on activity pings. + * + * @example + * const idleWaiter = new IdleWaiter(); + * + * // Somewhere in your code, when activity occurs: + * idleWaiter.ping(); + * + * // To wait for an idle period of 5 seconds: + * await idleWaiter.wait(5000); + * console.log('System has been idle for 5 seconds'); + */ +export class IdleWaiter { + lastActivityTime = Date.now(); + checkInterval = 100; // Default check interval in milliseconds + + constructor() { + this.ping(); + } + + ping() { + this.lastActivityTime = Date.now(); + return this; + } + + async wait(ms: number) { + while (this.lastActivityTime >= Date.now() - ms) + await new Promise((resolve) => setTimeout(resolve, this.checkInterval)); + } +} diff --git a/bot/README.md b/bot/README.md new file mode 100644 index 00000000..3dfc58de --- /dev/null +++ b/bot/README.md @@ -0,0 +1,133 @@ +# Bot Skills + +This directory contains the skills/utilities used by the ComfyPR Bot to interact with Slack, Notion, and other services. + +## Slack Skills + +### msg-update.ts + +Update an existing Slack message. + +**Usage:** + +```bash +bun bot/slack/msg-update.ts --channel --ts --text "" +``` + +**Example:** + +```bash +bun bot/slack/msg-update.ts --channel C123ABC --ts 1234567890.123456 --text "Updated message content" +``` + +**Environment Variables:** + +- `SLACK_BOT_TOKEN`: Your Slack bot token + +### msg-read-thread.ts + +Read all messages from a Slack thread. + +**Usage:** + +```bash +bun bot/slack/msg-read-thread.ts --channel --ts [--limit ] +``` + +**Example:** + +```bash +bun bot/slack/msg-read-thread.ts --channel C123ABC --ts 1234567890.123456 --limit 50 +``` + +**Environment Variables:** + +- `SLACK_BOT_TOKEN`: Your Slack bot token + +### parseSlackMessageToMarkdown.ts + +Utility function to convert Slack message formatting to Markdown. + +**Features:** + +- User mentions: `<@U123>` → `@U123` +- Channel mentions: `<#C456|general>` → `#general` +- Links: `` → `[text](https://example.com)` +- Bold: `*text*` → `**text**` +- Italic: `_text_` → `*text*` +- Preserves code blocks and inline code + +**Usage:** + +```typescript +import { parseSlackMessageToMarkdown } from "./bot/slack/parseSlackMessageToMarkdown"; + +const markdown = await parseSlackMessageToMarkdown("Hello <@U123> with *bold* text"); +``` + +### slackTsToISO.ts + +Convert Slack timestamp to ISO 8601 format. + +**Usage:** + +```typescript +import { slackTsToISO } from "./bot/slack/slackTsToISO"; + +const iso = slackTsToISO("1703347200.123456"); +// Returns: "2023-12-23T16:00:00.123Z" +``` + +## Notion Skills + +### notion-search.ts + +Search Notion pages in the Comfy-Org workspace. + +**Usage:** + +```bash +bun bot/notion-search.ts --query "" [--limit ] +``` + +**Example:** + +```bash +bun bot/notion-search.ts --query "ComfyUI setup" --limit 5 +``` + +**Environment Variables:** + +- `NOTION_TOKEN`: Your Notion integration token + +**Output:** +Returns a JSON array of matching pages with title, URL, and timestamps. + +## Testing + +Test individual utilities: + +```bash +# Test Slack timestamp conversion +bun bot/slack/slackTsToISO.ts + +# Test Slack to Markdown parsing +bun bot/slack/parseSlackMessageToMarkdown.ts +``` + +## Development + +All scripts follow the standard development pattern outlined in CLAUDE.md: + +1. TypeScript with full type safety +2. Executable with `bun ` when `import.meta.main` is true +3. Exportable functions for use as libraries +4. Command-line argument parsing with `parseArgs` +5. Proper error handling and validation +6. Cached API clients from `@/lib` + +## Notes + +- All Slack and Notion API calls are automatically cached using the cached clients from `@/lib` +- Cache is stored in `node_modules/.cache/` directory +- Scripts can be used both as standalone CLI tools and as importable modules diff --git a/bot/index.ts b/bot/index.ts new file mode 100644 index 00000000..76601e8b --- /dev/null +++ b/bot/index.ts @@ -0,0 +1,505 @@ +#!/usr/bin/env bun --watch +import { slack } from "@/lib"; +import { db } from "@/src/db"; +import { yaml } from "@/src/utils/yaml"; +import Slack from "@slack/web-api"; +import DIE from "@snomiao/die"; +import { spawn } from "child_process"; +import { compareBy } from "comparing"; +import { fromStdio, fromWritable } from "from-node-stream"; +import { mkdir } from "fs/promises"; +import { Keyv } from "keyv"; +import KeyvMongodbStore from "keyv-mongodb-store"; +import KeyvNedbStore from "keyv-nedb-store"; +import KeyvNest from "keyv-nest"; +import sflow, { pageFlow } from "sflow"; +import zChatCompletion from "z-chat-completion"; +import z from "zod"; +import { IdleWaiter } from "./IdleWaiter"; +import { parseSlackMessageToMarkdown } from "./slack/parseSlackMessageToMarkdown"; +import { slackTsToISO } from "./slack/slackTsToISO"; + +const State = new Keyv( + KeyvNest( + new Map(), + new KeyvNedbStore("./.cache/ComfyPRBotState.nedb.yaml"), + new KeyvMongodbStore(db.collection("ComfyPRBotState")), + ), + { namespace: "", serialize: undefined, deserialize: undefined }, +); + +const TaskInputFlows = new Map>(); + +const zAppMentionEvent = z.object({ + type: z.literal("app_mention"), + user: z.string(), + ts: z.string(), + client_msg_id: z.string().optional(), + text: z.string(), + team: z.string(), + thread_ts: z.string().optional(), + parent_user_id: z.string().optional(), + blocks: z.array(z.any()), + channel: z.string(), + assistant_thread: z.any().optional(), + attachments: z.array(z.any()).optional(), + event_ts: z.string(), +}); + +if (import.meta.main) { + console.log(`[${new Date().toISOString()}] Starting ComfyPR Bot...`); + + // console.log((await nedbstore.db.findAsync({}))); + // // Keep the process alive + // // The event loop will continue running due to: + // // 1. The periodic interval timer + // // 2. The webhook server (if enabled) + // // 3. The database connection pool + // slack. + // https://docs.slack.dev/reference/methods/apps.connections.open + const { url } = await new Slack.WebClient( + process.env.SLACK_SOCKET_TOKEN || DIE("missing env.SLACK_SOCKET_TOKEN"), + ).apps.connections.open(); + // console.log(url) + const ws = new WebSocket(url || DIE("No URL returned from Slack API")); + ws.onmessage = async ({ data }) => { + const parsed = JSON.parse(data); + + // Handle different Slack WebSocket message types + let handled = false; + + // 1. Handle "hello" message (connection 1wledgment) + if (parsed.type === "hello") { + console.log( + "[Slack WebSocket] Connection acknowledged:", + JSON.stringify({ + connections: parsed.num_connections, + host: parsed.debug_info?.host, + }), + ); + handled = true; + } + + // 2. Handle "disconnect" message + if (parsed.type === "disconnect") { + console.log("[Slack WebSocket] Disconnect message:", JSON.stringify(parsed.reason)); + handled = true; + } + + // 3. Handle "events_api" messages with app_mention events + if (parsed.type === "events_api" && parsed.payload?.event) { + const result = await zAppMentionEvent + .parseAsync(parsed.payload.event) + .then(async (event) => { + await processSlackAppMentionEvent(event); + return true; + }) + .catch((err) => { + // Log parse errors for debugging + console.log("[Slack Event Parse Error]", JSON.stringify(err.message)); + return false; + }); + handled = result; + } + + // Log unhandled messages + if (!handled) { + console.log("MSG_NOT_MATCHED: " + JSON.stringify(data)); + } + }; + ws.onerror = (event) => { + console.error("WebSocket error observed:", event); + throw new Error("Slack WebSocket error, exiting."); + }; + ws.onopen = () => { + console.log("Slack connection established."); + }; + console.log(`[${new Date().toISOString()}] ComfyPR Bot is running.`); +} + +async function processSlackAppMentionEvent(event: { + type: "app_mention"; + user: string; + ts: string; + client_msg_id?: string; + text: string; + team: string; + thread_ts?: string; + parent_user_id?: string; + blocks: any[]; + channel: string; + assistant_thread?: any; + attachments?: any[]; + event_ts: string; +}) { + // msg dedup + const eventProcessed = await State.get(`msg-event-${event.event_ts}`); + if (eventProcessed?.touchedAt) { + if (+new Date() - eventProcessed.touchedAt < 60000) return; // dedup for 1min + } + await State.set(`msg-event-${event.event_ts}`, { touchedAt: +new Date(), content: event.text }); + + // whitelist channel name #comfypr-bot, for security reason, only runs agent against @mention messages in #comfypr-bot channel + // you can forward other channel messages to #comfypr-bot if needed, and the bot will read the context from the original thread messages + const channelInfo = await slack.conversations.info({ channel: event.channel }); + const channelName = channelInfo.channel?.name || DIE("failed to get channel info"); + const isAgentChannel = channelName === "comfypr-bot" || channelName === "pr-bot"; + + // task state + const taskId = event.thread_ts || event.ts; + const task = await State.get(`task-${taskId}`); + + // Allow append messages to running task + const existedTaskInputFlow = TaskInputFlows.get(taskId); + if (existedTaskInputFlow) { + // while agent still running, user sent new message in the same thread very quickly + // lets understand the user's intent and give a quick response, and then append the msg to the existing agent input flow + const threadMessages = await pageFlow(undefined as undefined | string, async (cursor, limit = 100) => { + const resp = await slack.conversations.replies({ + channel: event.channel, + ts: taskId, + cursor, + limit, + }); + return { + data: resp.messages || [], + next: resp.response_metadata?.next_cursor, + }; + }) + .flat() + .map(async (m) => ({ + ts: slackTsToISO(m.ts || DIE("missing ts")), + username: await slack.users + .info({ user: m.user || DIE("missing user id in message") }) + .then((res) => res.user?.name || "<@" + m.user + ">"), + markdown: await parseSlackMessageToMarkdown(m.text || ""), + })) + .toArray(); + + // use LLM to understand the new message intent + const action = await zChatCompletion( + z.object({ + user_intent: z.string(), + my_quick_respond: z.string(), + stop_existing_task: z.boolean(), + msg_to_append_to_agent: z.string().optional(), + }), + { + model: "gpt-4o", + }, + )` +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: +${event.text} + +The thread's recent messages are:j +${yaml.stringify( + threadMessages.toSorted(compareBy((e) => +(e.ts || 0))), // sort by ts asc +)} + +Based on the new message and the thread context, + +Please analyze the new message and determine: +1. The user's intent behind this new message. +2. A quick response I can send to the user right away to acknowledge their new message. +3. Whether I should append this new message to the existing task's input flow for further processing. +4. Whether I should stop the existing task based on this new message. + +Respond in JSON format with the following fields: +- user_intent: A brief description of the user's intent regarding the new message. +- my_quick_respond: A short message I can send to the user immediately. +- stop_existing_task: true or false, indicating whether to stop the existing task. +- msg_to_append_to_agent: The content of the new message to append to the existing task's input flow, if applicable. +`; + console.log("New message intent analysis:", action); + + // send quick response + const myQuickRespondMsg = await slack.chat.postMessage({ + channel: event.channel, + thread_ts: event.ts, + text: action.my_quick_respond, + }); + if (action.stop_existing_task) { + // stop existing task + TaskInputFlows.delete(taskId); + await slack.chat.postMessage({ + channel: event.channel, + thread_ts: event.thread_ts || event.ts, + text: `The existing task has been stopped as per your request.`, + }); + await State.set(`task-${taskId}`, { ...(await State.get(`task-${taskId}`)), status: "stopped_by_user" }); + return "existing task stopped by user"; + } + if (action.msg_to_append_to_agent) { + const w = existedTaskInputFlow.writable.getWriter(); + await w.write( + `New message from <@${event.user}> in the thread:\n${event.text}\n\nMy quick response to the user: ${action.my_quick_respond}\n\n`, + ); + w.releaseLock(); + console.log(`Appended new message to existing task ${taskId} input flow.`); + return "msg appended to existing task"; + } + return; + } + + const taskInputFlow = new TransformStream(); + if (isAgentChannel) { + TaskInputFlows.set(taskId, taskInputFlow); + } + + // fetch full context from the thread using slack api + + // 1. fetch contexts from nearby messages (in the thread or channel) + // 2. use quick LLM to determine intent. + // 3. spawn agent if needed. + + // For now, just reply with a greeting. + + // await slack.chat.postMessage({ + // channel: event.channel, + // text: replyText, + // thread_ts: event.ts, + // }); + + // mark that msg as seeing + await State.set(`task-${taskId}`, { ...(await State.get(`task-${taskId}`)), status: "checking" }); + await slack.reactions.add({ name: "eyes", channel: event.channel, timestamp: event.ts }).catch(() => {}); + + // grab 100 most nearby messages in this thread or channel + const nearbyMessagesResp = await slack.conversations.replies({ + channel: event.channel, + ts: taskId, + limit: 100, + }); + + const nearbyMessages = ( + await sflow(nearbyMessagesResp.messages || []) + .map(async (m) => ({ + username: await slack.users + .info({ user: m.user || DIE("missing user id in message") }) + .then((res) => res.user?.name || "<@" + m.user + ">"), + text: m.text, + ts: m.ts, + })) + .toArray() + ).toSorted(compareBy((e) => +(e.ts || 0))); // sort by ts asc + + // quick-intent-detect-respond by chatgpt, give quick plan/context responds before start heavy agent work + const resp = await zChatCompletion( + z.object({ + user_intent: z.string(), + my_respond_before_spawn_agent: z.string(), + // spawn_agent: z.boolean(), + }), + { + model: "gpt-4o", + }, + )` +The user mentioned me with the following message in Slack: ${event.text} +Based on this message, please determine the user's intent in a concise manner. +Also, provide a brief response that I can send to the user immediately to acknowledge their request. +Finally, I will spawn an agent to help with this request if necessary. + +For context, Recent messages from this thread are as follows: +${nearbyMessages.map((m) => `- User ${m.username} said: ${JSON.stringify(m.text)}`).join("\n\n")} + +Possible Context Repos: +- https://github.com/comfyanonymous/ComfyUI: The main ComfyUI repository containing the core application logic and features. Its a python backend to run any machine learning models and solves various machine learning tasks. +- https://github.com/Comfy-Org/ComfyUI_frontend: The frontend codebase for ComfyUI, built with Vue and TypeScript. +- https://github.com/Comfy-Org/docs: Documentation for ComfyUI, including setup guides, tutorials, and API references. +- https://github.com/Comfy-Org/desktop: The desktop application for ComfyUI, providing a user-friendly interface and additional functionalities. +- https://github.com/Comfy-Org/registry: The registry.comfy.org, where users can share and discover ComfyUI custom-nodes, and extensions. +- https://github.com/Comfy-Org/workflow_templates: A collection of official shared workflow templates for ComfyUI to help users get started quickly. + +- https://github.com/Comfy-Org/comfy-api: A RESTful API service for comfy-registry, it stores custom-node metadatas and user profile/billings informations. + +- And also other repos under Comfy-Org organization on GitHub. + +Respond in JSON format with the following fields: +- 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." +- 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." +`; + // - 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. + console.log("Intent detection response:", resp); + + const quickRespondMsg = await slack.chat.postMessage({ + channel: event.channel, + thread_ts: event.ts, + text: resp.my_respond_before_spawn_agent, + }); + + // spawn agent if needed & allowed + // if (!resp.spawn_agent) { + // // update status + // await slack.reactions.remove({ name: 'eyes', channel: event.channel, timestamp: event.ts, }); + // await slack.reactions.add({ name: 'white_check_mark', channel: event.channel, timestamp: event.ts, }); + // await State.set(`task-${taskId}`, { ...await State.get(`task-${taskId}`), status: 'done' }); + // return 'no agent spawned' + // } + + // The problem not easy to solve in original thread, lets forward this message to #pr-bot channel, and then spawn agent using that message. + if (!isAgentChannel) { + // update status, remove eye, add forwarding reaction + await slack.reactions.remove({ name: "eyes", channel: event.channel, timestamp: event.ts }); + await slack.reactions.add({ name: "arrow_right", channel: event.channel, timestamp: event.ts }); + + const originalMessageUrl = `https://${event.team}.slack.com/archives/${event.channel}/p${event.ts.replace(".", "")}`; + // forward msg to #pr-bot channel, mention original msg user:content, and the original msg url for agent to read + const agentChannelId = + (await slack.conversations.list({ types: "public_channel" })).channels?.find((c) => c.name === "pr-bot")?.id || + DIE("failed to find #pr-bot channel id"); + // this is a user facing msg to tell user we are forwarding the msg + const text = `Forwarded message from <@${event.user}> in <#${event.channel}>:\n${await parseSlackMessageToMarkdown(event.text)}\n\nYou can view the original message here: ${originalMessageUrl}`; + const forwardedMsg = await slack.chat.postMessage({ + channel: agentChannelId, + text, + }); + + // mention forwarded msg in original thread says I will continue there + await slack.chat.update({ + channel: event.channel, + ts: quickRespondMsg.ts!, + text: `${resp.my_respond_before_spawn_agent}\n\nI have forwarded your message to <#${agentChannelId}>. I will continue the research there.`, + }); + await State.set(`task-${taskId}`, { ...(await State.get(`task-${taskId}`)), status: "forward_to_pr_bot_channel" }); + + // process the forwarded message in agent channel + return await processSlackAppMentionEvent({ + ...event, + channel: agentChannelId, + ts: forwardedMsg.ts!, + thread_ts: undefined, + text: forwardedMsg.text || "", + }); + } + + await State.set(`task-${taskId}`, { ...(await State.get(`task-${taskId}`)), status: "thinking" }); + await slack.reactions.remove({ name: "eyes", channel: event.channel, timestamp: event.ts }); + + await slack.reactions.add({ name: "thinking_face", channel: event.channel, timestamp: event.ts }); + // await slack.reactions.add({ name: 'thinking_face', channel: event.channel, timestamp: statusMsg.ts, }); + const agentPrompt = ` +You are @ComfyPR-Bot, act on behalf @snomiao of Comfy-Org, an AI assistant integrated into Slack, Notion, Github. +A user mentioned you with the following message: + +${JSON.stringify(await parseSlackMessageToMarkdown(event.text))} + +You have already determined the user's intent as follows: +${resp.user_intent} + +Your preliminary response to the user is: +${JSON.stringify(resp.my_respond_before_spawn_agent)} + +Now, based on the user's intent, please do research and provide a detailed and helpful response to assist the user with their request. + +Possible Context Repos: +- https://github.com/comfyanonymous/ComfyUI: The main ComfyUI repository containing the core application logic and features. Its a python backend to run any machine learning models and solves various machine learning tasks. +- https://github.com/Comfy-Org/ComfyUI_frontend: The frontend codebase for ComfyUI, built with Vue and TypeScript. +- https://github.com/Comfy-Org/docs: Documentation for ComfyUI, including setup guides, tutorials, and API references. +- https://github.com/Comfy-Org/desktop: The desktop application for ComfyUI, providing a user-friendly interface and additional functionalities. +- https://github.com/Comfy-Org/registry: The registry.comfy.org, where users can share and discover ComfyUI custom-nodes, and extensions. +- https://github.com/Comfy-Org/workflow_templates: A collection of official shared workflow templates for ComfyUI to help users get started quickly. +- https://github.com/Comfy-Org/comfy-api: A RESTful API service for comfy-registry, it stores custom-node metadatas and user profile/billings informations. + +- And also other repos under Comfy-Org organization on GitHub. + +## Respond Rules +- Use markdown format for all your responses. +- If you reference code, repos, or documents, provide links to them. +- Always prioritize user privacy and data security. +- Provide rich references and citations for your information. + +## Skills you have: +- Search the web for relevant information. +- github: Clone any repositories from https://github.com/Comfy-Org to ./codes/Comfy-Org/[repo]/tree/[branch] to inspect codebases. +- slack: You should update your response frequently by run 'bun ../bot/slack/msg-update.ts --channel ${event.channel} --ts ${quickRespondMsg.ts!} --text ""' +- slack: You should read threads in background for context if possible, run 'bun ../bot/slack/msg-read-thread.ts --channel ${event.channel} --ts [ts]' +- notion: Search Notion docs whenever possible from Comfy-Org team using ./bot/notion-search.ts +- Local file system: Your working directory are temp, make sure commit your work to external services like slack/github/notion where user can see it, before your ./ dir get cleaned up. +`; + + const taskUser = `bot-user-${taskId.replace(".", "-")}`; + await mkdir(`/home/${taskUser}`, { recursive: true }); + // todo: create a linux user for task + + // todo: spawn in a worker user + // create a user for task + await sflow( + "", // Can send later message from this thread + ) + .merge( + sflow(taskInputFlow.readable) + // send original message and then write '\n' after 1s delay to simulate user press Enter + .flatMap((msg) => [msg, sleep(1000).then(() => "\n")]) + .map(async (awaitableText) => await awaitableText), + ) + .by(fromStdio(spawn("claude-yes", ["--prompt", agentPrompt], { cwd: `/home/${taskUser}` }))) + // show loading icon when any output activity, and remove the loading icon after idle for 5s + .by((e) => { + const idleWaiter = new IdleWaiter(); + let isThinking = false; + return e.forEach(async (chunk) => { + idleWaiter.ping(); + if (!isThinking) { + isThinking = true; + await slack.reactions.add({ name: "thinking_face", channel: event.channel, timestamp: event.ts }); + idleWaiter.wait(5000).then(async () => { + // clearInterval(id); + await slack.reactions.remove({ name: "thinking_face", channel: event.channel, timestamp: event.ts }); + isThinking = false; + }); + } + return chunk; + }); + }) + // prints to stdout + // .by(e => { + // const tr = new TerminalTextRender() + // const liveLines: { + // text: string, // content of this line + // t: number // timestamp of this line that got updated, we can safely show it to user when its stable for a while, e.g. 5s + // }[] = [] + // let linesCheckpoint = 0 // last lines index that got sent to user + // const id = setInterval(async () => { + // console.log('Live lines: ', liveLines.length, (liveLines).map(l => l.t).join('|')) + // const now = Date.now() + // let newLines = false + // let renderedText = '' + // liveLines.forEach((line, idx) => { + // if (now - line.t > 3000) { // stable for 3s + // renderedText += line.text + '\n' + // if (idx >= linesCheckpoint) { + // newLines = true + // } + // } + // }) + // linesCheckpoint = liveLines.length + // }, 1000) + + // return e.forEach(chunk => { + // const rendered = tr.write(chunk).render(); + // console.log('Rendered chunk size:', rendered.length, 'lines: ', rendered.split(/\r|\n/).length); + // rendered.split(/\r|\n/) + // .forEach((lineText, idx) => { + // liveLines[idx] ??= { text: '', t: 0 } + // if (liveLines[idx].text !== lineText) { + // liveLines[idx].text = lineText + // liveLines[idx].t = Date.now() + // } + // }) + // }).onFlush(() => clearInterval(id)) + // }) + + // show contents in console + .forkTo((e) => e.pipeTo(fromWritable(process.stdout))) + .run(); + + TaskInputFlows.delete(taskId); + // claude exited as no more inputs/outputs for a while, update the status message + await slack.reactions.remove({ name: "thinking_face", channel: event.channel, timestamp: event.ts }); + await slack.reactions.add({ name: "white_check_mark", channel: event.channel, timestamp: event.ts }); + await State.set(`task-${taskId}`, { ...(await State.get(`task-${taskId}`)), status: "done" }); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/bot/notion-search.ts b/bot/notion-search.ts new file mode 100644 index 00000000..8d3cd253 --- /dev/null +++ b/bot/notion-search.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env bun +import { notion } from "@/lib"; +import sflow from "sflow"; +import { parseArgs } from "util"; + +/** + * Search Notion pages from Comfy-Org team workspace + * Usage: bun bot/notion-search.ts --query "search term" + */ +async function searchNotion(query: string, limit: number = 10) { + try { + const response = await notion.search({ + query, + page_size: limit, + filter: { + property: "object", + value: "page", + }, + sort: { + direction: "descending", + timestamp: "last_edited_time", + }, + }); + + const results = await sflow(response.results) + .map(async (page: any) => { + // Extract title from page properties + let title = "Untitled"; + if (page.properties) { + const titleProp = Object.values(page.properties).find((prop: any) => prop.type === "title") as any; + if (titleProp?.title?.[0]?.plain_text) { + title = titleProp.title[0].plain_text; + } + } + + return { + id: page.id, + title, + url: page.url, + last_edited_time: page.last_edited_time, + created_time: page.created_time, + }; + }) + .toArray(); + + return results; + } catch (error) { + console.error("Error searching Notion:", error); + throw error; + } +} + +if (import.meta.main) { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + query: { + type: "string", + short: "q", + }, + limit: { + type: "string", + short: "l", + default: "10", + }, + }, + strict: true, + allowPositionals: false, + }); + + if (!values.query) { + console.error('Usage: bun bot/notion-search.ts --query "" [--limit ]'); + console.error('Example: bun bot/notion-search.ts --query "ComfyUI setup" --limit 5'); + process.exit(1); + } + + const results = await searchNotion(values.query, parseInt(values.limit || "10")); + + console.log(`Found ${results.length} results for query: "${values.query}"\n`); + + for (const result of results) { + console.log(`Title: ${result.title}`); + console.log(`URL: ${result.url}`); + console.log(`Last edited: ${result.last_edited_time}`); + console.log("---"); + } +} + +export { searchNotion }; diff --git a/bot/slack/msg-read-thread.ts b/bot/slack/msg-read-thread.ts new file mode 100644 index 00000000..012ef591 --- /dev/null +++ b/bot/slack/msg-read-thread.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env bun +import { slack } from "@/lib"; +import DIE from "@snomiao/die"; +import sflow from "sflow"; +import { parseArgs } from "util"; +import { parseSlackMessageToMarkdown } from "./parseSlackMessageToMarkdown"; +import { slackTsToISO } from "./slackTsToISO"; + +/** + * Read messages from a Slack thread + * Usage: bun bot/slack/msg-read-thread.ts --channel C123 --ts 1234567890.123456 + */ +async function readSlackThread(channel: string, ts: string, limit: number = 100) { + try { + const result = await slack.conversations.replies({ + channel, + ts, + limit, + }); + + if (!result.ok) { + throw new Error(`Failed to read thread: ${result.error}`); + } + + const messages = await sflow(result.messages || []) + .map(async (m) => { + const user = m.user + ? await slack.users + .info({ user: m.user }) + .then((res) => res.user?.name || `<@${m.user}>`) + .catch(() => `<@${m.user}>`) + : "Unknown"; + + return { + ts: m.ts || DIE("missing ts"), + iso: slackTsToISO(m.ts || DIE("missing ts")), + username: user, + text: m.text || "", + markdown: await parseSlackMessageToMarkdown(m.text || ""), + }; + }) + .toArray(); + + return messages; + } catch (error) { + console.error("Error reading Slack thread:", error); + throw error; + } +} + +if (import.meta.main) { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + channel: { + type: "string", + short: "c", + }, + ts: { + type: "string", + short: "t", + }, + limit: { + type: "string", + short: "l", + default: "100", + }, + }, + strict: true, + allowPositionals: false, + }); + + if (!values.channel || !values.ts) { + console.error("Usage: bun bot/slack/msg-read-thread.ts --channel --ts [--limit ]"); + console.error("Example: bun bot/slack/msg-read-thread.ts --channel C123ABC --ts 1234567890.123456 --limit 50"); + process.exit(1); + } + + const messages = await readSlackThread(values.channel, values.ts, parseInt(values.limit || "100")); + + console.log(JSON.stringify(messages, null, 2)); +} + +export { readSlackThread }; diff --git a/bot/slack/msg-update.ts b/bot/slack/msg-update.ts new file mode 100644 index 00000000..0418e873 --- /dev/null +++ b/bot/slack/msg-update.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun +import { slack } from "@/lib"; +import { parseArgs } from "util"; + +/** + * Update a Slack message + * Usage: bun bot/slack/msg-update.ts --channel C123 --ts 1234567890.123456 --text "Updated message" + */ +async function updateSlackMessage(channel: string, ts: string, text: string) { + try { + const result = await slack.chat.update({ + channel, + ts, + text, + }); + + if (result.ok) { + console.log(`Message updated successfully: ${ts}`); + return result; + } else { + throw new Error(`Failed to update message: ${result.error}`); + } + } catch (error) { + console.error("Error updating Slack message:", error); + throw error; + } +} + +if (import.meta.main) { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + channel: { + type: "string", + short: "c", + }, + ts: { + type: "string", + short: "t", + }, + text: { + type: "string", + short: "m", + }, + }, + strict: true, + allowPositionals: false, + }); + + if (!values.channel || !values.ts || !values.text) { + console.error( + 'Usage: bun bot/slack/msg-update.ts --channel --ts --text ""', + ); + console.error( + 'Example: bun bot/slack/msg-update.ts --channel C123ABC --ts 1234567890.123456 --text "Updated message"', + ); + process.exit(1); + } + + await updateSlackMessage(values.channel, values.ts, values.text); +} + +export { updateSlackMessage }; diff --git a/bot/slack/parseSlackMessageToMarkdown.spec.ts b/bot/slack/parseSlackMessageToMarkdown.spec.ts new file mode 100644 index 00000000..af9aac8a --- /dev/null +++ b/bot/slack/parseSlackMessageToMarkdown.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { parseSlackMessageToMarkdown } from "./parseSlackMessageToMarkdown"; + +describe("parseSlackMessageToMarkdown", () => { + it("should convert user mentions", async () => { + const input = "Hello <@U123ABC>"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Hello @U123ABC"); + }); + + it("should convert channel mentions with pipe", async () => { + const input = "Check <#C123ABC|general>"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Check #general"); + }); + + it("should convert channel mentions without pipe", async () => { + const input = "Check <#C123ABC>"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Check #C123ABC"); + }); + + it("should convert links with text", async () => { + const input = "Visit "; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Visit [our website](https://example.com)"); + }); + + it("should convert plain links", async () => { + const input = "Visit "; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Visit https://example.com"); + }); + + it("should convert bold text", async () => { + const input = "This is *bold* text"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("This is **bold** text"); + }); + + it("should convert italic text", async () => { + const input = "This is _italic_ text"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("This is *italic* text"); + }); + + it("should preserve inline code", async () => { + const input = "Run `npm install` first"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Run `npm install` first"); + }); + + it("should preserve code blocks", async () => { + const input = "Example:\n```\nconst x = 1;\n```"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Example:\n```\nconst x = 1;\n```"); + }); + + it("should handle mixed formatting", async () => { + const input = "Hello <@U123> in <#C456|general> with *bold* and _italic_ and `code`"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Hello @U123 in #general with **bold** and *italic* and `code`"); + }); + + it("should not apply formatting inside inline code", async () => { + const input = "Use `*asterisks*` and `_underscores_` in code"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("Use `*asterisks*` and `_underscores_` in code"); + }); + + it("should not apply formatting inside code blocks", async () => { + const input = "```\n*bold* and _italic_ here\n```"; + const output = await parseSlackMessageToMarkdown(input); + expect(output).toBe("```\n*bold* and _italic_ here\n```"); + }); +}); diff --git a/bot/slack/parseSlackMessageToMarkdown.ts b/bot/slack/parseSlackMessageToMarkdown.ts new file mode 100644 index 00000000..672aa208 --- /dev/null +++ b/bot/slack/parseSlackMessageToMarkdown.ts @@ -0,0 +1,68 @@ +/** + * Parse Slack message format to Markdown + * Converts Slack's formatting to standard Markdown: + * - <@U123> -> @username + * - <#C123|channel> -> #channel + * - -> [link text](https://example.com) + * - *bold* -> **bold** + * - _italic_ -> *italic* + * - `code` -> `code` + * - ```code block``` -> ```code block``` + */ +export async function parseSlackMessageToMarkdown(text: string): Promise { + let markdown = text; + + // Convert user mentions <@U123> to @U123 (we'd need to fetch usernames for full conversion) + markdown = markdown.replace(/<@([A-Z0-9]+)>/g, "@$1"); + + // Convert channel mentions <#C123|channel-name> or <#C123> + markdown = markdown.replace(/<#([A-Z0-9]+)\|([^>]+)>/g, "#$2"); + markdown = markdown.replace(/<#([A-Z0-9]+)>/g, "#$1"); + + // Convert links to [link text](https://example.com) + markdown = markdown.replace(/<(https?:\/\/[^|>]+)\|([^>]+)>/g, "[$2]($1)"); + + // Convert plain links + markdown = markdown.replace(/<(https?:\/\/[^>]+)>/g, "$1"); + + // Convert Slack bold *text* to Markdown **text** + // But preserve code blocks and inline code first + const codeBlocks: string[] = []; + markdown = markdown.replace(/```[\s\S]*?```/g, (match) => { + codeBlocks.push(match); + return `\x00CODEBLOCK\x00${codeBlocks.length - 1}\x00`; + }); + + const inlineCode: string[] = []; + markdown = markdown.replace(/`[^`]+`/g, (match) => { + inlineCode.push(match); + return `\x00INLINECODE\x00${inlineCode.length - 1}\x00`; + }); + + // Now convert bold and italic + markdown = markdown.replace(/\*([^*]+)\*/g, "**$1**"); + markdown = markdown.replace(/_([^_]+)_/g, "*$1*"); + + // Restore code blocks and inline code + markdown = markdown.replace(/\x00INLINECODE\x00(\d+)\x00/g, (_, idx) => inlineCode[parseInt(idx)]); + markdown = markdown.replace(/\x00CODEBLOCK\x00(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)]); + + return markdown; +} + +if (import.meta.main) { + // Test examples + const tests = [ + "Hello <@U123> in <#C456|general>", + "Check out ", + "This is *bold* and _italic_ and `code`", + "```\ncode block\n```", + "Mixed <@U123> with *bold* and ", + ]; + + for (const test of tests) { + console.log("Input: ", test); + console.log("Output:", await parseSlackMessageToMarkdown(test)); + console.log("---"); + } +} diff --git a/bot/slack/slackTsToISO.spec.ts b/bot/slack/slackTsToISO.spec.ts new file mode 100644 index 00000000..175b80df --- /dev/null +++ b/bot/slack/slackTsToISO.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { slackTsToISO } from "./slackTsToISO"; + +describe("slackTsToISO", () => { + it("should convert Slack timestamp to ISO format", () => { + const ts = "1703347200.123456"; + const iso = slackTsToISO(ts); + expect(iso).toBe("2023-12-23T16:00:00.123Z"); + }); + + it("should handle timestamp with zeros in microseconds", () => { + const ts = "1703347200.000000"; + const iso = slackTsToISO(ts); + expect(iso).toBe("2023-12-23T16:00:00.000Z"); + }); + + it("should handle timestamp with different microseconds", () => { + const ts = "1703347200.999000"; + const iso = slackTsToISO(ts); + expect(iso).toBe("2023-12-23T16:00:00.999Z"); + }); + + it("should handle epoch timestamp", () => { + const ts = "0.000000"; + const iso = slackTsToISO(ts); + expect(iso).toBe("1970-01-01T00:00:00.000Z"); + }); +}); diff --git a/bot/slack/slackTsToISO.ts b/bot/slack/slackTsToISO.ts new file mode 100644 index 00000000..4c7d64b8 --- /dev/null +++ b/bot/slack/slackTsToISO.ts @@ -0,0 +1,17 @@ +/** + * Convert Slack timestamp to ISO 8601 format + * Slack timestamps are in the format: "1234567890.123456" + * where the integer part is Unix timestamp in seconds + */ +export function slackTsToISO(ts: string): string { + const [seconds, microseconds] = ts.split("."); + const milliseconds = parseInt(seconds) * 1000 + parseInt(microseconds.slice(0, 3)); + return new Date(milliseconds).toISOString(); +} + +if (import.meta.main) { + // Test example + const exampleTs = "1703347200.123456"; + console.log(`Slack TS: ${exampleTs}`); + console.log(`ISO: ${slackTsToISO(exampleTs)}`); +}