248 lines
12 KiB
Python
248 lines
12 KiB
Python
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<b>待办</b>\n{lines('todos')}\n\n<b>截止时间</b>\n{lines('deadlines')}\n\n<b>需准备物品</b>\n{lines('items_to_prepare')}\n\n<b>次日事项</b>\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())
|