Archivist 📚 #
A Discord bot that watches channels for links and builds a searchable archive with AI-generated summaries, auto-tags, web archive snapshots, and PDF captures.
Features #
- Link detection — processes every URL posted in watched channels; Discord invites, already-archived URLs, and rate-limited users are handled gracefully
- Content extraction — fetches pages, extracts readable text via Mozilla Readability +
strip-tags; YouTube URLs use the transcript API instead - Image understanding — Gemini multimodal: describes images and transcribes visible text from attachments or linked image URLs
- AI summaries — Gemini produces a one-line gloss and a 2–3 sentence genre-aware summary (fiction, technical, essay, social media, etc.)
- Auto-tagging — Gemini suggests tags from a curated pool; new suggestions enter a proposal queue and are promoted once they exceed a use threshold (or immediately with
!promote-tag) - Web archival — submits to archive.is and Wayback Machine, uses whichever succeeds first
- PDF snapshots — full-page Playwright captures stored locally and optionally uploaded to the channel
- Dual search —
!searchtries semantic vector search (pgvector) first, falls back to full-text (tsvector+ GIN index) if no embedding is available - Daily digest — sends a Markdown summary of the day's archived links to the owner as a DM; windowed by last send time so no overlap or gap
- SSRF protection — every outbound fetch validates the target IP before connecting; manual redirect loop re-validates every hop
- Rate limiting — per-user token bucket (configurable, owner exempt); quiet 🐢 reaction on limit, no noisy error messages
- Per-message flags — inline control over summary, tags, archive, and privacy
- Role-based overrides — per-server, per-role, and per-channel config hierarchy;
ignore: trueon a role makes Archivist completely invisible to that user - Homelab-ready —
uv-managed dependencies, K3sdeployment.yaml, secrets never committed
Quick Start #
Prerequisites #
- Python 3.12+ (managed by
uv) - PostgreSQL 14+ with the
pgvectorextension available
(the bot installs it on first connect, but the server must permitCREATE EXTENSION vector) - Google Gemini API key (Google AI Studio)
- Discord bot token with two Privileged Gateway Intents enabled in the
Discord Developer Portal → Bot → Privileged Gateway Intents:- Message Content — required to read message text
- Server Members — required for role-based overrides (including the "Archivist ignore" role)
- Permissions:
Send Messages,Embed Links,Attach Files,Read Message History
Setup #
# Install dependencies
uv sync
# Configure
cp config/default.yaml config/config.yaml
# Edit config/config.yaml — set discord.token, gemini.api_key, and database.*
# (Optional) create the database if it doesn't exist
psql -h localhost -U postgres -c "CREATE DATABASE archivist;"
# Run
uv run python bot.py
The bot creates all tables and installs the vector extension on first connect.
Configuration #
All settings live in config/config.yaml (copy from config/default.yaml). Key fields:
| Field | Description |
|---|---|
discord.token |
Bot token |
discord.watched_channels |
List of channel snowflakes to watch (empty = all channels) |
discord.owner_id |
Snowflake of the user who receives digests and can use owner commands |
discord.digest_time |
HH:MM time to send the daily digest |
discord.timezone |
IANA timezone name (e.g. America/New_York); blank = UTC |
gemini.api_key |
Gemini API key |
gemini.model |
Model name (default: gemini-2.0-flash) |
rate_limit.per_minute |
Max URLs processed per user per minute (default: 5) |
rate_limit.per_hour |
Max URLs processed per user per hour (default: 30) |
archive.snapshots.enabled |
Whether to take Playwright PDF snapshots |
Environment variable overrides: DISCORD_TOKEN, GEMINI_API_KEY, DB_PASSWORD, DB_HOST.
Role-based overrides #
The overrides section in config.yaml lets you tune behavior per server, per role, and per channel. The hierarchy is: defaults < server < role < channel (channel wins).
overrides:
servers:
"123456789012345678": # guild snowflake
roles:
"987654321098765432": # "Archivist-ignore" role — completely silent
ignore: true
"111222333444555666": # power-user role — always archive, no summary
summary: false
archive: true
channels:
"555666777888999000": # never tag anything in this channel
tags: false
Role overrides require the Server Members privileged intent.
Usage #
Post any link in a watched channel — Archivist handles the rest:
https://simonwillison.net/2024/Jan/26/strip-tags/
Per-message flags #
Add flags anywhere in your message to control this specific post:
| Flag | Short | Effect |
|---|---|---|
-nosummary |
-ns |
Skip AI summary |
-notags |
-nt |
Skip auto-tagging |
-noarchive |
-na |
Skip web archival |
-privacy |
-p |
Skip summary AND tags |
"Archivist, no" anywhere in the message skips all processing (legacy opt-out).
Owner commands #
| Command | Effect |
|---|---|
!search <query> |
Search the archive (semantic → full-text fallback) |
!promote-tag <name> |
Promote a pending tag proposal to the curated pool |
Architecture #
Discord message
│
├─ parser.py Extract URLs, flags, commentary
│
├─ net.py SSRF guard — validate every URL and redirect hop
│
├─ fetcher.py Fetch page → readability-lxml → strip-tags
│ youtube.py YouTube URL → transcript API
│
├─ (concurrent)
│ archiver.py archive.is → Wayback Machine fallback
│ ai.py Gemini: classify+tag → summary → embedding
│ snapshot.py Playwright PDF capture
│
├─ formatter.py Build Discord message (2000-char safe)
└─ db.py PostgreSQL: save link, tags, embedding, meta
Database tables #
| Table | Purpose |
|---|---|
archived_links |
One row per archived URL; includes embedding vector and generated fts column |
tags |
Curated tag pool fed back to Gemini |
tag_proposals |
LLM-suggested new tags with use counts; promoted to tags at threshold |
link_tags |
Many-to-many join between links and curated tags |
meta |
Key-value store for operational bookmarks (e.g. last_digest_sent_at) |
Deployment #
See DEPLOYMENT.md for full instructions and DATABASE.md for database setup, pgvector installation, and embedding internals. Short version:
# 1. Create the secret (never commit real values)
kubectl create secret generic archivist-secrets \
--namespace archivist \
--from-literal=DISCORD_TOKEN='...' \
--from-literal=GEMINI_API_KEY='...' \
--from-literal=DB_PASSWORD='...'
# 2. Build and push the image
docker build -t your-registry/archivist:latest .
docker push your-registry/archivist:latest
# 3. Apply
kubectl apply -f deployment.yaml
The deployment.yaml uses the pgvector/pgvector:pg16 image for PostgreSQL so the vector extension is available without any manual setup.
Testing #
uv run pytest
61 tests covering the SSRF guard, message parser, formatter length limits, and YouTube URL patterns.