Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node-version: [18, 20, 22]
node-version: [22, 24]

steps:
- uses: actions/checkout@v4
Expand Down
13 changes: 7 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`). Don't leave half-replaced code.
Expand All @@ -26,12 +27,12 @@ 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
- Work to completion; make reasonable decisions instead of stopping to ask.
- **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).
11 changes: 6 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,31 @@ 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:<userId>`, 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

`CHAT_BACKEND=ollama` (default) or `CHAT_BACKEND=gemini`. `buildDeps()` constructs the appropriate adapter and binds the system message + model at construction time, so `handleMessage` stays backend-agnostic. Adding a third backend (e.g. Anthropic) is a new factory in `lib/chat-backends.js` plus a branch in `buildDeps()`.

## 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:<userId>` as a `[{role, content}, ...]` array.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
70 changes: 43 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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=<your-bot-token> # from the OAuth section
export SLACK_APP_TOKEN=<your-app-level-token> # from the Basic Info App Token Section
export SLACK_BOT_USER_NAME=<your-bot-username> # must match the short name of your bot user
export OPENAI_API_KEY=<your-openai-api-key> # get from here: https://platform.openai.com/account/api-keys
export GEMINI_API_KEY=<your-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
Expand All @@ -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"
Expand Down Expand Up @@ -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):

Expand All @@ -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.
11 changes: 8 additions & 3 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading