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>
36 KiB
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
Table of Contents
- Architecture Overview
- Dependency: ZNC
- Dependency: Ollama
- Dependency: Web Portal
- Conversation Memory
- Project Structure
- Configuration Reference
- How the Bot Works
- Interaction Examples
- Installation
- Docker Compose
- Development Notes
- Security Notes
- 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.jsonon 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:
- A network entry with the name matching
ZNC_NETWORKin.env - The IRC server configured under that network (e.g.,
irc.libera.chat:6697) - Playback buffer enabled (recommended) — allows the bot to catch up on messages after a reconnect
- 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:
<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:
- Wait 5 seconds → retry
- Wait 10 seconds → retry
- Wait 30 seconds → retry
- 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.jsonand 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:
{
"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_predictvsmax_response_length: These are two different controls operating at different layers.num_predict(unit: tokens, driven byollama_num_predictinconfig.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: 120yields ~480 characters maximum, keeping it safely above themax_response_length: 400trim. Both values should be set together:ollama_num_predictshould always leave headroom formax_response_lengthto trim if needed. Both are configurable from the portal under LLM Settings.
Response field the bot reads:
{
"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:
ollama pull llama3.1 - Ollama must bind to
0.0.0.0(not just127.0.0.1) to be reachable from the bot host:# Add to Ollama's systemd unit Environment or /etc/default/ollama: OLLAMA_HOST=0.0.0.0 systemctl restart ollama
Verifying Ollama is Reachable
# 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:
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 #channelimmediately, persists toconfig.json - Remove channel — sends
PART #channelimmediately, removes fromconfig.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.jsondirectly 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
# 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:
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:
- Loads the last
memory_history_limitrows fromdata/history/<channel>/<nick>.db - Formats them as
User: ... / Assistant: ...pairs - Passes them to
llm_client.pyto be prepended to the prompt (after system prompt, before channel context) - After the bot sends its reply, writes the new
(user_input, bot_reply)pair to the database
Memory Configuration (in config.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 raisememory_history_limitabove10without also raising Ollama'snum_ctxparameter, or the prompt will be silently truncated and responses will degrade. To increase context size in Ollama: set"num_ctx": 4096in theoptionsblock of the/api/generaterequest, 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:
# 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.
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
portalservice overrides theCMDwithpython -m portal.appvia thecommand:key indocker-compose.yml. There is no separate Dockerfile for the portal.
Configuration Reference
.env — Secrets and Startup Defaults
# ── 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
{
"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:6501with valid credentials configured - Ollama running on
192.168.2.10:11434with 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
irclibrary. The bot uses Python's stdlibsocketmodule directly with a custom raw IRC parser inbot/irc_client.py. Thejaraco/irchigh-level framework conflicts with writing rawNICK/USER/PASScommands manually and adds unnecessary abstraction for a bot that only needs to handlePRIVMSG,PING, and connection state.
Manual Setup (venv)
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
cp .env.example .env
# Edit .env
docker compose up -d
docker compose logs -f irc-bot
docker compose logs -f portal
Docker Compose
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 writeconfig.jsonand the bot can read it./logs— so the portal log viewer can tailbot.log./data— shared volume for SQLite history files,ircbot.pid, andircbot.sock; using a bind-mounted directory avoids the Docker/tmpsocket 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.
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:
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:
def generate(prompt: str, system: str, config: dict) -> str:
# returns the response string, raises TimeoutError on timeout
Adding a Portal Page
- Add a route and handler in
portal/app.py - Create a Jinja2 template in
portal/templates/ - 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.gitignoreby 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
11434externally. 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.sockis accessible to any process that can read the./datadirectory. Set appropriate directory permissions in production (chmod 750 data/).
Troubleshooting
Bot connects to ZNC but never joins channels
- Confirm
ZNC_NETWORKin.envexactly 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.logfor IRC error numerics:433(nick in use),465(banned),464(bad password)
Ollama returns no response / timeout
# 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:
# /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
./configdirectory - Confirm
./data/ircbot.sockexists and is accessible by the portal process (it is created by the bot on startup) - Click Reload Config in the portal dashboard and watch
logsfor[CONFIG] Reloaded - If using SIGHUP mode, confirm
./data/ircbot.pidcontains the correct running PID
Bot responds to every message, not just mentions
- Confirm
"trigger_on_nick": trueinconfig.json - Confirm
"trigger_prefix"isnullor 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": trueinconfig.json - Confirm
./datais mounted as a volume in both containers (not justirc-bot) - Check that
data/history/exists and is writable by the bot process:ls -la data/history/ - Check
logs/bot.logfor[MEMORY] Failed to writeerrors - Confirm
memory_history_limitis greater than0
Memory database growing too large
- Lower
memory_max_age_daysinconfig.json(e.g.,30) — pruning runs on each startup - Use the portal
/memorypage to clear history for specific users or channels - To manually inspect a database:
sqlite3 data/history/general/alice.db "SELECT COUNT(*) FROM exchanges;"
ZNC playback flooding the bot on reconnect
- Enable ZNC's
clientbuffermodule — do not enableplaybackbufferalongside it - Set
MaxBufferSizeinznc.confto a reasonable value (e.g.,500lines) - Confirm the bot's raw line parser strips
@time=...IRCv3 tag prefixes before processing