bot.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/env python3
  2. """RoboChatto — a simple command bot for Chatto."""
  3. import asyncio
  4. import os
  5. import sqlite3
  6. import sys
  7. import time
  8. from datetime import datetime, timezone
  9. import httpx
  10. from chattolib.client import ChattoClient
  11. from chattolib.subscriptions import subscribe_space_events
  12. from chattolib.types import PresenceStatus
  13. START_TIME = time.monotonic()
  14. DB_PATH = os.environ.get("ROBOCHATTO_DB", "robochatto.db")
  15. OLLAMA_URL = os.environ.get("OLLAMA_URL", "https://ollama.f3l1x.it")
  16. OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5")
  17. COMMANDS = {
  18. "/rc:help": "This is a bot created by Felix (and Claude). Use `/rc:commands` to see available commands.",
  19. "/rc:shrug": "¯\\_(ツ)_/¯",
  20. }
  21. def init_db() -> sqlite3.Connection:
  22. db = sqlite3.connect(DB_PATH)
  23. db.execute("""
  24. CREATE TABLE IF NOT EXISTS command_log (
  25. id INTEGER PRIMARY KEY AUTOINCREMENT,
  26. timestamp TEXT NOT NULL,
  27. user_id TEXT NOT NULL,
  28. user_login TEXT NOT NULL,
  29. space_id TEXT NOT NULL,
  30. room_id TEXT NOT NULL,
  31. command TEXT NOT NULL,
  32. response TEXT NOT NULL
  33. )
  34. """)
  35. db.execute("""
  36. CREATE TABLE IF NOT EXISTS tiers (
  37. name TEXT PRIMARY KEY,
  38. level INTEGER NOT NULL UNIQUE
  39. )
  40. """)
  41. db.execute("""
  42. CREATE TABLE IF NOT EXISTS user_tiers (
  43. user_login TEXT PRIMARY KEY,
  44. tier TEXT NOT NULL REFERENCES tiers(name)
  45. )
  46. """)
  47. db.execute("""
  48. CREATE TABLE IF NOT EXISTS command_tiers (
  49. command TEXT PRIMARY KEY,
  50. tier TEXT NOT NULL REFERENCES tiers(name)
  51. )
  52. """)
  53. # Seed default tiers if empty
  54. if db.execute("SELECT COUNT(*) FROM tiers").fetchone()[0] == 0:
  55. db.executemany(
  56. "INSERT INTO tiers (name, level) VALUES (?, ?)",
  57. [("admin", 0), ("premium", 1), ("basic", 2), ("bot", 3)],
  58. )
  59. # Seed default user tiers if empty
  60. if db.execute("SELECT COUNT(*) FROM user_tiers").fetchone()[0] == 0:
  61. db.executemany(
  62. "INSERT OR IGNORE INTO user_tiers (user_login, tier) VALUES (?, ?)",
  63. [("felix", "admin"), ("RoboChatto", "bot"), ("chattobot", "bot")],
  64. )
  65. # Seed default command tiers if empty
  66. if db.execute("SELECT COUNT(*) FROM command_tiers").fetchone()[0] == 0:
  67. db.executemany(
  68. "INSERT OR IGNORE INTO command_tiers (command, tier) VALUES (?, ?)",
  69. [("/rc:commands", "basic"), ("/rc:help", "basic"), ("/rc:shrug", "basic"), ("/rc:uptime", "basic"), ("/rc:prompt", "premium")],
  70. )
  71. db.commit()
  72. return db
  73. def check_permission(db: sqlite3.Connection, command: str, user_login: str) -> str | None:
  74. """Check if a user's tier level is <= the command's required tier level.
  75. Returns None if allowed, or the required tier name if denied.
  76. No command_tiers row = open to all. No user_tiers row = treated as basic.
  77. """
  78. row = db.execute(
  79. "SELECT t.level, t.name FROM command_tiers ct JOIN tiers t ON t.name = ct.tier WHERE ct.command = ?",
  80. (command,),
  81. ).fetchone()
  82. if row is None:
  83. return None # no restriction on this command
  84. required_level, required_tier = row
  85. row = db.execute(
  86. "SELECT t.level FROM user_tiers ut JOIN tiers t ON t.name = ut.tier WHERE ut.user_login = ?",
  87. (user_login,),
  88. ).fetchone()
  89. if row is None:
  90. user_level = db.execute("SELECT level FROM tiers WHERE name = 'basic'").fetchone()[0]
  91. else:
  92. user_level = row[0]
  93. if user_level <= required_level:
  94. return None
  95. return required_tier
  96. def log_command(
  97. db: sqlite3.Connection,
  98. *,
  99. user_id: str,
  100. user_login: str,
  101. space_id: str,
  102. room_id: str,
  103. command: str,
  104. response: str,
  105. ) -> None:
  106. db.execute(
  107. "INSERT INTO command_log (timestamp, user_id, user_login, space_id, room_id, command, response) VALUES (?, ?, ?, ?, ?, ?, ?)",
  108. (datetime.now(timezone.utc).isoformat(), user_id, user_login, space_id, room_id, command, response),
  109. )
  110. db.commit()
  111. async def query_ollama(prompt: str) -> str:
  112. """Send a prompt to the Ollama-compatible API and return the response."""
  113. async with httpx.AsyncClient(timeout=120) as http:
  114. resp = await http.post(
  115. f"{OLLAMA_URL}/v1/chat/completions",
  116. json={
  117. "model": OLLAMA_MODEL,
  118. "messages": [
  119. {"role": "system", "content": "Do not use markdown headlines (lines starting with #). Use **bold** for emphasis instead."},
  120. {"role": "user", "content": prompt},
  121. ],
  122. "stream": False,
  123. },
  124. )
  125. resp.raise_for_status()
  126. return resp.json()["choices"][0]["message"]["content"]
  127. async def handle_event(client: ChattoClient, db: sqlite3.Connection, event: dict) -> None:
  128. inner = event.get("event", {})
  129. body = inner.get("body", "")
  130. if not body:
  131. return
  132. cmd = body.strip()
  133. # Extract the command name (first word) for prefix commands
  134. cmd_name = cmd.split()[0] if cmd.split() else cmd
  135. space_id = inner["spaceId"]
  136. room_id = inner["roomId"]
  137. event_id = event.get("id")
  138. in_thread = inner.get("inThread")
  139. actor = event.get("actor", {})
  140. user_id = actor.get("id", "?")
  141. user_login = actor.get("login", "?")
  142. if cmd_name == "/rc:commands":
  143. rows = db.execute(
  144. "SELECT ct.command, ct.tier FROM command_tiers ct ORDER BY ct.command"
  145. ).fetchall()
  146. lines = ["**Available commands:**"]
  147. for command, tier in rows:
  148. lines.append(f"- `{command}` — {tier}+")
  149. response = "\n".join(lines)
  150. elif cmd_name == "/rc:uptime":
  151. elapsed = int(time.monotonic() - START_TIME)
  152. days, rem = divmod(elapsed, 86400)
  153. hours, rem = divmod(rem, 3600)
  154. minutes, seconds = divmod(rem, 60)
  155. parts = []
  156. if days:
  157. parts.append(f"{days}d")
  158. if hours:
  159. parts.append(f"{hours}h")
  160. if minutes:
  161. parts.append(f"{minutes}m")
  162. parts.append(f"{seconds}s")
  163. response = f"Uptime: {' '.join(parts)}"
  164. elif cmd_name == "/rc:prompt":
  165. prompt_text = cmd[len("/rc:prompt"):].strip()
  166. if not prompt_text:
  167. response = "Usage: /rc:prompt <your question>"
  168. else:
  169. await client.add_reaction(space_id, room_id, event_id, "hourglass_flowing_sand")
  170. try:
  171. response = await query_ollama(prompt_text)
  172. except Exception as e:
  173. print(f"[{user_login}] {cmd_name} → error: {e}", flush=True)
  174. response = "Something went wrong."
  175. finally:
  176. try:
  177. await client.remove_reaction(space_id, room_id, event_id, "hourglass_flowing_sand")
  178. except Exception:
  179. pass
  180. else:
  181. response = COMMANDS.get(cmd)
  182. if response is None:
  183. return
  184. denied_tier = check_permission(db, cmd_name, user_login)
  185. if denied_tier is not None:
  186. print(f"[{user_login}] {cmd_name} → denied (requires {denied_tier} tier)")
  187. await client.post_message(
  188. space_id, room_id,
  189. f"Access denied. `{cmd_name}` requires **{denied_tier}** tier or above.",
  190. in_reply_to=event_id,
  191. in_thread=in_thread,
  192. )
  193. return
  194. print(f"[{user_login}] {cmd} → replying in {room_id}")
  195. await client.post_message(space_id, room_id, response, in_reply_to=event_id, in_thread=in_thread)
  196. log_command(
  197. db,
  198. user_id=user_id,
  199. user_login=user_login,
  200. space_id=space_id,
  201. room_id=room_id,
  202. command=cmd,
  203. response=response,
  204. )
  205. async def main() -> None:
  206. login = os.environ.get("CHATTO_LOGIN")
  207. password = os.environ.get("CHATTO_PASSWORD")
  208. if not login or not password:
  209. print("Set CHATTO_LOGIN and CHATTO_PASSWORD environment variables", file=sys.stderr)
  210. sys.exit(1)
  211. db = init_db()
  212. print(f"Database: {DB_PATH}")
  213. client = await ChattoClient.login(login, password)
  214. async with client:
  215. me = await client.me()
  216. print(f"Logged in as {me.login} (id={me.id})")
  217. await client.update_presence(PresenceStatus.ONLINE)
  218. print("Presence set to ONLINE")
  219. spaces = await client.spaces()
  220. if not spaces:
  221. print("No spaces found", file=sys.stderr)
  222. sys.exit(1)
  223. print(f"Listening on {len(spaces)} space(s):")
  224. for s in spaces:
  225. print(f" - {s.name} ({s.id})")
  226. # Subscribe to all spaces + keep presence alive
  227. try:
  228. async with asyncio.TaskGroup() as tg:
  229. tg.create_task(presence_keepalive(client))
  230. for space in spaces:
  231. tg.create_task(listen_space(client, db, space.id, space.name))
  232. finally:
  233. print("Shutting down (disconnecting sets presence to offline)")
  234. db.close()
  235. async def presence_keepalive(client: ChattoClient) -> None:
  236. """Re-send ONLINE presence every 60s to stay visible in the user list."""
  237. while True:
  238. try:
  239. await asyncio.sleep(60)
  240. await client.update_presence(PresenceStatus.ONLINE)
  241. except Exception as e:
  242. print(f"[presence] Error: {e}")
  243. await asyncio.sleep(10)
  244. async def listen_space(
  245. client: ChattoClient, db: sqlite3.Connection, space_id: str, space_name: str
  246. ) -> None:
  247. while True:
  248. try:
  249. async for event in subscribe_space_events(
  250. client._url, client._token, space_id
  251. ):
  252. await handle_event(client, db, event)
  253. except Exception as e:
  254. print(f"[{space_name}] Connection lost: {e}. Reconnecting in 5s...")
  255. await asyncio.sleep(5)
  256. if __name__ == "__main__":
  257. asyncio.run(main())