#!/usr/bin/env python3 """handle.py —— 消费飞书群消息事件,调 /kb-ask 回答,回复到群。 轮询 events/ 目录(subscribe.sh 落的事件文件): 过滤 @机器人 的消息 → 抽问题 → CC headless 跑 /kb-ask → lark-cli 回复。 用法: python3 tools/kb-bot/handle.py # 轮询一轮已有事件后退出 python3 tools/kb-bot/handle.py --watch # 常驻轮询 铁律:只读知识库、答案带溯源、无据报 Gap、绝不编造(由 /kb-ask 保证)。 凭据由 lark-cli 自管,不在本脚本。 """ import sys import os import re import json import time import subprocess BOT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.abspath(os.path.join(BOT_DIR, "..", "..")) EVENTS_DIR = os.path.join(BOT_DIR, "events") DONE_DIR = os.path.join(BOT_DIR, "events", ".done") SEEN_FILE = os.path.join(BOT_DIR, ".seen-msg-ids") os.environ["PATH"] = os.path.expanduser("~/.npm-global/bin") + ":" + os.environ.get("PATH", "") def load_seen(): if os.path.isfile(SEEN_FILE): return set(open(SEEN_FILE, encoding="utf-8").read().split()) return set() def mark_seen(mid): with open(SEEN_FILE, "a", encoding="utf-8") as f: f.write(mid + "\n") def extract(event): """从事件里取 (message_id, chat_id, 纯文本问题);非群 @ 消息返回 None。""" # --compact 扁平结构;兼容原始嵌套结构 mid = event.get("message_id") or event.get("event", {}).get("message", {}).get("message_id") chat_id = event.get("chat_id") or event.get("event", {}).get("message", {}).get("chat_id") text = event.get("text") or "" if not text: content = event.get("content") or event.get("event", {}).get("message", {}).get("content", "") if isinstance(content, str) and content: try: text = json.loads(content).get("text", "") except json.JSONDecodeError: text = content if not (mid and chat_id and text): return None # 剥离 @_user_1 等 @ 标记 q = re.sub(r"@_?\w+", "", text).strip() if not q: return None return mid, chat_id, q def ask_kb(question): """调 CC headless 跑 /kb-ask,返回答案文本。""" try: r = subprocess.run( ["claude", "-p", f"/kb-ask {question}"], cwd=ROOT, capture_output=True, text=True, timeout=180, ) return (r.stdout or "").strip() or "(知识库查询无输出,请稍后重试)" except FileNotFoundError: return "(CC 未安装,无法查询)" except subprocess.TimeoutExpired: return "(查询超时,请缩小问题范围)" def reply(chat_id, mid, answer): subprocess.run( ["lark-cli", "im", "+messages-reply", "--message-id", mid, "--markdown", answer, "--as", "bot"], capture_output=True, text=True, ) def process_once(seen): if not os.path.isdir(EVENTS_DIR): return os.makedirs(DONE_DIR, exist_ok=True) for fn in sorted(os.listdir(EVENTS_DIR)): fp = os.path.join(EVENTS_DIR, fn) if not fn.endswith(".json") or not os.path.isfile(fp): continue try: event = json.load(open(fp, encoding="utf-8")) except (json.JSONDecodeError, OSError): continue parsed = extract(event) if parsed: mid, chat_id, q = parsed if mid not in seen: answer = ask_kb(q) reply(chat_id, mid, answer) mark_seen(mid) seen.add(mid) print(f"[kb-bot] answered {mid}: {q[:40]}") os.replace(fp, os.path.join(DONE_DIR, fn)) def main(): seen = load_seen() watch = "--watch" in sys.argv[1:] while True: process_once(seen) if not watch: break time.sleep(3) if __name__ == "__main__": main()