| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- #!/usr/bin/env python3
- """RoboChatto — a simple command bot for Chatto."""
- import asyncio
- import os
- import sqlite3
- import sys
- import time
- from datetime import datetime, timezone
- import httpx
- from chattolib.client import ChattoClient
- from chattolib.subscriptions import subscribe_space_events
- from chattolib.types import PresenceStatus
- START_TIME = time.monotonic()
- DB_PATH = os.environ.get("ROBOCHATTO_DB", "robochatto.db")
- OLLAMA_URL = os.environ.get("OLLAMA_URL", "https://ollama.f3l1x.it")
- OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5")
- COMMANDS = {
- "/rc:help": "This is a bot created by Felix (and Claude). Use `/rc:commands` to see available commands.",
- "/rc:shrug": "¯\\_(ツ)_/¯",
- }
- def init_db() -> sqlite3.Connection:
- db = sqlite3.connect(DB_PATH)
- db.execute("""
- CREATE TABLE IF NOT EXISTS command_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- timestamp TEXT NOT NULL,
- user_id TEXT NOT NULL,
- user_login TEXT NOT NULL,
- space_id TEXT NOT NULL,
- room_id TEXT NOT NULL,
- command TEXT NOT NULL,
- response TEXT NOT NULL
- )
- """)
- db.execute("""
- CREATE TABLE IF NOT EXISTS tiers (
- name TEXT PRIMARY KEY,
- level INTEGER NOT NULL UNIQUE
- )
- """)
- db.execute("""
- CREATE TABLE IF NOT EXISTS user_tiers (
- user_login TEXT PRIMARY KEY,
- tier TEXT NOT NULL REFERENCES tiers(name)
- )
- """)
- db.execute("""
- CREATE TABLE IF NOT EXISTS command_tiers (
- command TEXT PRIMARY KEY,
- tier TEXT NOT NULL REFERENCES tiers(name)
- )
- """)
- # Seed default tiers if empty
- if db.execute("SELECT COUNT(*) FROM tiers").fetchone()[0] == 0:
- db.executemany(
- "INSERT INTO tiers (name, level) VALUES (?, ?)",
- [("admin", 0), ("premium", 1), ("basic", 2), ("bot", 3)],
- )
- # Seed default user tiers if empty
- if db.execute("SELECT COUNT(*) FROM user_tiers").fetchone()[0] == 0:
- db.executemany(
- "INSERT OR IGNORE INTO user_tiers (user_login, tier) VALUES (?, ?)",
- [("felix", "admin"), ("RoboChatto", "bot"), ("chattobot", "bot")],
- )
- # Seed default command tiers if empty
- if db.execute("SELECT COUNT(*) FROM command_tiers").fetchone()[0] == 0:
- db.executemany(
- "INSERT OR IGNORE INTO command_tiers (command, tier) VALUES (?, ?)",
- [("/rc:commands", "basic"), ("/rc:help", "basic"), ("/rc:shrug", "basic"), ("/rc:uptime", "basic"), ("/rc:prompt", "premium")],
- )
- db.commit()
- return db
- def check_permission(db: sqlite3.Connection, command: str, user_login: str) -> str | None:
- """Check if a user's tier level is <= the command's required tier level.
- Returns None if allowed, or the required tier name if denied.
- No command_tiers row = open to all. No user_tiers row = treated as basic.
- """
- row = db.execute(
- "SELECT t.level, t.name FROM command_tiers ct JOIN tiers t ON t.name = ct.tier WHERE ct.command = ?",
- (command,),
- ).fetchone()
- if row is None:
- return None # no restriction on this command
- required_level, required_tier = row
- row = db.execute(
- "SELECT t.level FROM user_tiers ut JOIN tiers t ON t.name = ut.tier WHERE ut.user_login = ?",
- (user_login,),
- ).fetchone()
- if row is None:
- user_level = db.execute("SELECT level FROM tiers WHERE name = 'basic'").fetchone()[0]
- else:
- user_level = row[0]
- if user_level <= required_level:
- return None
- return required_tier
- def log_command(
- db: sqlite3.Connection,
- *,
- user_id: str,
- user_login: str,
- space_id: str,
- room_id: str,
- command: str,
- response: str,
- ) -> None:
- db.execute(
- "INSERT INTO command_log (timestamp, user_id, user_login, space_id, room_id, command, response) VALUES (?, ?, ?, ?, ?, ?, ?)",
- (datetime.now(timezone.utc).isoformat(), user_id, user_login, space_id, room_id, command, response),
- )
- db.commit()
- async def query_ollama(prompt: str) -> str:
- """Send a prompt to the Ollama-compatible API and return the response."""
- async with httpx.AsyncClient(timeout=120) as http:
- resp = await http.post(
- f"{OLLAMA_URL}/v1/chat/completions",
- json={
- "model": OLLAMA_MODEL,
- "messages": [
- {"role": "system", "content": "Do not use markdown headlines (lines starting with #). Use **bold** for emphasis instead."},
- {"role": "user", "content": prompt},
- ],
- "stream": False,
- },
- )
- resp.raise_for_status()
- return resp.json()["choices"][0]["message"]["content"]
- async def handle_event(client: ChattoClient, db: sqlite3.Connection, event: dict) -> None:
- inner = event.get("event", {})
- body = inner.get("body", "")
- if not body:
- return
- cmd = body.strip()
- # Extract the command name (first word) for prefix commands
- cmd_name = cmd.split()[0] if cmd.split() else cmd
- space_id = inner["spaceId"]
- room_id = inner["roomId"]
- event_id = event.get("id")
- in_thread = inner.get("inThread")
- actor = event.get("actor", {})
- user_id = actor.get("id", "?")
- user_login = actor.get("login", "?")
- if cmd_name == "/rc:commands":
- rows = db.execute(
- "SELECT ct.command, ct.tier FROM command_tiers ct ORDER BY ct.command"
- ).fetchall()
- lines = ["**Available commands:**"]
- for command, tier in rows:
- lines.append(f"- `{command}` — {tier}+")
- response = "\n".join(lines)
- elif cmd_name == "/rc:uptime":
- elapsed = int(time.monotonic() - START_TIME)
- days, rem = divmod(elapsed, 86400)
- hours, rem = divmod(rem, 3600)
- minutes, seconds = divmod(rem, 60)
- parts = []
- if days:
- parts.append(f"{days}d")
- if hours:
- parts.append(f"{hours}h")
- if minutes:
- parts.append(f"{minutes}m")
- parts.append(f"{seconds}s")
- response = f"Uptime: {' '.join(parts)}"
- elif cmd_name == "/rc:prompt":
- prompt_text = cmd[len("/rc:prompt"):].strip()
- if not prompt_text:
- response = "Usage: /rc:prompt <your question>"
- else:
- await client.add_reaction(space_id, room_id, event_id, "hourglass_flowing_sand")
- try:
- response = await query_ollama(prompt_text)
- except Exception as e:
- print(f"[{user_login}] {cmd_name} → error: {e}", flush=True)
- response = "Something went wrong."
- finally:
- try:
- await client.remove_reaction(space_id, room_id, event_id, "hourglass_flowing_sand")
- except Exception:
- pass
- else:
- response = COMMANDS.get(cmd)
- if response is None:
- return
- denied_tier = check_permission(db, cmd_name, user_login)
- if denied_tier is not None:
- print(f"[{user_login}] {cmd_name} → denied (requires {denied_tier} tier)")
- await client.post_message(
- space_id, room_id,
- f"Access denied. `{cmd_name}` requires **{denied_tier}** tier or above.",
- in_reply_to=event_id,
- in_thread=in_thread,
- )
- return
- print(f"[{user_login}] {cmd} → replying in {room_id}")
- await client.post_message(space_id, room_id, response, in_reply_to=event_id, in_thread=in_thread)
- log_command(
- db,
- user_id=user_id,
- user_login=user_login,
- space_id=space_id,
- room_id=room_id,
- command=cmd,
- response=response,
- )
- async def main() -> None:
- login = os.environ.get("CHATTO_LOGIN")
- password = os.environ.get("CHATTO_PASSWORD")
- if not login or not password:
- print("Set CHATTO_LOGIN and CHATTO_PASSWORD environment variables", file=sys.stderr)
- sys.exit(1)
- db = init_db()
- print(f"Database: {DB_PATH}")
- client = await ChattoClient.login(login, password)
- async with client:
- me = await client.me()
- print(f"Logged in as {me.login} (id={me.id})")
- await client.update_presence(PresenceStatus.ONLINE)
- print("Presence set to ONLINE")
- spaces = await client.spaces()
- if not spaces:
- print("No spaces found", file=sys.stderr)
- sys.exit(1)
- print(f"Listening on {len(spaces)} space(s):")
- for s in spaces:
- print(f" - {s.name} ({s.id})")
- # Subscribe to all spaces + keep presence alive
- try:
- async with asyncio.TaskGroup() as tg:
- tg.create_task(presence_keepalive(client))
- for space in spaces:
- tg.create_task(listen_space(client, db, space.id, space.name))
- finally:
- print("Shutting down (disconnecting sets presence to offline)")
- db.close()
- async def presence_keepalive(client: ChattoClient) -> None:
- """Re-send ONLINE presence every 60s to stay visible in the user list."""
- while True:
- try:
- await asyncio.sleep(60)
- await client.update_presence(PresenceStatus.ONLINE)
- except Exception as e:
- print(f"[presence] Error: {e}")
- await asyncio.sleep(10)
- async def listen_space(
- client: ChattoClient, db: sqlite3.Connection, space_id: str, space_name: str
- ) -> None:
- while True:
- try:
- async for event in subscribe_space_events(
- client._url, client._token, space_id
- ):
- await handle_event(client, db, event)
- except Exception as e:
- print(f"[{space_name}] Connection lost: {e}. Reconnecting in 5s...")
- await asyncio.sleep(5)
- if __name__ == "__main__":
- asyncio.run(main())
|