foxclaw 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/CHANGELOG.md +57 -0
  6. package/python/CHANGELOG_CN.md +57 -0
  7. package/python/README.md +11 -2
  8. package/python/README_CN.md +32 -20
  9. package/python/bench/bm25_audit.py +1 -14
  10. package/python/bench/bm25_regression.py +0 -1
  11. package/python/bench/bm25_stress.py +0 -4
  12. package/python/bench/cd_audit.py +0 -23
  13. package/python/bench/chaos.py +6 -8
  14. package/python/bench/e2e_real_models.py +0 -1
  15. package/python/bench/llm_retry_test.py +2 -9
  16. package/python/bench/load_test.py +5 -8
  17. package/python/bench/vector_ivf_upgrade.py +1 -7
  18. package/python/docs/RELEASE.md +4 -3
  19. package/python/docs/RUN_CN.md +2 -2
  20. package/python/evals/governance/cases.py +0 -37
  21. package/python/evals/governance/run_evals.py +5 -37
  22. package/python/examples/governance_middleware_demo.py +1 -6
  23. package/python/examples/langgraph_governance_demo.py +7 -11
  24. package/python/foxclaw/__init__.py +34 -152
  25. package/python/foxclaw/agents/base.py +65 -136
  26. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  27. package/python/foxclaw/agents/impl/finance.py +4 -21
  28. package/python/foxclaw/agents/impl/office.py +7 -24
  29. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  30. package/python/foxclaw/agents/mixins/_context.py +0 -23
  31. package/python/foxclaw/agents/mixins/augment.py +10 -16
  32. package/python/foxclaw/agents/mixins/llm.py +7 -6
  33. package/python/foxclaw/agents/mixins/memory.py +7 -1
  34. package/python/foxclaw/agents/mixins/preference.py +58 -8
  35. package/python/foxclaw/agents/mixins/prompt.py +0 -6
  36. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  37. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  38. package/python/foxclaw/api/__init__.py +18 -77
  39. package/python/foxclaw/api/_state.py +43 -0
  40. package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
  41. package/python/foxclaw/api/middleware.py +5 -28
  42. package/python/foxclaw/api/routes/ahs.py +60 -73
  43. package/python/foxclaw/api/routes/attachments.py +5 -6
  44. package/python/foxclaw/api/routes/browser.py +7 -11
  45. package/python/foxclaw/api/routes/domain.py +18 -26
  46. package/python/foxclaw/api/routes/knowledge.py +3 -21
  47. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  48. package/python/foxclaw/api/routes/memory.py +28 -12
  49. package/python/foxclaw/api/routes/orchestration.py +3 -5
  50. package/python/foxclaw/api/routes/recovery.py +4 -3
  51. package/python/foxclaw/api/routes/review.py +2 -11
  52. package/python/foxclaw/api/routes/search.py +3 -7
  53. package/python/foxclaw/api/routes/system.py +33 -49
  54. package/python/foxclaw/api/routes/task.py +31 -49
  55. package/python/foxclaw/api/routes/ws.py +2 -6
  56. package/python/foxclaw/api/spine.py +1 -4
  57. package/python/foxclaw/browser/action/action_executor.py +1 -5
  58. package/python/foxclaw/browser/action/action_types.py +0 -1
  59. package/python/foxclaw/browser/action/url_policy.py +1 -5
  60. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  61. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  62. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  63. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  64. package/python/foxclaw/browser/service.py +7 -11
  65. package/python/foxclaw/capability/embedding_service.py +4 -6
  66. package/python/foxclaw/channels/__init__.py +9 -11
  67. package/python/foxclaw/channels/discord.py +4 -13
  68. package/python/foxclaw/channels/dispatcher.py +2 -27
  69. package/python/foxclaw/channels/feishu.py +10 -20
  70. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  71. package/python/foxclaw/channels/signing.py +9 -9
  72. package/python/foxclaw/channels/telegram.py +3 -4
  73. package/python/foxclaw/channels/weixin.py +7 -17
  74. package/python/foxclaw/cli.py +42 -13
  75. package/python/foxclaw/cognitive/reasoner.py +9 -10
  76. package/python/foxclaw/cognitive/state.py +18 -24
  77. package/python/foxclaw/core/audit_chain.py +1 -3
  78. package/python/foxclaw/core/audit_sink.py +3 -3
  79. package/python/foxclaw/core/compaction.py +8 -26
  80. package/python/foxclaw/core/config.py +2 -2
  81. package/python/foxclaw/core/confirm_nonce.py +6 -12
  82. package/python/foxclaw/core/envfile.py +2 -2
  83. package/python/foxclaw/core/failure_policy.py +17 -26
  84. package/python/foxclaw/core/gateway.py +6 -10
  85. package/python/foxclaw/core/governance_audit.py +3 -7
  86. package/python/foxclaw/core/idempotency.py +15 -22
  87. package/python/foxclaw/core/injection_guard.py +10 -29
  88. package/python/foxclaw/core/memory_adapter.py +30 -57
  89. package/python/foxclaw/core/metrics.py +4 -5
  90. package/python/foxclaw/core/model_router.py +36 -148
  91. package/python/foxclaw/core/paths.py +1 -1
  92. package/python/foxclaw/core/recovery.py +21 -34
  93. package/python/foxclaw/core/scheduler.py +23 -26
  94. package/python/foxclaw/core/secrets.py +0 -3
  95. package/python/foxclaw/core/sessions.py +4 -6
  96. package/python/foxclaw/core/settings.py +16 -32
  97. package/python/foxclaw/core/shared_write.py +11 -23
  98. package/python/foxclaw/core/steering_queue.py +12 -11
  99. package/python/foxclaw/core/system_bus.py +1 -1
  100. package/python/foxclaw/core/thought_chain.py +30 -32
  101. package/python/foxclaw/core/token_meter.py +9 -13
  102. package/python/foxclaw/core/tools.py +4 -12
  103. package/python/foxclaw/core/tracing.py +25 -40
  104. package/python/foxclaw/core/truth_pipeline.py +5 -9
  105. package/python/foxclaw/core/update_check.py +149 -0
  106. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  107. package/python/foxclaw/core/verify.py +6 -14
  108. package/python/foxclaw/core/web_search.py +4 -28
  109. package/python/foxclaw/finance/evidence.py +14 -18
  110. package/python/foxclaw/finance/fx.py +1 -3
  111. package/python/foxclaw/finance/graph.py +9 -18
  112. package/python/foxclaw/finance/models.py +20 -24
  113. package/python/foxclaw/finance/nlparse.py +12 -72
  114. package/python/foxclaw/finance/policy.py +8 -36
  115. package/python/foxclaw/finance/reasoner.py +3 -11
  116. package/python/foxclaw/finance/reasoning.py +13 -34
  117. package/python/foxclaw/finance/reconcile.py +5 -10
  118. package/python/foxclaw/finance/runtime.py +19 -46
  119. package/python/foxclaw/governance/middleware.py +13 -21
  120. package/python/foxclaw/harness/_base.py +2 -8
  121. package/python/foxclaw/harness/_policy.py +19 -38
  122. package/python/foxclaw/harness/drift.py +1 -13
  123. package/python/foxclaw/harness/event_bus.py +2 -5
  124. package/python/foxclaw/harness/harness.py +9 -97
  125. package/python/foxclaw/harness/loop_detection.py +11 -18
  126. package/python/foxclaw/itinerary/amap_client.py +2 -5
  127. package/python/foxclaw/itinerary/models.py +7 -7
  128. package/python/foxclaw/itinerary/reasoner.py +1 -6
  129. package/python/foxclaw/itinerary/reasoning.py +15 -21
  130. package/python/foxclaw/itinerary/runtime.py +8 -12
  131. package/python/foxclaw/kernel/capabilities.py +5 -6
  132. package/python/foxclaw/kernel/policy_gate.py +4 -10
  133. package/python/foxclaw/kernel/process_manager.py +17 -36
  134. package/python/foxclaw/kernel/scheduler.py +3 -9
  135. package/python/foxclaw/kernel/sentinel.py +13 -23
  136. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  137. package/python/foxclaw/kernel/spine.py +9 -9
  138. package/python/foxclaw/knowledge/__init__.py +4 -20
  139. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  140. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  141. package/python/foxclaw/learning/models.py +13 -13
  142. package/python/foxclaw/learning/reasoner.py +1 -4
  143. package/python/foxclaw/learning/runtime.py +19 -31
  144. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  145. package/python/foxclaw/mcp_server.py +2 -3
  146. package/python/foxclaw/orchestration/__init__.py +16 -33
  147. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  148. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  149. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  150. package/python/foxclaw/planning/llm_planner.py +3 -3
  151. package/python/foxclaw/planning/planner.py +6 -11
  152. package/python/foxclaw/search/agent_port.py +7 -11
  153. package/python/foxclaw/search/evidence.py +0 -4
  154. package/python/foxclaw/search/internal_search.py +3 -5
  155. package/python/foxclaw/search/models.py +11 -13
  156. package/python/foxclaw/search/planner.py +3 -3
  157. package/python/foxclaw/search/retriever.py +1 -1
  158. package/python/foxclaw/search/runtime.py +1 -8
  159. package/python/foxclaw/skills/__init__.py +46 -100
  160. package/python/foxclaw/skills/archive.py +111 -0
  161. package/python/foxclaw/skills/fetch.py +226 -0
  162. package/python/foxclaw/skills/hooks_spec.py +21 -25
  163. package/python/foxclaw/skills/install_cmd.py +216 -0
  164. package/python/foxclaw/skills/lifecycle.py +16 -42
  165. package/python/foxclaw/skills/markdown_skill.py +10 -9
  166. package/python/foxclaw/skills/mcp_client.py +5 -11
  167. package/python/foxclaw/skills/mcp_store.py +0 -3
  168. package/python/foxclaw/skills/permission.py +13 -48
  169. package/python/foxclaw/skills/pipeline.py +21 -53
  170. package/python/foxclaw/skills/policy.py +4 -10
  171. package/python/foxclaw/skills/review.py +3 -3
  172. package/python/frontend/deck/auth.js +0 -7
  173. package/python/frontend/deck/chat_model_picker.js +0 -54
  174. package/python/frontend/deck/domain.js +0 -20
  175. package/python/frontend/deck/main.js +0 -144
  176. package/python/frontend/deck/markdown.js +0 -4
  177. package/python/frontend/deck/settings.js +1 -18
  178. package/python/frontend/deck/state.js +0 -1
  179. package/python/frontend/deck/util.js +0 -2
  180. package/python/frontend/deck/visuals.js +0 -22
  181. package/python/frontend/planet/planet.js +0 -201
  182. package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
  183. package/python/frontend/v3/assets/index-D5nIsjms.css +1 -0
  184. package/python/frontend/v3/index.html +3 -3
  185. package/python/frontend/widget_runtime.js +0 -43
  186. package/python/frontend/widgets.js +0 -57
  187. package/python/frontend_src/index.html +1 -1
  188. package/python/frontend_src/package-lock.json +2 -2
  189. package/python/frontend_src/package.json +1 -1
  190. package/python/frontend_src/src/App.vue +3 -7
  191. package/python/frontend_src/src/api.js +0 -7
  192. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  193. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  194. package/python/frontend_src/src/components/Inspector.vue +0 -6
  195. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  196. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  197. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  198. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  199. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  200. package/python/frontend_src/src/components/Stage.vue +71 -25
  201. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  202. package/python/frontend_src/src/components/TopBar.vue +38 -10
  203. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  204. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  205. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  206. package/python/frontend_src/src/components/panels/MemoryPanel.vue +0 -1
  207. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  208. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  209. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  210. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  211. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  212. package/python/frontend_src/src/lib/fmt.js +0 -4
  213. package/python/frontend_src/src/lib/i18n.js +53 -0
  214. package/python/frontend_src/src/lib/locales/en.js +173 -0
  215. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  216. package/python/frontend_src/src/lib/md.js +0 -14
  217. package/python/frontend_src/src/main.js +2 -0
  218. package/python/frontend_src/src/stores/app.js +17 -32
  219. package/python/frontend_src/src/stores/chat.js +0 -34
  220. package/python/frontend_src/src/stores/model.js +0 -8
  221. package/python/frontend_src/src/stores/panels.js +0 -41
  222. package/python/frontend_src/src/stores/profile.js +31 -0
  223. package/python/frontend_src/src/stores/system.js +25 -0
  224. package/python/frontend_src/vite.config.js +0 -7
  225. package/python/pyproject.toml +6 -3
  226. package/python/requirements.txt +311 -0
  227. package/python/tests/conftest.py +0 -22
  228. package/python/tests/run_all.py +3 -66
  229. package/python/tests/test_agent_lifecycle.py +4 -38
  230. package/python/tests/test_ahs_p2.py +4 -25
  231. package/python/tests/test_anthropic_tools.py +1 -15
  232. package/python/tests/test_api.py +19 -9
  233. package/python/tests/test_api_auth.py +1 -5
  234. package/python/tests/test_attachments.py +9 -25
  235. package/python/tests/test_audit_chain.py +2 -7
  236. package/python/tests/test_audit_sink.py +0 -6
  237. package/python/tests/test_browser.py +2 -6
  238. package/python/tests/test_browser_request_guard.py +0 -4
  239. package/python/tests/test_capabilities.py +0 -6
  240. package/python/tests/test_channel_signing.py +2 -9
  241. package/python/tests/test_channels.py +1 -11
  242. package/python/tests/test_config_layering.py +0 -4
  243. package/python/tests/test_confirm_nonce.py +1 -12
  244. package/python/tests/test_context_compaction_wiring.py +1 -7
  245. package/python/tests/test_dispatch.py +0 -6
  246. package/python/tests/test_domain_evals.py +11 -80
  247. package/python/tests/test_domain_import.py +0 -6
  248. package/python/tests/test_domain_reasoners.py +0 -14
  249. package/python/tests/test_e2e.py +0 -17
  250. package/python/tests/test_emb_cache.py +7 -17
  251. package/python/tests/test_embedding_singleton.py +1 -3
  252. package/python/tests/test_evals_smoke.py +0 -24
  253. package/python/tests/test_event_spine.py +10 -18
  254. package/python/tests/test_evidence_semantic.py +1 -14
  255. package/python/tests/test_evidence_sse.py +3 -6
  256. package/python/tests/test_file_ingest.py +0 -11
  257. package/python/tests/test_finance.py +0 -9
  258. package/python/tests/test_finance_ledger.py +5 -12
  259. package/python/tests/test_finance_persist.py +8 -12
  260. package/python/tests/test_gateway.py +3 -22
  261. package/python/tests/test_governance.py +5 -52
  262. package/python/tests/test_governance_audit.py +4 -39
  263. package/python/tests/test_governance_middleware.py +1 -7
  264. package/python/tests/test_hkc.py +6 -24
  265. package/python/tests/test_hkc_index_build_manager.py +0 -33
  266. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  267. package/python/tests/test_hkc_search_event_sync.py +3 -54
  268. package/python/tests/test_import_path_guard.py +0 -6
  269. package/python/tests/test_injection_guard.py +18 -36
  270. package/python/tests/test_integration.py +5 -29
  271. package/python/tests/test_itinerary.py +0 -3
  272. package/python/tests/test_kee_semantic.py +0 -5
  273. package/python/tests/test_kernel.py +1 -11
  274. package/python/tests/test_learning.py +3 -12
  275. package/python/tests/test_llm_admin.py +4 -19
  276. package/python/tests/test_llm_planner.py +1 -23
  277. package/python/tests/test_markdown_skills.py +3 -14
  278. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  279. package/python/tests/test_mcp_connectors.py +3 -15
  280. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  281. package/python/tests/test_mcp_no_memory.py +3 -3
  282. package/python/tests/test_mcp_policy_gate.py +0 -1
  283. package/python/tests/test_mcp_server.py +6 -9
  284. package/python/tests/test_memory_recall.py +1 -4
  285. package/python/tests/test_metrics.py +0 -8
  286. package/python/tests/test_multi_chain.py +1 -13
  287. package/python/tests/test_multimodal.py +5 -18
  288. package/python/tests/test_negotiation.py +3 -13
  289. package/python/tests/test_orchestration.py +1 -11
  290. package/python/tests/test_orchestration_decoupled.py +0 -6
  291. package/python/tests/test_p3_p5_hardening.py +3 -17
  292. package/python/tests/test_parallel_dispatch.py +4 -12
  293. package/python/tests/test_pipeline.py +17 -58
  294. package/python/tests/test_plan_visible.py +4 -4
  295. package/python/tests/test_planner.py +0 -7
  296. package/python/tests/test_policy_gate.py +2 -3
  297. package/python/tests/test_profile.py +67 -0
  298. package/python/tests/test_readme_claims.py +163 -0
  299. package/python/tests/test_reasoner.py +2 -17
  300. package/python/tests/test_release_check.py +2 -13
  301. package/python/tests/test_resume_checkpoint.py +7 -15
  302. package/python/tests/test_routing.py +2 -12
  303. package/python/tests/test_scheduler.py +7 -10
  304. package/python/tests/test_search.py +1 -11
  305. package/python/tests/test_secrets.py +4 -10
  306. package/python/tests/test_semantic.py +1 -10
  307. package/python/tests/test_sessions.py +3 -4
  308. package/python/tests/test_settings.py +2 -18
  309. package/python/tests/test_shadow_learning.py +3 -43
  310. package/python/tests/test_shared_write.py +0 -7
  311. package/python/tests/test_skill_taint_gate.py +1 -10
  312. package/python/tests/test_skills.py +2 -18
  313. package/python/tests/test_skills_fetch.py +282 -0
  314. package/python/tests/test_stats_and_events.py +2 -5
  315. package/python/tests/test_streaming.py +2 -7
  316. package/python/tests/test_supervisor_pipeline.py +4 -5
  317. package/python/tests/test_supervisor_triage.py +4 -10
  318. package/python/tests/test_taint.py +2 -7
  319. package/python/tests/test_task_stream.py +1 -12
  320. package/python/tests/test_thinking_sse.py +0 -1
  321. package/python/tests/test_tool_center.py +1 -17
  322. package/python/tests/test_tool_parallel.py +2 -2
  323. package/python/tests/test_tool_params.py +1 -5
  324. package/python/tests/test_tool_use.py +0 -13
  325. package/python/tests/test_toplevel_concurrency.py +3 -4
  326. package/python/tests/test_tracing.py +7 -41
  327. package/python/tests/test_truth_crystallizer.py +4 -6
  328. package/python/tests/test_truth_flow.py +2 -7
  329. package/python/tests/test_update_check.py +95 -0
  330. package/python/tests/test_verify_evidence.py +8 -21
  331. package/python/tests/test_version_sync.py +35 -6
  332. package/python/tests/test_web_trigger.py +0 -17
  333. package/python/tools/check_faiss_type.py +0 -1
  334. package/python/tools/check_log.ps1 +0 -8
  335. package/python/tools/mypy_baseline.txt +12 -4
  336. package/python/tools/mypy_ratchet.py +52 -6
  337. package/python/tools/pack.py +0 -5
  338. package/python/tools/release_check.py +7 -7
  339. package/python/tools/restart_foxclaw.ps1 +5 -8
  340. package/python/tools/shared_write_audit.py +1 -15
  341. package/python/tools/size_check.py +1 -1
  342. package/python/tools/strip_comments.py +228 -0
  343. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  344. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -24,11 +24,9 @@ from typing import List, Any
24
24
  from .base_driver import BaseDriver
25
25
 
26
26
 
27
- # 被拦请求只留最近这些条:诊断/测试用,不落盘
28
27
  _MAX_BLOCKED = 50
29
28
 
30
29
 
31
- # role -> 对应的 CSS selector,v1 用简单选择器覆盖大多数场景
32
30
  ROLE_SELECTOR_MAP = {
33
31
  "button": "button, [role=button], input[type=button], input[type=submit]",
34
32
  "link": "a[href]",
@@ -44,8 +42,6 @@ ROLE_SELECTOR_MAP = {
44
42
  class PlaywrightDriver(BaseDriver):
45
43
 
46
44
  def __init__(self, headless: bool = True, guard_requests: bool | None = None):
47
- # A1:惰性导入——缺 playwright 时这里才抛 ImportError,由上层能力探测拦截,
48
- # 不会污染 `import foxclaw.browser`。
49
45
  from playwright.sync_api import sync_playwright
50
46
 
51
47
  self._pw = None
@@ -60,12 +56,8 @@ class PlaywrightDriver(BaseDriver):
60
56
  guard_requests = os.environ.get(
61
57
  "FOXCLAW_BROWSER_GUARD_REQUESTS", "1").strip() not in ("0", "false", "no")
62
58
  if guard_requests:
63
- # `**/*` 覆盖主文档、3xx 跳转后的新地址、iframe 与全部子资源。
64
- # 原先只校验 navigate 的目标:公网页面跳一次转,或塞一个
65
- # `<img src="http://169.254.169.254/...">` 就能越过准入。
66
59
  self._page.route("**/*", self._on_route)
67
60
  except Exception:
68
- # A4:构造中途失败(如 chromium 内核未 install)也不能泄漏原生资源
69
61
  self._safe_teardown()
70
62
  raise
71
63
 
@@ -95,7 +87,6 @@ class PlaywrightDriver(BaseDriver):
95
87
  try:
96
88
  route.continue_()
97
89
  except Exception:
98
- # 会话已在拆卸中(回调与 close 竞争):请求已无意义,静默丢弃
99
90
  pass
100
91
 
101
92
  def _safe_teardown(self):
@@ -112,14 +103,27 @@ class PlaywrightDriver(BaseDriver):
112
103
  self._browser = None
113
104
  self._pw = None
114
105
 
106
+ def _require_page(self):
107
+ """取当前页面;未打开或已拆卸时立刻报错,而不是等 None 属性访问炸。
108
+
109
+ `_page` 的静态类型是 `Page | None`(初始化与拆卸都会置 None)。下面几个
110
+ 方法原先直接 `self._page.xxx`:正常流程里 close() 之后没人再调,所以一直
111
+ 没出事;一旦有人在拆卸后调用,得到的是 `'NoneType' object has no attribute
112
+ 'goto'`,看不出「会话已关闭」这层意思。集中在这里判一次,报错即意图。
113
+ """
114
+ if self._page is None:
115
+ raise RuntimeError(
116
+ "browser session is not open: driver was closed or failed to launch"
117
+ )
118
+ return self._page
119
+
115
120
  def navigate(self, url: str) -> None:
116
- self._page.goto(url, wait_until="domcontentloaded")
121
+ self._require_page().goto(url, wait_until="domcontentloaded")
117
122
 
118
123
  def query_interactive_elements(self, roles: tuple) -> List[Any]:
119
124
  selectors = [ROLE_SELECTOR_MAP[r] for r in roles if r in ROLE_SELECTOR_MAP]
120
125
  combined = ", ".join(selectors)
121
- elements = self._page.query_selector_all(combined)
122
- # 只保留当前可见的元素,避免 LLM 尝试操作看不见的东西
126
+ elements = self._require_page().query_selector_all(combined)
123
127
  return [el for el in elements if el.is_visible()]
124
128
 
125
129
  def describe_element(self, handle) -> dict:
@@ -148,19 +152,19 @@ class PlaywrightDriver(BaseDriver):
148
152
 
149
153
  def scroll(self, direction: str) -> None:
150
154
  delta = 600 if direction == "down" else -600
151
- self._page.mouse.wheel(0, delta)
155
+ self._require_page().mouse.wheel(0, delta)
152
156
 
153
157
  def wait(self, seconds: float) -> None:
154
158
  time.sleep(seconds)
155
159
 
156
160
  def get_visible_text(self) -> str:
157
- return self._page.inner_text("body")
161
+ return self._require_page().inner_text("body")
158
162
 
159
163
  def current_url(self) -> str:
160
- return self._page.url
164
+ return self._require_page().url
161
165
 
162
166
  def current_title(self) -> str:
163
- return self._page.title()
167
+ return self._require_page().title()
164
168
 
165
169
  def close(self) -> None:
166
170
  self._safe_teardown()
@@ -7,7 +7,6 @@ DOM 提取器。
7
7
  (比如以后换成 CDP driver,只需要改这一层)。
8
8
  """
9
9
 
10
- # 只关心这些角色的元素,减少噪音;按需扩展
11
10
  INTERACTIVE_ROLES = ("button", "link", "textbox", "checkbox", "radio", "combobox", "menuitem")
12
11
 
13
12
 
@@ -16,7 +16,6 @@ from typing import List, Dict, Any
16
16
  class ElementIndexer:
17
17
 
18
18
  def __init__(self):
19
- # id(str) -> 底层 driver 元素句柄
20
19
  self._registry: Dict[str, Any] = {}
21
20
  self._counter = 0
22
21
 
@@ -42,7 +41,7 @@ class ElementIndexer:
42
41
  indexed.append({
43
42
  "id": eid,
44
43
  "role": desc.get("role", "unknown"),
45
- "text": desc.get("text", "")[:80], # 截断,控制 token 成本
44
+ "text": desc.get("text", "")[:80],
46
45
  "attrs": desc.get("attrs", {}),
47
46
  })
48
47
 
@@ -23,21 +23,22 @@ import asyncio
23
23
  import importlib.util
24
24
  import threading
25
25
  from concurrent.futures import ThreadPoolExecutor
26
- from typing import Optional
26
+ from typing import Optional, TYPE_CHECKING
27
27
 
28
28
 
29
+ if TYPE_CHECKING:
30
+ from .session.session_manager import SessionManager
31
+
29
32
  class BrowserService:
30
33
 
31
34
  def __init__(self, headless: bool = True):
32
35
  self._headless = headless
33
- self._session = None
36
+ self._session: "SessionManager | None" = None
34
37
  self._executor: Optional[ThreadPoolExecutor] = None
35
38
  self._closed = False
36
39
  self._launch_error: Optional[str] = None
37
- # 保护 executor/session 的创建与关闭(仅协调,不进浏览器调用)
38
40
  self._guard = threading.Lock()
39
41
 
40
- # ── 能力探测 ────────────────────────────────────────────────
41
42
  @staticmethod
42
43
  def available() -> bool:
43
44
  """playwright 包是否可导入。注意:这不保证 chromium 内核已 install,
@@ -53,7 +54,6 @@ class BrowserService:
53
54
  "launch_error": self._launch_error,
54
55
  }
55
56
 
56
- # ── 工作线程上运行的同步实现 ──────────────────────────────────
57
57
  def _get_executor(self) -> ThreadPoolExecutor:
58
58
  with self._guard:
59
59
  if self._closed:
@@ -89,7 +89,6 @@ class BrowserService:
89
89
  loop = asyncio.get_running_loop()
90
90
  return await loop.run_in_executor(self._get_executor(), fn, *args)
91
91
 
92
- # ── 对外 async API ─────────────────────────────────────────
93
92
  async def perceive(self) -> dict:
94
93
  """只感知当前页面语义结构,不改变页面状态。"""
95
94
  if not self.available():
@@ -107,9 +106,9 @@ class BrowserService:
107
106
  return {"ok": False, "error": "非法动作:需 {type, target?, value?}"}
108
107
  try:
109
108
  return {"ok": True, **await self._run(self._act_sync, action)}
110
- except ValueError as e: # 不支持的动作类型 / 非法编号
109
+ except ValueError as e:
111
110
  return {"ok": False, "error": str(e)}
112
- except KeyError as e: # 元素编号过期
111
+ except KeyError as e:
113
112
  return {"ok": False, "error": str(e).strip('"')}
114
113
  except Exception as e:
115
114
  return self._handle_error(e)
@@ -123,13 +122,11 @@ class BrowserService:
123
122
  executor = self._executor
124
123
  if executor is not None:
125
124
  try:
126
- # 会话的 close 必须回到工作线程执行(Playwright 对象线程亲和)
127
125
  loop = asyncio.get_running_loop()
128
126
  await loop.run_in_executor(executor, self._close_sync)
129
127
  finally:
130
128
  executor.shutdown(wait=True)
131
129
 
132
- # ── 错误/降级处理 ──────────────────────────────────────────
133
130
  def _unavailable(self) -> dict:
134
131
  return {
135
132
  "ok": False,
@@ -140,7 +137,6 @@ class BrowserService:
140
137
 
141
138
  def _handle_error(self, e: Exception) -> dict:
142
139
  msg = str(e)
143
- # chromium 内核未安装是最常见的 launch 失败,给出可操作提示
144
140
  if "playwright install" in msg or "Executable doesn't exist" in msg:
145
141
  self._launch_error = msg
146
142
  return {"ok": False, "available": False,
@@ -22,8 +22,8 @@ import os
22
22
  import threading
23
23
 
24
24
  _lock = threading.Lock()
25
- _cache: dict[str, object] = {} # model_name -> model 实例 或 None(加载失败/缺库)
26
- _load_attempts = 0 # 实际发起过多少次"加载尝试"(缓存命中不计),用于验证收敛
25
+ _cache: dict[str, object] = {}
26
+ _load_attempts = 0
27
27
 
28
28
 
29
29
  def get_text_embedder(model_name: str | None = None):
@@ -42,17 +42,15 @@ def get_text_embedder(model_name: str | None = None):
42
42
  or os.environ.get("FOXCLAW_EMBEDDING_MODEL")
43
43
  or "all-MiniLM-L6-v2").strip()
44
44
  with _lock:
45
- if name in _cache: # 本服务缓存命中(不重复走 HKC):绝不重复加载
45
+ if name in _cache:
46
46
  return _cache[name]
47
47
  _load_attempts += 1
48
48
  model = None
49
49
  try:
50
- # 【P2·share-bge-m3】走 HKC 共享进程缓存 —— 同名 model 全 Foxclaw 只 load 1 次
51
- # (HKC 检索 / HMR VectorStore / Harness SemanticDrift 共用同一份 instance)
52
50
  from hkc_search.embedding_backends import get_st_model as _hk_get
53
51
  model = _hk_get(name)
54
52
  except Exception:
55
- model = None # HKC 不可用 / 缺库 / 离线 → 降级哈希嵌入
53
+ model = None
56
54
  _cache[name] = model
57
55
  return model
58
56
 
@@ -20,15 +20,15 @@ import time
20
20
  @dataclass
21
21
  class InboundMessage:
22
22
  """标准化的入站消息(各平台 normalize 后的统一格式)。"""
23
- channel: str # 来源渠道(telegram/slack/...)
23
+ channel: str
24
24
  message_id: str
25
25
  text: str
26
- sender_id: str # 发送者唯一 ID(平台内)
26
+ sender_id: str
27
27
  sender_name: str = ""
28
- conversation_id: str = "" # 会话 ID(群=群ID,私聊=对方ID)
28
+ conversation_id: str = ""
29
29
  is_group: bool = False
30
30
  timestamp: float = field(default_factory=time.time)
31
- raw: dict = field(default_factory=dict) # 平台原始数据(留底,便于调试)
31
+ raw: dict = field(default_factory=dict)
32
32
 
33
33
  def to_dict(self) -> dict:
34
34
  return {"channel": self.channel, "message_id": self.message_id,
@@ -41,9 +41,9 @@ class InboundMessage:
41
41
  @dataclass
42
42
  class OutboundMessage:
43
43
  """标准化的出站消息(上层产出,各适配器转成平台格式发送)。"""
44
- conversation_id: str # 发往哪个会话
44
+ conversation_id: str
45
45
  text: str
46
- reply_to: str = "" # 可选:回复某条消息
46
+ reply_to: str = ""
47
47
  extra: dict = field(default_factory=dict)
48
48
 
49
49
 
@@ -63,13 +63,12 @@ class ChannelAdapter(ABC):
63
63
  - 入站统一回调 on_message,上层无需关心平台差异
64
64
  """
65
65
 
66
- # 子类覆盖:渠道名
67
66
  CHANNEL = "base"
68
67
 
69
- def __init__(self, on_message: Callable[[InboundMessage], None] = None):
68
+ def __init__(self, on_message: Callable[[InboundMessage], None] | None = None):
70
69
  self.on_message = on_message
71
70
  self._running = False
72
- self.last_error = "" # 最近一次处理错误(便于排查,不抛断轮询)
71
+ self.last_error = ""
73
72
 
74
73
  @abstractmethod
75
74
  def normalize(self, raw: dict) -> Optional[InboundMessage]:
@@ -93,12 +92,11 @@ class ChannelAdapter(ABC):
93
92
  self.last_error = f"normalize 失败: {e}"
94
93
  return None
95
94
  if inbound is None:
96
- return None # 非消息事件(如已读回执),忽略
95
+ return None
97
96
  if self.on_message:
98
97
  try:
99
98
  self.on_message(inbound)
100
99
  except Exception as e:
101
- # on_message(主管处理)失败:记录但不抛,保证整批轮询不中断
102
100
  self.last_error = f"on_message 失败: {e}"
103
101
  return inbound
104
102
 
@@ -18,20 +18,18 @@ Foxclaw Channels — Discord 适配器
18
18
  """
19
19
  from __future__ import annotations
20
20
  import json
21
- from typing import Optional
21
+ from typing import Any, Optional
22
22
  from . import ChannelAdapter, InboundMessage, OutboundMessage, SendResult
23
23
 
24
24
  DISCORD_API_BASE = "https://discord.com/api/v10"
25
25
  DISCORD_GATEWAY = "wss://gateway.discord.gg/?v=10&encoding=json"
26
26
 
27
- # Gateway opcodes
28
27
  OP_DISPATCH = 0
29
28
  OP_HEARTBEAT = 1
30
29
  OP_IDENTIFY = 2
31
30
  OP_HELLO = 10
32
31
  OP_HEARTBEAT_ACK = 11
33
32
 
34
- # Intents(收消息内容需要 GUILD_MESSAGES + MESSAGE_CONTENT)
35
33
  INTENT_GUILD_MESSAGES = 1 << 9
36
34
  INTENT_DIRECT_MESSAGES = 1 << 12
37
35
  INTENT_MESSAGE_CONTENT = 1 << 15
@@ -47,20 +45,18 @@ class DiscordAdapter(ChannelAdapter):
47
45
  self.bot_token = bot_token
48
46
  self.intents = intents
49
47
  self.timeout = timeout
50
- self._bot_user_id = "" # 自己的 bot user id(用于忽略自己的消息)
48
+ self._bot_user_id = ""
51
49
 
52
50
  def _headers(self) -> dict:
53
51
  return {"Authorization": f"Bot {self.bot_token}",
54
52
  "Content-Type": "application/json",
55
53
  "User-Agent": "Foxclaw (https://github.com, 1.0)"}
56
54
 
57
- # ── 入站:标准化 Gateway MESSAGE_CREATE 事件 ───────────
58
55
  def normalize(self, raw: dict) -> Optional[InboundMessage]:
59
56
  """Discord Gateway 事件 → 标准 InboundMessage。
60
57
 
61
58
  raw 可以是完整 Gateway 帧({op,t,d})或直接是 MESSAGE_CREATE 的 data。
62
59
  """
63
- # 取出 data 部分
64
60
  if "op" in raw:
65
61
  if raw.get("op") != OP_DISPATCH or raw.get("t") != "MESSAGE_CREATE":
66
62
  return None
@@ -70,15 +66,13 @@ class DiscordAdapter(ChannelAdapter):
70
66
  if not data:
71
67
  return None
72
68
  author = data.get("author", {})
73
- # 忽略机器人消息(含自己),避免自循环
74
69
  if author.get("bot"):
75
70
  return None
76
71
  if self._bot_user_id and str(author.get("id")) == self._bot_user_id:
77
72
  return None
78
73
  text = data.get("content", "")
79
74
  if not text:
80
- return None # 非文本(纯附件/嵌入)暂忽略
81
- # 有 guild_id = 服务器频道(群),无 = 私信
75
+ return None
82
76
  is_group = bool(data.get("guild_id"))
83
77
  return InboundMessage(
84
78
  channel=self.CHANNEL,
@@ -90,7 +84,6 @@ class DiscordAdapter(ChannelAdapter):
90
84
  is_group=is_group,
91
85
  raw=data)
92
86
 
93
- # ── 出站:REST 发送 ────────────────────────────────────
94
87
  def send(self, msg: OutboundMessage) -> SendResult:
95
88
  """POST /channels/{channel_id}/messages 发文本。"""
96
89
  try:
@@ -99,7 +92,7 @@ class DiscordAdapter(ChannelAdapter):
99
92
  return SendResult(False, error="缺少 httpx 依赖")
100
93
  if not self.bot_token:
101
94
  return SendResult(False, error="未配置 Discord bot token")
102
- payload = {"content": msg.text}
95
+ payload: dict[str, Any] = {"content": msg.text}
103
96
  if msg.reply_to:
104
97
  payload["message_reference"] = {"message_id": msg.reply_to}
105
98
  url = f"{DISCORD_API_BASE}/channels/{msg.conversation_id}/messages"
@@ -156,10 +149,8 @@ class DiscordAdapter(ChannelAdapter):
156
149
  raise RuntimeError("需要 websockets 库:pip install websockets")
157
150
  import asyncio
158
151
  async with websockets.connect(DISCORD_GATEWAY, max_size=None) as ws:
159
- # 1. HELLO:拿心跳间隔
160
152
  hello = json.loads(await ws.recv())
161
153
  heartbeat_ms = hello["d"]["heartbeat_interval"]
162
- # 2. IDENTIFY
163
154
  await ws.send(json.dumps(self.build_identify()))
164
155
  self._running = True
165
156
 
@@ -18,7 +18,7 @@ from . import InboundMessage, ChannelRegistry, ChannelAdapter
18
18
  class ChannelDispatcher:
19
19
  """渠道闭环:把入站消息路由到主管,回复发回原渠道。"""
20
20
 
21
- def __init__(self, supervisor, registry: ChannelRegistry = None,
21
+ def __init__(self, supervisor, registry: ChannelRegistry | None = None,
22
22
  idempotency=None):
23
23
  """
24
24
  supervisor: 主管 Agent(有 async run_task 方法)
@@ -27,7 +27,7 @@ class ChannelDispatcher:
27
27
  """
28
28
  self.supervisor = supervisor
29
29
  self.registry = registry or ChannelRegistry()
30
- self._pending: set = set() # 持有运行中的 task 引用,防止被 GC 回收
30
+ self._pending: set = set()
31
31
  if idempotency is None:
32
32
  from ..core.idempotency import IdempotencyGuard
33
33
  idempotency = IdempotencyGuard(ttl_seconds=3600)
@@ -61,23 +61,18 @@ class ChannelDispatcher:
61
61
  running = None
62
62
 
63
63
  if running is not None:
64
- # 在异步环境:发任务 + 持有引用(防止无引用 task 被 GC 静默回收)
65
64
  task = running.create_task(self.handle(msg))
66
65
  self._pending.add(task)
67
66
  task.add_done_callback(self._pending.discard)
68
67
  else:
69
- # 同步环境(轮询渠道):跑到完成,调用方得到完整处理结果
70
68
  asyncio.run(self.handle(msg))
71
69
 
72
70
  async def handle(self, msg: InboundMessage) -> dict:
73
71
  """处理一条入站消息:主管思考 → 回复原渠道。返回处理结果。"""
74
- # 幂等检查:同一渠道+消息ID只处理一次(防平台重发/轮询游标边界导致重复回复)
75
72
  from ..core.idempotency import make_key
76
73
  if msg.message_id:
77
74
  idem_key = make_key("message", msg.channel, msg.message_id, msg.sender_id)
78
75
  else:
79
- # 无稳定 message_id 的渠道/消息:用 渠道+发送者+内容+时间窗(10秒)
80
- # 算 fallback key——退化但仍能挡住短时间内的重发,不留盲区。
81
76
  import time as _t
82
77
  window = int(_t.time() // 10)
83
78
  idem_key = make_key("message_fallback", msg.channel, msg.sender_id,
@@ -86,7 +81,6 @@ class ChannelDispatcher:
86
81
  return {"handled": True, "replied": False, "reason": "重复消息,已跳过",
87
82
  "idempotent_skip": True}
88
83
 
89
- # 交给主管处理,带上渠道上下文(便于主管知道来源)
90
84
  context = {
91
85
  "channel": msg.channel,
92
86
  "session_id": self._session_key(msg),
@@ -94,14 +88,7 @@ class ChannelDispatcher:
94
88
  "sender_name": msg.sender_name,
95
89
  "is_group": msg.is_group,
96
90
  }
97
- # 渠道入站文本来自外部(Telegram/Discord/…),默认按**不可信数据**处理:
98
- # 此前 msg.text 原样当任务喂主管,等于把任意外部聊天用户当指令源(无发送者
99
- # 白名单、无 <untrusted> 包裹)。可信发送者用 FOXCLAW_CHANNEL_TRUSTED_SENDERS
100
- # 显式声明,逗号分隔,支持 `sender_id` 或 `channel:sender_id`,`*` 为该渠道全信任。
101
91
  context["sender_trusted"] = self._sender_trusted(msg)
102
- # 下传给权限判定:不可信来源驱动的任务里,写/发送/外发动作一律按「已接触敏感数据」
103
- # 处理(见 agents/_context._untrusted_get → tool_exec._guard_tool / skillbox)。
104
- # 只影响带此键的入口(渠道),本地 HTTP 对话不带 → 行为不变。
105
92
  context["untrusted_source"] = not context["sender_trusted"]
106
93
  text = msg.text
107
94
  if not context["sender_trusted"]:
@@ -114,12 +101,10 @@ class ChannelDispatcher:
114
101
  except Exception as e:
115
102
  result = {"error": f"主管处理失败: {e}"}
116
103
 
117
- # 提取回复文本
118
104
  reply_text = self._extract_reply(result)
119
105
  if not reply_text:
120
106
  return {"handled": True, "replied": False, "reason": "无回复内容"}
121
107
 
122
- # 通过原渠道回复
123
108
  adapter = self.registry.get(msg.channel)
124
109
  if adapter is None:
125
110
  return {"handled": True, "replied": False, "reason": f"渠道 {msg.channel} 未注册"}
@@ -141,9 +126,6 @@ class ChannelDispatcher:
141
126
  return False
142
127
  sid = str(getattr(msg, "sender_id", "") or "")
143
128
  chan = str(getattr(msg, "channel", "") or "")
144
- # 裸 `*` 曾等于「所有渠道的所有发送者都可信」——一次手滑就把任意外部聊天用户提成
145
- # 指令源,而列表里看不出它比 channel:sender_id 宽多少。现在必须二选一:
146
- # 写 `channel:*`(限定渠道,推荐),或用 FOXCLAW_CHANNEL_TRUST_ALL=1 显式确认全局信任。
147
129
  trust_all = _os.environ.get("FOXCLAW_CHANNEL_TRUST_ALL", "").strip() in ("1", "true", "True")
148
130
  for item in (x.strip() for x in raw.split(",")):
149
131
  if not item:
@@ -169,16 +151,12 @@ class ChannelDispatcher:
169
151
  return str(result) if result else ""
170
152
 
171
153
  inner = result.get("result")
172
- # 情况1:result 直接是字符串
173
154
  if isinstance(inner, str) and inner.strip():
174
155
  return inner
175
- # 情况2:result 是嵌套字典(主管分诊结果)
176
156
  if isinstance(inner, dict):
177
- # 单 Agent:sub_result
178
157
  sub = inner.get("sub_result")
179
158
  if isinstance(sub, str) and sub.strip():
180
159
  return sub
181
- # 多 Agent:拼接每步输出
182
160
  steps = inner.get("multi_steps")
183
161
  if isinstance(steps, list) and steps:
184
162
  parts = []
@@ -188,17 +166,14 @@ class ChannelDispatcher:
188
166
  parts.append(str(out))
189
167
  if parts:
190
168
  return "\n\n".join(parts)
191
- # 元问题自答等:answer / summary
192
169
  for k in ("answer", "summary", "response"):
193
170
  v = inner.get(k)
194
171
  if isinstance(v, str) and v.strip():
195
172
  return v
196
- # 情况3:顶层的 summary/response/output
197
173
  for key in ("summary", "response", "output"):
198
174
  val = result.get(key)
199
175
  if isinstance(val, str) and val.strip():
200
176
  return val
201
- # 出错兜底
202
177
  if result.get("error"):
203
178
  return f"抱歉,处理时遇到问题:{result['error']}"
204
179
  return ""
@@ -30,15 +30,14 @@ class FeishuAdapter(ChannelAdapter):
30
30
  super().__init__(on_message=on_message)
31
31
  self.app_id = app_id
32
32
  self.app_secret = app_secret
33
- self.verification_token = verification_token # webhook 模式校验
34
- self.encrypt_key = encrypt_key # 若开启加密
33
+ self.verification_token = verification_token
34
+ self.encrypt_key = encrypt_key
35
35
  self.base = FEISHU_BASE if domain == "feishu" else LARK_BASE
36
36
  self.timeout = timeout
37
- self._token = "" # 缓存的 tenant_access_token
38
- self._token_exp = 0.0 # 过期时间戳
39
- self._last_error = "" # 最近一次解密/处理错误(便于排查)
37
+ self._token = ""
38
+ self._token_exp = 0.0
39
+ self._last_error = ""
40
40
 
41
- # ── 入站:事件订阅 ──────────────────────────────────────
42
41
  def decrypt_event(self, encrypt: str) -> Optional[dict]:
43
42
  """解密飞书加密事件(AES-256-CBC,key=sha256(encrypt_key))。
44
43
 
@@ -60,7 +59,6 @@ class FeishuAdapter(ChannelAdapter):
60
59
  plain = self._aes_cbc_decrypt(key, iv, ciphertext)
61
60
  if plain is None:
62
61
  return None
63
- # PKCS7 去 padding(带校验,防损坏数据)
64
62
  if not plain:
65
63
  return None
66
64
  pad = plain[-1]
@@ -76,7 +74,6 @@ class FeishuAdapter(ChannelAdapter):
76
74
 
77
75
  def _aes_cbc_decrypt(self, key: bytes, iv: bytes, ciphertext: bytes) -> Optional[bytes]:
78
76
  """AES-256-CBC 解密。优先用 cryptography,回退 pycryptodome。"""
79
- # 优先 cryptography(更常见)
80
77
  try:
81
78
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
82
79
  cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
@@ -85,7 +82,7 @@ class FeishuAdapter(ChannelAdapter):
85
82
  except ImportError:
86
83
  pass
87
84
  try:
88
- from Crypto.Cipher import AES # pycryptodome
85
+ from Crypto.Cipher import AES
89
86
  return AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
90
87
  except ImportError:
91
88
  self._last_error = ("缺少 AES 库,无法解密飞书加密事件。"
@@ -99,12 +96,12 @@ class FeishuAdapter(ChannelAdapter):
99
96
  signature: str) -> bool:
100
97
  """验签:sha256(timestamp + nonce + encrypt_key + body)。"""
101
98
  if not self.encrypt_key:
102
- return True # 未开启加密则不验签(飞书此时也不发签名)
99
+ return True
103
100
  content = (timestamp + nonce + self.encrypt_key).encode() + body
104
101
  digest = hashlib.sha256(content).hexdigest()
105
102
  return digest == signature
106
103
 
107
- def handle_event(self, payload: dict, headers: dict = None,
104
+ def handle_event(self, payload: dict, headers: dict | None = None,
108
105
  raw_body: bytes = b"") -> dict:
109
106
  """处理飞书事件回调入口。返回应答给飞书的 dict。
110
107
 
@@ -114,23 +111,19 @@ class FeishuAdapter(ChannelAdapter):
114
111
  - 验签 → normalize → on_message
115
112
  """
116
113
  headers = headers or {}
117
- # 1. 加密:先解密外层
118
114
  if "encrypt" in payload:
119
115
  dec = self.decrypt_event(payload["encrypt"])
120
116
  if dec is None:
121
117
  return {"error": "decrypt_failed"}
122
118
  payload = dec
123
- # 2. URL 验证(首次配置回调地址时)
124
119
  if payload.get("type") == "url_verification":
125
120
  return {"challenge": payload.get("challenge", "")}
126
- # 3. 验签(若有签名头)
127
121
  sig = headers.get("X-Lark-Signature") or headers.get("x-lark-signature")
128
122
  if sig:
129
123
  ts = headers.get("X-Lark-Request-Timestamp", "")
130
124
  nonce = headers.get("X-Lark-Request-Nonce", "")
131
125
  if not self.verify_signature(ts, nonce, raw_body, sig):
132
126
  return {"error": "invalid_signature"}
133
- # 4. 消息事件 → normalize → 回调
134
127
  inbound = self.normalize(payload)
135
128
  if inbound and self.on_message:
136
129
  self.on_message(inbound)
@@ -145,7 +138,6 @@ class FeishuAdapter(ChannelAdapter):
145
138
  message = event.get("message", {})
146
139
  sender = event.get("sender", {})
147
140
  sender_sid = sender.get("sender_id", {})
148
- # 飞书消息内容是 JSON 字符串,文本在 {"text": "..."}
149
141
  text = ""
150
142
  try:
151
143
  import json
@@ -155,19 +147,18 @@ class FeishuAdapter(ChannelAdapter):
155
147
  pass
156
148
  if not text:
157
149
  return None
158
- chat_type = message.get("chat_type", "p2p") # p2p=私聊, group=群
150
+ chat_type = message.get("chat_type", "p2p")
159
151
  return InboundMessage(
160
152
  channel=self.CHANNEL,
161
153
  message_id=message.get("message_id", ""),
162
154
  text=text,
163
155
  sender_id=sender_sid.get("open_id", ""),
164
- sender_name="", # 飞书需额外调 API 取名,默认留空(可选 resolveSenderNames)
156
+ sender_name="",
165
157
  conversation_id=message.get("chat_id", ""),
166
158
  is_group=(chat_type == "group"),
167
159
  timestamp=float(message.get("create_time", 0)) / 1000 or time.time(),
168
160
  raw=raw)
169
161
 
170
- # ── 出站:tenant_access_token + 发消息 ──────────────────
171
162
  def _get_token(self) -> str:
172
163
  """取 tenant_access_token,带缓存与自动刷新(过期前 5 分钟刷新)。"""
173
164
  if self._token and time.time() < self._token_exp - 300:
@@ -197,7 +188,6 @@ class FeishuAdapter(ChannelAdapter):
197
188
  token = self._get_token()
198
189
  if not token:
199
190
  return SendResult(False, error="获取 tenant_access_token 失败")
200
- # receive_id_type 用 chat_id(群和私聊的会话 ID 都用 chat_id 发)
201
191
  payload = {
202
192
  "receive_id": msg.conversation_id,
203
193
  "msg_type": "text",