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())