commit b154f63cfa0130a764dd5859d38905f7745ff630 Author: tocmo0nlord Date: Fri Apr 17 22:08:53 2026 -0400 Initial implementation of IRC LLM bot 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ae11e3 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# ── 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a612344 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +__pycache__/ +*.py[cod] +*.pyo +venv/ +.venv/ +data/history/ +data/ircbot.pid +data/ircbot.sock +logs/ +*.db +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7101007 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot/ ./bot/ +COPY portal/ ./portal/ + +RUN mkdir -p config logs data/history + +CMD ["python", "-m", "bot.irc_client"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c54a1ff --- /dev/null +++ b/README.md @@ -0,0 +1,975 @@ +# 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 /: +``` + +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//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 + + Pass = + Nick = avcbot + AltNick = avcbot_ + RealName = Active Blue IRC Bot + + + Server = irc.libera.chat +6697 + Chan = #general + Chan = #support + + +``` + +### 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] 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": "", + "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//.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] + anyone know about VLANs? + 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://: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//.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//.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 + USER 0 * : + PASS /: +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//.db +│ │ reply ": Done, I've cleared your history." +│ │ → done +│ │ +│ ├─ Strip trigger prefix/nick from message text +│ ├─ Load last memory_history_limit exchanges from data/history//.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//.db +│ └─ PRIVMSG #channel :: +│ +└─ Connection drop / ERROR → backoff reconnect loop +``` + +--- + +## Interaction Examples + +### Standard nick trigger + +``` + avcbot: what's the difference between TCP and UDP? + 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 + +``` + avcbot: explain subnetting + alice: Subnetting divides a network into smaller blocks using a subnet mask... + avcbot: give me a /24 example + 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) + +``` + !ask what is VLAN tagging? + 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 diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/irc_client.py b/bot/irc_client.py new file mode 100644 index 0000000..bcec6db --- /dev/null +++ b/bot/irc_client.py @@ -0,0 +1,372 @@ +""" +IRC bot entry point — connects to ZNC via TLS, handles the message loop, +reconnect backoff, config reload (SIGHUP + Unix socket), and PID file. +""" + +import json +import logging +import logging.handlers +import os +import re +import signal +import socket +import ssl +import sys +import threading +import time +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +# ── Logging ──────────────────────────────────────────────────────────────── + +os.makedirs("logs", exist_ok=True) +os.makedirs("data", exist_ok=True) +os.makedirs("config", exist_ok=True) + +handler = logging.handlers.RotatingFileHandler( + "logs/bot.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8" +) +handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") +) +logging.basicConfig( + level=logging.INFO, + handlers=[handler, logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + +from bot import memory as mem +from bot.message_handler import handle_privmsg + +# ── Config ───────────────────────────────────────────────────────────────── + +CONFIG_PATH = "config/config.json" +PID_PATH = "data/ircbot.pid" +SOCK_PATH = "data/ircbot.sock" + +_config: dict = {} +_config_lock = threading.Lock() + +# Runtime state +_sock: socket.socket | None = None +_connected = False +_session_msg_count = 0 +_status = "disconnected" # disconnected | connecting | connected | reconnecting + + +def _load_config() -> dict: + defaults = { + "channels": [], + "trigger_on_nick": True, + "trigger_prefix": None, + "ignored_nicks": ["ChanServ", "NickServ"], + "bot_nick": os.getenv("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": os.getenv("OLLAMA_HOST", "192.168.2.10"), + "ollama_port": int(os.getenv("OLLAMA_PORT", 11434)), + "ollama_model": os.getenv("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", + } + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, "r") as f: + file_cfg = json.load(f) + defaults.update(file_cfg) + logger.info("[CONFIG] Loaded config.json") + except Exception as e: + logger.error(f"[CONFIG] Failed to load config.json: {e}") + return defaults + + +def _reload_config() -> None: + global _config + new_cfg = _load_config() + with _config_lock: + _config = new_cfg + level = logging.getLevelName(_config.get("log_level", "INFO")) + logging.getLogger().setLevel(level) + logger.info("[CONFIG] Reloaded") + + +def get_config() -> dict: + with _config_lock: + return dict(_config) + + +# ── PID + Unix socket ────────────────────────────────────────────────────── + +def _write_pid() -> None: + with open(PID_PATH, "w") as f: + f.write(str(os.getpid())) + + +def _remove_pid() -> None: + try: + os.remove(PID_PATH) + except FileNotFoundError: + pass + + +def _start_sock_listener() -> None: + """Listens for RELOAD command on Unix socket (used by portal in Docker).""" + if sys.platform == "win32": + return # Unix sockets not supported on Windows + try: + if os.path.exists(SOCK_PATH): + os.remove(SOCK_PATH) + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(SOCK_PATH) + srv.listen(5) + srv.settimeout(1) + os.chmod(SOCK_PATH, 0o660) + logger.info(f"[CONFIG] Unix socket listening at {SOCK_PATH}") + + def _loop(): + while True: + try: + conn, _ = srv.accept() + data = conn.recv(64).decode().strip() + conn.close() + if data == "RELOAD": + _reload_config() + elif data == "RECONNECT": + _trigger_reconnect() + except socket.timeout: + continue + except Exception as e: + logger.error(f"[CONFIG] Socket error: {e}") + + t = threading.Thread(target=_loop, daemon=True) + t.start() + except Exception as e: + logger.warning(f"[CONFIG] Could not start Unix socket: {e}") + + +# ── SIGHUP handler (non-Docker) ──────────────────────────────────────────── + +_reconnect_flag = threading.Event() + +def _trigger_reconnect() -> None: + _reconnect_flag.set() + +if sys.platform != "win32": + signal.signal(signal.SIGHUP, lambda s, f: _reload_config()) + + +# ── IRC helpers ──────────────────────────────────────────────────────────── + +def _send(sock: socket.socket, line: str) -> None: + logger.debug(f"IRC OUT: {line}") + sock.sendall((line + "\r\n").encode("utf-8", errors="replace")) + + +def _join_channels(sock: socket.socket, channels: list[str]) -> None: + for ch in channels: + _send(sock, f"JOIN {ch}") + logger.info(f"[IRC] Joining {ch}") + + +PLAYBACK_RE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ") + + +def _is_playback(text: str) -> bool: + return bool(PLAYBACK_RE.match(text)) + + +def _parse_privmsg(line: str) -> tuple[str, str, str] | None: + """Returns (nick, channel, text) or None.""" + m = re.match(r"^:([^!]+)![^ ]+ PRIVMSG (#\S+) :(.+)$", line) + if m: + return m.group(1), m.group(2), m.group(3) + return None + + +# ── Connection ───────────────────────────────────────────────────────────── + +def _connect() -> socket.socket: + global _status + host = os.getenv("ZNC_HOST", "ham.activeblue.net") + port = int(os.getenv("ZNC_PORT", 6501)) + use_ssl = os.getenv("ZNC_SSL", "true").lower() == "true" + znc_user = os.getenv("ZNC_USER", "") + znc_password = os.getenv("ZNC_PASSWORD", "") + znc_network = os.getenv("ZNC_NETWORK", "activeblue") + bot_nick = get_config().get("bot_nick", os.getenv("BOT_NICK", "avcbot")) + bot_realname = os.getenv("BOT_REALNAME", "Active Blue IRC Bot") + + _status = "connecting" + logger.info(f"[IRC] Connecting to {host}:{port} (SSL={use_ssl})") + + raw = socket.create_connection((host, port), timeout=30) + if use_ssl: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + sock = ctx.wrap_socket(raw, server_hostname=host) + else: + sock = raw + + _send(sock, f"NICK {bot_nick}") + _send(sock, f"USER {bot_nick} 0 * :{bot_realname}") + _send(sock, f"PASS {znc_user}/{znc_network}:{znc_password}") + + return sock + + +# ── Main message loop ────────────────────────────────────────────────────── + +def _run_loop(sock: socket.socket) -> None: + global _connected, _status, _session_msg_count + buf = "" + _connected = True + _status = "connected" + + while True: + if _reconnect_flag.is_set(): + _reconnect_flag.clear() + raise ConnectionResetError("Reconnect triggered") + + try: + sock.settimeout(1) + chunk = sock.recv(4096).decode("utf-8", errors="replace") + except socket.timeout: + continue + except Exception: + raise + + if not chunk: + raise ConnectionResetError("Remote closed connection") + + buf += chunk + while "\r\n" in buf: + line, buf = buf.split("\r\n", 1) + + # Strip IRCv3 server-time tag (clientbuffer playback) + is_tagged_playback = False + if line.startswith("@time="): + is_tagged_playback = True + line = re.sub(r"^@[^ ]+ ", "", line) + + logger.debug(f"IRC IN: {line}") + + if line.startswith("PING"): + _send(sock, "PONG" + line[4:]) + continue + + if " 001 " in line: + logger.info("[IRC] Connected — joining channels") + cfg = get_config() + _join_channels(sock, cfg.get("channels", [])) + continue + + if " 433 " in line: + bot_nick = get_config().get("bot_nick", "avcbot") + _send(sock, f"NICK {bot_nick}_") + logger.warning("[IRC] Nick in use, trying alternate") + continue + + parsed = _parse_privmsg(line) + if not parsed: + continue + + nick, channel, text = parsed + + if is_tagged_playback or _is_playback(text): + # Add to context buffer but don't send to LLM + from bot.message_handler import _get_context + cfg = get_config() + ctx = _get_context(channel, cfg.get("context_window", 5)) + ctx.append(f"<{nick}> {text}") + continue + + cfg = get_config() + _session_msg_count += 1 + reply = handle_privmsg(nick, channel, text, cfg) + if reply: + _send(sock, f"PRIVMSG {channel} :{reply}") + logger.info(f"IRC OUT: PRIVMSG {channel} :{reply[:80]}") + + +def get_status() -> dict: + cfg = get_config() + return { + "status": _status, + "nick": cfg.get("bot_nick", "avcbot"), + "znc_host": os.getenv("ZNC_HOST", "ham.activeblue.net"), + "znc_port": os.getenv("ZNC_PORT", "6501"), + "znc_network": os.getenv("ZNC_NETWORK", "activeblue"), + "ollama_host": cfg.get("ollama_host"), + "ollama_port": cfg.get("ollama_port"), + "ollama_model": cfg.get("ollama_model"), + "channels": cfg.get("channels", []), + "session_msg_count": _session_msg_count, + } + + +def send_raw(line: str) -> None: + global _sock + if _sock and _connected: + _send(_sock, line) + + +# ── Entry point ──────────────────────────────────────────────────────────── + +def main() -> None: + global _sock, _connected, _status + + _reload_config() + + cfg = get_config() + mem.prune_old_exchanges(cfg.get("memory_max_age_days", 90)) + + _write_pid() + _start_sock_listener() + + backoff = [5, 10, 30, 60, 120, 300] + attempt = 0 + + while True: + try: + _sock = _connect() + attempt = 0 + _run_loop(_sock) + except (ConnectionResetError, ConnectionRefusedError, OSError) as e: + _connected = False + _status = "reconnecting" + logger.warning(f"[IRC] Disconnected: {e}") + except Exception as e: + _connected = False + _status = "reconnecting" + logger.error(f"[IRC] Unexpected error: {e}", exc_info=True) + finally: + if _sock: + try: + _sock.close() + except Exception: + pass + _sock = None + + delay = backoff[min(attempt, len(backoff) - 1)] + attempt += 1 + logger.info(f"[IRC] Reconnecting in {delay}s (attempt {attempt})") + time.sleep(delay) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + logger.info("[IRC] Shutting down") + _remove_pid() diff --git a/bot/llm_client.py b/bot/llm_client.py new file mode 100644 index 0000000..3010778 --- /dev/null +++ b/bot/llm_client.py @@ -0,0 +1,70 @@ +import httpx +import logging + +logger = logging.getLogger(__name__) + + +def generate(prompt: str, system: str, config: dict) -> str: + host = config.get("ollama_host", "192.168.2.10") + port = config.get("ollama_port", 11434) + model = config.get("ollama_model", "llama3.1") + timeout = config.get("response_timeout_seconds", 30) + num_predict = config.get("ollama_num_predict", 120) + num_ctx = config.get("ollama_num_ctx", 2048) + temperature = config.get("ollama_temperature", 0.7) + max_length = config.get("max_response_length", 400) + + url = f"http://{host}:{port}/api/generate" + payload = { + "model": model, + "system": system, + "prompt": prompt, + "stream": False, + "options": { + "temperature": temperature, + "num_predict": num_predict, + "num_ctx": num_ctx, + }, + } + + logger.debug(f"[LLM] POST {url} model={model} prompt_len={len(prompt)}") + + try: + response = httpx.post(url, json=payload, timeout=timeout) + response.raise_for_status() + text = response.json().get("response", "").strip() + if len(text) > max_length: + text = text[:max_length].rsplit(" ", 1)[0] + "…" + logger.debug(f"[LLM] Response ({len(text)} chars): {text[:80]}") + return text + except httpx.TimeoutException: + logger.error(f"[LLM] Timeout after {timeout}s") + raise TimeoutError(f"Ollama did not respond within {timeout}s") + except Exception as e: + logger.error(f"[LLM] Request failed: {e}") + raise + + +def build_prompt( + user_message: str, + nick: str, + persistent_history: list[dict], + context_buffer: list[str], +) -> str: + parts = [] + + if persistent_history: + parts.append("--- Past conversation with this user ---") + for ex in persistent_history: + parts.append(f"User: {ex['user']}") + parts.append(f"Assistant: {ex['assistant']}") + parts.append("--- End of past conversation ---") + + if context_buffer: + parts.append("--- Recent channel activity ---") + parts.extend(context_buffer) + parts.append("--- End of channel activity ---") + + parts.append(f"{nick} asks: {user_message}") + + return "\n".join(parts) diff --git a/bot/memory.py b/bot/memory.py new file mode 100644 index 0000000..7fdd9c4 --- /dev/null +++ b/bot/memory.py @@ -0,0 +1,164 @@ +import sqlite3 +import os +import logging +import re + +logger = logging.getLogger(__name__) + +HISTORY_DIR = "data/history" + + +def _sanitize_channel(channel: str) -> str: + name = channel.lstrip("#") + name = re.sub(r"[#&+!]", "_", name) + return name + + +def _db_path(channel: str, nick: str) -> str: + chan_dir = os.path.join(HISTORY_DIR, _sanitize_channel(channel)) + os.makedirs(chan_dir, exist_ok=True) + return os.path.join(chan_dir, f"{nick}.db") + + +def _get_conn(path: str) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute(""" + 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 + ) + """) + conn.commit() + return conn + + +def load_history(channel: str, nick: str, limit: int) -> list[dict]: + path = _db_path(channel, nick) + if not os.path.exists(path): + return [] + try: + conn = _get_conn(path) + cursor = conn.execute( + "SELECT user_input, bot_reply FROM exchanges ORDER BY id DESC LIMIT ?", + (limit,), + ) + rows = cursor.fetchall() + conn.close() + return [{"user": r[0], "assistant": r[1]} for r in reversed(rows)] + except Exception as e: + logger.error(f"[MEMORY] Failed to load history for {nick} in {channel}: {e}") + return [] + + +def save_exchange(channel: str, nick: str, user_input: str, bot_reply: str) -> None: + path = _db_path(channel, nick) + try: + conn = _get_conn(path) + conn.execute( + "INSERT INTO exchanges (user_input, bot_reply) VALUES (?, ?)", + (user_input, bot_reply), + ) + conn.commit() + conn.close() + except Exception as e: + logger.error(f"[MEMORY] Failed to write exchange for {nick} in {channel}: {e}") + + +def delete_user_history(channel: str, nick: str) -> None: + path = _db_path(channel, nick) + if os.path.exists(path): + os.remove(path) + logger.info(f"[MEMORY] Deleted history for {nick} in {channel}") + + +def delete_channel_history(channel: str) -> None: + chan_dir = os.path.join(HISTORY_DIR, _sanitize_channel(channel)) + if os.path.isdir(chan_dir): + for f in os.listdir(chan_dir): + if f.endswith(".db"): + os.remove(os.path.join(chan_dir, f)) + logger.info(f"[MEMORY] Cleared all history for {channel}") + + +def delete_all_history() -> None: + for root, dirs, files in os.walk(HISTORY_DIR): + for f in files: + if f.endswith(".db"): + os.remove(os.path.join(root, f)) + logger.info("[MEMORY] All history deleted") + + +def prune_old_exchanges(max_age_days: int) -> None: + if max_age_days <= 0: + return + pruned = 0 + for root, dirs, files in os.walk(HISTORY_DIR): + for f in files: + if not f.endswith(".db"): + continue + path = os.path.join(root, f) + try: + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=WAL;") + cursor = conn.execute( + "DELETE FROM exchanges WHERE timestamp < datetime('now', ?)", + (f"-{max_age_days} days",), + ) + pruned += cursor.rowcount + conn.commit() + conn.close() + except Exception as e: + logger.error(f"[MEMORY] Pruning failed for {path}: {e}") + if pruned: + logger.info(f"[MEMORY] Pruned {pruned} old exchanges (>{max_age_days} days)") + + +def list_channels() -> list[str]: + if not os.path.isdir(HISTORY_DIR): + return [] + return [d for d in os.listdir(HISTORY_DIR) if os.path.isdir(os.path.join(HISTORY_DIR, d))] + + +def list_nicks(channel_dir: str) -> list[str]: + path = os.path.join(HISTORY_DIR, channel_dir) + if not os.path.isdir(path): + return [] + return [f[:-3] for f in os.listdir(path) if f.endswith(".db")] + + +def get_all_exchanges(channel_dir: str, nick: str) -> list[dict]: + path = os.path.join(HISTORY_DIR, channel_dir, f"{nick}.db") + if not os.path.exists(path): + return [] + try: + conn = sqlite3.connect(path) + cursor = conn.execute( + "SELECT id, timestamp, user_input, bot_reply FROM exchanges ORDER BY id ASC" + ) + rows = cursor.fetchall() + conn.close() + return [{"id": r[0], "timestamp": r[1], "user": r[2], "assistant": r[3]} for r in rows] + except Exception as e: + logger.error(f"[MEMORY] Failed to read all exchanges: {e}") + return [] + + +def get_stats() -> dict: + total = 0 + total_size = 0 + for root, dirs, files in os.walk(HISTORY_DIR): + for f in files: + if f.endswith(".db"): + path = os.path.join(root, f) + total_size += os.path.getsize(path) + try: + conn = sqlite3.connect(path) + row = conn.execute("SELECT COUNT(*) FROM exchanges").fetchone() + total += row[0] if row else 0 + conn.close() + except Exception: + pass + return {"total_exchanges": total, "total_size_bytes": total_size} diff --git a/bot/message_handler.py b/bot/message_handler.py new file mode 100644 index 0000000..5c95f56 --- /dev/null +++ b/bot/message_handler.py @@ -0,0 +1,99 @@ +import logging +import re +from collections import deque + +from bot import memory as mem +from bot import llm_client + +logger = logging.getLogger(__name__) + +# Per-channel rolling context buffer: {channel: deque} +_context_buffers: dict[str, deque] = {} + + +def _get_context(channel: str, window: int) -> deque: + if channel not in _context_buffers: + _context_buffers[channel] = deque(maxlen=window) + else: + _context_buffers[channel] = deque(_context_buffers[channel], maxlen=window) + return _context_buffers[channel] + + +def handle_privmsg(nick: str, channel: str, text: str, config: dict) -> str | None: + """ + Returns a reply string if the bot should respond, else None. + Also maintains the context buffer as a side effect. + """ + window = config.get("context_window", 5) + ctx = _get_context(channel, window) + + ignored = [n.lower() for n in config.get("ignored_nicks", [])] + if nick.lower() in ignored: + return None + + bot_nick = config.get("bot_nick", "avcbot").lower() + trigger_prefix = config.get("trigger_prefix") + trigger_on_nick = config.get("trigger_on_nick", True) + + # Detect "forget me" command before trigger check + forget_pattern = re.compile( + rf"^{re.escape(bot_nick)}\s*[:,]\s*forget\s+me\s*$", re.IGNORECASE + ) + if forget_pattern.match(text.strip()): + mem.delete_user_history(channel, nick) + logger.info(f"[MEMORY] Forgot history for {nick} in {channel}") + return f"{nick}: Done, I've cleared your history." + + # Determine if triggered + stripped = None + + if trigger_on_nick: + nick_pattern = re.compile( + rf"^{re.escape(bot_nick)}\s*[:,]\s*", re.IGNORECASE + ) + m = nick_pattern.match(text) + if m: + stripped = text[m.end():].strip() + + if stripped is None and trigger_prefix: + if text.startswith(trigger_prefix): + stripped = text[len(trigger_prefix):].strip() + + # Add to context buffer regardless + ctx.append(f"<{nick}> {text}") + + if stripped is None: + return None + + # Build and send to LLM + history = [] + if config.get("memory_enabled", True): + limit = config.get("memory_history_limit", 8) + history = mem.load_history(channel, nick, limit) + + prompt = llm_client.build_prompt( + user_message=stripped, + nick=nick, + persistent_history=history, + context_buffer=list(ctx)[:-1], # exclude the current message already in buffer + ) + + system = config.get( + "system_prompt", + "You are a helpful IRC assistant for Active Blue. Keep responses concise and under 3 sentences when possible.", + ) + + logger.info(f"[LLM] Request from {nick} in {channel}: {stripped[:80]}") + + try: + reply = llm_client.generate(prompt, system, config) + except TimeoutError: + return f"{nick}: [LLM timeout — try again]" + except Exception as e: + logger.error(f"[LLM] Generation error: {e}") + return f"{nick}: [LLM error — check logs]" + + if config.get("memory_enabled", True): + mem.save_exchange(channel, nick, stripped, reply) + + return f"{nick}: {reply}" diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..f28313e --- /dev/null +++ b/config/config.json @@ -0,0 +1,21 @@ +{ + "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" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12b2093 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +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 diff --git a/portal/__init__.py b/portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/app.py b/portal/app.py new file mode 100644 index 0000000..b9c73a8 --- /dev/null +++ b/portal/app.py @@ -0,0 +1,291 @@ +import json +import logging +import os +import sys + +from dotenv import load_dotenv +from flask import Flask, abort, jsonify, redirect, render_template, request, url_for, send_file +import io + +load_dotenv() + +app = Flask(__name__, template_folder="templates", static_folder="static") +app.secret_key = os.getenv("PORTAL_SECRET_KEY", "changeme") + + +@app.template_filter("log_class") +def log_class_filter(line: str) -> str: + if "IRC IN:" in line: return "log-irc-in" + if "IRC OUT:" in line: return "log-irc-out" + if "[LLM]" in line: return "log-llm" + if "[MEMORY]" in line: return "log-memory" + if "[CONFIG]" in line: return "log-config" + if "ERROR" in line: return "log-error" + return "" + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +from portal import config_manager as cm +from bot import memory as mem + +# ── Dashboard ────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + cfg = cm.load_config() + pid_exists = os.path.exists("data/ircbot.pid") + sock_exists = os.path.exists("data/ircbot.sock") + return render_template("index.html", cfg=cfg, pid_exists=pid_exists, sock_exists=sock_exists) + + +@app.route("/api/status") +def api_status(): + cfg = cm.load_config() + pid_exists = os.path.exists("data/ircbot.pid") + return jsonify({ + "bot_running": pid_exists, + "channels": cfg.get("channels", []), + "ollama_model": cfg.get("ollama_model"), + "bot_nick": cfg.get("bot_nick"), + }) + + +@app.route("/action/reload", methods=["POST"]) +def action_reload(): + cm.signal_bot_reload() + return redirect(url_for("index")) + + +@app.route("/action/reconnect", methods=["POST"]) +def action_reconnect(): + cm.signal_bot_reconnect() + return redirect(url_for("index")) + + +@app.route("/action/clear_log", methods=["POST"]) +def action_clear_log(): + try: + open("logs/bot.log", "w").close() + except Exception: + pass + return redirect(url_for("logs")) + + +# ── Channels ─────────────────────────────────────────────────────────────── + +@app.route("/channels") +def channels(): + cfg = cm.load_config() + return render_template("channels.html", cfg=cfg) + + +@app.route("/channels/add", methods=["POST"]) +def channel_add(): + ch = request.form.get("channel", "").strip() + if not ch.startswith("#"): + ch = "#" + ch + cfg = cm.load_config() + if ch not in cfg.get("channels", []): + cfg.setdefault("channels", []).append(ch) + cm.save_config(cfg) + cm.signal_bot_reload() + return redirect(url_for("channels")) + + +@app.route("/channels/remove", methods=["POST"]) +def channel_remove(): + ch = request.form.get("channel", "").strip() + cfg = cm.load_config() + channels_list = cfg.get("channels", []) + if ch in channels_list: + channels_list.remove(ch) + cfg["channels"] = channels_list + cm.save_config(cfg) + cm.signal_bot_reload() + return redirect(url_for("channels")) + + +# ── LLM Settings ─────────────────────────────────────────────────────────── + +@app.route("/llm", methods=["GET", "POST"]) +def llm(): + cfg = cm.load_config() + errors = [] + + if request.method == "POST": + try: + cfg["ollama_host"] = request.form["ollama_host"].strip() + cfg["ollama_port"] = int(request.form["ollama_port"]) + cfg["ollama_model"] = request.form["ollama_model"].strip() + cfg["system_prompt"] = request.form["system_prompt"] + cfg["max_response_length"] = int(request.form["max_response_length"]) + cfg["ollama_num_predict"] = int(request.form["ollama_num_predict"]) + cfg["ollama_num_ctx"] = int(request.form["ollama_num_ctx"]) + cfg["response_timeout_seconds"] = int(request.form["response_timeout_seconds"]) + cfg["context_window"] = int(request.form["context_window"]) + cfg["ollama_temperature"] = float(request.form["ollama_temperature"]) + cfg["memory_enabled"] = "memory_enabled" in request.form + cfg["memory_history_limit"] = int(request.form["memory_history_limit"]) + cfg["memory_max_age_days"] = int(request.form["memory_max_age_days"]) + cm.save_config(cfg) + cm.signal_bot_reload() + except (ValueError, KeyError) as e: + errors.append(str(e)) + + return render_template("llm.html", cfg=cfg, errors=errors) + + +# ── Bot Identity ──────────────────────────────────────────────────────────── + +@app.route("/bot", methods=["GET", "POST"]) +def bot(): + cfg = cm.load_config() + errors = [] + + if request.method == "POST": + cfg["bot_nick"] = request.form.get("bot_nick", "avcbot").strip() + bot_realname = request.form.get("bot_realname", "").strip() + if bot_realname: + cfg["bot_realname"] = bot_realname + cfg["trigger_on_nick"] = "trigger_on_nick" in request.form + prefix = request.form.get("trigger_prefix", "").strip() + cfg["trigger_prefix"] = prefix if prefix else None + ignored = request.form.get("ignored_nicks", "") + cfg["ignored_nicks"] = [n.strip() for n in ignored.split(",") if n.strip()] + cm.save_config(cfg) + cm.signal_bot_reload() + + return render_template("bot.html", cfg=cfg, errors=errors) + + +# ── Logs ──────────────────────────────────────────────────────────────────── + +@app.route("/logs") +def logs(): + lines = [] + log_path = "logs/bot.log" + if os.path.exists(log_path): + with open(log_path, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + lines = all_lines[-200:] + return render_template("logs.html", lines=lines) + + +@app.route("/logs/download") +def logs_download(): + log_path = "logs/bot.log" + if not os.path.exists(log_path): + abort(404) + return send_file(log_path, as_attachment=True, download_name="bot.log") + + +@app.route("/api/logs") +def api_logs(): + lines = [] + log_path = "logs/bot.log" + if os.path.exists(log_path): + with open(log_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines()[-200:] + return jsonify({"lines": [l.rstrip() for l in lines]}) + + +# ── Memory ───────────────────────────────────────────────────────────────── + +@app.route("/memory") +def memory(): + channels_list = mem.list_channels() + selected_chan = request.args.get("channel") + selected_nick = request.args.get("nick") + nicks = [] + exchanges = [] + + if selected_chan: + nicks = mem.list_nicks(selected_chan) + if selected_chan and selected_nick: + exchanges = mem.get_all_exchanges(selected_chan, selected_nick) + + stats = mem.get_stats() + return render_template( + "memory.html", + channels=channels_list, + selected_chan=selected_chan, + nicks=nicks, + selected_nick=selected_nick, + exchanges=exchanges, + stats=stats, + ) + + +@app.route("/memory/clear_user", methods=["POST"]) +def memory_clear_user(): + chan = request.form.get("channel_dir", "") + nick = request.form.get("nick", "") + if chan and nick: + mem.delete_user_history("#" + chan, nick) + return redirect(url_for("memory", channel=chan)) + + +@app.route("/memory/clear_channel", methods=["POST"]) +def memory_clear_channel(): + chan = request.form.get("channel_dir", "") + if chan: + mem.delete_channel_history("#" + chan) + return redirect(url_for("memory")) + + +@app.route("/memory/clear_all", methods=["POST"]) +def memory_clear_all(): + confirm = request.form.get("confirm", "") + if confirm == "yes": + mem.delete_all_history() + return redirect(url_for("memory")) + + +# ── Config editor ─────────────────────────────────────────────────────────── + +@app.route("/config", methods=["GET", "POST"]) +def config_editor(): + error = None + success = None + + if request.method == "POST": + raw = request.form.get("config_raw", "") + try: + parsed = json.loads(raw) + cm.save_config(parsed) + cm.signal_bot_reload() + success = "Config saved and bot signaled to reload." + except json.JSONDecodeError as e: + error = f"Invalid JSON: {e}" + else: + parsed = cm.load_config() + + raw_json = json.dumps(cm.load_config(), indent=2) + return render_template("config.html", raw_json=raw_json, error=error, success=success) + + +@app.route("/config/download") +def config_download(): + return send_file("config/config.json", as_attachment=True, download_name="config.json") + + +@app.route("/config/upload", methods=["POST"]) +def config_upload(): + f = request.files.get("config_file") + if not f: + return redirect(url_for("config_editor")) + try: + data = json.loads(f.read().decode()) + cm.save_config(data) + cm.signal_bot_reload() + except Exception: + pass + return redirect(url_for("config_editor")) + + +# ── Run ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + port = int(os.getenv("PORTAL_PORT", 8080)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/portal/config_manager.py b/portal/config_manager.py new file mode 100644 index 0000000..bd80be4 --- /dev/null +++ b/portal/config_manager.py @@ -0,0 +1,68 @@ +import json +import logging +import os +import signal +import socket +import sys + +logger = logging.getLogger(__name__) + +CONFIG_PATH = "config/config.json" +PID_PATH = "data/ircbot.pid" +SOCK_PATH = "data/ircbot.sock" + + +def load_config() -> dict: + if not os.path.exists(CONFIG_PATH): + return {} + with open(CONFIG_PATH, "r") as f: + return json.load(f) + + +def save_config(cfg: dict) -> None: + os.makedirs("config", exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(cfg, f, indent=2) + logger.info("[CONFIG] Saved config.json") + + +def signal_bot_reload() -> bool: + """Signal bot to reload config. Returns True on success.""" + # Try Unix socket first (Docker mode) + if sys.platform != "win32" and os.path.exists(SOCK_PATH): + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(SOCK_PATH) + s.sendall(b"RELOAD") + s.close() + logger.info("[CONFIG] Sent RELOAD via Unix socket") + return True + except Exception as e: + logger.warning(f"[CONFIG] Socket reload failed: {e}") + + # Fall back to SIGHUP + if sys.platform != "win32" and os.path.exists(PID_PATH): + try: + with open(PID_PATH) as f: + pid = int(f.read().strip()) + os.kill(pid, signal.SIGHUP) + logger.info(f"[CONFIG] Sent SIGHUP to PID {pid}") + return True + except Exception as e: + logger.warning(f"[CONFIG] SIGHUP failed: {e}") + + logger.warning("[CONFIG] Could not signal bot (no socket or PID available)") + return False + + +def signal_bot_reconnect() -> bool: + if sys.platform != "win32" and os.path.exists(SOCK_PATH): + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(SOCK_PATH) + s.sendall(b"RECONNECT") + s.close() + return True + except Exception as e: + logger.warning(f"[CONFIG] Reconnect signal failed: {e}") + return False diff --git a/portal/static/app.js b/portal/static/app.js new file mode 100644 index 0000000..de1bf74 --- /dev/null +++ b/portal/static/app.js @@ -0,0 +1,52 @@ +// Auto-refresh for logs page +(function () { + const checkbox = document.getElementById("auto-refresh"); + if (!checkbox) return; + + let timer = null; + + function refresh() { + fetch("/api/logs") + .then((r) => r.json()) + .then((data) => { + const box = document.getElementById("log-box"); + if (!box) return; + box.innerHTML = data.lines + .map((l) => `
${escHtml(l)}
`) + .join(""); + box.scrollTop = box.scrollHeight; + }) + .catch(() => {}); + } + + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + refresh(); + timer = setInterval(refresh, 3000); + } else { + clearInterval(timer); + timer = null; + } + }); + + // Scroll to bottom on load + const box = document.getElementById("log-box"); + if (box) box.scrollTop = box.scrollHeight; + + function logClass(line) { + if (line.includes("IRC IN:")) return "log-irc-in"; + if (line.includes("IRC OUT:")) return "log-irc-out"; + if (line.includes("[LLM]")) return "log-llm"; + if (line.includes("[ERROR]") || line.includes("ERROR")) return "log-error"; + if (line.includes("[CONFIG]")) return "log-config"; + if (line.includes("[MEMORY]")) return "log-memory"; + return ""; + } + + function escHtml(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">"); + } +})(); diff --git a/portal/static/style.css b/portal/static/style.css new file mode 100644 index 0000000..7c7bfc4 --- /dev/null +++ b/portal/static/style.css @@ -0,0 +1,325 @@ +:root { + --bg: #0f1117; + --sidebar-bg: #161b22; + --card-bg: #1c2128; + --border: #30363d; + --text: #c9d1d9; + --muted: #8b949e; + --accent: #58a6ff; + --danger: #f85149; + --success: #3fb950; + --warning: #d29922; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + display: flex; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; +} + +/* ── Sidebar ─────────────────────────────────────────────────────────── */ +.sidebar { + width: 220px; + min-height: 100vh; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.sidebar-header { + padding: 24px 16px 16px; + border-bottom: 1px solid var(--border); +} + +.logo { + display: block; + font-size: 18px; + font-weight: 600; + color: var(--accent); +} + +.subtitle { + display: block; + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +.nav-links { + list-style: none; + padding: 12px 0; +} + +.nav-links li a { + display: block; + padding: 8px 16px; + color: var(--muted); + text-decoration: none; + border-left: 3px solid transparent; + transition: all 0.15s; +} + +.nav-links li a:hover { + color: var(--text); + background: rgba(255,255,255,0.04); +} + +.nav-links li a.active { + color: var(--accent); + border-left-color: var(--accent); + background: rgba(88,166,255,0.08); +} + +/* ── Content ─────────────────────────────────────────────────────────── */ +.content { + flex: 1; + padding: 32px 40px; + max-width: 1000px; +} + +h1 { + font-size: 22px; + font-weight: 600; + margin-bottom: 24px; + color: var(--text); +} + +h2 { + font-size: 15px; + font-weight: 600; + margin-bottom: 14px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.section { + margin-bottom: 32px; +} + +/* ── Cards ───────────────────────────────────────────────────────────── */ +.cards { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 32px; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + min-width: 140px; +} + +.card-label { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; +} + +.card-value { + font-size: 20px; + font-weight: 600; +} + +.status-online { color: var(--success); } +.status-offline { color: var(--danger); } + +/* ── Tables ──────────────────────────────────────────────────────────── */ +.info-table, .data-table { + width: 100%; + border-collapse: collapse; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.info-table td, .data-table td, .data-table th { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.info-table tr:last-child td, +.data-table tr:last-child td { border-bottom: none; } + +.info-table td:first-child, +.data-table th { color: var(--muted); font-size: 12px; text-transform: uppercase; } + +.data-table thead { background: rgba(255,255,255,0.03); } + +/* ── Buttons ─────────────────────────────────────────────────────────── */ +.btn { + display: inline-block; + padding: 7px 16px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + color: var(--text); + font-size: 13px; + cursor: pointer; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.btn:hover { background: rgba(255,255,255,0.06); border-color: var(--muted); } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #000; + font-weight: 600; +} +.btn-primary:hover { background: #79b8ff; border-color: #79b8ff; } + +.btn-danger { color: var(--danger); border-color: var(--danger); } +.btn-danger:hover { background: rgba(248,81,73,0.12); } + +.btn-sm { padding: 4px 10px; font-size: 12px; } + +.actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +/* ── Forms ───────────────────────────────────────────────────────────── */ +.settings-form .field-row { + display: grid; + grid-template-columns: 200px 1fr; + gap: 12px; + align-items: start; + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} + +.settings-form label { + padding-top: 7px; + color: var(--muted); + font-size: 13px; +} + +.settings-form input[type="text"], +.settings-form input[type="number"], +.settings-form textarea, +.settings-form select { + width: 100%; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 7px 10px; + font-size: 13px; + font-family: var(--font); +} + +.settings-form textarea { resize: vertical; font-family: var(--font); } +.settings-form textarea.mono { font-family: var(--mono); font-size: 12px; } + +.settings-form input[type="checkbox"] { + width: 16px; height: 16px; margin-top: 8px; accent-color: var(--accent); +} + +.hint { + font-size: 11px; + color: var(--muted); + grid-column: 2; + margin-top: -8px; +} + +.inline-form { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px; +} + +.inline-form input[type="text"], +.inline-form select { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 7px 10px; + font-size: 13px; +} + +/* ── Alerts ──────────────────────────────────────────────────────────── */ +.alert { + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 16px; + font-size: 13px; +} +.alert-error { background: rgba(248,81,73,0.12); border: 1px solid var(--danger); color: var(--danger); } +.alert-success { background: rgba(63,185,80,0.12); border: 1px solid var(--success); color: var(--success); } + +/* ── Logs ────────────────────────────────────────────────────────────── */ +.log-toolbar { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.log-box { + background: #0d1117; + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + font-family: var(--mono); + font-size: 12px; + line-height: 1.6; + max-height: 600px; + overflow-y: auto; +} + +.log-line { padding: 1px 0; } +.log-irc-in { color: #79b8ff; } +.log-irc-out { color: #85e89d; } +.log-llm { color: #ffab70; } +.log-error { color: var(--danger); } +.log-config { color: #b392f0; } +.log-memory { color: #f97583; } + +/* ── Memory exchanges ────────────────────────────────────────────────── */ +.exchange-list { margin-top: 16px; } + +.exchange { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 10px; +} + +.exchange-meta { font-size: 11px; color: var(--muted); margin-bottom: 6px; } +.exchange-user { margin-bottom: 4px; } +.exchange-bot { color: var(--muted); } + +/* ── Misc ────────────────────────────────────────────────────────────── */ +.muted { color: var(--muted); font-size: 13px; } + +code { + background: rgba(255,255,255,0.06); + padding: 1px 5px; + border-radius: 4px; + font-family: var(--mono); + font-size: 12px; +} diff --git a/portal/templates/base.html b/portal/templates/base.html new file mode 100644 index 0000000..5a62846 --- /dev/null +++ b/portal/templates/base.html @@ -0,0 +1,30 @@ + + + + + + {% block title %}IRC Bot Portal{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/portal/templates/bot.html b/portal/templates/bot.html new file mode 100644 index 0000000..7a1a709 --- /dev/null +++ b/portal/templates/bot.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Bot Identity — IRC Bot Portal{% endblock %} +{% block content %} +

Bot Identity

+ +{% for e in errors %} +
{{ e }}
+{% endfor %} + +
+
+

Identity

+
+ + + Changing sends a live NICK command. +
+
+ + + Requires reconnect to take effect. +
+
+ +
+

Trigger Settings

+
+ + + Respond when someone says avcbot: ... +
+
+ + + Leave blank to disable prefix trigger. +
+
+ +
+

Ignored Nicks

+
+ + + Comma-separated. Bot never responds to these nicks. +
+
+ +
+ +
+
+{% endblock %} diff --git a/portal/templates/channels.html b/portal/templates/channels.html new file mode 100644 index 0000000..9fafda7 --- /dev/null +++ b/portal/templates/channels.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Channels — IRC Bot Portal{% endblock %} +{% block content %} +

Channel Management

+ +
+

Add Channel

+
+ + +
+
+ +
+

Joined Channels

+ {% if cfg.get('channels') %} + + + + + + {% for ch in cfg['channels'] %} + + + + + {% endfor %} + +
ChannelAction
{{ ch }} +
+ + +
+
+ {% else %} +

No channels configured.

+ {% endif %} +
+{% endblock %} diff --git a/portal/templates/config.html b/portal/templates/config.html new file mode 100644 index 0000000..785f912 --- /dev/null +++ b/portal/templates/config.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Raw Config — IRC Bot Portal{% endblock %} +{% block content %} +

Raw Config Editor

+ +{% if error %} +
{{ error }}
+{% endif %} +{% if success %} +
{{ success }}
+{% endif %} + +
+
+
+ + +
+
+
+ + Download + + + + +
+ +{% endblock %} diff --git a/portal/templates/index.html b/portal/templates/index.html new file mode 100644 index 0000000..9d593e7 --- /dev/null +++ b/portal/templates/index.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Dashboard — IRC Bot Portal{% endblock %} +{% block content %} +

Dashboard

+ +
+
+
Bot Status
+
+ {{ 'Running' if pid_exists else 'Stopped' }} +
+
+
+
Nick
+
{{ cfg.get('bot_nick', 'avcbot') }}
+
+
+
Ollama Model
+
{{ cfg.get('ollama_model', '—') }}
+
+
+
Channels
+
{{ cfg.get('channels', [])|length }}
+
+
+
Memory
+
{{ 'Enabled' if cfg.get('memory_enabled', True) else 'Disabled' }}
+
+
+
Reload Socket
+
+ {{ 'Available' if sock_exists else 'Not available' }} +
+
+
+ +
+

Connection

+ + + + + + +
ZNC Host{{ cfg.get('znc_host', 'ham.activeblue.net') }}
Ollama{{ cfg.get('ollama_host', '—') }}:{{ cfg.get('ollama_port', '—') }}
Joined Channels{{ cfg.get('channels', [])|join(', ') or '—' }}
Trigger on Nick{{ 'Yes' if cfg.get('trigger_on_nick') else 'No' }}
Trigger Prefix{{ cfg.get('trigger_prefix') or '—' }}
+
+ +
+

Quick Actions

+
+
+ +
+
+ +
+
+ +
+
+
+{% endblock %} diff --git a/portal/templates/llm.html b/portal/templates/llm.html new file mode 100644 index 0000000..5f3402d --- /dev/null +++ b/portal/templates/llm.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}LLM Settings — IRC Bot Portal{% endblock %} +{% block content %} +

LLM Settings

+ +{% for e in errors %} +
{{ e }}
+{% endfor %} + +
+
+

Ollama Backend

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Response Handling

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Persistent Memory

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+{% endblock %} diff --git a/portal/templates/logs.html b/portal/templates/logs.html new file mode 100644 index 0000000..2efbd00 --- /dev/null +++ b/portal/templates/logs.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Logs — IRC Bot Portal{% endblock %} +{% block content %} +

Bot Logs

+ +
+ + Download +
+ +
+
+ +
+ {% for line in lines %} +
{{ line|e }}
+ {% endfor %} +
+{% endblock %} diff --git a/portal/templates/memory.html b/portal/templates/memory.html new file mode 100644 index 0000000..f1ab368 --- /dev/null +++ b/portal/templates/memory.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Memory — IRC Bot Portal{% endblock %} +{% block content %} +

Conversation Memory

+ +
+
+
Total Exchanges
+
{{ stats.total_exchanges }}
+
+
+
Database Size
+
{{ (stats.total_size_bytes / 1024)|round(1) }} KB
+
+
+ +
+

Browse History

+
+ + {% if selected_chan %} + + {% endif %} +
+ + {% if selected_chan and selected_nick and exchanges %} +
+ {% for ex in exchanges %} +
+
{{ ex.timestamp }}
+
{{ selected_nick }}: {{ ex.user }}
+
bot: {{ ex.assistant }}
+
+ {% endfor %} +
+ {% elif selected_chan and selected_nick %} +

No exchanges found.

+ {% endif %} +
+ +
+

Clear History

+
+ {% if selected_chan and selected_nick %} +
+ + + +
+ {% endif %} + {% if selected_chan %} +
+ + +
+ {% endif %} +
+ + +
+
+
+{% endblock %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5fad44f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# 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