diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f63b990..cac0d49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [18, 20, 22] + node-version: [22, 24] steps: - uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 0a1ec7a..92c19db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,19 +3,20 @@ Persistent, repo-specific context. Read this before starting work here. ## What this project is -A Slack bot built on **Slack Bolt** (Node.js, ES modules) that answers using **ChatGPT/OpenAI** (or any OpenAI-compatible local LLM via `LLM_API_BASE_URL`). Conversation memory is stored in **Redis via Keyv** (`keyv` + `@keyv/redis`). Image generation uses **Gemini (Nano Banana)** via `@google/genai`. +A Slack bot built on **Slack Bolt** (Node.js 22+, ES modules) that answers using **Ollama** (default, self-hosted) or **Gemini** — each via its native SDK, no OpenAI compatibility shim. Conversation memory is owned by the bot and stored in **Redis via Keyv** (`keyv` + `@keyv/redis`). Image generation uses **Gemini (Nano Banana)** via `@google/genai`. Entry point: `app.js` — a thin glue layer that wires Bolt event handlers onto pure helpers in `lib/`: - `lib/responses.js` — pure trigger-word matchers, dance-party RNG, help text, Asimov rules, dad-joke fetch/format -- `lib/chat.js` — `handleMessage`, `cleanLocalLlmResponse` (deps injected) +- `lib/chat.js` — `handleMessage`, `clearHistory` (deps injected; backend-agnostic) +- `lib/chat-backends.js` — `makeOllamaChat()` / `makeGeminiChat()` adapter factories - `lib/image.js` — `generateImage` (Gemini client + model injected) - `lib/deps.js` — `buildDeps()` factory and `validateRequiredEnv()` Tests: `node --test` under `test/`. ## Testing conventions -- **Mocking the external services (OpenAI/ChatGPT, Slack/Bolt) is REQUIRED and strictly necessary here.** They need network access and paid/credentialed calls that cannot run in the sandbox, so unit tests MUST mock those boundaries. This is the justified exception to the general "test real code paths, not mocks" guidance — mock *only* the external clients, and test all real internal logic directly. -- **Tests must pass with no secrets set** (no `SLACK_*` / `OPENAI_API_KEY` required). +- **Mocking the external services (Ollama/Gemini, Slack/Bolt) is REQUIRED and strictly necessary here.** They need network access and credentialed calls that cannot run in the sandbox, so unit tests MUST mock those boundaries. This is the justified exception to the general "test real code paths, not mocks" guidance — mock *only* the external clients, and test all real internal logic directly. +- **Tests must pass with no secrets set** (no `SLACK_*` / `GEMINI_API_KEY` required). - **Redis:** prefer mocking Keyv/Redis in unit tests. If a test genuinely needs a live store, run a **local** Redis only (`redis-server --daemonize yes`, `REDIS_URL=redis://localhost:6379`). **Never** connect to a remote/production Redis. - Run tests **non-interactively** (`node --test`; never watch mode). - After editing a source file, verify syntax (`node --check `). Don't leave half-replaced code. @@ -26,7 +27,7 @@ Tests: `node --test` under `test/`. To add tests for new logic: - Put pure helpers in `lib/responses.js` (or a new sibling module) and import them directly. -- For anything that touches an external client (ChatGPT, Gemini, Slack), take the client as a `deps` parameter and have tests pass a fake. See `handleMessage(message, { chat, parentIds, isLocalLlm })` and `generateImage(prompt, { client, model })` for the pattern. +- For anything that touches an external client (Ollama, Gemini, Slack), take the client as a `deps` parameter and have tests pass a fake. See `handleMessage(message, { chat, convoStore })` and `generateImage(prompt, { client, model })` for the pattern. - Avoid adding new module-level `new SomeClient(...)` instantiations — extend `buildDeps()` in `lib/deps.js` instead so tests can override. ## Working autonomously @@ -34,4 +35,4 @@ To add tests for new logic: - **Escalation:** if you get genuinely stuck — the same step fails ~3 times, or you're not making progress — use the `switch_llm` tool to switch to profile **`qwen3-coder`** (a stronger coding model) and continue, instead of repeating a failing approach. Use it as a real "I'm stuck" lever, not for trivial hiccups. ## Backend note -LLM backend is OpenAI-API-based and already supports OpenAI-compatible local endpoints via `LLM_API_BASE_URL` + `LLM_MODEL` (e.g. `http://kepler.local:11434/v1` running Ollama). Image generation uses Gemini regardless of which LLM backend is configured; the `/image` slash command requires `GEMINI_API_KEY`. +Chat runs through one of two native adapters in `lib/chat-backends.js`, selected by `CHAT_BACKEND`: **`ollama`** (default; talks to `OLLAMA_HOST`, model via `OLLAMA_MODEL`) or **`gemini`** (`GEMINI_CHAT_MODEL`, reuses the image-generation client). Each adapter exposes a uniform `chat({ messages }) → { text, thinking? }`, so `handleMessage` is backend-agnostic. Image generation always uses Gemini; the `/image` slash command requires `GEMINI_API_KEY`. See `CLAUDE.md` for model-selection notes (which Ollama models do vision vs. tool calling). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cfd0bd2..5f0b650 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,22 +16,23 @@ This document explains the high-level architecture of the `data` Slack chatbot: - **`lib/image.js`** — `generateImage()`. Takes the Gemini client and model name via `deps`. - **`lib/deps.js`** — `buildDeps()` factory + `validateRequiredEnv()`. Constructs the Slack `App`, the Keyv/Redis `convoStore`, the `GoogleGenAI` client, and the selected chat adapter; tests can override any of them. - **Slack (Bolt JS)** — receives events in Socket Mode and dispatches them to the handlers registered by `registerHandlers(deps)`. -- **Ollama (`ollama` npm package)** — default chat backend. Native SDK; no OpenAI compat shim. Talks to `OLLAMA_HOST` (default `http://localhost:11434`). Room to extend with `think`, `tools`, `format`, vision. +- **Ollama (`ollama` npm package)** — default chat backend. Native SDK; no OpenAI compat shim. Talks to `OLLAMA_HOST` (default `http://localhost:11434`). Supports vision (base64 images) and reasoning traces (`OLLAMA_THINK`); strips Llama tokenizer artifacts at the adapter boundary. - **Gemini (`@google/genai`)** — image generation (`gemini-3.1-flash-image`, "Nano Banana 2") and optional chat backend (`gemini-3-flash-latest`). One client serves both. - **Persistence (Keyv + KeyvRedis)** — stores per-user conversation history (`{role, content}` arrays) keyed by `convo:`, backed by Redis (`REDIS_URL`). TTL via `MEMORY_TTL_HOURS`. History survives process restarts. - **Tests** — under `test/` run with `node --test`; see `test/chat.test.js` and `test/chat-backends.test.js` for the adapter + convoStore mock patterns. -- **CI** — GitHub Actions matrix on Node 18/20/22: install, lint, tests, syntax-check of `app.js` + every `lib/*.js`, and a non-blocking `npm audit` at high severity. +- **CI** — GitHub Actions matrix on Node 22/24: install, lint, tests, syntax-check of `app.js` + every `lib/*.js`, and a non-blocking `npm audit` at high severity. ## Message flows 1. Incoming message arrives via Bolt Socket Mode. 2. The general message handler (`app.message(...)`) checks pure matchers from `lib/responses.js` in order (love-you → pod-bay → danceparty → tiktok → rickroll). On a match it calls `say()` with the helper output and returns. -3. If no canned match and the channel is a DM or MPIM, the handler posts a "thinking" indicator, calls `handleMessage(msg, { chat, convoStore })`, deletes the indicator, and replies with the result. -4. Direct-mention handler (`app.message(directMention(), ...)`) checks for `help`, `the rules`, `dad joke`, and image-request guidance before falling through to `handleMessage()` with the same thinking UX. +3. If no canned match and the channel is a DM or MPIM, the handler adds a `:brain:` reaction to the user's message, extracts any image attachments (`extractMessageImages`), calls `handleMessage(msg, { chat, convoStore })`, removes the reaction, and replies with the result. +4. Direct-mention handler (`app.message(directMention(), ...)`) checks for `help`, `the rules`, `dad joke`, and image-request guidance before falling through to `handleMessage()` with the same reaction UX. Channel @-mentions reply in-thread (continuing an existing thread or starting one rooted at the mention); DMs reply flat. 5. Slash command `/image`: - `ack()` immediately to avoid Slack timeouts. - Respond with an ephemeral progress message. - Schedule the heavy work via `queueMicrotask`: call `generateImage(prompt, { client: geminiClient, model })`, then upload the returned `Buffer` to the channel with `files.uploadV2()`. +6. Slash command `/forget` — calls `clearHistory(user_id, { convoStore })` to wipe the invoking user's stored conversation, replying ephemerally. ## Chat backend selection @@ -39,7 +40,7 @@ This document explains the high-level architecture of the `data` Slack chatbot: ## Concurrency & UX - Image generation is deferred via `queueMicrotask` so the slash command's `ack()` returns immediately while the upload happens in the background. -- A small thinking helper (`postThinking` / `clearThinking` in `app.js`) centralizes posting and deleting progress messages. +- "I'm working on this" UX is a `:brain:` reaction on the user's message: `addThinkingReaction` / `removeThinkingReaction` in `app.js` (requires `reactions:read` + `reactions:write` scopes). Reasoning traces from the backend are captured but deliberately not rendered. ## Persistence & Conversation Context - Per-user message history is stored in `Keyv` (backed by `KeyvRedis` when `REDIS_URL` is set) under `convo:` as a `[{role, content}, ...]` array. diff --git a/CLAUDE.md b/CLAUDE.md index 74fda00..84ba967 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ npm run format # Format with Prettier - **`app.js` is a thin glue layer** (~300 lines) that wires Bolt handlers onto pure helpers in `lib/` - **Socket Mode**: no public URL required; uses WebSocket connection - **Persistence**: Redis-backed via Keyv for conversation context (per-user message history) -- **Node.js 18+** (CI matrices on 18, 20, 22; production runs 18 but it's EOL) +- **Node.js 22+** (CI matrices on 22, 24; production runs 22 LTS) - ESM modules throughout ## Layout @@ -47,7 +47,7 @@ package.json # Dependencies and scripts .env.example # Environment variable template ARCHITECTURE.md # Detailed architecture documentation AGENTS.md # Conventions for AI agents working in this repo -.github/workflows/ci.yml # Lint + tests + syntax check + audit on Node 18/20/22 +.github/workflows/ci.yml # Lint + tests + syntax check + audit on Node 22/24 ``` ## Key Exports diff --git a/README.md b/README.md index b7e1e26..12847f4 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,21 @@ ## Overview -This is a AI-powered Slack chatbot built on the Bolt JS framework. The bot includes canned responses and falls back to AI for messages that don't match a predefined pattern. You can customize the bot's personality and responses to suit your needs. +This is an AI-powered Slack chatbot built on the Bolt JS framework. The bot includes canned responses and falls back to a language model for messages that don't match a predefined pattern. Chat is powered by **Ollama** (default, self-hosted) or **Gemini**; image generation uses **Gemini**. You can customize the bot's personality and responses to suit your needs. ## Prerequisites -You will need a local Redis installation to persist the bot's conversation memory. You can install Redis server on Ubuntu like this: +- **Node.js 22+** (LTS). +- A **Redis** installation to persist the bot's conversation memory. On Ubuntu: -```zsh -sudo apt -y install redis-server -``` + ```zsh + sudo apt -y install redis-server + ``` + +- A chat backend: + - **Ollama** (default) — a running [Ollama](https://ollama.com) instance with a model pulled (e.g. `ollama pull llama3.1`). Point the bot at it with `OLLAMA_HOST`. + - **or Gemini** — set `CHAT_BACKEND=gemini` and supply a `GEMINI_API_KEY`. +- A **`GEMINI_API_KEY`** is required for the `/image` slash command regardless of which chat backend you use. ## Installation @@ -32,14 +38,20 @@ Then scroll down in Basic Info and click **Generate Token and Scopes** with all #### For Linux/Mac +The easiest way is to copy `.env.example` to `.env` and fill it in; the bot loads it via dotenv. Or export them in your shell: + ```zsh # Replace with your bot and tokens export SLACK_BOT_TOKEN= # from the OAuth section export SLACK_APP_TOKEN= # from the Basic Info App Token Section export SLACK_BOT_USER_NAME= # must match the short name of your bot user -export OPENAI_API_KEY= # get from here: https://platform.openai.com/account/api-keys +export GEMINI_API_KEY= # for /image, and for chat if CHAT_BACKEND=gemini export BOT_PERSONALITY="Your custom bot personality prompt here" # Optional: Set a custom personality for your bot -export THINKING_MESSAGE=":gear: _Processing your request..._" # Optional: Customize the thinking indicator message + +# Optional: choose and configure the chat backend (defaults to local Ollama) +# export CHAT_BACKEND=ollama +# export OLLAMA_HOST=http://localhost:11434 +# export OLLAMA_MODEL=llama3.1 ``` #### For Windows PowerShell @@ -49,9 +61,8 @@ export THINKING_MESSAGE=":gear: _Processing your request..._" # Optional: Custom $env:SLACK_BOT_TOKEN = "xoxb-your-bot-token" $env:SLACK_APP_TOKEN = "xapp-your-app-token" $env:SLACK_BOT_USER_NAME = "Data" # Change to match your bot's name -$env:OPENAI_API_KEY = "your-openai-api-key" +$env:GEMINI_API_KEY = "your-gemini-api-key" $env:BOT_PERSONALITY = "Your custom bot personality prompt here" # Optional: Set a custom personality for your bot -$env:THINKING_MESSAGE = ":gear: _Processing your request..._" # Optional: Customize the thinking indicator message # Optional: Set Redis URL if you're using a custom Redis instance # $env:REDIS_URL = "redis://localhost:6379" @@ -92,8 +103,7 @@ npm run start ### 4. Test -Go to the installed workspace and type **help** in a DM to your new bot. -Use the `/dalle` slash command for functionality: +Go to the installed workspace and DM your new bot, or `@`-mention it in a channel. Direct mention example (in a channel or DM): @@ -104,35 +114,41 @@ Direct mention example (in a channel or DM): Slash command example (image generation): ```text -/dalle An image of Lt. Commander Data and his cat +/image An image of Lt. Commander Data and his cat ``` ### 5. Deploy to production You'll need a Linux server, container, or application platform that supports nodejs to keep the bot running. Slack has a tutorial for getting an app running on the Glitch platform: https://api.slack.com/tutorials/hello-world-bolt -## Image Generation and Troubleshooting - -The bot supports generating images with DALL-E through the `/dalle` slash command. +## Image Generation -Note: The older direct `@Data image ...` handler was removed to simplify the codebase. Please use `/dalle` for image generation. +The bot generates images with **Gemini** (Nano Banana) through the `/image` slash command. ### How Image Generation Works -When using the `/dalle` slash command: +When using the `/image` slash command: -1. The bot acknowledges your request and shows a "generating" message +1. The bot acknowledges your request and shows an ephemeral "generating" message 2. Image generation happens asynchronously in the background 3. When complete, the image is posted directly to the channel ## Environment Variables -| Variable | Required | Description | -| ------------------- | -------- | -------------------------------------------------- | -| SLACK_BOT_TOKEN | Yes | Your Slack bot token from OAuth section | -| SLACK_APP_TOKEN | Yes | Your Slack app-level token | -| SLACK_BOT_USER_NAME | Yes | Must match the short name of your bot user | -| GEMINI_API_KEY | Yes | Used for image generation | -| BOT_PERSONALITY | No | Custom personality prompt for your bot | -| THINKING_MESSAGE | No | Custom thinking indicator message | -| REDIS_URL | No | Custom Redis URL (default: redis://localhost:6379) | +| Variable | Required | Description | +| ------------------- | --------------- | -------------------------------------------------------------------- | +| SLACK_BOT_TOKEN | Yes | Your Slack bot token from the OAuth section | +| SLACK_APP_TOKEN | Yes | Your Slack app-level token (Socket Mode) | +| SLACK_BOT_USER_NAME | Yes | Must match the short name of your bot user | +| GEMINI_API_KEY | Yes | Used for `/image`; also for chat when `CHAT_BACKEND=gemini` | +| CHAT_BACKEND | No | `ollama` (default) or `gemini` | +| OLLAMA_HOST | No | Ollama endpoint (default: `http://localhost:11434`) | +| OLLAMA_MODEL | No | Ollama chat model (default: `llama3.1`) | +| OLLAMA_THINK | No | Surface reasoning traces: `true\|low\|medium\|high` | +| GEMINI_CHAT_MODEL | No | Gemini chat model (default: `gemini-3-flash-latest`) | +| GEMINI_IMAGE_MODEL | No | Override the default image model | +| BOT_PERSONALITY | No | Custom personality prompt for your bot | +| REDIS_URL | No | Custom Redis URL (default: `redis://localhost:6379`) | +| MEMORY_TTL_HOURS | No | Conversation memory lifetime in hours (default: 24) | + +See `.env.example` for a copy-pasteable template. diff --git a/app.js b/app.js index 5aa43a8..2e37576 100644 --- a/app.js +++ b/app.js @@ -61,7 +61,7 @@ async function removeThinkingReaction(app, channel, ts) { try { await app.client.reactions.remove({ channel, timestamp: ts, name: THINKING_REACTION }); } catch (err) { - console.log('Failed to remove thinking reaction:', err && err.message ? err.message : err); + console.warn('Failed to remove thinking reaction:', err && err.message ? err.message : err); } } @@ -210,8 +210,13 @@ export function registerHandlers(deps) { const { joke: jokeText, zinger } = formatDadJoke(joke); await sayInThread(jokeText); if (zinger) { - await new Promise((resolve) => setTimeout(resolve, 10000)); - await sayInThread(zinger); + // Fire-and-forget the zinger after a beat for comedic timing — don't + // hold the handler open for 10s waiting on it. Errors are logged only. + setTimeout(() => { + sayInThread(zinger).catch((err) => + console.error('Failed to post dad joke zinger:', err) + ); + }, 10000); } } catch (error) { console.error(error); diff --git a/lib/responses.js b/lib/responses.js index f3e4533..a241ba8 100644 --- a/lib/responses.js +++ b/lib/responses.js @@ -121,18 +121,19 @@ export function buildHelpText(botName) { 'rickroll - Never gonna give you up, never gonna let you down.', '', '# Slash commands:', - '/askgpt - Ask ChatGPT and get an ephemeral reply', - '/image - Generate an image with Gemini', + '/image - Generate an image with Gemini', + '/forget - Erase my memory of our conversation', '', `# Address the bot directly with @${botName} syntax:`, `@${botName} the rules - Explains Asimov's laws of robotics`, `@${botName} dad joke - Provides a random dad joke`, - `@${botName} image - Create an image with Gemini`, '', - `# All other queries will be handled by ChatGPT, so you can ask it anything!`, + `# All other queries are answered by my language model, so you can ask me anything!`, `@${botName} what is the capital of Australia?`, `@${botName} what is the square root of 9?`, `@${botName} write me a bash script to install nginx`, + '', + `# I can see images too — attach one and ask me about it.`, ].join('\n'); return `You can message me in the channel with @${botName} or chat with me directly in a DM.\n\`\`\`${commandsList}\`\`\``; diff --git a/package.json b/package.json index 223edef..dadfcce 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "url": "https://github.com/scarolan/data.git" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "license": "MIT", "dependencies": {