import asyncio import html import json import logging import os from datetime import datetime, timedelta, timezone from pathlib import Path from zoneinfo import ZoneInfo import yaml from aiohttp import web from .llm import Analyzer, acknowledgement_analysis, is_pure_acknowledgement from .media import cleanup_images, extract_message, fetch_images from .notification import build_notifier from .storage import Storage LOG = logging.getLogger("class-monitor") def esc(value: object) -> str: return html.escape(str(value or "")) def daily_summary_period(now: datetime) -> tuple[datetime, str, str, str]: """Return local start time, LLM period label, title kind, and empty message.""" day = now.date() if day.weekday() == 6: # Sunday: summarize Friday through Sunday together. start_day = day - timedelta(days=2) period = f"{start_day.isoformat()} 至 {day.isoformat()}" return ( datetime.combine(start_day, datetime.min.time(), now.tzinfo), period, "周末总结", "周五至周日群内无消息", ) return ( datetime.combine(day, datetime.min.time(), now.tzinfo), day.isoformat(), "日报", "今日群内无消息", ) def sender_is_excluded(cfg: dict, event: dict) -> bool: group_id = str(event.get("group_id", "")) sender_id = str(event.get("user_id", "")) excluded = (cfg.get("excluded_sender_ids_by_group") or {}).get(group_id, []) return sender_id in {str(value) for value in excluded} def immediate_notification(group_name: str, row: dict) -> tuple[str, str]: """Build the compact immediate notification without changing daily reports.""" try: has_media = bool(json.loads(row.get("media_json") or "[]")) except (TypeError, json.JSONDecodeError): has_media = True message_label = "摘要" if has_media else "消息" message = row.get("summary") if has_media else row.get("text") try: analysis = json.loads(row.get("result_json") or "{}") except (TypeError, json.JSONDecodeError): analysis = {} items = analysis.get("items_to_prepare") or [] if isinstance(items, str): items = [items] if not isinstance(items, list): items = [] items = [str(item).strip() for item in items if str(item).strip()] lines = [ f"【{group_name}】 发送人:{esc(row.get('sender_name'))}", f"{message_label}:{esc(message)}", ] if items: lines.append(f"准备物品:{esc('、'.join(items))}") return "", "\n".join(lines) class App: def __init__(self, cfg: dict): self.cfg = cfg self.store = Storage(os.environ.get("CLASS_MONITOR_DB", "/app/data/messages.db")) self.analyzer = Analyzer(cfg) self.notifier = build_notifier(cfg["notification"]["adapter"], cfg["notification"]) self.tz = ZoneInfo(cfg["timezone"]) self.onebot_connections = 0 self.runtime_group_names: dict[str, str] = {} self.group_names_ready = asyncio.Event() configured_names = cfg.get("group_names") or {} if all(str(group_id) in configured_names for group_id in cfg.get("target_group_ids", [])): self.group_names_ready.set() def group_name(self, group_id: object) -> str: key = str(group_id) configured = self.cfg.get("group_names") or {} return str(configured.get(key) or self.runtime_group_names.get(key) or f"QQ群 {key}") async def wait_for_group_names(self) -> None: if self.group_names_ready.is_set(): return try: await asyncio.wait_for(self.group_names_ready.wait(), timeout=10) except TimeoutError: LOG.warning("QQ group names not available after 10s; using configured names or group ids") async def websocket(self, request: web.Request): expected = os.getenv("ONEBOT_ACCESS_TOKEN") or self.cfg.get("onebot_access_token", "") auth = request.headers.get("Authorization", "") if expected and auth != f"Bearer {expected}" and request.query.get("access_token") != expected: raise web.HTTPUnauthorized() ws = web.WebSocketResponse(heartbeat=30) await ws.prepare(request) self.onebot_connections += 1 LOG.info("NapCat OneBot connection established") try: await ws.send_json({"action": "get_group_list", "params": {}, "echo": "class-monitor:group-list"}) async for msg in ws: if msg.type != web.WSMsgType.TEXT: continue try: event = json.loads(msg.data) if event.get("echo") == "class-monitor:group-list" and event.get("status") == "ok": for group in event.get("data") or []: if group.get("group_id") is not None and group.get("group_name"): self.runtime_group_names[str(group["group_id"])] = str(group["group_name"]) self.group_names_ready.set() LOG.info("loaded names for %s QQ groups", len(self.runtime_group_names)) continue if event.get("post_type") != "message" or event.get("message_type") != "group": continue targets = {str(x) for x in self.cfg.get("target_group_ids", [])} if not targets or str(event.get("group_id")) not in targets: continue if sender_is_excluded(self.cfg, event): LOG.info("message ignored by group-specific sender exclusion group=%s", event.get("group_id")) continue text, media = extract_message(event) pk, inserted = await self.store.save_message(event, text, media) if inserted: LOG.info("message persisted id=%s group=%s message=%s", pk, event.get("group_id"), event.get("message_id")) except Exception: LOG.exception("failed to handle OneBot event") finally: self.onebot_connections = max(0, self.onebot_connections - 1) LOG.info("NapCat OneBot connection closed") return ws async def health(self, request): return web.json_response({"ok": True, "onebot_connected": self.onebot_connections > 0, "configured_groups": len(self.cfg.get("target_group_ids", [])), "model": self.cfg["llm"]["model"], **await self.store.stats()}) async def analysis_worker(self): while True: row = await self.store.claim_pending() if not row: await asyncio.sleep(2) continue try: if is_pure_acknowledgement(row["text"], row.get("media_json") or "[]"): result = acknowledgement_analysis() else: result = await self.analyzer.analyze(row, await self.store.context(row, int(self.cfg["context_messages"]))) await self.store.save_analysis(row["id"], result) except Exception as exc: attempts = int(row["analysis_attempts"]) + 1 exponent = min(attempts, int(self.cfg["llm"]["max_attempts"])) delay = min(3600, int(self.cfg["llm"]["retry_base_seconds"]) * 2 ** exponent) await self.store.retry_analysis(row["id"], repr(exc), (datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat()) LOG.exception("analysis failed id=%s attempt=%s retry_in=%ss", row["id"], attempts, delay) async def notify_worker(self): await self.wait_for_group_names() while True: threshold = int(self.cfg["llm"].get("importance_threshold", 1)) for row in await self.store.pending_notifications(threshold): title, text = immediate_notification(self.group_name(row["group_id"]), row) images: list[dict] = [] try: images = await fetch_images( row["media_json"], os.environ.get("CLASS_MONITOR_MEDIA_DIR", "/app/data/media-cache"), int(row["id"]), ) await self.notifier.send( title, text, f"class-monitor:message:{row['id']}", images ) await self.store.notification_result(row["id"], True) cleanup_images(images) except Exception as exc: delay = min(3600, 30 * 2 ** min(int(row["notify_attempts"]), 7)) await self.store.notification_result(row["id"], False, repr(exc), (datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat()) LOG.exception("notification failed id=%s", row["id"]) await asyncio.sleep(3) async def daily_worker(self): await self.wait_for_group_names() hour, minute = int(self.cfg["daily_summary"]["hour"]), int(self.cfg["daily_summary"]["minute"]) while True: if not self.cfg.get("target_group_ids"): await asyncio.sleep(30) continue now = datetime.now(self.tz) day = now.date().isoformat() if (now.hour, now.minute) >= (hour, minute): start, period, title_kind, empty_overview = daily_summary_period(now) end = now.astimezone(timezone.utc).isoformat() for raw_group_id in self.cfg.get("target_group_ids", []): group_id = str(raw_group_id) if await self.store.has_daily(day, group_id): continue rows = await self.store.messages_for_day(start.astimezone(timezone.utc).isoformat(), end, group_id) try: result = await self.analyzer.daily(period, rows) if rows else {"overview": empty_overview, "todos": [], "deadlines": [], "items_to_prepare": [], "next_day": []} def lines(name): return "\n".join(f"• {esc(x)}" for x in result.get(name, [])) or "• 无" title = f"【{self.group_name(group_id)}】{title_kind} {period}" text = f"{esc(result.get('overview'))}\n\n待办\n{lines('todos')}\n\n截止时间\n{lines('deadlines')}\n\n需准备物品\n{lines('items_to_prepare')}\n\n次日事项\n{lines('next_day')}" await self.notifier.send(title, text, f"class-monitor:daily:{day}:{group_id}") await self.store.save_daily(day, group_id, text, result, True) except Exception: LOG.exception("daily summary failed group=%s; will retry", group_id) await asyncio.sleep(30) async def run(self): await self.store.init() site = web.Application() site.router.add_get(self.cfg["onebot_path"], self.websocket) site.router.add_get("/health", self.health) runner = web.AppRunner(site) await runner.setup() await web.TCPSite(runner, self.cfg["listen_host"], int(self.cfg["listen_port"])).start() LOG.info("service ready model=%s daily=%02d:%02d timezone=%s groups=%s", self.cfg["llm"]["model"], self.cfg["daily_summary"]["hour"], self.cfg["daily_summary"]["minute"], self.cfg["timezone"], len(self.cfg.get("target_group_ids", []))) await asyncio.gather(self.analysis_worker(), self.notify_worker(), self.daily_worker()) def load_config(): with open(os.environ.get("CLASS_MONITOR_CONFIG", "/app/config/config.yaml"), encoding="utf-8") as f: return yaml.safe_load(f) def main(): Path("/app/logs").mkdir(parents=True, exist_ok=True) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[logging.StreamHandler(), logging.FileHandler("/app/logs/service.log", encoding="utf-8")]) # httpx logs full request URLs; notification providers may put secrets there. logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) asyncio.run(App(load_config()).run())