Discord Bot Development
Build production Discord bots with discord.py, Hermes-powered AI filtering, multi-source data ingestion, and autonomous maintenance cron jobs.
Quick Architecture
┌──────────────────────────────────────────┐
│ discord.py Bot │
├──────────────────────────────────────────┤
│ @tasks.loop() │ @bot.command() │
│ Reddit (PRAW) │ !check, !status │
│ arXiv/PubMed │ │
│ Patent APIs │ │
└────────┬─────────┴──────────┬───────────┘
│ │
▼ ▼
Hermes AI Filter cron job (every N days)
"is this novel?" checks liveness, logs,
→ posts to channel restarts if crashed
Project Structure
~/discord-bots/
├── venv/ # isolated Python environment
├── run.sh # start/stop/restart/status launcher
└── bot-name/
├── bot.py # Discord client, commands, scheduled tasks
├── config.py # tokens, channel IDs, intervals, API keys
├── filter.py # Hermes AI filtering (subprocess)
├── requirements.txt
├── fetchers/ # data source modules
│ ├── __init__.py
│ ├── reddit.py # PRAW-based Reddit scraper
│ ├── papers.py # arXiv + PubMed/EuropePMC
│ └── patents.py # Google Patents / USPTO
└── data/
└── state.json # seen-item dedup (auto-created)
Bot Core (bot.py)
Scheduled Task Pattern
Use @tasks.loop(minutes=N) with a bot.wait_until_ready() guard:
from discord.ext import commands, tasks
bot = commands.Bot(command_prefix="!", intents=intents)
@tasks.loop(minutes=30)
async def check_source():
await bot.wait_until_ready()
items = fetch_and_filter()
await post_to_channel(items, "channel-key")
@bot.event
async def on_ready():
if not check_source.is_running():
check_source.start()
Posting with Embeds
Use discord.Embed for formatted posts. Assign distinct colors per source type for visual scanning:
def format_post(item: dict, item_type: str) -> discord.Embed:
colors = {"reddit": 0xFF4500, "paper": 0x3498DB, "patent": 0xE74C3C}
embed = discord.Embed(
title=item["title"][:256],
url=item["url"],
color=colors[item_type],
description=item.get("abstract", item.get("content", ""))[:1500]
)
embed.add_field(name="AI Verdict", value=item.get("reason", "relevant")[:1024], inline=False)
return embed
Pitfall: embed.add_field values are capped at 1024 chars. Long AI reasons or abstracts must be truncated. Also, title is capped at 256 chars — slice it when constructing.
Manual Trigger Commands
Give admins a !check command for on-demand runs:
@bot.command(name="check")
@commands.has_permissions(administrator=True)
async def manual_check(ctx, source: str = ""):
# source: reddit|papers|patents|all
await ctx.send(f"⚡ Running {source or 'all'} check...")
# ... fetch, filter, post ...
AI Filtering via Hermes (filter.py)
Call Hermes as a subprocess from the bot. Each call is a fresh session — include all context in the prompt:
import json, subprocess
HERMES_CMD = ["hermes", "chat", "-q", "--quiet"]
PROMPTS = {
"reddit": """Evaluate this Reddit post for novelty. Title: {title}. Content: {content}.
Return ONLY JSON: {{"relevant": true/false, "reason": "one sentence"}}""",
# ... per-source prompts ...
}
def filter_content(content_type: str, data: dict) -> dict:
if not data.get("title"):
return {"relevant": False, "reason": "no title"}
prompt = PROMPTS[content_type].format(**data)
result = subprocess.run(HERMES_CMD + [prompt], capture_output=True, text=True, timeout=90)
# Extract JSON from response (may be wrapped in markdown)
output = result.stdout.strip()
start = output.find("{")
end = output.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(output[start:end])
# Fallback: keyword check
if any(w in output.lower() for w in ["true", "relevant", "yes", "novel"]):
return {"relevant": True, "reason": output[:100]}
return {"relevant": False, "reason": output[:100]}
Key design decisions:
- Each item gets its own Hermes call (not batch) — avoids overwhelming context
time.sleep(0.5)between calls to rate-limit Hermes- JSON extraction handles Hermes wrapping output in markdown code blocks
- Fallback keyword check catches non-JSON responses
Per-Source Prompt Design
The prompt is the most important piece. It must define "relevant" with concrete rules:
FILTER_PROMPTS = {
"reddit": """Evaluate: Is this post offering truly novel, useful, or esoteric knowledge?
NO: beginner questions, sourcing requests, repeated topics, personal anecdotes without insight
YES: novel mechanisms, under-discussed compounds, detailed experience reports with unique observations""",
"paper": """Evaluate: Is this paper offering truly novel knowledge?
NO: well-known mechanisms, incremental results, replication of established findings
YES: novel compounds, unexpected mechanisms, under-studied pathways, surprising results""",
"patent": """Evaluate: Is this patent describing a truly novel compound/technology?
NO: obvious combinations, minor formulation tweaks, patents on well-known compounds
YES: novel chemical structures, unexpected synergistic combinations, genuinely new mechanisms""",
}
State Tracking (Avoid Reposts)
Maintain a data/state.json file tracking seen item IDs per source:
def load_seen() -> set:
if not os.path.exists(STATE_FILE):
return set()
with open(STATE_FILE) as f:
return set(json.load(f).get("seen_reddit", []))
def save_seen(seen: set):
state = {}
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
state = json.load(f)
state["seen_reddit"] = list(seen)
state["last_reddit_check"] = datetime.now(timezone.utc).isoformat()
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
Use the source + item ID as the dedup key (e.g., arxiv:2103.12345, post IDs from PRAW, patent IDs).
Data Fetchers
Reddit (PRAW)
import praw
reddit = praw.Reddit(
client_id=REDDIT_CLIENT_ID,
client_secret=REDDIT_CLIENT_SECRET,
user_agent="BotName/1.0 (by /u/username)",
)
for sub in ["nootopics", "nootropics"]:
for post in reddit.subreddit(sub).new(limit=15):
if post.score < MIN_SCORE or post.id in seen_ids:
continue
# ... filter and collect ...
Credentials: Create at https://www.reddit.com/prefs/apps → "script" app.
arXiv API
Free, no auth. XML-based API at http://export.arxiv.org/api/query:
import urllib.request, xml.etree.ElementTree as ET
from urllib.parse import urlencode
params = {
"search_query": '(cognitive OR nootropic) AND (cat:q-bio.NC)',
"max_results": 20,
"sortBy": "submittedDate",
"sortOrder": "descending",
}
url = f"http://export.arxiv.org/api/query?{urlencode(params)}"
# Parse XML response via ElementTree
# Extract: title, summary (abstract), authors, id, published
PubMed / EuropePMC
Free, no auth. REST API at https://www.ebi.ac.uk/europepmc/webservices/rest/search:
params = {
"query": '(nootropic OR "cognitive enhancement")',
"resultType": "core",
"pageSize": 10,
"format": "json",
"sort": "FIRST_PUBLICATION_DATE desc",
}
# Returns JSON with resultList.result[] containing title, abstractText, authorString
Google Patents
Free, no auth. Returns JSON (with a )]}' prefix to strip):
query = quote('"nootropic" cognitive')
url = f"https://patents.google.com/?q={query}&language=ENGLISH&format=json&num=10"
# Strip ")]}'" prefix before JSON parsing
# Navigate: data["results"]["cluster"][0]["result"][0]["patent"]
Bot Launcher (run.sh)
A bash script for lifecycle management. Avoids the agent needing to remember the venv activation and nohup pattern:
#!/bin/bash
BOT_DIR="$HOME/discord-bots/bot-name"
VENV="$HOME/discord-bots/venv"
PID_FILE="$BOT_DIR/.bot.pid"
start_bot() {
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
echo "Already running (PID: $(cat $PID_FILE))"
return
fi
cd "$BOT_DIR"
source "$VENV/bin/activate"
nohup python bot.py >> bot.log 2>&1 &
echo $! > "$PID_FILE"
echo "Started (PID: $!)"
}
Supports: start, stop, restart, status.
Auto-Maintenance Cron Job
Set up a Hermes cron job that checks bot health every few days. The prompt must be self-contained with exact paths:
You are the maintenance agent for the Discord bot at /home/user/discord-bots/bot-name/
Your job:
1. Check if the bot process is running: ps aux | grep "bot.py"
2. Read last 30 lines of bot.log for ERROR lines
3. Check state.json — when was the last data check? >12h since last check = warning
4. If bot is NOT running, restart it: /home/user/discord-bots/run.sh start
5. Report status concisely: "RUNNING/STOPPED. Errors: X. Last activity: Y. Action: ..."
Create it:
cronjob action=create schedule="0 9 */3 * *" name="Bot Maintenance" prompt="..."
This runs every 3 days at 9 AM. Adjust frequency based on bot criticality.
Setup Checklist
- Discord bot token: https://discord.com/developers/applications → New App → Bot → Copy Token
- Invite bot to server: OAuth2 → URL Generator → bot + Send Messages + Embed Links → paste URL in browser
- Channel IDs: Enable Developer Mode in Discord → right-click channel → Copy ID
- Reddit API creds (if needed): https://www.reddit.com/prefs/apps → script app
- Python venv:
python3 -m venv venv && source venv/bin/activate && pip install discord.py praw aiohttp - Test:
./run.sh start→ checkbot.log→!statusin Discord - Cron: create maintenance job
Pitfalls
embed.add_fieldvalue cap: 1024 chars. AI-filter reasons and abstracts must be sliced.embed.titlecap: 256 chars. Slice when constructing.- Rate limiting Hermes: Space AI filter calls by 0.5+ seconds to avoid overwhelming the model.
- Hermes JSON extraction: Hermes often wraps JSON in markdown code blocks. Extract
{...}substring rather than expecting raw JSON. - State file race: The bot writes
state.jsonafter each check. If multiple sources run concurrently, they'll clobber each other. Use per-source state files or a single-threaded check loop if sources share a state file. - WSL cold-start for maintenance cron: If the maintenance cron runs when WSL isn't warmed up,
hermes chat -qmay take 5-15 seconds to start. Not an issue for a 3-day cron — the delay is negligible. - Google Patents JSON prefix: Responses start with
)]}'— strip beforejson.loads(). - arXiv rate limiting: Be polite — space requests by 3+ seconds. arXiv asks for this in their API terms.