- 完整的知识库框架(P0-P5 路线图) - 12篇治理文档 - 工具脚本(kb-init.sh, kb-lint-fm.py, feishu-sync.py, kb-bot) - 7个 Claude 命令 - 示例项目和测试项目(wanniu-l1) - 契约文件(meta/kb-contract.yaml) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
3.8 KiB
Python
Executable File
121 lines
3.8 KiB
Python
Executable File
#!/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()
|