Initial commit: company-kb framework
- 完整的知识库框架(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>
This commit is contained in:
43
tools/kb-bot/README.md
Executable file
43
tools/kb-bot/README.md
Executable file
@@ -0,0 +1,43 @@
|
||||
# kb-bot —— 飞书群知识库问答机器人
|
||||
|
||||
> 群里 @机器人 提问 → 查知识库 → 带溯源回复。**长连接,无需公网服务器。**
|
||||
> 底座:lark-cli(bot 身份自管凭据,不入库)。详见 `docs/08-飞书双向前端方案.md`。
|
||||
|
||||
## 组件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `subscribe.sh` | 起 WebSocket 长连接,把 @机器人 群消息事件落到 `events/` |
|
||||
| `handle.py` | 轮询 `events/`,抽问题→调 `/kb-ask`→`lark-cli` 回复 |
|
||||
| `send.sh` | 手动发消息(验证发消息通路用) |
|
||||
|
||||
## 分步验证
|
||||
|
||||
```bash
|
||||
# P2-a 发消息:查群 id 后手动发一条
|
||||
lark-cli im +chat-search --query "你的群名"
|
||||
bash tools/kb-bot/send.sh oc_xxxxx "**kb-bot 上线测试**"
|
||||
|
||||
# P2-b 收消息:起长连接,去群里 @机器人 说句话,看 events/ 是否落文件
|
||||
bash tools/kb-bot/subscribe.sh # 前台观察
|
||||
|
||||
# P2-c 闭环:另开一个终端跑消费者
|
||||
python3 tools/kb-bot/handle.py --watch
|
||||
```
|
||||
|
||||
## 常驻部署(验证通过后)
|
||||
|
||||
跑在任一常开机器(Mac mini / VPS 皆可,长连接不挑落点):
|
||||
|
||||
```bash
|
||||
nohup bash tools/kb-bot/subscribe.sh > /tmp/kb-bot-sub.log 2>&1 &
|
||||
nohup python3 tools/kb-bot/handle.py --watch > /tmp/kb-bot-handle.log 2>&1 &
|
||||
```
|
||||
|
||||
稳定后再包成 launchd(macOS)/systemd(Linux) 常驻服务。
|
||||
|
||||
## 铁律(继承知识库)
|
||||
|
||||
- 机器人**只读**知识库,绝不写/删。
|
||||
- 答案强制带 `source_link` 溯源;无据回「知识库暂无」,**绝不编造**(由 `/kb-ask` 保证)。
|
||||
- `events/` 与 `.seen-msg-ids` 是运行时产物,已 gitignore。
|
||||
120
tools/kb-bot/handle.py
Executable file
120
tools/kb-bot/handle.py
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/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()
|
||||
24
tools/kb-bot/send.sh
Executable file
24
tools/kb-bot/send.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# send.sh —— 手动发一条 markdown 消息到飞书群(P2-a 验证发消息通路)
|
||||
#
|
||||
# 用法:
|
||||
# bash tools/kb-bot/send.sh <chat-id> "markdown 内容"
|
||||
# # chat-id 用 lark-cli im +chat-search --query "群名" 查
|
||||
#
|
||||
# 加 --dry-run 只打印不发。
|
||||
set -euo pipefail
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
CHAT_ID="${1:-}"
|
||||
CONTENT="${2:-}"
|
||||
|
||||
if [ -z "$CHAT_ID" ] || [ -z "$CONTENT" ]; then
|
||||
echo "用法: bash tools/kb-bot/send.sh <chat-id> \"markdown 内容\"" >&2
|
||||
echo "提示: 先用 lark-cli im +chat-search --query \"群名\" 查 chat-id" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lark-cli im +messages-send \
|
||||
--chat-id "$CHAT_ID" \
|
||||
--markdown "$CONTENT" \
|
||||
--as bot
|
||||
35
tools/kb-bot/subscribe.sh
Executable file
35
tools/kb-bot/subscribe.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# subscribe.sh —— 飞书群消息长连接订阅(WebSocket,无需公网回调)
|
||||
#
|
||||
# 起一个常驻长连接,把 @机器人 的群消息事件逐条落到 events/ 目录,
|
||||
# 由 handle.py 消费。单实例锁由 lark-cli 保证(勿加 --force)。
|
||||
#
|
||||
# 用法:
|
||||
# bash tools/kb-bot/subscribe.sh # 前台跑
|
||||
# nohup bash tools/kb-bot/subscribe.sh & # 后台常驻
|
||||
#
|
||||
# 依赖: lark-cli 已装且 bot 身份可用(lark-cli auth status)。
|
||||
set -euo pipefail
|
||||
|
||||
BOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
EVENTS_DIR="$BOT_DIR/events"
|
||||
mkdir -p "$EVENTS_DIR"
|
||||
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
if ! command -v lark-cli >/dev/null 2>&1; then
|
||||
echo "ERROR: lark-cli 不在 PATH。请先安装并 lark-cli auth login。" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[kb-bot] 订阅飞书群消息事件 → $EVENTS_DIR"
|
||||
echo "[kb-bot] 只收 im.message.receive_v1;Ctrl-C 停止。"
|
||||
|
||||
# --output-dir: 每个事件写成独立 JSON 文件,handle.py 轮询消费
|
||||
# --compact: 扁平化,提取 text,去噪
|
||||
# --quiet: 抑制 stderr 状态噪音(避免日志泄露)
|
||||
exec lark-cli event +subscribe \
|
||||
--event-types im.message.receive_v1 \
|
||||
--compact \
|
||||
--quiet \
|
||||
--output-dir "$EVENTS_DIR"
|
||||
Reference in New Issue
Block a user