Full implementation from spec: ZNC/IRC client with TLS, Ollama LLM backend, per-user SQLite conversation memory, and Flask web admin portal with 7 pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
976 lines
36 KiB
Markdown
976 lines
36 KiB
Markdown
# IRC LLM Bot
|
||
|
||
An IRC bot that connects to a ZNC bouncer, joins configured channels, and responds to users via a locally hosted Llama model (Ollama). Conversation history is persisted to disk per user and per channel so Llama remembers past interactions across restarts. Includes a web-based admin portal for live configuration changes — no restart required.
|
||
|
||
**Gitea Repository:** [http://192.168.1.64:3000/tocmo0nlord/irc-bot](http://192.168.1.64:3000/tocmo0nlord/irc-bot)
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
1. [Architecture Overview](#architecture-overview)
|
||
2. [Dependency: ZNC](#dependency-znc)
|
||
3. [Dependency: Ollama](#dependency-ollama)
|
||
4. [Dependency: Web Portal](#dependency-web-portal)
|
||
5. [Conversation Memory](#conversation-memory)
|
||
6. [Project Structure](#project-structure)
|
||
7. [Configuration Reference](#configuration-reference)
|
||
8. [How the Bot Works](#how-the-bot-works)
|
||
9. [Interaction Examples](#interaction-examples)
|
||
10. [Installation](#installation)
|
||
11. [Docker Compose](#docker-compose)
|
||
12. [Development Notes](#development-notes)
|
||
13. [Security Notes](#security-notes)
|
||
14. [Troubleshooting](#troubleshooting)
|
||
|
||
---
|
||
|
||
## Architecture Overview
|
||
|
||
```
|
||
IRC Network
|
||
│
|
||
▼
|
||
┌──────────┐ TLS/6501 ┌────────────────────────────┐
|
||
│ ZNC │◄──────────────────────►│ IRC Bot │
|
||
│ Bouncer │ │ (bot/irc_client.py) │
|
||
│ham.active│ │ │
|
||
│blue.net │ │ bot/message_handler.py │
|
||
└──────────┘ │ │ │
|
||
│ ▼ │
|
||
│ bot/llm_client.py │
|
||
│ │ │
|
||
│ bot/memory.py ◄──────────┼─── data/history/
|
||
│ │ │ (SQLite, per user
|
||
└──────────┼─────────────────┘ per channel)
|
||
│ HTTP /api/generate
|
||
▼
|
||
┌──────────────────┐
|
||
│ Ollama │
|
||
│ 192.168.2.10 │
|
||
│ :11434 │
|
||
│ llama3.1 (8B) │
|
||
└──────────────────┘
|
||
|
||
┌──────────────────┐
|
||
│ Web Portal │
|
||
│ portal/app.py │
|
||
│ :8080 │
|
||
│ R/W config.json │
|
||
│ View/clear mem │
|
||
└──────────────────┘
|
||
```
|
||
|
||
The bot, portal, and Ollama are **three separate processes** that communicate through:
|
||
- **Bot ↔ ZNC**: persistent TLS socket (IRC protocol)
|
||
- **Bot ↔ Ollama**: HTTP POST per message
|
||
- **Bot ↔ Memory store**: SQLite read/write per message (`data/history/`)
|
||
- **Portal ↔ Bot**: shared `config/config.json` on disk + reload signal (SIGHUP or Unix socket)
|
||
- **Portal ↔ Memory store**: SQLite read for viewing, delete for clearing history
|
||
- **Portal ↔ User**: browser HTTP on port 8080
|
||
|
||
---
|
||
|
||
## Dependency: ZNC
|
||
|
||
### What ZNC Does for This Bot
|
||
|
||
ZNC is an IRC bouncer — it maintains a persistent connection to the upstream IRC network on behalf of the bot. The bot connects to ZNC rather than directly to an IRC server. This means:
|
||
|
||
- If the bot process crashes or restarts, ZNC stays connected to IRC and buffers messages
|
||
- The bot reconnects to ZNC and replays any missed messages via the playback buffer
|
||
- ZNC handles TLS termination to the upstream IRC server
|
||
- Multiple bots or clients can share a single ZNC user/network without multiple upstream connections
|
||
|
||
### ZNC Connection Details
|
||
|
||
| Parameter | Value |
|
||
|---|---|
|
||
| Host | `ham.activeblue.net` |
|
||
| Port | `6501` |
|
||
| TLS | Yes |
|
||
| Protocol | IRC over TLS |
|
||
|
||
### ZNC Authentication Format
|
||
|
||
ZNC uses a special login format passed as the IRC `PASS` command during connection handshake:
|
||
|
||
```
|
||
PASS <ZNC_USER>/<ZNC_NETWORK>:<ZNC_PASSWORD>
|
||
```
|
||
|
||
Full handshake sequence the bot sends:
|
||
|
||
```
|
||
NICK avcbot
|
||
USER avcbot 0 * :Active Blue IRC Bot
|
||
PASS tocmo0nlord/activeblue:mysecretpassword
|
||
```
|
||
|
||
The `ZNC_NETWORK` value must exactly match the name of a network configured in the ZNC user's account. Verify this in ZNC's web panel or in `~znc/.znc/users/<user>/networks/`.
|
||
|
||
### ZNC Requirements for This Bot
|
||
|
||
The ZNC user account must have:
|
||
|
||
1. **A network entry** with the name matching `ZNC_NETWORK` in `.env`
|
||
2. **The IRC server configured** under that network (e.g., `irc.libera.chat:6697`)
|
||
3. **Playback buffer enabled** (recommended) — allows the bot to catch up on messages after a reconnect
|
||
4. **The bot's nick registered** on the upstream IRC network (optional but recommended for +v/+o access)
|
||
|
||
### ZNC Config Snippet Reference
|
||
|
||
Relevant portion of `~znc/.znc/configs/znc.conf` for a bot user:
|
||
|
||
```ini
|
||
<User tocmo0nlord>
|
||
Pass = <hashed_password>
|
||
Nick = avcbot
|
||
AltNick = avcbot_
|
||
RealName = Active Blue IRC Bot
|
||
|
||
<Network activeblue>
|
||
Server = irc.libera.chat +6697
|
||
Chan = #general
|
||
Chan = #support
|
||
</Network>
|
||
</User>
|
||
```
|
||
|
||
### ZNC Modules the Bot Benefits From
|
||
|
||
| Module | Purpose | Notes |
|
||
|---|---|---|
|
||
| `clientbuffer` | Per-client replay queue on reconnect | **Recommended** — use instead of `playbackbuffer` |
|
||
| `playbackbuffer` | Replay missed messages on reconnect | Do not enable alongside `clientbuffer` |
|
||
| `sasl` | SASL authentication to upstream IRC server | |
|
||
| `nickserv` | Auto-identify with NickServ on connect | |
|
||
|
||
### ZNC Reconnect Behavior
|
||
|
||
The bot implements exponential backoff on disconnect:
|
||
|
||
1. Wait 5 seconds → retry
|
||
2. Wait 10 seconds → retry
|
||
3. Wait 30 seconds → retry
|
||
4. Cap at 5-minute intervals until reconnected
|
||
|
||
The portal **Reconnect** button triggers an immediate disconnect + reconnect cycle bypassing the backoff.
|
||
|
||
### ZNC Playback Line Detection
|
||
|
||
When the bot reconnects, ZNC replays buffered messages. These must be detected and skipped to prevent the bot from feeding old messages to Ollama en masse.
|
||
|
||
The detection approach depends on which ZNC module is active:
|
||
|
||
**`playbackbuffer` module** (wraps message text):
|
||
```
|
||
:irc.server.name PRIVMSG #channel :[HH:MM:SS] <originalnick> original message text
|
||
```
|
||
Detection: PRIVMSG text matches `^\[\d{2}:\d{2}:\d{2}\] `
|
||
|
||
**`clientbuffer` module** (uses IRCv3 server-time tags):
|
||
```
|
||
@time=2024-01-01T12:00:00.000Z :nick!user@host PRIVMSG #channel :original message text
|
||
```
|
||
Detection: raw line starts with `@time=`
|
||
|
||
> **Use only one of these modules, not both.** They serve the same purpose (per-client replay) and enabling both causes double-replay. The recommended choice is `clientbuffer` — it's the more modern approach and its IRCv3 tag format is unambiguous to detect. The bot must strip the `@time=...` prefix before parsing the rest of the line as a normal IRC message.
|
||
|
||
Playback lines matched by either pattern are added to the context buffer (so Llama has channel awareness) but are **never** forwarded to Ollama for a response.
|
||
|
||
---
|
||
|
||
## Dependency: Ollama
|
||
|
||
### What Ollama Does for This Bot
|
||
|
||
Ollama serves the LLM locally over HTTP. The bot sends each user message as an HTTP POST and receives the generated response. No external API, no API key — fully self-hosted on LAN.
|
||
|
||
### Ollama Connection Details
|
||
|
||
| Parameter | Default Value | Configurable |
|
||
|---|---|---|
|
||
| Host | `192.168.2.10` | Yes — via portal or `config.json` |
|
||
| Port | `11434` | Yes — via portal or `config.json` |
|
||
| Model | `llama3.1` | Yes — via portal or `config.json` |
|
||
| Protocol | HTTP (unencrypted, LAN only) | — |
|
||
|
||
> The Ollama host, port, and model are runtime-configurable without a bot restart. The web portal writes changes to `config.json` and the bot picks them up on the next incoming message.
|
||
|
||
### Ollama API Endpoint Used
|
||
|
||
```
|
||
POST http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/generate
|
||
```
|
||
|
||
Request body sent by the bot:
|
||
|
||
```json
|
||
{
|
||
"model": "llama3.1",
|
||
"system": "You are a helpful IRC assistant for Active Blue. Keep responses concise.",
|
||
"prompt": "<assembled user message with optional context>",
|
||
"stream": false,
|
||
"options": {
|
||
"temperature": 0.7,
|
||
"num_predict": 120,
|
||
"num_ctx": 2048
|
||
}
|
||
}
|
||
```
|
||
|
||
> **`num_predict` vs `max_response_length`:** These are two different controls operating at different layers. `num_predict` (unit: **tokens**, driven by `ollama_num_predict` in `config.json`) caps how many tokens the model generates — enforced by Ollama before text is returned. `max_response_length` (unit: **characters**, `config.json`) is a hard trim the bot applies after receiving the response — a safety net against flooding the IRC channel. At roughly 4 characters per token, `ollama_num_predict: 120` yields ~480 characters maximum, keeping it safely above the `max_response_length: 400` trim. Both values should be set together: `ollama_num_predict` should always leave headroom for `max_response_length` to trim if needed. Both are configurable from the portal under **LLM Settings**.
|
||
|
||
Response field the bot reads:
|
||
|
||
```json
|
||
{
|
||
"response": "The bot reply text goes here.",
|
||
"done": true
|
||
}
|
||
```
|
||
|
||
### Context Window and Persistent Memory
|
||
|
||
The bot maintains **two layers** of conversation history that are both included in each Ollama prompt:
|
||
|
||
**Layer 1 — Channel context buffer (in-memory)**
|
||
A rolling buffer of the last N messages in the channel (`context_window` in config, default: `5`). This gives Llama awareness of the surrounding conversation even for messages not directed at the bot.
|
||
|
||
**Layer 2 — Per-user persistent history (SQLite)**
|
||
Every exchange between a specific user and the bot is saved to `data/history/<channel>/<nick>.db`. On the next message from that user — even after a bot restart — the last `memory_history_limit` exchanges (default: `8`) are loaded from the database and prepended to the prompt. This is what allows Llama to remember past conversations.
|
||
|
||
Full prompt assembly order:
|
||
|
||
```
|
||
[System prompt]
|
||
You are a helpful IRC assistant for Active Blue...
|
||
|
||
[Persistent history — last 8 exchanges with this user]
|
||
User: what is DNS?
|
||
Assistant: DNS maps domain names to IP addresses...
|
||
User: what about DNSSEC?
|
||
Assistant: DNSSEC adds cryptographic signatures to DNS records...
|
||
|
||
[Channel context — last 5 messages from the channel]
|
||
<alice> anyone know about VLANs?
|
||
<bob> avcbot: can you explain VLAN tagging?
|
||
|
||
[Current message]
|
||
bob asks: can you explain VLAN tagging?
|
||
```
|
||
|
||
The persistent history and channel context windows are both configurable from the web portal under **LLM Settings**.
|
||
|
||
### Ollama Setup Requirements
|
||
|
||
- Ollama installed and running on `192.168.2.10`
|
||
- The configured model must be pulled before the bot starts:
|
||
```bash
|
||
ollama pull llama3.1
|
||
```
|
||
- Ollama must bind to `0.0.0.0` (not just `127.0.0.1`) to be reachable from the bot host:
|
||
```bash
|
||
# Add to Ollama's systemd unit Environment or /etc/default/ollama:
|
||
OLLAMA_HOST=0.0.0.0
|
||
systemctl restart ollama
|
||
```
|
||
|
||
### Verifying Ollama is Reachable
|
||
|
||
```bash
|
||
# List available models
|
||
curl http://192.168.2.10:11434/api/tags
|
||
|
||
# Test a generation end-to-end
|
||
curl -s http://192.168.2.10:11434/api/generate \
|
||
-d '{"model":"llama3.1","prompt":"Say hello in one sentence","stream":false}' \
|
||
| jq .response
|
||
```
|
||
|
||
### Changing the LLM Model at Runtime
|
||
|
||
Models can be swapped from the web portal under **LLM Settings** at any time. To make a new model available, pull it first on the Ollama host:
|
||
|
||
```bash
|
||
ollama pull mistral
|
||
ollama pull llama3.2
|
||
ollama list
|
||
```
|
||
|
||
Then select the model in the portal. The bot uses whatever `ollama_model` is set in `config.json` at the time of each request.
|
||
|
||
### Ollama Timeout Handling
|
||
|
||
The bot enforces a request timeout (`response_timeout_seconds` in config, default: `30`). If Ollama does not respond in time:
|
||
|
||
- The bot sends a fallback message to the channel: `[LLM timeout — try again]`
|
||
- The full error is written to `logs/bot.log`
|
||
- The bot continues processing subsequent messages normally
|
||
|
||
---
|
||
|
||
## Dependency: Web Portal
|
||
|
||
### Purpose
|
||
|
||
The web portal provides live management of all bot settings without touching files on the server or restarting any process. It is the primary operational interface.
|
||
|
||
### Portal Access
|
||
|
||
| Parameter | Value |
|
||
|---|---|
|
||
| URL | `http://<bot-host>:8080` |
|
||
| Default Port | `8080` (set via `PORTAL_PORT` in `.env`) |
|
||
| Auth | None by default — restrict before exposing to any network |
|
||
| Backend | Flask (Python) |
|
||
| Frontend | Jinja2 templates + vanilla JS |
|
||
|
||
### Portal Pages
|
||
|
||
#### `/` — Dashboard
|
||
|
||
- Current bot status: `connected` / `disconnected` / `reconnecting`
|
||
- Active ZNC connection info (host, port, network name, nick)
|
||
- Current Ollama host, port, and model name
|
||
- Number of channels currently joined
|
||
- Session message count
|
||
- Quick-action buttons: **Reconnect**, **Reload Config**, **Clear Log**
|
||
|
||
#### `/channels` — Channel Management
|
||
|
||
- List of currently joined channels with per-channel message counts
|
||
- **Add channel** — sends `JOIN #channel` immediately, persists to `config.json`
|
||
- **Remove channel** — sends `PART #channel` immediately, removes from `config.json`
|
||
|
||
#### `/llm` — LLM Settings
|
||
|
||
| Setting | `config.json` Key | Description |
|
||
|---|---|---|
|
||
| Ollama Host | `ollama_host` | IP or hostname of the Ollama server |
|
||
| Ollama Port | `ollama_port` | Port Ollama listens on (default: `11434`) |
|
||
| Ollama Model | `ollama_model` | Model name — must be pulled on the Ollama host |
|
||
| System Prompt | `system_prompt` | Instruction prepended to every LLM request |
|
||
| Max Response Length | `max_response_length` | Character cap applied after Ollama responds |
|
||
| Token Limit | `ollama_num_predict` | Max tokens Ollama generates per response |
|
||
| Context Size | `ollama_num_ctx` | Ollama context window in tokens (default: `2048`) |
|
||
| Response Timeout | `response_timeout_seconds` | Seconds before declaring a timeout |
|
||
| Channel Context | `context_window` | In-memory channel messages included in prompt |
|
||
| Temperature | `ollama_temperature` | LLM sampling temperature (0.0 – 1.0) |
|
||
| Memory Enabled | `memory_enabled` | Toggle persistent per-user history on/off |
|
||
| Memory Depth | `memory_history_limit` | Past exchanges loaded from SQLite per request |
|
||
| Memory Max Age | `memory_max_age_days` | Days before exchanges are pruned (0 = keep forever) |
|
||
|
||
All fields validate before save. Changes apply on the next incoming message — no restart required.
|
||
|
||
#### `/bot` — Bot Identity
|
||
|
||
| Setting | Description |
|
||
|---|---|
|
||
| Bot Nick | IRC nickname — changing this sends a live `NICK` command |
|
||
| Real Name | IRC GECOS/realname field — requires reconnect to update |
|
||
| Trigger on Nick | Respond only when the bot's nick is mentioned |
|
||
| Trigger Prefix | Alternative trigger string (e.g., `!ask`) |
|
||
| Ignored Nicks | Comma-separated list of nicks the bot never responds to |
|
||
|
||
#### `/logs` — Live Logs
|
||
|
||
- Tail of the last 200 lines from `logs/bot.log`
|
||
- Color-coded by type: `IRC IN` / `IRC OUT` / `LLM` / `ERROR` / `CONFIG`
|
||
- Auto-refresh toggle (polls every 3 seconds)
|
||
- Download full log as `.txt`
|
||
|
||
#### `/memory` — Conversation Memory
|
||
|
||
- Browse persistent history by channel and nick
|
||
- View the full stored exchange history for any user
|
||
- **Clear user history** — deletes all stored exchanges for a specific nick
|
||
- **Clear channel history** — deletes all stored history for an entire channel
|
||
- **Clear all history** — wipes the full `data/history/` database (with confirmation prompt)
|
||
- Shows total stored exchange count and database size
|
||
|
||
#### `/config` — Raw Config Editor
|
||
|
||
- View and edit `config.json` directly in a text area
|
||
- JSON syntax validation before save
|
||
- **Reload** button to signal the bot to re-read config
|
||
- Download and upload config file buttons
|
||
|
||
### Portal ↔ Bot Communication
|
||
|
||
The portal and bot share `config/config.json` on disk. After writing changes, the portal signals the bot to reload:
|
||
|
||
**Option A (default for non-Docker): SIGHUP**
|
||
```python
|
||
# portal sends:
|
||
os.kill(bot_pid, signal.SIGHUP)
|
||
# bot handles:
|
||
signal.signal(signal.SIGHUP, lambda s, f: reload_config())
|
||
```
|
||
|
||
**Option B (Docker): Unix socket**
|
||
The portal sends a `RELOAD` command over `./data/ircbot.sock`. The bot listens on this socket and reloads config on receipt. Both containers mount the `./data` directory as a shared volume, so the socket file is always accessible to both processes without any pre-creation requirement.
|
||
|
||
Bot PID is written to `./data/ircbot.pid` and the socket is created at `./data/ircbot.sock` on startup.
|
||
|
||
---
|
||
|
||
## Conversation Memory
|
||
|
||
### Overview
|
||
|
||
Conversation memory is a **required core feature**. Without it, Llama starts fresh on every bot restart and has no recollection of prior exchanges with any user. With it, the model builds a genuine per-user history that persists indefinitely until explicitly cleared.
|
||
|
||
### Storage Backend
|
||
|
||
History is stored in **SQLite** at `data/history/<sanitized_channel>/<nick>.db`. Each database file is initialized with WAL (Write-Ahead Logging) mode enabled to prevent `database is locked` errors when the portal and bot access the same file concurrently:
|
||
|
||
```sql
|
||
PRAGMA journal_mode=WAL;
|
||
|
||
CREATE TABLE IF NOT EXISTS exchanges (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
user_input TEXT NOT NULL,
|
||
bot_reply TEXT NOT NULL
|
||
);
|
||
```
|
||
|
||
WAL mode allows the bot to write a new exchange at the same time the portal is reading history for display, without either operation blocking the other.
|
||
|
||
One database file per nick per channel. This keeps history isolated — a user's conversation in `#general` does not bleed into their conversation in `#support`.
|
||
|
||
### How Memory is Used in Each Request
|
||
|
||
On every triggered message, `bot/memory.py`:
|
||
|
||
1. Loads the last `memory_history_limit` rows from `data/history/<channel>/<nick>.db`
|
||
2. Formats them as `User: ... / Assistant: ...` pairs
|
||
3. Passes them to `llm_client.py` to be prepended to the prompt (after system prompt, before channel context)
|
||
4. After the bot sends its reply, writes the new `(user_input, bot_reply)` pair to the database
|
||
|
||
### Memory Configuration (in `config.json`)
|
||
|
||
```json
|
||
{
|
||
"memory_enabled": true,
|
||
"memory_history_limit": 8,
|
||
"memory_max_age_days": 90
|
||
}
|
||
```
|
||
|
||
| Key | Description |
|
||
|---|---|
|
||
| `memory_enabled` | Toggle persistent memory on/off globally |
|
||
| `memory_history_limit` | Max number of past exchanges loaded per request |
|
||
| `memory_max_age_days` | Exchanges older than this are pruned on bot startup. Set to `0` to keep forever. |
|
||
|
||
> **Token budget warning:** Each exchange averages ~100–150 words (~130–200 tokens). At the default `memory_history_limit: 8`, persistent history consumes roughly 1,000–1,600 tokens. Add the system prompt (~50 tokens), channel context buffer (~200 tokens), and the current message, and the total prompt sits comfortably under Llama 3.1 8B's default 2,048-token context window in Ollama. Do **not** raise `memory_history_limit` above `10` without also raising Ollama's `num_ctx` parameter, or the prompt will be silently truncated and responses will degrade. To increase context size in Ollama: set `"num_ctx": 4096` in the `options` block of the `/api/generate` request, and ensure the model was loaded with sufficient VRAM to support it.
|
||
|
||
All three values are editable from the portal under **LLM Settings** without restart.
|
||
|
||
### Memory File Layout
|
||
|
||
Channel names are sanitized before use as filesystem directory names: the leading `#` is stripped and any remaining special characters (`#`, `&`, `+`, `!`) are replaced with `_`. This avoids shell escaping issues and ensures compatibility across platforms.
|
||
|
||
| IRC Channel | Filesystem Directory |
|
||
|---|---|
|
||
| `#general` | `data/history/general/` |
|
||
| `##linux` | `data/history/_linux/` |
|
||
| `#support-us` | `data/history/support-us/` |
|
||
|
||
```
|
||
data/
|
||
└── history/
|
||
├── general/
|
||
│ ├── alice.db
|
||
│ ├── bob.db
|
||
│ └── charlie.db
|
||
└── support/
|
||
├── alice.db
|
||
└── dave.db
|
||
```
|
||
|
||
### Startup Pruning
|
||
|
||
On each bot startup, `bot/memory.py` iterates all `.db` files in `data/history/` and runs a pruning pass on each. The SQL uses a parameterized query — the `memory_max_age_days` value from config is passed as a bound parameter, not interpolated as a string:
|
||
|
||
```python
|
||
# In bot/memory.py — correct parameterized form
|
||
cursor.execute(
|
||
"DELETE FROM exchanges WHERE timestamp < datetime('now', ?)",
|
||
(f"-{memory_max_age_days} days",)
|
||
)
|
||
```
|
||
|
||
This runs before the IRC connection is established and keeps databases from growing unbounded. If `memory_max_age_days` is `0`, the pruning pass is skipped entirely.
|
||
|
||
### Disabling Memory Per-User
|
||
|
||
A user can ask the bot to forget them. The bot responds to `avcbot: forget me` by deleting their database file for the current channel and confirming in-channel. This can also be done manually from the portal `/memory` page.
|
||
|
||
---
|
||
|
||
## Project Structure
|
||
|
||
```
|
||
irc-bot/
|
||
├── bot/
|
||
│ ├── __init__.py
|
||
│ ├── irc_client.py # ZNC/IRC connection, PING/PONG, reconnect loop
|
||
│ ├── llm_client.py # Ollama HTTP client, timeout handling, prompt builder
|
||
│ ├── memory.py # SQLite read/write, pruning, per-user history loader
|
||
│ └── message_handler.py # Parses PRIVMSG, checks triggers, calls LLM, replies
|
||
├── portal/
|
||
│ ├── app.py # Flask routes for all portal pages
|
||
│ ├── config_manager.py # Read/write config.json, signal bot reload
|
||
│ ├── templates/
|
||
│ │ ├── base.html
|
||
│ │ ├── index.html
|
||
│ │ ├── channels.html
|
||
│ │ ├── llm.html
|
||
│ │ ├── bot.html
|
||
│ │ ├── logs.html
|
||
│ │ ├── memory.html # Browse/clear conversation history
|
||
│ │ └── config.html
|
||
│ └── static/
|
||
│ ├── style.css
|
||
│ └── app.js
|
||
├── config/
|
||
│ └── config.json # Runtime config — written by portal, read by bot
|
||
├── data/
|
||
│ └── history/ # SQLite DBs, one per nick per channel
|
||
│ ├── general/ # channel names are sanitized (# stripped)
|
||
│ │ └── alice.db
|
||
│ └── support/
|
||
│ └── bob.db
|
||
├── logs/
|
||
│ └── bot.log # Rotating log file (read by portal log viewer)
|
||
├── .env # Secrets and startup defaults — never commit
|
||
├── .env.example # Safe template to commit
|
||
├── requirements.txt
|
||
├── docker-compose.yml
|
||
├── Dockerfile # Single image used by both bot and portal services
|
||
└── README.md
|
||
```
|
||
|
||
---
|
||
|
||
## Dockerfile
|
||
|
||
A single image is built and used by both the `irc-bot` and `portal` services. The `command:` in `docker-compose.yml` determines which process each container runs.
|
||
|
||
```dockerfile
|
||
FROM python:3.11-slim
|
||
|
||
WORKDIR /app
|
||
|
||
# Install dependencies
|
||
COPY requirements.txt .
|
||
RUN pip install --no-cache-dir -r requirements.txt
|
||
|
||
# Copy source
|
||
COPY bot/ ./bot/
|
||
COPY portal/ ./portal/
|
||
|
||
# Create runtime directories (volumes will overlay these at runtime)
|
||
RUN mkdir -p config logs data/history
|
||
|
||
# Default entrypoint — overridden by docker-compose command:
|
||
CMD ["python", "-m", "bot.irc_client"]
|
||
```
|
||
|
||
> Both services use the same image. The `portal` service overrides the `CMD` with `python -m portal.app` via the `command:` key in `docker-compose.yml`. There is no separate Dockerfile for the portal.
|
||
|
||
---
|
||
|
||
## Configuration Reference
|
||
|
||
### `.env` — Secrets and Startup Defaults
|
||
|
||
```env
|
||
# ── ZNC ──────────────────────────────────────────
|
||
ZNC_HOST=ham.activeblue.net
|
||
ZNC_PORT=6501
|
||
ZNC_USER=your_znc_username
|
||
ZNC_PASSWORD=your_znc_password
|
||
ZNC_SSL=true
|
||
ZNC_NETWORK=activeblue
|
||
|
||
# ── Bot Identity ──────────────────────────────────
|
||
BOT_NICK=avcbot
|
||
BOT_REALNAME=Active Blue IRC Bot
|
||
|
||
# ── LLM Backend (startup defaults) ───────────────
|
||
# config.json values override these at runtime
|
||
OLLAMA_HOST=192.168.2.10
|
||
OLLAMA_PORT=11434
|
||
OLLAMA_MODEL=llama3.1
|
||
|
||
# ── Web Portal ────────────────────────────────────
|
||
PORTAL_PORT=8080
|
||
PORTAL_SECRET_KEY=changeme_use_a_long_random_string
|
||
```
|
||
|
||
### `config/config.json` — Runtime Config
|
||
|
||
```json
|
||
{
|
||
"channels": ["#general", "#support"],
|
||
"trigger_on_nick": true,
|
||
"trigger_prefix": null,
|
||
"ignored_nicks": ["ChanServ", "NickServ"],
|
||
"bot_nick": "avcbot",
|
||
"system_prompt": "You are a helpful IRC assistant for Active Blue. Keep responses concise and under 3 sentences when possible.",
|
||
"max_response_length": 400,
|
||
"ollama_host": "192.168.2.10",
|
||
"ollama_port": 11434,
|
||
"ollama_model": "llama3.1",
|
||
"ollama_temperature": 0.7,
|
||
"ollama_num_predict": 120,
|
||
"ollama_num_ctx": 2048,
|
||
"response_timeout_seconds": 30,
|
||
"context_window": 5,
|
||
"memory_enabled": true,
|
||
"memory_history_limit": 8,
|
||
"memory_max_age_days": 90,
|
||
"log_level": "INFO"
|
||
}
|
||
```
|
||
|
||
**Priority rule:** `config.json` values always take precedence over `.env` values for runtime settings. `.env` is the fallback used only on first startup or if a key is absent from `config.json`.
|
||
|
||
---
|
||
|
||
## How the Bot Works
|
||
|
||
### Startup Sequence
|
||
|
||
```
|
||
1. Load .env
|
||
2. Load config/config.json (values override .env defaults)
|
||
3. Run memory pruning pass — delete exchanges older than memory_max_age_days
|
||
4. Write PID to ./data/ircbot.pid, open Unix socket at ./data/ircbot.sock
|
||
5. Connect: TLS socket → ham.activeblue.net:6501
|
||
6. Send: NICK <BOT_NICK>
|
||
USER <BOT_NICK> 0 * :<BOT_REALNAME>
|
||
PASS <ZNC_USER>/<ZNC_NETWORK>:<ZNC_PASSWORD>
|
||
7. On numeric 001 (RPL_WELCOME): JOIN all channels from config.json
|
||
8. Enter message loop
|
||
```
|
||
|
||
### Message Loop
|
||
|
||
```
|
||
Receive raw IRC line
|
||
│
|
||
├─ PING :server → PONG :server (immediate, keeps connection alive)
|
||
│
|
||
├─ :nick!user@host PRIVMSG #channel :message
|
||
│ │
|
||
│ ├─ ZNC playback line? (text matches ^\[\d{2}:\d{2}:\d{2}\] )
|
||
│ │ → YES → add to context buffer only, discard (never send to Ollama)
|
||
│ │
|
||
│ ├─ sender in ignored_nicks? → discard
|
||
│ │
|
||
│ ├─ trigger_on_nick=true AND message starts with "avcbot:"?
|
||
│ │ OR trigger_prefix set AND message starts with prefix?
|
||
│ │ OR message == "avcbot: forget me" (special command)?
|
||
│ │ → NO → add to context buffer, discard
|
||
│ │ → YES → continue
|
||
│ │
|
||
│ ├─ "forget me" command? → delete data/history/<channel>/<nick>.db
|
||
│ │ reply "<nick>: Done, I've cleared your history."
|
||
│ │ → done
|
||
│ │
|
||
│ ├─ Strip trigger prefix/nick from message text
|
||
│ ├─ Load last memory_history_limit exchanges from data/history/<channel>/<nick>.db
|
||
│ ├─ Append current message to context buffer (capped at context_window)
|
||
│ ├─ Build prompt:
|
||
│ │ system_prompt
|
||
│ │ + persistent history (from SQLite)
|
||
│ │ + channel context buffer
|
||
│ │ + current message
|
||
│ ├─ POST to Ollama http://{ollama_host}:{ollama_port}/api/generate
|
||
│ ├─ Await response (timeout: response_timeout_seconds)
|
||
│ │ └─ Timeout → send "[LLM timeout — try again]" to channel
|
||
│ ├─ Trim response to max_response_length chars
|
||
│ ├─ Save (user_input, bot_reply) to data/history/<channel>/<nick>.db
|
||
│ └─ PRIVMSG #channel :<triggering_nick>: <response>
|
||
│
|
||
└─ Connection drop / ERROR → backoff reconnect loop
|
||
```
|
||
|
||
---
|
||
|
||
## Interaction Examples
|
||
|
||
### Standard nick trigger
|
||
|
||
```
|
||
<alice> avcbot: what's the difference between TCP and UDP?
|
||
<avcbot> alice: TCP is connection-oriented and guarantees ordered delivery.
|
||
UDP is connectionless and faster but has no delivery guarantees.
|
||
Use TCP for HTTP/SSH, UDP for DNS/VoIP/gaming.
|
||
```
|
||
|
||
### Continued context
|
||
|
||
```
|
||
<alice> avcbot: explain subnetting
|
||
<avcbot> alice: Subnetting divides a network into smaller blocks using a subnet mask...
|
||
<alice> avcbot: give me a /24 example
|
||
<avcbot> alice: A /24 like 192.168.1.0/24 has 256 addresses (254 usable hosts),
|
||
with .0 as network address and .255 as broadcast.
|
||
```
|
||
|
||
### Prefix trigger (if configured)
|
||
|
||
```
|
||
<bob> !ask what is VLAN tagging?
|
||
<avcbot> bob: VLAN tagging (802.1Q) adds a 4-byte tag to Ethernet frames to identify
|
||
which VLAN the traffic belongs to, enabling a single trunk port to carry
|
||
multiple VLANs simultaneously.
|
||
```
|
||
|
||
---
|
||
|
||
## Installation
|
||
|
||
### Prerequisites
|
||
|
||
- Python 3.11+
|
||
- Access to ZNC at `ham.activeblue.net:6501` with valid credentials configured
|
||
- Ollama running on `192.168.2.10:11434` with at least one model pulled (`ollama pull llama3.1`)
|
||
- Docker + Docker Compose (recommended) or a bare Python venv
|
||
|
||
### `requirements.txt`
|
||
|
||
```
|
||
# HTTP client for Ollama
|
||
httpx==0.27.0
|
||
|
||
# Web portal
|
||
flask==3.0.3
|
||
jinja2==3.1.4
|
||
|
||
# Config / env
|
||
python-dotenv==1.0.1
|
||
|
||
# socket and sqlite3 are Python stdlib — no install needed
|
||
```
|
||
|
||
> **No `irc` library.** The bot uses Python's stdlib `socket` module directly with a custom raw IRC parser in `bot/irc_client.py`. The `jaraco/irc` high-level framework conflicts with writing raw `NICK`/`USER`/`PASS` commands manually and adds unnecessary abstraction for a bot that only needs to handle `PRIVMSG`, `PING`, and connection state.
|
||
|
||
### Manual Setup (venv)
|
||
|
||
```bash
|
||
git clone http://192.168.1.64:3000/tocmo0nlord/irc-bot
|
||
cd irc-bot
|
||
|
||
python -m venv venv
|
||
source venv/bin/activate
|
||
pip install -r requirements.txt
|
||
|
||
cp .env.example .env
|
||
# Edit .env — set ZNC_USER, ZNC_PASSWORD, ZNC_NETWORK at minimum
|
||
|
||
# Terminal 1: start bot
|
||
python -m bot.irc_client
|
||
|
||
# Terminal 2: start portal
|
||
python -m portal.app
|
||
```
|
||
|
||
### Docker Compose
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
# Edit .env
|
||
|
||
docker compose up -d
|
||
docker compose logs -f irc-bot
|
||
docker compose logs -f portal
|
||
```
|
||
|
||
---
|
||
|
||
## Docker Compose
|
||
|
||
```yaml
|
||
version: "3.9"
|
||
|
||
services:
|
||
|
||
irc-bot:
|
||
build: .
|
||
container_name: irc-bot
|
||
restart: unless-stopped
|
||
command: python -m bot.irc_client
|
||
env_file: .env
|
||
volumes:
|
||
- ./config:/app/config
|
||
- ./logs:/app/logs
|
||
- ./data:/app/data
|
||
networks:
|
||
- botnet
|
||
|
||
portal:
|
||
build: .
|
||
container_name: irc-bot-portal
|
||
restart: unless-stopped
|
||
command: python -m portal.app
|
||
env_file: .env
|
||
ports:
|
||
- "${PORTAL_PORT:-8080}:8080"
|
||
volumes:
|
||
- ./config:/app/config
|
||
- ./logs:/app/logs
|
||
- ./data:/app/data
|
||
networks:
|
||
- botnet
|
||
depends_on:
|
||
- irc-bot
|
||
|
||
networks:
|
||
botnet:
|
||
driver: bridge
|
||
```
|
||
|
||
Both containers share:
|
||
- `./config` — so the portal can write `config.json` and the bot can read it
|
||
- `./logs` — so the portal log viewer can tail `bot.log`
|
||
- `./data` — shared volume for SQLite history files, `ircbot.pid`, and `ircbot.sock`; using a bind-mounted directory avoids the Docker `/tmp` socket pre-creation problem
|
||
|
||
---
|
||
|
||
## Development Notes
|
||
|
||
### IRC Connection Implementation
|
||
|
||
`bot/irc_client.py` uses Python's stdlib `socket` module directly — no third-party IRC library. It opens a TLS-wrapped TCP socket, sends the handshake commands as raw bytes, and reads the server line-by-line in a loop. This keeps the implementation minimal and avoids framework conflicts.
|
||
|
||
```python
|
||
import socket, ssl
|
||
|
||
sock = socket.create_connection((ZNC_HOST, ZNC_PORT))
|
||
tls_sock = ssl.wrap_socket(sock)
|
||
tls_sock.sendall(b"NICK avcbot\r\n")
|
||
tls_sock.sendall(b"USER avcbot 0 * :Active Blue IRC Bot\r\n")
|
||
tls_sock.sendall(f"PASS {ZNC_USER}/{ZNC_NETWORK}:{ZNC_PASSWORD}\r\n".encode())
|
||
```
|
||
|
||
All incoming data is buffered and split on `\r\n` before dispatch to `message_handler.py`.
|
||
|
||
### Extending Bot Commands
|
||
|
||
Add static command handlers in `bot/message_handler.py` before the LLM call to short-circuit common requests:
|
||
|
||
```python
|
||
if stripped_text.lower() == "ping":
|
||
return "pong"
|
||
if stripped_text.lower().startswith("version"):
|
||
return f"irc-bot v1.0 | model: {config['ollama_model']}"
|
||
```
|
||
|
||
### Swapping the LLM Backend
|
||
|
||
`bot/llm_client.py` is the only file that needs to change to use a different backend (LM Studio, vLLM, OpenAI-compatible endpoint, etc.). The interface contract is:
|
||
|
||
```python
|
||
def generate(prompt: str, system: str, config: dict) -> str:
|
||
# returns the response string, raises TimeoutError on timeout
|
||
```
|
||
|
||
### Adding a Portal Page
|
||
|
||
1. Add a route and handler in `portal/app.py`
|
||
2. Create a Jinja2 template in `portal/templates/`
|
||
3. Add a navigation link in `portal/templates/base.html`
|
||
|
||
### Log Levels
|
||
|
||
Set `log_level` in `config.json` to one of: `DEBUG`, `INFO`, `WARNING`, `ERROR`. All bot activity, LLM requests/responses, and config changes are written to `logs/bot.log` with timestamps and severity. The portal log viewer tails this file.
|
||
|
||
---
|
||
|
||
## Security Notes
|
||
|
||
- **Never commit `.env`** — it contains ZNC credentials. It is in `.gitignore` by default.
|
||
- **Portal has no authentication by default** — restrict to LAN/VPN (NetBird) before production use. Add Traefik BasicAuth middleware or a session login page before exposing.
|
||
- **Ollama is unauthenticated HTTP** — do not expose port `11434` externally. Keep it LAN-only behind a firewall.
|
||
- **ZNC password in `.env`** — consider using ZNC's token-based auth (`/znc AddToken`) as an alternative to the plaintext password.
|
||
- **Config reload socket** — `./data/ircbot.sock` is accessible to any process that can read the `./data` directory. Set appropriate directory permissions in production (`chmod 750 data/`).
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
### Bot connects to ZNC but never joins channels
|
||
|
||
- Confirm `ZNC_NETWORK` in `.env` exactly matches the network name in the ZNC user config (case-sensitive)
|
||
- Confirm the ZNC network is connected to the upstream IRC server (check ZNC web panel)
|
||
- Check `logs/bot.log` for IRC error numerics: `433` (nick in use), `465` (banned), `464` (bad password)
|
||
|
||
### Ollama returns no response / timeout
|
||
|
||
```bash
|
||
# Verify Ollama is reachable
|
||
curl http://192.168.2.10:11434/api/tags
|
||
|
||
# Confirm the model is pulled
|
||
curl http://192.168.2.10:11434/api/tags | jq '.models[].name'
|
||
|
||
# Test a full generation
|
||
curl -s http://192.168.2.10:11434/api/generate \
|
||
-d '{"model":"llama3.1","prompt":"hello","stream":false}' | jq .response
|
||
```
|
||
|
||
If Ollama is only bound to `127.0.0.1`:
|
||
|
||
```bash
|
||
# /etc/default/ollama or systemd unit [Service] section:
|
||
Environment="OLLAMA_HOST=0.0.0.0"
|
||
systemctl daemon-reload && systemctl restart ollama
|
||
```
|
||
|
||
### Portal changes not picked up by bot
|
||
|
||
- Confirm both the bot and portal containers mount the same `./config` directory
|
||
- Confirm `./data/ircbot.sock` exists and is accessible by the portal process (it is created by the bot on startup)
|
||
- Click **Reload Config** in the portal dashboard and watch `logs` for `[CONFIG] Reloaded`
|
||
- If using SIGHUP mode, confirm `./data/ircbot.pid` contains the correct running PID
|
||
|
||
### Bot responds to every message, not just mentions
|
||
|
||
- Confirm `"trigger_on_nick": true` in `config.json`
|
||
- Confirm `"trigger_prefix"` is `null` or a specific prefix string, not an empty string
|
||
- Use the portal **Reload Config** button after any direct file edits
|
||
|
||
### Bot does not remember past conversations after restart
|
||
|
||
- Confirm `"memory_enabled": true` in `config.json`
|
||
- Confirm `./data` is mounted as a volume in both containers (not just `irc-bot`)
|
||
- Check that `data/history/` exists and is writable by the bot process:
|
||
```bash
|
||
ls -la data/history/
|
||
```
|
||
- Check `logs/bot.log` for `[MEMORY] Failed to write` errors
|
||
- Confirm `memory_history_limit` is greater than `0`
|
||
|
||
### Memory database growing too large
|
||
|
||
- Lower `memory_max_age_days` in `config.json` (e.g., `30`) — pruning runs on each startup
|
||
- Use the portal `/memory` page to clear history for specific users or channels
|
||
- To manually inspect a database:
|
||
```bash
|
||
sqlite3 data/history/general/alice.db "SELECT COUNT(*) FROM exchanges;"
|
||
```
|
||
|
||
### ZNC playback flooding the bot on reconnect
|
||
|
||
- Enable ZNC's `clientbuffer` module — do **not** enable `playbackbuffer` alongside it
|
||
- Set `MaxBufferSize` in `znc.conf` to a reasonable value (e.g., `500` lines)
|
||
- Confirm the bot's raw line parser strips `@time=...` IRCv3 tag prefixes before processing
|