feishu_agent.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. """
  2. 飞书实时对话 Agent
  3. 通过飞书 WebSocket 监听消息,调用 Qwen LLM 生成回复,实现实时对话。
  4. 支持工具调用:浏览目录、读取文件、执行 bash 命令。
  5. 用法:
  6. python -m agent.tools.builtin.feishu.feishu_agent
  7. 环境变量:
  8. FEISHU_APP_ID / FEISHU_APP_SECRET: 飞书应用凭证
  9. QWEN_API_KEY: 通义千问 API Key
  10. QWEN_BASE_URL: 通义千问 API 地址(可选,默认阿里云)
  11. FEISHU_AGENT_MODEL: 模型名称(默认 qwen-plus)
  12. FEISHU_AGENT_SYSTEM_PROMPT: 自定义 system prompt(可选)
  13. """
  14. import os
  15. import sys
  16. import json
  17. import asyncio
  18. import logging
  19. import subprocess
  20. import threading
  21. import base64
  22. import zipfile
  23. from typing import Dict, List, Any, Optional
  24. from collections import defaultdict
  25. PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
  26. if PROJECT_ROOT not in sys.path:
  27. sys.path.insert(0, PROJECT_ROOT)
  28. from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain, ReceiveIdType
  29. from agent.tools.builtin.feishu.chat import (
  30. FEISHU_APP_ID,
  31. FEISHU_APP_SECRET,
  32. get_contact_by_id,
  33. load_chat_history,
  34. save_chat_history,
  35. )
  36. from agent.llm.qwen import qwen_llm_call
  37. logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s %(message)s')
  38. logger = logging.getLogger("FeishuAgent")
  39. # ===== 配置 =====
  40. MODEL = os.getenv("FEISHU_AGENT_MODEL", "qwen3.5-397b-a17b")
  41. MAX_HISTORY = 50
  42. MAX_TOOL_ROUNDS = 10 # 工具调用最大循环次数
  43. ALLOWED_CONTACTS = {"关涛"}
  44. DEFAULT_SYSTEM_PROMPT = """你是一个友好、有帮助的 AI 助手,正在通过飞书和用户对话。
  45. 你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
  46. 请用简洁清晰的中文回复。如果用户使用其他语言,请用对应语言回复。"""
  47. SYSTEM_PROMPT = os.getenv("FEISHU_AGENT_SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT)
  48. # ===== 工具定义 =====
  49. TOOLS = [
  50. {
  51. "type": "function",
  52. "function": {
  53. "name": "list_directory",
  54. "description": "列出指定目录下的文件和子目录",
  55. "parameters": {
  56. "type": "object",
  57. "properties": {
  58. "path": {"type": "string", "description": "目录路径,默认当前目录"}
  59. },
  60. "required": []
  61. }
  62. }
  63. },
  64. {
  65. "type": "function",
  66. "function": {
  67. "name": "read_file",
  68. "description": "读取指定文件的内容",
  69. "parameters": {
  70. "type": "object",
  71. "properties": {
  72. "path": {"type": "string", "description": "文件路径"},
  73. "max_lines": {"type": "integer", "description": "最多读取行数,默认200"}
  74. },
  75. "required": ["path"]
  76. }
  77. }
  78. },
  79. {
  80. "type": "function",
  81. "function": {
  82. "name": "run_bash",
  83. "description": "执行 bash/shell 命令并返回输出",
  84. "parameters": {
  85. "type": "object",
  86. "properties": {
  87. "command": {"type": "string", "description": "要执行的命令"}
  88. },
  89. "required": ["command"]
  90. }
  91. }
  92. },
  93. {
  94. "type": "function",
  95. "function": {
  96. "name": "send_image",
  97. "description": "发送图片到飞书",
  98. "parameters": {
  99. "type": "object",
  100. "properties": {
  101. "path": {"type": "string", "description": "本地图片文件路径"}
  102. },
  103. "required": ["path"]
  104. }
  105. }
  106. },
  107. {
  108. "type": "function",
  109. "function": {
  110. "name": "send_file",
  111. "description": "发送文件到飞书",
  112. "parameters": {
  113. "type": "object",
  114. "properties": {
  115. "path": {"type": "string", "description": "本地文件路径"}
  116. },
  117. "required": ["path"]
  118. }
  119. }
  120. },
  121. {
  122. "type": "function",
  123. "function": {
  124. "name": "zip_and_send",
  125. "description": "将本地目录打包成 zip 并发送到飞书",
  126. "parameters": {
  127. "type": "object",
  128. "properties": {
  129. "path": {"type": "string", "description": "要打包的目录路径"}
  130. },
  131. "required": ["path"]
  132. }
  133. }
  134. },
  135. {
  136. "type": "function",
  137. "function": {
  138. "name": "run_background",
  139. "description": "在后台启动一个长期运行的命令(如服务器、agent),返回进程 ID。不会等待命令结束。",
  140. "parameters": {
  141. "type": "object",
  142. "properties": {
  143. "command": {"type": "string", "description": "要执行的命令"},
  144. "name": {"type": "string", "description": "给这个后台进程起个名字,方便后续管理"}
  145. },
  146. "required": ["command"]
  147. }
  148. }
  149. },
  150. {
  151. "type": "function",
  152. "function": {
  153. "name": "stop_process",
  154. "description": "停止一个后台运行的进程",
  155. "parameters": {
  156. "type": "object",
  157. "properties": {
  158. "pid": {"type": "integer", "description": "进程 ID"}
  159. },
  160. "required": ["pid"]
  161. }
  162. }
  163. },
  164. {
  165. "type": "function",
  166. "function": {
  167. "name": "list_processes",
  168. "description": "列出所有通过 run_background 启动的后台进程及其状态",
  169. "parameters": {
  170. "type": "object",
  171. "properties": {},
  172. "required": []
  173. }
  174. }
  175. },
  176. {
  177. "type": "function",
  178. "function": {
  179. "name": "get_process_output",
  180. "description": "获取后台进程的最新输出日志",
  181. "parameters": {
  182. "type": "object",
  183. "properties": {
  184. "pid": {"type": "integer", "description": "进程 ID"},
  185. "tail": {"type": "integer", "description": "获取最后 N 行,默认50"}
  186. },
  187. "required": ["pid"]
  188. }
  189. }
  190. },
  191. {
  192. "type": "function",
  193. "function": {
  194. "name": "send_input",
  195. "description": "向后台进程发送输入(如交互式命令)",
  196. "parameters": {
  197. "type": "object",
  198. "properties": {
  199. "pid": {"type": "integer", "description": "进程 ID"},
  200. "text": {"type": "string", "description": "要发送的文本"}
  201. },
  202. "required": ["pid", "text"]
  203. }
  204. }
  205. }
  206. ]
  207. # ===== 工具执行 =====
  208. # 全局变量:保存当前会话的 chat_id,供工具使用
  209. _current_chat_id = None
  210. # 后台进程管理
  211. _background_processes: Dict[int, Dict[str, Any]] = {} # pid → {proc, name, command, output_lines, started_at}
  212. def execute_tool(name: str, arguments: Dict[str, Any]) -> str:
  213. """执行工具调用,返回结果字符串"""
  214. try:
  215. if name == "list_directory":
  216. return _tool_list_directory(arguments.get("path", "."))
  217. elif name == "read_file":
  218. return _tool_read_file(arguments["path"], arguments.get("max_lines", 200))
  219. elif name == "run_bash":
  220. return _tool_run_bash(arguments["command"])
  221. elif name == "send_image":
  222. return _tool_send_image(arguments["path"])
  223. elif name == "send_file":
  224. return _tool_send_file(arguments["path"])
  225. elif name == "zip_and_send":
  226. return _tool_zip_and_send(arguments["path"])
  227. elif name == "run_background":
  228. return _tool_run_background(arguments["command"], arguments.get("name", ""))
  229. elif name == "stop_process":
  230. return _tool_stop_process(arguments["pid"])
  231. elif name == "list_processes":
  232. return _tool_list_processes()
  233. elif name == "get_process_output":
  234. return _tool_get_process_output(arguments["pid"], arguments.get("tail", 50))
  235. elif name == "send_input":
  236. return _tool_send_input(arguments["pid"], arguments["text"])
  237. else:
  238. return f"未知工具: {name}"
  239. except Exception as e:
  240. return f"工具执行出错: {type(e).__name__}: {e}"
  241. def _tool_list_directory(path: str) -> str:
  242. path = os.path.abspath(path)
  243. if not os.path.isdir(path):
  244. return f"目录不存在: {path}"
  245. entries = []
  246. for name in sorted(os.listdir(path)):
  247. full = os.path.join(path, name)
  248. suffix = "/" if os.path.isdir(full) else ""
  249. entries.append(f" {name}{suffix}")
  250. header = f"目录: {path} ({len(entries)} 项)\n"
  251. return header + "\n".join(entries) if entries else header + " (空目录)"
  252. def _tool_read_file(path: str, max_lines: int = 200) -> str:
  253. path = os.path.abspath(path)
  254. if not os.path.isfile(path):
  255. return f"文件不存在: {path}"
  256. try:
  257. with open(path, "r", encoding="utf-8", errors="replace") as f:
  258. lines = []
  259. for i, line in enumerate(f):
  260. if i >= max_lines:
  261. lines.append(f"\n... (截断,共读取 {max_lines} 行)")
  262. break
  263. lines.append(line.rstrip())
  264. return "\n".join(lines)
  265. except Exception as e:
  266. return f"读取失败: {e}"
  267. def _tool_run_bash(command: str) -> str:
  268. # 将项目 .venv 的 Scripts/bin 目录加到 PATH 最前面,确保 python 指向虚拟环境
  269. env = os.environ.copy()
  270. venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts") # Windows
  271. if not os.path.isdir(venv_scripts):
  272. venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin") # Linux/Mac
  273. env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
  274. env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
  275. env["PYTHONIOENCODING"] = "utf-8"
  276. try:
  277. proc = subprocess.Popen(
  278. command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  279. text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
  280. )
  281. try:
  282. stdout, stderr = proc.communicate(timeout=30)
  283. except subprocess.TimeoutExpired:
  284. # 超时:杀掉整个进程树(Windows 需要 taskkill)
  285. try:
  286. if sys.platform == "win32":
  287. subprocess.run(
  288. f"taskkill /F /T /PID {proc.pid}",
  289. shell=True, capture_output=True, timeout=5,
  290. )
  291. else:
  292. import signal
  293. os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
  294. except Exception:
  295. proc.kill()
  296. proc.wait(timeout=5)
  297. return "命令执行超时(30秒),已终止进程"
  298. output = ""
  299. if stdout:
  300. output += stdout
  301. if stderr:
  302. output += ("\n--- stderr ---\n" + stderr) if output else stderr
  303. if proc.returncode != 0:
  304. output += f"\n(exit code: {proc.returncode})"
  305. return output.strip() or "(无输出)"
  306. except Exception as e:
  307. return f"执行失败: {e}"
  308. def _tool_send_image(path: str) -> str:
  309. """发送图片到飞书"""
  310. from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
  311. from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain, ReceiveIdType
  312. if not _current_chat_id:
  313. return "错误:无法获取当前会话 ID"
  314. path = os.path.abspath(path)
  315. if not os.path.isfile(path):
  316. return f"文件不存在: {path}"
  317. try:
  318. client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
  319. client.send_image(to=_current_chat_id, image=path)
  320. return f"图片已发送: {os.path.basename(path)}"
  321. except Exception as e:
  322. return f"发送图片失败: {e}"
  323. def _tool_send_file(path: str) -> str:
  324. """发送文件到飞书"""
  325. from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
  326. from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
  327. if not _current_chat_id:
  328. return "错误:无法获取当前会话 ID"
  329. path = os.path.abspath(path)
  330. if not os.path.isfile(path):
  331. return f"文件不存在: {path}"
  332. try:
  333. client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
  334. client.send_file(to=_current_chat_id, file=path, file_name=os.path.basename(path))
  335. return f"文件已发送: {os.path.basename(path)}"
  336. except Exception as e:
  337. return f"发送文件失败: {e}"
  338. def _tool_zip_and_send(path: str) -> str:
  339. """打包目录并发送到飞书"""
  340. from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
  341. from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
  342. if not _current_chat_id:
  343. return "错误:无法获取当前会话 ID"
  344. path = os.path.abspath(path)
  345. if not os.path.isdir(path):
  346. return f"目录不存在: {path}"
  347. try:
  348. # 创建临时 zip 文件
  349. zip_name = os.path.basename(path.rstrip(os.sep)) + ".zip"
  350. zip_path = os.path.join(PROJECT_ROOT, zip_name)
  351. with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
  352. for root, dirs, files in os.walk(path):
  353. for file in files:
  354. file_path = os.path.join(root, file)
  355. arcname = os.path.relpath(file_path, path)
  356. zipf.write(file_path, arcname)
  357. client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
  358. client.send_file(to=_current_chat_id, file=zip_path, file_name=zip_name)
  359. # 删除临时文件
  360. os.remove(zip_path)
  361. return f"目录已打包并发送: {zip_name}"
  362. except Exception as e:
  363. return f"打包发送失败: {e}"
  364. # ===== 后台进程管理 =====
  365. def _output_reader(proc: subprocess.Popen, pid: int):
  366. """后台线程:持续读取进程输出并存入缓冲区"""
  367. try:
  368. for line in iter(proc.stdout.readline, ''):
  369. if pid not in _background_processes:
  370. break
  371. _background_processes[pid]["output_lines"].append(line.rstrip())
  372. # 只保留最近 500 行
  373. if len(_background_processes[pid]["output_lines"]) > 500:
  374. _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
  375. except Exception:
  376. pass
  377. # stderr 也读
  378. try:
  379. for line in iter(proc.stderr.readline, ''):
  380. if pid not in _background_processes:
  381. break
  382. _background_processes[pid]["output_lines"].append(f"[stderr] {line.rstrip()}")
  383. if len(_background_processes[pid]["output_lines"]) > 500:
  384. _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
  385. except Exception:
  386. pass
  387. def _tool_run_background(command: str, name: str = "") -> str:
  388. """后台启动命令"""
  389. env = os.environ.copy()
  390. venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")
  391. if not os.path.isdir(venv_scripts):
  392. venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin")
  393. env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
  394. env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
  395. env["PYTHONIOENCODING"] = "utf-8"
  396. env["PYTHONUNBUFFERED"] = "1" # 强制 Python 无缓冲输出
  397. env["PYTHONIOENCODING"] = "utf-8" # 强制 Python IO 编码为 utf-8
  398. try:
  399. proc = subprocess.Popen(
  400. command, shell=True,
  401. stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  402. text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
  403. bufsize=1, # 行缓冲
  404. )
  405. from datetime import datetime
  406. _background_processes[proc.pid] = {
  407. "proc": proc,
  408. "name": name or command[:40],
  409. "command": command,
  410. "output_lines": [],
  411. "started_at": datetime.now().strftime("%H:%M:%S"),
  412. }
  413. # 启动输出读取线程
  414. t = threading.Thread(target=_output_reader, args=(proc, proc.pid), daemon=True)
  415. t.start()
  416. return f"后台进程已启动: PID={proc.pid}, name={name or command[:40]}"
  417. except Exception as e:
  418. return f"启动失败: {e}"
  419. def _tool_stop_process(pid: int) -> str:
  420. """停止后台进程"""
  421. info = _background_processes.get(pid)
  422. if not info:
  423. return f"未找到 PID={pid} 的后台进程"
  424. proc = info["proc"]
  425. name = info["name"]
  426. try:
  427. if sys.platform == "win32":
  428. subprocess.run(f"taskkill /F /T /PID {pid}", shell=True, capture_output=True, timeout=5)
  429. else:
  430. import signal
  431. os.killpg(os.getpgid(pid), signal.SIGKILL)
  432. except Exception:
  433. proc.kill()
  434. try:
  435. proc.wait(timeout=5)
  436. except Exception:
  437. pass
  438. _background_processes.pop(pid, None)
  439. return f"已停止进程: PID={pid} ({name})"
  440. def _tool_list_processes() -> str:
  441. """列出所有后台进程"""
  442. if not _background_processes:
  443. return "当前没有后台进程"
  444. lines = []
  445. for pid, info in _background_processes.items():
  446. proc = info["proc"]
  447. status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
  448. lines.append(f" PID={pid} | {status} | {info['name']} | 启动于 {info['started_at']}")
  449. return f"后台进程 ({len(lines)} 个):\n" + "\n".join(lines)
  450. def _tool_get_process_output(pid: int, tail: int = 50) -> str:
  451. """获取后台进程的最新输出"""
  452. info = _background_processes.get(pid)
  453. if not info:
  454. return f"未找到 PID={pid} 的后台进程"
  455. output_lines = info["output_lines"]
  456. proc = info["proc"]
  457. status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
  458. if not output_lines:
  459. return f"PID={pid} ({info['name']}) [{status}]: 暂无输出"
  460. recent = output_lines[-tail:]
  461. header = f"PID={pid} ({info['name']}) [{status}] 最近 {len(recent)} 行:\n"
  462. return header + "\n".join(recent)
  463. def _tool_send_input(pid: int, text: str) -> str:
  464. """向后台进程发送输入(通过控制文件)"""
  465. info = _background_processes.get(pid)
  466. if not info:
  467. return f"未找到 PID={pid} 的后台进程"
  468. proc = info["proc"]
  469. if proc.poll() is not None:
  470. return f"进程已退出,无法发送输入"
  471. try:
  472. # 使用控制文件方式(更可靠)
  473. control_file = os.path.join(PROJECT_ROOT, ".agent_control")
  474. with open(control_file, "w", encoding="utf-8") as f:
  475. f.write(text.strip())
  476. return f"已向 PID={pid} 发送控制指令: {text.strip()}"
  477. except Exception as e:
  478. return f"发送输入失败: {e}"
  479. return f"发送输入失败: {e}"
  480. class FeishuAgent:
  481. """飞书实时对话 Agent"""
  482. def __init__(self):
  483. self.client = FeishuClient(
  484. app_id=FEISHU_APP_ID,
  485. app_secret=FEISHU_APP_SECRET,
  486. domain=FeishuDomain.FEISHU,
  487. )
  488. self.conversations: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
  489. self._history_loaded: set = set()
  490. self.loop = asyncio.new_event_loop()
  491. self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
  492. self._loop_thread.start()
  493. def _run_loop(self):
  494. asyncio.set_event_loop(self.loop)
  495. self.loop.run_forever()
  496. def _get_conversation_key(self, event: FeishuMessageEvent) -> str:
  497. if event.chat_type and event.chat_type.value == "p2p":
  498. return event.sender_open_id
  499. return event.chat_id
  500. def _build_messages(self, conv_key: str) -> List[Dict[str, Any]]:
  501. messages = [{"role": "system", "content": SYSTEM_PROMPT}]
  502. conv = self.conversations[conv_key]
  503. # 验证并清理 tool_calls:确保每个 assistant+tool_calls 后面都有对应的 tool response
  504. clean = []
  505. i = 0
  506. while i < len(conv):
  507. msg = conv[i]
  508. if msg.get("role") == "assistant" and msg.get("tool_calls"):
  509. # 收集这个 assistant 消息的所有 tool_call_id
  510. expected_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}
  511. # 检查后续消息是否有对应的 tool response
  512. j = i + 1
  513. found_ids = set()
  514. while j < len(conv) and conv[j].get("role") == "tool":
  515. found_ids.add(conv[j].get("tool_call_id"))
  516. j += 1
  517. # 只有所有 tool_call_id 都有对应 response 才保留
  518. if expected_ids == found_ids:
  519. clean.append(msg)
  520. # 添加对应的 tool response
  521. for k in range(i + 1, j):
  522. clean.append(conv[k])
  523. i = j
  524. else:
  525. # 不完整,跳过这组
  526. i = j
  527. elif msg.get("role") == "tool":
  528. # 孤立的 tool response,跳过
  529. i += 1
  530. else:
  531. clean.append(msg)
  532. i += 1
  533. messages.extend(clean)
  534. return messages
  535. def _append_message(self, conv_key: str, role: str, content: Any, **extra):
  536. msg = {"role": role, "content": content, **extra}
  537. self.conversations[conv_key].append(msg)
  538. if len(self.conversations[conv_key]) > MAX_HISTORY:
  539. self.conversations[conv_key] = self.conversations[conv_key][-MAX_HISTORY:]
  540. def _extract_content(self, event: FeishuMessageEvent) -> Optional[Any]:
  541. """从飞书消息中提取内容(文本/图片/文件),返回 OpenAI 多模态格式"""
  542. if event.content_type == "text":
  543. try:
  544. parsed = json.loads(event.content)
  545. return parsed.get("text", event.content)
  546. except (json.JSONDecodeError, TypeError):
  547. return event.content
  548. elif event.content_type == "image":
  549. # 下载图片并转成 base64
  550. try:
  551. content_dict = json.loads(event.content)
  552. image_key = content_dict.get("image_key")
  553. if image_key and event.message_id:
  554. img_bytes = self.client.download_message_resource(
  555. message_id=event.message_id,
  556. file_key=image_key,
  557. resource_type="image"
  558. )
  559. b64_str = base64.b64encode(img_bytes).decode('utf-8')
  560. # 返回 OpenAI 多模态格式
  561. return [
  562. {"type": "text", "text": "[用户发送了一张图片]"},
  563. {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_str}"}}
  564. ]
  565. except Exception as e:
  566. logger.error(f"下载图片失败: {e}")
  567. return "[图片下载失败]"
  568. elif event.content_type == "file":
  569. # 下载文件到本地
  570. try:
  571. content_dict = json.loads(event.content)
  572. file_key = content_dict.get("file_key")
  573. file_name = content_dict.get("file_name", "unknown_file")
  574. if file_key and event.message_id:
  575. file_bytes = self.client.download_message_resource(
  576. message_id=event.message_id,
  577. file_key=file_key,
  578. resource_type="file"
  579. )
  580. # 保存到项目根目录的 downloads 文件夹
  581. download_dir = os.path.join(PROJECT_ROOT, "downloads")
  582. os.makedirs(download_dir, exist_ok=True)
  583. save_path = os.path.join(download_dir, file_name)
  584. with open(save_path, "wb") as f:
  585. f.write(file_bytes)
  586. return f"[用户发送了文件: {file_name},已保存到 {save_path}]"
  587. except Exception as e:
  588. logger.error(f"下载文件失败: {e}")
  589. return "[文件下载失败]"
  590. return None
  591. def _load_history_from_disk(self, contact_name: str, conv_key: str):
  592. if conv_key in self._history_loaded:
  593. return
  594. self._history_loaded.add(conv_key)
  595. history = load_chat_history(contact_name)
  596. for msg in history[-MAX_HISTORY:]:
  597. role = msg.get("role", "user")
  598. content = msg.get("content", "")
  599. if isinstance(content, list):
  600. text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
  601. content = "\n".join(text_parts)
  602. if isinstance(content, str) and content.strip():
  603. self.conversations[conv_key].append({"role": role, "content": content})
  604. def _save_to_disk(self, contact_name: str, conv_key: str):
  605. # 保存完整对话历史,包括工具调用和结果
  606. saveable = []
  607. for m in self.conversations[conv_key]:
  608. role = m.get("role")
  609. content = m.get("content")
  610. # 保存所有消息类型
  611. if role in ("user", "assistant", "tool"):
  612. msg = {"role": role}
  613. # 处理 content
  614. if isinstance(content, str):
  615. msg["content"] = content
  616. elif isinstance(content, list):
  617. # 多模态消息:提取文本部分保存
  618. text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
  619. if text_parts:
  620. msg["content"] = "\n".join(text_parts)
  621. else:
  622. msg["content"] = "[多模态内容]"
  623. else:
  624. msg["content"] = str(content) if content else ""
  625. # 保存 tool_calls(如果有)
  626. if "tool_calls" in m:
  627. msg["tool_calls"] = m["tool_calls"]
  628. # 保存 tool_call_id(如果有)
  629. if "tool_call_id" in m:
  630. msg["tool_call_id"] = m["tool_call_id"]
  631. saveable.append(msg)
  632. save_chat_history(contact_name, saveable)
  633. def handle_message(self, event: FeishuMessageEvent):
  634. global _current_chat_id
  635. contact = get_contact_by_id(event.sender_open_id) or get_contact_by_id(event.chat_id)
  636. if not contact:
  637. logger.debug(f"忽略非白名单消息: {event.sender_open_id}")
  638. return
  639. sender_name = contact.get("name", "未知")
  640. if sender_name not in ALLOWED_CONTACTS:
  641. logger.debug(f"忽略非白名单联系人: {sender_name}")
  642. return
  643. user_content = self._extract_content(event)
  644. if not user_content:
  645. logger.info(f"[{sender_name}] 发送了不支持的消息类型,跳过")
  646. return
  647. # 记录消息类型
  648. if isinstance(user_content, str):
  649. logger.info(f"收到 [{sender_name}]: {user_content[:80]}")
  650. else:
  651. logger.info(f"收到 [{sender_name}]: [多模态消息]")
  652. conv_key = self._get_conversation_key(event)
  653. _current_chat_id = event.chat_id # 设置全局变量供工具使用
  654. self._load_history_from_disk(sender_name, conv_key)
  655. self._append_message(conv_key, "user", user_content)
  656. self._save_to_disk(sender_name, conv_key)
  657. future = asyncio.run_coroutine_threadsafe(
  658. self._generate_and_reply(conv_key, event, sender_name),
  659. self.loop,
  660. )
  661. future.add_done_callback(self._on_reply_done)
  662. async def _generate_and_reply(self, conv_key: str, event: FeishuMessageEvent, sender_name: str):
  663. """调用 Qwen LLM,支持多轮工具调用循环"""
  664. try:
  665. last_tool_calls = [] # 记录最近的工具调用,用于检测循环
  666. for round_i in range(MAX_TOOL_ROUNDS):
  667. messages = self._build_messages(conv_key)
  668. result = await qwen_llm_call(
  669. messages=messages,
  670. model=MODEL,
  671. tools=TOOLS,
  672. temperature=0.7,
  673. max_tokens=4096,
  674. )
  675. tool_calls = result.get("tool_calls")
  676. if not tool_calls:
  677. # 没有工具调用,直接回复
  678. reply_text = result.get("content", "").strip()
  679. if not reply_text:
  680. reply_text = "(抱歉,我暂时无法生成回复)"
  681. self._append_message(conv_key, "assistant", reply_text)
  682. self._save_to_disk(sender_name, conv_key)
  683. self.client.send_message(
  684. to=event.chat_id,
  685. text=reply_text,
  686. receive_id_type=ReceiveIdType.CHAT_ID,
  687. )
  688. tokens_in = result.get("prompt_tokens", 0)
  689. tokens_out = result.get("completion_tokens", 0)
  690. cost = result.get("cost", 0)
  691. logger.info(
  692. f"回复 [{sender_name}]: {reply_text[:80]}... "
  693. f"(tokens: {tokens_in}+{tokens_out}, cost: ¥{cost:.4f})"
  694. )
  695. return
  696. # 检测循环:如果连续 2 次调用相同工具(仅比较工具名),强制退出
  697. current_call_names = [tc.get("function", {}).get("name") for tc in tool_calls]
  698. last_tool_calls.append(current_call_names)
  699. if len(last_tool_calls) >= 2 and last_tool_calls[-1] == last_tool_calls[-2]:
  700. logger.warning(f"[{sender_name}] 检测到工具调用循环: {current_call_names}")
  701. self._append_message(conv_key, "assistant", "(检测到重复调用,已停止)")
  702. self._save_to_disk(sender_name, conv_key)
  703. self.client.send_message(
  704. to=event.chat_id,
  705. text="⚠️ 检测到重复调用相同工具,已停止。请换一个方式或直接告诉我结果。",
  706. receive_id_type=ReceiveIdType.CHAT_ID,
  707. )
  708. return
  709. # 有工具调用:追加 assistant 消息(含 tool_calls),然后执行
  710. assistant_msg = {"role": "assistant", "content": result.get("content", "") or ""}
  711. assistant_msg["tool_calls"] = tool_calls
  712. self.conversations[conv_key].append(assistant_msg)
  713. for tc in tool_calls:
  714. func = tc.get("function", {})
  715. tool_name = func.get("name", "")
  716. try:
  717. tool_args = json.loads(func.get("arguments", "{}"))
  718. except json.JSONDecodeError:
  719. tool_args = {}
  720. logger.info(f"[{sender_name}] 调用工具: {tool_name}({tool_args})")
  721. # 在线程池中执行工具,避免阻塞 event loop
  722. loop = asyncio.get_event_loop()
  723. tool_result = await loop.run_in_executor(
  724. None, execute_tool, tool_name, tool_args
  725. )
  726. # 截断过长的工具结果
  727. if len(tool_result) > 4000:
  728. tool_result = tool_result[:4000] + "\n... (结果已截断)"
  729. self.conversations[conv_key].append({
  730. "role": "tool",
  731. "tool_call_id": tc.get("id", ""),
  732. "content": tool_result,
  733. })
  734. # 超过最大轮次
  735. self._append_message(conv_key, "assistant", "(工具调用轮次过多,已停止)")
  736. self._save_to_disk(sender_name, conv_key)
  737. self.client.send_message(
  738. to=event.chat_id,
  739. text="⚠️ 工具调用轮次过多,已停止",
  740. receive_id_type=ReceiveIdType.CHAT_ID,
  741. )
  742. except Exception as e:
  743. logger.error(f"生成回复失败: {e}", exc_info=True)
  744. try:
  745. self.client.send_message(
  746. to=event.chat_id,
  747. text=f"⚠️ 生成回复时出错: {type(e).__name__}",
  748. receive_id_type=ReceiveIdType.CHAT_ID,
  749. )
  750. except Exception:
  751. logger.error("发送错误消息也失败了", exc_info=True)
  752. def _on_reply_done(self, future):
  753. exc = future.exception()
  754. if exc:
  755. logger.error(f"回复任务异常: {exc}", exc_info=exc)
  756. def start(self):
  757. logger.info(f"启动飞书对话 Agent (model={MODEL})")
  758. logger.info("等待飞书消息... 按 Ctrl+C 退出")
  759. try:
  760. self.client.start_websocket(
  761. on_message=self.handle_message,
  762. blocking=True,
  763. )
  764. except KeyboardInterrupt:
  765. logger.info("Agent 已停止")
  766. finally:
  767. self.loop.call_soon_threadsafe(self.loop.stop)
  768. if __name__ == "__main__":
  769. agent = FeishuAgent()
  770. agent.start()