Coverage for src/asketmc_bot/discord_bot.py: 42%

260 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2026-04-08 17:14 +0000

1#!/usr/bin/env python3.10 

2from __future__ import annotations 

3 

4import asyncio 

5import hashlib 

6import logging 

7import time 

8from dataclasses import dataclass 

9from pathlib import Path 

10from typing import Any, Awaitable, Callable, Dict, Optional 

11 

12import aiohttp 

13import discord 

14from aiohttp import ClientSession, TCPConnector 

15from discord.ext import commands 

16 

17from asketmc_bot import config as cfg 

18from asketmc_bot.rag_filter import purge_filter_cache 

19 

20disc_log = logging.getLogger("asketmc.discord") 

21disc_log.setLevel(logging.DEBUG if getattr(cfg, "DEBUG", False) else logging.INFO) 

22 

23RagGenFn = Callable[..., Awaitable[str]] 

24QueryModelFn = Callable[..., Awaitable[tuple[str, bool]]] 

25CallLocalFn = Callable[[str], Awaitable[str]] 

26BuildIndexFn = Callable[[], Awaitable[Any]] 

27IsBlockedFn = Callable[[], bool] 

28ShutdownFn = Callable[[], Awaitable[None]] 

29 

30 

31@dataclass 

32class AppDeps: 

33 index: Any 

34 retriever: Any 

35 generate_rag_answer: RagGenFn 

36 query_model: QueryModelFn 

37 call_local_llm: CallLocalFn 

38 build_index: BuildIndexFn 

39 is_openrouter_blocked: IsBlockedFn 

40 on_core_shutdown: Optional[ShutdownFn] = None 

41 

42 

43_STATE: Optional[AppDeps] = None 

44_user_last: Dict[int, float] = {} 

45 

46 

47class _AsyncSessionHolder: 

48 def __init__(self) -> None: 

49 self._lock = asyncio.Lock() 

50 self._session: Optional[ClientSession] = None 

51 

52 async def get(self) -> ClientSession: 

53 async with self._lock: 

54 if self._session is None or self._session.closed: 

55 self._session = ClientSession( 

56 connector=TCPConnector(limit=cfg.HTTP_CONN_LIMIT) 

57 ) 

58 return self._session 

59 

60 async def close(self) -> None: 

61 async with self._lock: 

62 if self._session and not self._session.closed: 62 ↛ 61line 62 didn't jump to line 61

63 await self._session.close() 

64 self._session = None 

65 

66 

67_SESSION_HOLDER = _AsyncSessionHolder() 

68 

69 

70def _configure_logging(*, verbose: bool) -> None: 

71 """ 

72 Keep semantic bot logs; suppress discord.py transport/debug noise. 

73 

74 This config is local to loggers' levels and does not require changes in main.py. 

75 """ 

76 disc_log.setLevel(logging.DEBUG if verbose else logging.INFO) 

77 

78 # discord.py internal chatter 

79 logging.getLogger("discord").setLevel(logging.WARNING) 

80 logging.getLogger("discord.gateway").setLevel(logging.WARNING) 

81 logging.getLogger("discord.client").setLevel(logging.WARNING) 

82 logging.getLogger("discord.http").setLevel(logging.WARNING) 

83 

84 # optional: aiohttp can be noisy too 

85 logging.getLogger("aiohttp").setLevel(logging.WARNING) 

86 

87 

88def _require_state() -> AppDeps: 

89 if _STATE is None: 

90 raise RuntimeError( 

91 "Discord module is not initialized. Call run_bot(...) or start_bot_async(...)." 

92 ) 

93 return _STATE 

94 

95 

96def _check_cooldown(user_id: int) -> bool: 

97 now = time.monotonic() 

98 last = _user_last.get(user_id, 0.0) 

99 if now - last < cfg.USER_COOLDOWN: 

100 return False 

101 _user_last[user_id] = now 

102 return True 

103 

104 

105def _sanitize(text: str) -> str: 

106 return ( 

107 text.replace("@", "@\u200b") 

108 .replace("```", " ") 

109 .replace("</sys>", " ") 

110 .replace("<sys>", " ") 

111 ) 

112 

113 

114def _split_for_discord(text: str, limit: int = 2000) -> list[str]: 

115 parts: list[str] = [] 

116 while text: 116 ↛ 125line 116 didn't jump to line 125, because the condition on line 116 was always true

117 if len(text) <= limit: 

118 parts.append(text) 

119 break 

120 cut = text.rfind("\n", 0, limit) 

121 if cut == -1: 

122 cut = limit 

123 parts.append(text[:cut]) 

124 text = text[cut:].lstrip("\n") 

125 return parts 

126 

127 

128def _qhash(text: str) -> str: 

129 return hashlib.sha1(text.encode("utf-8")).hexdigest()[:10] 

130 

131 

132async def _send_long(ctx: commands.Context, text: str, limit: int = 1900) -> None: 

133 parts = _split_for_discord(text, limit) 

134 disc_log.debug( 

135 "send parts=%s total_len=%s limit=%s ch_id=%s", 

136 len(parts), 

137 len(text), 

138 limit, 

139 getattr(ctx.channel, "id", "?"), 

140 ) 

141 for i, chunk in enumerate(parts, start=1): 

142 disc_log.debug("send_part i=%s len=%s", i, len(chunk)) 

143 await ctx.send(chunk) 

144 

145 

146_bot: Optional[commands.Bot] = None 

147_shutdown_lock = asyncio.Lock() 

148_shutdown_flag = False 

149 

150 

151def _build_bot() -> commands.Bot: 

152 # Minimal intents for prefix commands; avoids presence/member noise. 

153 intents = discord.Intents.none() 

154 intents.guilds = True 

155 intents.messages = True 

156 intents.message_content = True 

157 return commands.Bot(command_prefix="!", intents=intents) 

158 

159 

160def _check_admin_only(): 

161 def predicate(ctx: commands.Context) -> bool: 

162 if ctx.author.id not in cfg.ADMIN_IDS: 

163 raise commands.CheckFailure("not_admin") 

164 return True 

165 

166 return commands.check(predicate) 

167 

168 

169def _check_channel_allowed(): 

170 def predicate(ctx: commands.Context) -> bool: 

171 if ctx.channel.id not in cfg.ALLOWED_CHANNELS: 

172 raise commands.CheckFailure("not_allowed_channel") 

173 return True 

174 

175 return commands.check(predicate) 

176 

177 

178def _read_prompt_file(prompt_path: Path) -> str: 

179 return prompt_path.read_text(encoding="utf-8") 

180 

181 

182async def _answer_rag( 

183 ctx: commands.Context, 

184 question_raw: str, 

185 *, 

186 prompt_path: Path, 

187 force_local: bool | None = None, 

188) -> None: 

189 deps = _require_state() 

190 

191 q = _sanitize((question_raw or "").strip().replace("\n", " ")) 

192 if not q or len(q) > cfg.MAX_QUESTION_LEN or not cfg.ALLOWED_CHARS.match(q): 

193 await ctx.send("❌ Invalid query format.") 

194 return 

195 

196 if not _check_cooldown(ctx.author.id): 

197 await ctx.send("⏳ Please wait before sending another query.") 

198 return 

199 

200 is_blocked = deps.is_openrouter_blocked() 

201 use_remote = (force_local is None and not is_blocked) or (force_local is False) 

202 

203 disc_log.debug( 

204 "rq user_id=%s ch_id=%s cmd=%s qlen=%s qhash=%s mode=%s", 

205 getattr(ctx.author, "id", "?"), 

206 getattr(ctx.channel, "id", "?"), 

207 getattr(getattr(ctx, "command", None), "qualified_name", "?"), 

208 len(q), 

209 _qhash(q), 

210 "remote" if use_remote else "local", 

211 ) 

212 

213 await ctx.send("🧠 Thinking locally…" if not use_remote else "🔍 Thinking…") 

214 

215 start = time.monotonic() 

216 try: 

217 async with cfg.REQUEST_SEMAPHORE: 

218 sys_prompt = _read_prompt_file(Path(prompt_path)) 

219 answer = await deps.generate_rag_answer(q, sys_prompt, use_remote=use_remote) 

220 except FileNotFoundError: 

221 await ctx.send("⚠️ System prompt file is missing.") 

222 return 

223 except UnicodeError: 

224 await ctx.send("⚠️ System prompt file is not valid UTF-8.") 

225 return 

226 except asyncio.TimeoutError: 

227 await ctx.send("⚠️ Request timed out. Try again later.") 

228 return 

229 except aiohttp.ClientError as exc: 

230 disc_log.warning("[_answer_rag] network error: %s", exc) 

231 await ctx.send("⚠️ Network error while contacting the model. Try again later.") 

232 return 

233 except ValueError as exc: 

234 disc_log.info("[_answer_rag] bad request: %s", exc) 

235 await ctx.send("⚠️ Request rejected by validation.") 

236 return 

237 except discord.HTTPException as exc: 

238 disc_log.warning("[_answer_rag] discord HTTP error: %s", exc) 

239 await ctx.send("⚠️ Discord API error while sending the response.") 

240 return 

241 except Exception as exc: 

242 disc_log.exception("[_answer_rag] unexpected error: %r", exc) 

243 await ctx.send("❌ Internal error. Try again later.") 

244 return 

245 

246 out = answer or "❌ No answer." 

247 disc_log.debug( 

248 "rs user_id=%s ch_id=%s olen=%s dt_ms=%s", 

249 getattr(ctx.author, "id", "?"), 

250 getattr(ctx.channel, "id", "?"), 

251 len(out), 

252 int((time.monotonic() - start) * 1000), 

253 ) 

254 await _send_long(ctx, out) 

255 

256 if force_local is None and is_blocked: 

257 await ctx.send("⚠️ OpenRouter unavailable, local model used.") 

258 

259 

260def _register_commands(bot: commands.Bot) -> None: 

261 @bot.command(name="strict", help="Answer using strict prompt and RAG.") 

262 @_check_channel_allowed() 

263 async def cmd_strict(ctx: commands.Context, *, q: str) -> None: 

264 await _answer_rag(ctx, q, prompt_path=Path(cfg.PROMPT_STRICT), force_local=None) 

265 

266 @bot.command(name="think", help="Answer using reasoning prompt and RAG.") 

267 @_check_channel_allowed() 

268 async def cmd_think(ctx: commands.Context, *, q: str) -> None: 

269 await _answer_rag(ctx, q, prompt_path=Path(cfg.PROMPT_REASON), force_local=None) 

270 

271 @bot.command(name="local", help="Answer only with local LLM; cloud is skipped.") 

272 @_check_channel_allowed() 

273 async def cmd_local(ctx: commands.Context, *, q: str) -> None: 

274 await _answer_rag(ctx, q, prompt_path=Path(cfg.PROMPT_STRICT), force_local=True) 

275 

276 @bot.command(name="status", help="Show bot/index/cache status.") 

277 async def cmd_status(ctx: commands.Context) -> None: 

278 deps = _require_state() 

279 try: 

280 docs = len(deps.index.docstore.docs) # type: ignore[attr-defined] 

281 except Exception: 

282 docs = -1 

283 await ctx.send( 

284 f"🧠 Documents: {docs}\n" 

285 f"💾 Cache: {'yes' if cfg.CACHE_PATH.exists() else 'no'}\n" 

286 f"🌐 OpenRouter: {'blocked' if deps.is_openrouter_blocked() else 'ok'}" 

287 ) 

288 

289 @bot.command(name="reload_index", help="Rebuild the index (admin only).") 

290 @_check_admin_only() 

291 async def cmd_reload_index(ctx: commands.Context) -> None: 

292 deps = _require_state() 

293 try: 

294 purge_filter_cache() 

295 new_index = await deps.build_index() 

296 deps.index = new_index 

297 deps.retriever = new_index.as_retriever(similarity_top_k=cfg.TOP_K) 

298 await ctx.send("✅ Index reloaded.") 

299 except Exception as exc: 

300 disc_log.exception("[reload_index] error: %r", exc) 

301 await ctx.send("❌ Internal error while rebuilding index.") 

302 

303 @bot.command(name="stop", help="Stop the bot (admin only).") 

304 @_check_admin_only() 

305 async def cmd_stop(ctx: commands.Context) -> None: 

306 await ctx.send("🛑 Shutting down bot...") 

307 await _shutdown_once(bot) 

308 

309 @bot.event 

310 async def on_ready() -> None: 

311 disc_log.info("Bot started as %s (ID: %s)", bot.user, getattr(bot.user, "id", "?")) 

312 try: 

313 ch = bot.get_channel(next(iter(cfg.ALLOWED_CHANNELS))) 

314 if ch: 

315 await ch.send("✅ Bot started and ready.") 

316 except Exception as exc: 

317 disc_log.error("[on_ready] startup notify failed: %s", exc) 

318 

319 @bot.event 

320 async def on_command_error(ctx: commands.Context, error: Exception) -> None: 

321 if isinstance(error, commands.CheckFailure): 

322 msg = str(error) 

323 if msg == "not_admin": 

324 await ctx.send("❌ Access denied.") 

325 elif msg == "not_allowed_channel": 

326 await ctx.send("❌ This command is not allowed in this channel.") 

327 else: 

328 await ctx.send("❌ You are not allowed to use this command here.") 

329 return 

330 if isinstance(error, commands.MissingRequiredArgument): 

331 await ctx.send("❌ Invalid command usage.") 

332 return 

333 disc_log.exception("[on_command_error] Unhandled: %r", error) 

334 await ctx.send("❌ Internal error.") 

335 

336 

337async def _shutdown_once(bot: commands.Bot) -> None: 

338 global _shutdown_flag 

339 async with _shutdown_lock: 

340 if _shutdown_flag: 

341 return 

342 _shutdown_flag = True 

343 

344 try: 

345 ch = bot.get_channel(next(iter(cfg.ALLOWED_CHANNELS))) # type: ignore[arg-type] 

346 if ch: 

347 await ch.send("🛑 Bot stopped.") 

348 except Exception as exc: 

349 disc_log.error("[shutdown] notify failed: %s", exc) 

350 

351 try: 

352 await _SESSION_HOLDER.close() 

353 finally: 

354 deps = _require_state() 

355 if deps.on_core_shutdown: 

356 try: 

357 await deps.on_core_shutdown() 

358 except Exception as exc: 

359 disc_log.exception("[shutdown] on_core_shutdown error: %s", exc) 

360 await bot.close() 

361 

362 

363def run_bot( 

364 *, 

365 token: str, 

366 index: Any, 

367 retriever: Any, 

368 generate_rag_answer: RagGenFn, 

369 query_model: QueryModelFn, 

370 call_local_llm: CallLocalFn, 

371 build_index: BuildIndexFn, 

372 is_openrouter_blocked: IsBlockedFn, 

373 on_core_shutdown: Optional[ShutdownFn] = None, 

374) -> None: 

375 global _STATE, _bot, _shutdown_flag 

376 _shutdown_flag = False 

377 

378 _configure_logging(verbose=bool(getattr(cfg, "DEBUG", False))) 

379 

380 _STATE = AppDeps( 

381 index=index, 

382 retriever=retriever, 

383 generate_rag_answer=generate_rag_answer, 

384 query_model=query_model, 

385 call_local_llm=call_local_llm, 

386 build_index=build_index, 

387 is_openrouter_blocked=is_openrouter_blocked, 

388 on_core_shutdown=on_core_shutdown, 

389 ) 

390 _bot = _build_bot() 

391 _register_commands(_bot) 

392 _bot.run(token, log_handler=None) 

393 

394 

395async def start_bot_async( 

396 *, 

397 token: str, 

398 index: Any, 

399 retriever: Any, 

400 generate_rag_answer: RagGenFn, 

401 query_model: QueryModelFn, 

402 call_local_llm: CallLocalFn, 

403 build_index: BuildIndexFn, 

404 is_openrouter_blocked: IsBlockedFn, 

405 on_core_shutdown: Optional[ShutdownFn] = None, 

406) -> None: 

407 global _STATE, _bot, _shutdown_flag 

408 _shutdown_flag = False 

409 

410 _configure_logging(verbose=bool(getattr(cfg, "DEBUG", False))) 

411 

412 _STATE = AppDeps( 

413 index=index, 

414 retriever=retriever, 

415 generate_rag_answer=generate_rag_answer, 

416 query_model=query_model, 

417 call_local_llm=call_local_llm, 

418 build_index=build_index, 

419 is_openrouter_blocked=is_openrouter_blocked, 

420 on_core_shutdown=on_core_shutdown, 

421 ) 

422 _bot = _build_bot() 

423 _register_commands(_bot) 

424 try: 

425 await _bot.start(token) 

426 finally: 

427 if _bot is not None: 

428 await _shutdown_once(_bot)