calvyn-code 0.14.0

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 (1718) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/README.zh-CN.md +180 -0
  4. package/acp_adapter/__init__.py +1 -0
  5. package/acp_adapter/__main__.py +5 -0
  6. package/acp_adapter/auth.py +68 -0
  7. package/acp_adapter/bootstrap/__init__.py +0 -0
  8. package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
  9. package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
  10. package/acp_adapter/entry.py +292 -0
  11. package/acp_adapter/events.py +265 -0
  12. package/acp_adapter/permissions.py +148 -0
  13. package/acp_adapter/server.py +1713 -0
  14. package/acp_adapter/session.py +629 -0
  15. package/acp_adapter/tools.py +1180 -0
  16. package/agent/__init__.py +6 -0
  17. package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
  19. package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
  20. package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
  21. package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
  22. package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
  23. package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
  24. package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
  25. package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
  26. package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
  27. package/agent/__pycache__/curator.cpython-312.pyc +0 -0
  28. package/agent/__pycache__/display.cpython-312.pyc +0 -0
  29. package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
  30. package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
  31. package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
  32. package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
  33. package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
  34. package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
  35. package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
  36. package/agent/__pycache__/insights.cpython-312.pyc +0 -0
  37. package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
  38. package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
  39. package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
  40. package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
  41. package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
  42. package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
  43. package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
  44. package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
  45. package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
  46. package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
  47. package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
  48. package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
  49. package/agent/__pycache__/redact.cpython-312.pyc +0 -0
  50. package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
  51. package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
  52. package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
  53. package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
  54. package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
  55. package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
  56. package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
  57. package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
  58. package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
  59. package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
  60. package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
  61. package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
  62. package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
  63. package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
  64. package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
  65. package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
  66. package/agent/account_usage.py +326 -0
  67. package/agent/anthropic_adapter.py +2087 -0
  68. package/agent/async_utils.py +68 -0
  69. package/agent/auxiliary_client.py +4893 -0
  70. package/agent/bedrock_adapter.py +1276 -0
  71. package/agent/codex_responses_adapter.py +1084 -0
  72. package/agent/context_compressor.py +1583 -0
  73. package/agent/context_engine.py +211 -0
  74. package/agent/context_references.py +519 -0
  75. package/agent/copilot_acp_client.py +684 -0
  76. package/agent/credential_pool.py +1780 -0
  77. package/agent/credential_sources.py +449 -0
  78. package/agent/curator.py +1782 -0
  79. package/agent/curator_backup.py +694 -0
  80. package/agent/display.py +987 -0
  81. package/agent/error_classifier.py +1058 -0
  82. package/agent/file_safety.py +112 -0
  83. package/agent/gemini_cloudcode_adapter.py +909 -0
  84. package/agent/gemini_native_adapter.py +971 -0
  85. package/agent/gemini_schema.py +99 -0
  86. package/agent/google_code_assist.py +452 -0
  87. package/agent/google_oauth.py +1062 -0
  88. package/agent/i18n.py +258 -0
  89. package/agent/image_gen_provider.py +243 -0
  90. package/agent/image_gen_registry.py +145 -0
  91. package/agent/image_routing.py +301 -0
  92. package/agent/insights.py +931 -0
  93. package/agent/lmstudio_reasoning.py +48 -0
  94. package/agent/lsp/__init__.py +106 -0
  95. package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
  97. package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
  98. package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
  99. package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
  100. package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
  101. package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
  102. package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
  103. package/agent/lsp/cli.py +308 -0
  104. package/agent/lsp/client.py +930 -0
  105. package/agent/lsp/eventlog.py +213 -0
  106. package/agent/lsp/install.py +376 -0
  107. package/agent/lsp/manager.py +644 -0
  108. package/agent/lsp/protocol.py +196 -0
  109. package/agent/lsp/range_shift.py +149 -0
  110. package/agent/lsp/reporter.py +78 -0
  111. package/agent/lsp/servers.py +1040 -0
  112. package/agent/lsp/workspace.py +223 -0
  113. package/agent/manual_compression_feedback.py +49 -0
  114. package/agent/markdown_tables.py +309 -0
  115. package/agent/memory_manager.py +556 -0
  116. package/agent/memory_provider.py +279 -0
  117. package/agent/model_metadata.py +1827 -0
  118. package/agent/models_dev.py +724 -0
  119. package/agent/moonshot_schema.py +231 -0
  120. package/agent/nous_rate_guard.py +326 -0
  121. package/agent/onboarding.py +193 -0
  122. package/agent/plugin_llm.py +1046 -0
  123. package/agent/portal_tags.py +64 -0
  124. package/agent/prompt_builder.py +1457 -0
  125. package/agent/prompt_caching.py +79 -0
  126. package/agent/rate_limit_tracker.py +246 -0
  127. package/agent/redact.py +403 -0
  128. package/agent/retry_utils.py +57 -0
  129. package/agent/shell_hooks.py +837 -0
  130. package/agent/skill_commands.py +502 -0
  131. package/agent/skill_preprocessing.py +131 -0
  132. package/agent/skill_utils.py +512 -0
  133. package/agent/subdirectory_hints.py +224 -0
  134. package/agent/think_scrubber.py +386 -0
  135. package/agent/title_generator.py +171 -0
  136. package/agent/tool_guardrails.py +458 -0
  137. package/agent/tool_result_classification.py +26 -0
  138. package/agent/trajectory.py +56 -0
  139. package/agent/transports/__init__.py +68 -0
  140. package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
  141. package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
  142. package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
  143. package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
  144. package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
  145. package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
  146. package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
  147. package/agent/transports/anthropic.py +179 -0
  148. package/agent/transports/base.py +89 -0
  149. package/agent/transports/bedrock.py +154 -0
  150. package/agent/transports/chat_completions.py +614 -0
  151. package/agent/transports/codex.py +283 -0
  152. package/agent/transports/codex_app_server.py +368 -0
  153. package/agent/transports/codex_app_server_session.py +810 -0
  154. package/agent/transports/codex_event_projector.py +312 -0
  155. package/agent/transports/hermes_tools_mcp_server.py +233 -0
  156. package/agent/transports/types.py +162 -0
  157. package/agent/usage_pricing.py +877 -0
  158. package/agent/video_gen_provider.py +300 -0
  159. package/agent/video_gen_registry.py +117 -0
  160. package/agent/web_search_provider.py +221 -0
  161. package/agent/web_search_registry.py +262 -0
  162. package/assets/banner.png +0 -0
  163. package/batch_runner.py +1303 -0
  164. package/bin/calvyn.js +67 -0
  165. package/calvyn_bootstrap.py +130 -0
  166. package/calvyn_constants.py +346 -0
  167. package/calvyn_logging.py +390 -0
  168. package/calvyn_state.py +2967 -0
  169. package/calvyn_time.py +105 -0
  170. package/cli.py +14160 -0
  171. package/cron/__init__.py +42 -0
  172. package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
  173. package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
  174. package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
  175. package/cron/jobs.py +1160 -0
  176. package/cron/scheduler.py +1832 -0
  177. package/gateway/__init__.py +35 -0
  178. package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  179. package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
  180. package/gateway/__pycache__/config.cpython-312.pyc +0 -0
  181. package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
  182. package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
  183. package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
  184. package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
  185. package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
  186. package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
  187. package/gateway/__pycache__/run.cpython-312.pyc +0 -0
  188. package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
  189. package/gateway/__pycache__/session.cpython-312.pyc +0 -0
  190. package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
  191. package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
  192. package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
  193. package/gateway/__pycache__/status.cpython-312.pyc +0 -0
  194. package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
  195. package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
  196. package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
  197. package/gateway/builtin_hooks/__init__.py +1 -0
  198. package/gateway/channel_directory.py +357 -0
  199. package/gateway/config.py +1873 -0
  200. package/gateway/delivery.py +258 -0
  201. package/gateway/display_config.py +206 -0
  202. package/gateway/hooks.py +210 -0
  203. package/gateway/mirror.py +179 -0
  204. package/gateway/pairing.py +322 -0
  205. package/gateway/platform_registry.py +260 -0
  206. package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
  207. package/gateway/platforms/__init__.py +45 -0
  208. package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
  209. package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
  210. package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
  211. package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
  212. package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
  213. package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
  214. package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
  215. package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
  216. package/gateway/platforms/_http_client_limits.py +84 -0
  217. package/gateway/platforms/api_server.py +3488 -0
  218. package/gateway/platforms/base.py +3747 -0
  219. package/gateway/platforms/bluebubbles.py +937 -0
  220. package/gateway/platforms/dingtalk.py +1473 -0
  221. package/gateway/platforms/discord.py +5584 -0
  222. package/gateway/platforms/email.py +773 -0
  223. package/gateway/platforms/feishu.py +5059 -0
  224. package/gateway/platforms/feishu_comment.py +1382 -0
  225. package/gateway/platforms/feishu_comment_rules.py +430 -0
  226. package/gateway/platforms/helpers.py +279 -0
  227. package/gateway/platforms/homeassistant.py +449 -0
  228. package/gateway/platforms/matrix.py +2777 -0
  229. package/gateway/platforms/mattermost.py +852 -0
  230. package/gateway/platforms/msgraph_webhook.py +397 -0
  231. package/gateway/platforms/qqbot/__init__.py +91 -0
  232. package/gateway/platforms/qqbot/adapter.py +3072 -0
  233. package/gateway/platforms/qqbot/chunked_upload.py +602 -0
  234. package/gateway/platforms/qqbot/constants.py +74 -0
  235. package/gateway/platforms/qqbot/crypto.py +45 -0
  236. package/gateway/platforms/qqbot/keyboards.py +473 -0
  237. package/gateway/platforms/qqbot/onboard.py +220 -0
  238. package/gateway/platforms/qqbot/utils.py +71 -0
  239. package/gateway/platforms/signal.py +1518 -0
  240. package/gateway/platforms/signal_rate_limit.py +369 -0
  241. package/gateway/platforms/slack.py +3028 -0
  242. package/gateway/platforms/sms.py +377 -0
  243. package/gateway/platforms/telegram.py +4836 -0
  244. package/gateway/platforms/telegram_network.py +249 -0
  245. package/gateway/platforms/webhook.py +806 -0
  246. package/gateway/platforms/wecom.py +1610 -0
  247. package/gateway/platforms/wecom_callback.py +403 -0
  248. package/gateway/platforms/wecom_crypto.py +142 -0
  249. package/gateway/platforms/weixin.py +2170 -0
  250. package/gateway/platforms/whatsapp.py +1283 -0
  251. package/gateway/platforms/yuanbao.py +4873 -0
  252. package/gateway/platforms/yuanbao_media.py +645 -0
  253. package/gateway/platforms/yuanbao_proto.py +1209 -0
  254. package/gateway/platforms/yuanbao_sticker.py +558 -0
  255. package/gateway/restart.py +20 -0
  256. package/gateway/run.py +17074 -0
  257. package/gateway/runtime_footer.py +150 -0
  258. package/gateway/session.py +1399 -0
  259. package/gateway/session_context.py +156 -0
  260. package/gateway/shutdown_forensics.py +462 -0
  261. package/gateway/slash_access.py +229 -0
  262. package/gateway/status.py +972 -0
  263. package/gateway/sticker_cache.py +111 -0
  264. package/gateway/stream_consumer.py +1286 -0
  265. package/gateway/whatsapp_identity.py +156 -0
  266. package/hermes_cli/__init__.py +47 -0
  267. package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  268. package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
  269. package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
  270. package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
  271. package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
  272. package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
  273. package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
  274. package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
  275. package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
  276. package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
  277. package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
  278. package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
  279. package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
  280. package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
  281. package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
  282. package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
  283. package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
  284. package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
  285. package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
  286. package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
  287. package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
  288. package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
  289. package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
  290. package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
  291. package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
  292. package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
  293. package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
  294. package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
  295. package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
  296. package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
  297. package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
  298. package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
  299. package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
  300. package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
  301. package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
  302. package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
  303. package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
  304. package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
  305. package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
  306. package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
  307. package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
  308. package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
  309. package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
  310. package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
  311. package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
  312. package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
  313. package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
  314. package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
  315. package/hermes_cli/_parser.py +365 -0
  316. package/hermes_cli/_subprocess_compat.py +175 -0
  317. package/hermes_cli/auth.py +6299 -0
  318. package/hermes_cli/auth_commands.py +749 -0
  319. package/hermes_cli/azure_detect.py +300 -0
  320. package/hermes_cli/backup.py +938 -0
  321. package/hermes_cli/banner.py +703 -0
  322. package/hermes_cli/browser_connect.py +139 -0
  323. package/hermes_cli/callbacks.py +243 -0
  324. package/hermes_cli/checkpoints.py +244 -0
  325. package/hermes_cli/claw.py +810 -0
  326. package/hermes_cli/cli_output.py +78 -0
  327. package/hermes_cli/clipboard.py +495 -0
  328. package/hermes_cli/codex_models.py +198 -0
  329. package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
  330. package/hermes_cli/codex_runtime_switch.py +266 -0
  331. package/hermes_cli/colors.py +38 -0
  332. package/hermes_cli/commands.py +1728 -0
  333. package/hermes_cli/completion.py +315 -0
  334. package/hermes_cli/config.py +5382 -0
  335. package/hermes_cli/copilot_auth.py +392 -0
  336. package/hermes_cli/cron.py +313 -0
  337. package/hermes_cli/curator.py +598 -0
  338. package/hermes_cli/curses_ui.py +472 -0
  339. package/hermes_cli/debug.py +747 -0
  340. package/hermes_cli/default_soul.py +11 -0
  341. package/hermes_cli/dep_ensure.py +107 -0
  342. package/hermes_cli/dingtalk_auth.py +293 -0
  343. package/hermes_cli/doctor.py +1863 -0
  344. package/hermes_cli/dump.py +326 -0
  345. package/hermes_cli/env_loader.py +175 -0
  346. package/hermes_cli/fallback_cmd.py +361 -0
  347. package/hermes_cli/gateway.py +5422 -0
  348. package/hermes_cli/gateway_windows.py +692 -0
  349. package/hermes_cli/goals.py +757 -0
  350. package/hermes_cli/hooks.py +385 -0
  351. package/hermes_cli/inventory.py +240 -0
  352. package/hermes_cli/kanban.py +2252 -0
  353. package/hermes_cli/kanban_db.py +4840 -0
  354. package/hermes_cli/kanban_diagnostics.py +776 -0
  355. package/hermes_cli/kanban_specify.py +266 -0
  356. package/hermes_cli/logs.py +391 -0
  357. package/hermes_cli/main.py +12396 -0
  358. package/hermes_cli/mcp_config.py +781 -0
  359. package/hermes_cli/memory_setup.py +465 -0
  360. package/hermes_cli/model_catalog.py +330 -0
  361. package/hermes_cli/model_normalize.py +473 -0
  362. package/hermes_cli/model_switch.py +1777 -0
  363. package/hermes_cli/models.py +3789 -0
  364. package/hermes_cli/nous_subscription.py +799 -0
  365. package/hermes_cli/oneshot.py +351 -0
  366. package/hermes_cli/pairing.py +115 -0
  367. package/hermes_cli/platforms.py +83 -0
  368. package/hermes_cli/plugins.py +1562 -0
  369. package/hermes_cli/plugins_cmd.py +1587 -0
  370. package/hermes_cli/profile_distribution.py +703 -0
  371. package/hermes_cli/profiles.py +1319 -0
  372. package/hermes_cli/providers.py +720 -0
  373. package/hermes_cli/proxy/__init__.py +20 -0
  374. package/hermes_cli/proxy/adapters/__init__.py +35 -0
  375. package/hermes_cli/proxy/adapters/base.py +94 -0
  376. package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
  377. package/hermes_cli/proxy/cli.py +141 -0
  378. package/hermes_cli/proxy/server.py +265 -0
  379. package/hermes_cli/pt_input_extras.py +83 -0
  380. package/hermes_cli/pty_bridge.py +237 -0
  381. package/hermes_cli/relaunch.py +205 -0
  382. package/hermes_cli/runtime_provider.py +1428 -0
  383. package/hermes_cli/security_advisories.py +452 -0
  384. package/hermes_cli/setup.py +3559 -0
  385. package/hermes_cli/skills_config.py +177 -0
  386. package/hermes_cli/skills_hub.py +1595 -0
  387. package/hermes_cli/skin_engine.py +929 -0
  388. package/hermes_cli/slack_cli.py +160 -0
  389. package/hermes_cli/status.py +550 -0
  390. package/hermes_cli/stdio.py +252 -0
  391. package/hermes_cli/timeouts.py +82 -0
  392. package/hermes_cli/tips.py +487 -0
  393. package/hermes_cli/tools_config.py +3151 -0
  394. package/hermes_cli/uninstall.py +681 -0
  395. package/hermes_cli/vercel_auth.py +70 -0
  396. package/hermes_cli/voice.py +846 -0
  397. package/hermes_cli/web_server.py +4438 -0
  398. package/hermes_cli/webhook.py +275 -0
  399. package/locales/af.yaml +350 -0
  400. package/locales/de.yaml +350 -0
  401. package/locales/en.yaml +365 -0
  402. package/locales/es.yaml +350 -0
  403. package/locales/fr.yaml +350 -0
  404. package/locales/ga.yaml +354 -0
  405. package/locales/hu.yaml +350 -0
  406. package/locales/it.yaml +350 -0
  407. package/locales/ja.yaml +350 -0
  408. package/locales/ko.yaml +350 -0
  409. package/locales/pt.yaml +350 -0
  410. package/locales/ru.yaml +350 -0
  411. package/locales/tr.yaml +350 -0
  412. package/locales/uk.yaml +350 -0
  413. package/locales/zh-hant.yaml +350 -0
  414. package/locales/zh.yaml +350 -0
  415. package/mcp_serve.py +898 -0
  416. package/model_tools.py +899 -0
  417. package/optional-skills/DESCRIPTION.md +24 -0
  418. package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
  419. package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
  420. package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
  421. package/optional-skills/blockchain/evm/SKILL.md +211 -0
  422. package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
  423. package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
  424. package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
  425. package/optional-skills/blockchain/solana/SKILL.md +208 -0
  426. package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
  427. package/optional-skills/communication/DESCRIPTION.md +1 -0
  428. package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
  429. package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
  430. package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
  431. package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
  432. package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
  433. package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
  434. package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
  435. package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
  436. package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
  437. package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
  438. package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
  439. package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
  440. package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
  441. package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
  442. package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
  443. package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
  444. package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
  445. package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
  446. package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
  447. package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
  448. package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
  449. package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
  450. package/optional-skills/creative/hyperframes/SKILL.md +191 -0
  451. package/optional-skills/creative/hyperframes/references/cli.md +185 -0
  452. package/optional-skills/creative/hyperframes/references/composition.md +129 -0
  453. package/optional-skills/creative/hyperframes/references/features.md +289 -0
  454. package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
  455. package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
  456. package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
  457. package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
  458. package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
  459. package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
  460. package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
  461. package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
  462. package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
  463. package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
  464. package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
  465. package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
  466. package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
  467. package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
  468. package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
  469. package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
  470. package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
  471. package/optional-skills/creative/meme-generation/SKILL.md +130 -0
  472. package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
  473. package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
  474. package/optional-skills/devops/cli/SKILL.md +156 -0
  475. package/optional-skills/devops/cli/references/app-discovery.md +112 -0
  476. package/optional-skills/devops/cli/references/authentication.md +59 -0
  477. package/optional-skills/devops/cli/references/cli-reference.md +104 -0
  478. package/optional-skills/devops/cli/references/running-apps.md +171 -0
  479. package/optional-skills/devops/docker-management/SKILL.md +281 -0
  480. package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
  481. package/optional-skills/devops/watchers/SKILL.md +112 -0
  482. package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
  483. package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
  484. package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
  485. package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
  486. package/optional-skills/dogfood/DESCRIPTION.md +3 -0
  487. package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
  488. package/optional-skills/email/agentmail/SKILL.md +126 -0
  489. package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
  490. package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
  491. package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
  492. package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
  493. package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
  494. package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
  495. package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
  496. package/optional-skills/finance/dcf-model/requirements.txt +7 -0
  497. package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
  498. package/optional-skills/finance/excel-author/SKILL.md +244 -0
  499. package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
  500. package/optional-skills/finance/lbo-model/SKILL.md +291 -0
  501. package/optional-skills/finance/merger-model/SKILL.md +144 -0
  502. package/optional-skills/finance/pptx-author/SKILL.md +173 -0
  503. package/optional-skills/finance/stocks/SKILL.md +95 -0
  504. package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
  505. package/optional-skills/health/DESCRIPTION.md +1 -0
  506. package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
  507. package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
  508. package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
  509. package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
  510. package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
  511. package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
  512. package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
  513. package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
  514. package/optional-skills/mcp/DESCRIPTION.md +3 -0
  515. package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
  516. package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
  517. package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
  518. package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
  519. package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
  520. package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
  521. package/optional-skills/mcp/mcporter/SKILL.md +123 -0
  522. package/optional-skills/migration/DESCRIPTION.md +2 -0
  523. package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
  524. package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
  525. package/optional-skills/mlops/accelerate/SKILL.md +336 -0
  526. package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
  527. package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
  528. package/optional-skills/mlops/accelerate/references/performance.md +525 -0
  529. package/optional-skills/mlops/chroma/SKILL.md +410 -0
  530. package/optional-skills/mlops/chroma/references/integration.md +38 -0
  531. package/optional-skills/mlops/clip/SKILL.md +257 -0
  532. package/optional-skills/mlops/clip/references/applications.md +207 -0
  533. package/optional-skills/mlops/faiss/SKILL.md +225 -0
  534. package/optional-skills/mlops/faiss/references/index_types.md +280 -0
  535. package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
  536. package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
  537. package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
  538. package/optional-skills/mlops/guidance/SKILL.md +576 -0
  539. package/optional-skills/mlops/guidance/references/backends.md +554 -0
  540. package/optional-skills/mlops/guidance/references/constraints.md +674 -0
  541. package/optional-skills/mlops/guidance/references/examples.md +767 -0
  542. package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
  543. package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
  544. package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
  545. package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
  546. package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
  547. package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
  548. package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
  549. package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
  550. package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
  551. package/optional-skills/mlops/instructor/SKILL.md +744 -0
  552. package/optional-skills/mlops/instructor/references/examples.md +107 -0
  553. package/optional-skills/mlops/instructor/references/providers.md +70 -0
  554. package/optional-skills/mlops/instructor/references/validation.md +606 -0
  555. package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
  556. package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
  557. package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
  558. package/optional-skills/mlops/llava/SKILL.md +308 -0
  559. package/optional-skills/mlops/llava/references/training.md +197 -0
  560. package/optional-skills/mlops/modal/SKILL.md +345 -0
  561. package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
  562. package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
  563. package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
  564. package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
  565. package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
  566. package/optional-skills/mlops/peft/SKILL.md +435 -0
  567. package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
  568. package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
  569. package/optional-skills/mlops/pinecone/SKILL.md +362 -0
  570. package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
  571. package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
  572. package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
  573. package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
  574. package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
  575. package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
  576. package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
  577. package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
  578. package/optional-skills/mlops/qdrant/SKILL.md +497 -0
  579. package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
  580. package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
  581. package/optional-skills/mlops/saelens/SKILL.md +390 -0
  582. package/optional-skills/mlops/saelens/references/README.md +69 -0
  583. package/optional-skills/mlops/saelens/references/api.md +333 -0
  584. package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
  585. package/optional-skills/mlops/simpo/SKILL.md +223 -0
  586. package/optional-skills/mlops/simpo/references/datasets.md +478 -0
  587. package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
  588. package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
  589. package/optional-skills/mlops/slime/SKILL.md +468 -0
  590. package/optional-skills/mlops/slime/references/api-reference.md +392 -0
  591. package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
  592. package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
  593. package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
  594. package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
  595. package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
  596. package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
  597. package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
  598. package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
  599. package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
  600. package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
  601. package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
  602. package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
  603. package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
  604. package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
  605. package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
  606. package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
  607. package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
  608. package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
  609. package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
  610. package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
  611. package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
  612. package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
  613. package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
  614. package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
  615. package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
  616. package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
  617. package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
  618. package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
  619. package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
  620. package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
  621. package/optional-skills/mlops/whisper/SKILL.md +321 -0
  622. package/optional-skills/mlops/whisper/references/languages.md +189 -0
  623. package/optional-skills/productivity/canvas/SKILL.md +98 -0
  624. package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
  625. package/optional-skills/productivity/here-now/SKILL.md +217 -0
  626. package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
  627. package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
  628. package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
  629. package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
  630. package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
  631. package/optional-skills/productivity/shop-app/SKILL.md +340 -0
  632. package/optional-skills/productivity/shopify/SKILL.md +373 -0
  633. package/optional-skills/productivity/siyuan/SKILL.md +298 -0
  634. package/optional-skills/productivity/telephony/SKILL.md +418 -0
  635. package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
  636. package/optional-skills/research/bioinformatics/SKILL.md +235 -0
  637. package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
  638. package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
  639. package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
  640. package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
  641. package/optional-skills/research/domain-intel/SKILL.md +97 -0
  642. package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
  643. package/optional-skills/research/drug-discovery/SKILL.md +227 -0
  644. package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
  645. package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
  646. package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
  647. package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
  648. package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
  649. package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
  650. package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
  651. package/optional-skills/research/osint-investigation/SKILL.md +277 -0
  652. package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
  653. package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
  654. package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
  655. package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
  656. package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
  657. package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
  658. package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
  659. package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
  660. package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
  661. package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
  662. package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
  663. package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
  664. package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
  665. package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
  666. package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
  667. package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
  668. package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
  669. package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
  670. package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
  671. package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
  672. package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
  673. package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
  674. package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
  675. package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
  676. package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
  677. package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
  678. package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
  679. package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
  680. package/optional-skills/research/parallel-cli/SKILL.md +391 -0
  681. package/optional-skills/research/qmd/SKILL.md +441 -0
  682. package/optional-skills/research/scrapling/SKILL.md +336 -0
  683. package/optional-skills/research/searxng-search/SKILL.md +212 -0
  684. package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
  685. package/optional-skills/security/1password/SKILL.md +163 -0
  686. package/optional-skills/security/1password/references/cli-examples.md +31 -0
  687. package/optional-skills/security/1password/references/get-started.md +21 -0
  688. package/optional-skills/security/DESCRIPTION.md +3 -0
  689. package/optional-skills/security/oss-forensics/SKILL.md +423 -0
  690. package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
  691. package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
  692. package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
  693. package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
  694. package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
  695. package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
  696. package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
  697. package/optional-skills/security/sherlock/SKILL.md +193 -0
  698. package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
  699. package/optional-skills/web-development/DESCRIPTION.md +5 -0
  700. package/optional-skills/web-development/page-agent/SKILL.md +190 -0
  701. package/package.json +78 -0
  702. package/plugins/__init__.py +1 -0
  703. package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
  704. package/plugins/context_engine/__init__.py +219 -0
  705. package/plugins/disk-cleanup/README.md +51 -0
  706. package/plugins/disk-cleanup/__init__.py +316 -0
  707. package/plugins/disk-cleanup/disk_cleanup.py +497 -0
  708. package/plugins/disk-cleanup/plugin.yaml +7 -0
  709. package/plugins/example-dashboard/dashboard/manifest.json +14 -0
  710. package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
  711. package/plugins/google_meet/README.md +131 -0
  712. package/plugins/google_meet/SKILL.md +148 -0
  713. package/plugins/google_meet/__init__.py +103 -0
  714. package/plugins/google_meet/audio_bridge.py +244 -0
  715. package/plugins/google_meet/cli.py +479 -0
  716. package/plugins/google_meet/meet_bot.py +852 -0
  717. package/plugins/google_meet/node/__init__.py +54 -0
  718. package/plugins/google_meet/node/cli.py +125 -0
  719. package/plugins/google_meet/node/client.py +107 -0
  720. package/plugins/google_meet/node/protocol.py +124 -0
  721. package/plugins/google_meet/node/registry.py +113 -0
  722. package/plugins/google_meet/node/server.py +201 -0
  723. package/plugins/google_meet/plugin.yaml +16 -0
  724. package/plugins/google_meet/process_manager.py +324 -0
  725. package/plugins/google_meet/realtime/__init__.py +10 -0
  726. package/plugins/google_meet/realtime/openai_client.py +332 -0
  727. package/plugins/google_meet/tools.py +348 -0
  728. package/plugins/hermes-achievements/LICENSE +21 -0
  729. package/plugins/hermes-achievements/README.md +150 -0
  730. package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
  731. package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
  732. package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
  733. package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
  734. package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
  735. package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
  736. package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
  737. package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
  738. package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
  739. package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
  740. package/plugins/image_gen/openai/__init__.py +303 -0
  741. package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
  742. package/plugins/image_gen/openai/plugin.yaml +7 -0
  743. package/plugins/image_gen/openai-codex/__init__.py +378 -0
  744. package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  745. package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
  746. package/plugins/image_gen/xai/__init__.py +316 -0
  747. package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  748. package/plugins/image_gen/xai/plugin.yaml +7 -0
  749. package/plugins/kanban/dashboard/dist/index.js +3143 -0
  750. package/plugins/kanban/dashboard/dist/style.css +1500 -0
  751. package/plugins/kanban/dashboard/manifest.json +14 -0
  752. package/plugins/kanban/dashboard/plugin_api.py +1612 -0
  753. package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
  754. package/plugins/memory/__init__.py +408 -0
  755. package/plugins/memory/byterover/README.md +41 -0
  756. package/plugins/memory/byterover/__init__.py +384 -0
  757. package/plugins/memory/byterover/plugin.yaml +9 -0
  758. package/plugins/memory/hindsight/README.md +138 -0
  759. package/plugins/memory/hindsight/__init__.py +1758 -0
  760. package/plugins/memory/hindsight/plugin.yaml +8 -0
  761. package/plugins/memory/holographic/README.md +36 -0
  762. package/plugins/memory/holographic/__init__.py +409 -0
  763. package/plugins/memory/holographic/holographic.py +203 -0
  764. package/plugins/memory/holographic/plugin.yaml +5 -0
  765. package/plugins/memory/holographic/retrieval.py +593 -0
  766. package/plugins/memory/holographic/store.py +579 -0
  767. package/plugins/memory/honcho/README.md +328 -0
  768. package/plugins/memory/honcho/__init__.py +1329 -0
  769. package/plugins/memory/honcho/cli.py +1452 -0
  770. package/plugins/memory/honcho/client.py +784 -0
  771. package/plugins/memory/honcho/plugin.yaml +7 -0
  772. package/plugins/memory/honcho/session.py +1255 -0
  773. package/plugins/memory/mem0/README.md +38 -0
  774. package/plugins/memory/mem0/__init__.py +374 -0
  775. package/plugins/memory/mem0/plugin.yaml +5 -0
  776. package/plugins/memory/openviking/README.md +40 -0
  777. package/plugins/memory/openviking/__init__.py +945 -0
  778. package/plugins/memory/openviking/plugin.yaml +9 -0
  779. package/plugins/memory/retaindb/README.md +40 -0
  780. package/plugins/memory/retaindb/__init__.py +767 -0
  781. package/plugins/memory/retaindb/plugin.yaml +7 -0
  782. package/plugins/memory/supermemory/README.md +99 -0
  783. package/plugins/memory/supermemory/__init__.py +792 -0
  784. package/plugins/memory/supermemory/plugin.yaml +5 -0
  785. package/plugins/model-providers/README.md +70 -0
  786. package/plugins/model-providers/ai-gateway/__init__.py +43 -0
  787. package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  788. package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
  789. package/plugins/model-providers/alibaba/__init__.py +13 -0
  790. package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
  791. package/plugins/model-providers/alibaba/plugin.yaml +5 -0
  792. package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
  793. package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
  794. package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
  795. package/plugins/model-providers/anthropic/__init__.py +52 -0
  796. package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
  797. package/plugins/model-providers/anthropic/plugin.yaml +5 -0
  798. package/plugins/model-providers/arcee/__init__.py +13 -0
  799. package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
  800. package/plugins/model-providers/arcee/plugin.yaml +5 -0
  801. package/plugins/model-providers/azure-foundry/__init__.py +21 -0
  802. package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
  803. package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
  804. package/plugins/model-providers/bedrock/__init__.py +29 -0
  805. package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
  806. package/plugins/model-providers/bedrock/plugin.yaml +5 -0
  807. package/plugins/model-providers/copilot/__init__.py +58 -0
  808. package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
  809. package/plugins/model-providers/copilot/plugin.yaml +5 -0
  810. package/plugins/model-providers/copilot-acp/__init__.py +34 -0
  811. package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
  812. package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
  813. package/plugins/model-providers/custom/__init__.py +68 -0
  814. package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
  815. package/plugins/model-providers/custom/plugin.yaml +5 -0
  816. package/plugins/model-providers/deepseek/__init__.py +99 -0
  817. package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
  818. package/plugins/model-providers/deepseek/plugin.yaml +5 -0
  819. package/plugins/model-providers/gemini/__init__.py +72 -0
  820. package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
  821. package/plugins/model-providers/gemini/plugin.yaml +5 -0
  822. package/plugins/model-providers/gmi/__init__.py +31 -0
  823. package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
  824. package/plugins/model-providers/gmi/plugin.yaml +5 -0
  825. package/plugins/model-providers/huggingface/__init__.py +20 -0
  826. package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
  827. package/plugins/model-providers/huggingface/plugin.yaml +5 -0
  828. package/plugins/model-providers/kilocode/__init__.py +14 -0
  829. package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
  830. package/plugins/model-providers/kilocode/plugin.yaml +5 -0
  831. package/plugins/model-providers/kimi-coding/__init__.py +71 -0
  832. package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
  833. package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
  834. package/plugins/model-providers/minimax/__init__.py +45 -0
  835. package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
  836. package/plugins/model-providers/minimax/plugin.yaml +5 -0
  837. package/plugins/model-providers/nous/__init__.py +54 -0
  838. package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
  839. package/plugins/model-providers/nous/plugin.yaml +5 -0
  840. package/plugins/model-providers/novita/__init__.py +27 -0
  841. package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
  842. package/plugins/model-providers/novita/plugin.yaml +5 -0
  843. package/plugins/model-providers/nvidia/__init__.py +21 -0
  844. package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
  845. package/plugins/model-providers/nvidia/plugin.yaml +5 -0
  846. package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
  847. package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
  848. package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
  849. package/plugins/model-providers/openai-codex/__init__.py +15 -0
  850. package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  851. package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
  852. package/plugins/model-providers/opencode-zen/__init__.py +30 -0
  853. package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
  854. package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
  855. package/plugins/model-providers/openrouter/__init__.py +115 -0
  856. package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
  857. package/plugins/model-providers/openrouter/plugin.yaml +5 -0
  858. package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
  859. package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
  860. package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
  861. package/plugins/model-providers/stepfun/__init__.py +14 -0
  862. package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
  863. package/plugins/model-providers/stepfun/plugin.yaml +5 -0
  864. package/plugins/model-providers/xai/__init__.py +15 -0
  865. package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  866. package/plugins/model-providers/xai/plugin.yaml +5 -0
  867. package/plugins/model-providers/xiaomi/__init__.py +14 -0
  868. package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
  869. package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
  870. package/plugins/model-providers/zai/__init__.py +21 -0
  871. package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
  872. package/plugins/model-providers/zai/plugin.yaml +5 -0
  873. package/plugins/observability/langfuse/README.md +53 -0
  874. package/plugins/observability/langfuse/__init__.py +1004 -0
  875. package/plugins/observability/langfuse/plugin.yaml +14 -0
  876. package/plugins/platforms/google_chat/__init__.py +3 -0
  877. package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
  878. package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
  879. package/plugins/platforms/google_chat/adapter.py +3343 -0
  880. package/plugins/platforms/google_chat/oauth.py +639 -0
  881. package/plugins/platforms/google_chat/plugin.yaml +39 -0
  882. package/plugins/platforms/irc/__init__.py +3 -0
  883. package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
  884. package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
  885. package/plugins/platforms/irc/adapter.py +969 -0
  886. package/plugins/platforms/irc/plugin.yaml +54 -0
  887. package/plugins/platforms/line/__init__.py +3 -0
  888. package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
  889. package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
  890. package/plugins/platforms/line/adapter.py +1639 -0
  891. package/plugins/platforms/line/plugin.yaml +65 -0
  892. package/plugins/platforms/simplex/__init__.py +3 -0
  893. package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
  894. package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
  895. package/plugins/platforms/simplex/adapter.py +746 -0
  896. package/plugins/platforms/simplex/plugin.yaml +37 -0
  897. package/plugins/platforms/teams/__init__.py +3 -0
  898. package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
  899. package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
  900. package/plugins/platforms/teams/adapter.py +1188 -0
  901. package/plugins/platforms/teams/plugin.yaml +48 -0
  902. package/plugins/spotify/__init__.py +66 -0
  903. package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
  904. package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
  905. package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
  906. package/plugins/spotify/client.py +435 -0
  907. package/plugins/spotify/plugin.yaml +13 -0
  908. package/plugins/spotify/tools.py +454 -0
  909. package/plugins/teams_pipeline/__init__.py +23 -0
  910. package/plugins/teams_pipeline/cli.py +463 -0
  911. package/plugins/teams_pipeline/meetings.py +333 -0
  912. package/plugins/teams_pipeline/models.py +350 -0
  913. package/plugins/teams_pipeline/pipeline.py +692 -0
  914. package/plugins/teams_pipeline/plugin.yaml +9 -0
  915. package/plugins/teams_pipeline/runtime.py +135 -0
  916. package/plugins/teams_pipeline/store.py +194 -0
  917. package/plugins/teams_pipeline/subscriptions.py +249 -0
  918. package/plugins/video_gen/fal/__init__.py +523 -0
  919. package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
  920. package/plugins/video_gen/fal/plugin.yaml +7 -0
  921. package/plugins/video_gen/xai/__init__.py +441 -0
  922. package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  923. package/plugins/video_gen/xai/plugin.yaml +7 -0
  924. package/plugins/web/__init__.py +7 -0
  925. package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
  926. package/plugins/web/brave_free/__init__.py +14 -0
  927. package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
  928. package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
  929. package/plugins/web/brave_free/plugin.yaml +7 -0
  930. package/plugins/web/brave_free/provider.py +137 -0
  931. package/plugins/web/ddgs/__init__.py +15 -0
  932. package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
  933. package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
  934. package/plugins/web/ddgs/plugin.yaml +7 -0
  935. package/plugins/web/ddgs/provider.py +104 -0
  936. package/plugins/web/exa/__init__.py +15 -0
  937. package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
  938. package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
  939. package/plugins/web/exa/plugin.yaml +7 -0
  940. package/plugins/web/exa/provider.py +212 -0
  941. package/plugins/web/firecrawl/__init__.py +28 -0
  942. package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
  943. package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
  944. package/plugins/web/firecrawl/plugin.yaml +7 -0
  945. package/plugins/web/firecrawl/provider.py +773 -0
  946. package/plugins/web/parallel/__init__.py +16 -0
  947. package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
  948. package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
  949. package/plugins/web/parallel/plugin.yaml +7 -0
  950. package/plugins/web/parallel/provider.py +291 -0
  951. package/plugins/web/searxng/__init__.py +15 -0
  952. package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
  953. package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
  954. package/plugins/web/searxng/plugin.yaml +7 -0
  955. package/plugins/web/searxng/provider.py +140 -0
  956. package/plugins/web/tavily/__init__.py +15 -0
  957. package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
  958. package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
  959. package/plugins/web/tavily/plugin.yaml +7 -0
  960. package/plugins/web/tavily/provider.py +285 -0
  961. package/providers/README.md +78 -0
  962. package/providers/__init__.py +192 -0
  963. package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
  964. package/providers/__pycache__/base.cpython-312.pyc +0 -0
  965. package/providers/base.py +184 -0
  966. package/pyproject.toml +255 -0
  967. package/run_agent.py +16409 -0
  968. package/scripts/benchmark_browser_eval.py +138 -0
  969. package/scripts/build_model_catalog.py +95 -0
  970. package/scripts/build_skills_index.py +325 -0
  971. package/scripts/check-windows-footguns.py +624 -0
  972. package/scripts/contributor_audit.py +473 -0
  973. package/scripts/discord-voice-doctor.py +396 -0
  974. package/scripts/hermes-gateway +416 -0
  975. package/scripts/install.cmd +28 -0
  976. package/scripts/install.ps1 +1611 -0
  977. package/scripts/install.sh +2007 -0
  978. package/scripts/install_psutil_android.py +117 -0
  979. package/scripts/keystroke_diagnostic.py +81 -0
  980. package/scripts/kill_modal.sh +34 -0
  981. package/scripts/lib/node-bootstrap.sh +238 -0
  982. package/scripts/lint_diff.py +207 -0
  983. package/scripts/postinstall.js +150 -0
  984. package/scripts/profile-tui.py +626 -0
  985. package/scripts/release.py +1680 -0
  986. package/scripts/run_tests.sh +129 -0
  987. package/scripts/sample_and_compress.py +409 -0
  988. package/scripts/setup_open_webui.sh +349 -0
  989. package/scripts/whatsapp-bridge/allowlist.js +88 -0
  990. package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
  991. package/scripts/whatsapp-bridge/bridge.js +729 -0
  992. package/scripts/whatsapp-bridge/package-lock.json +2141 -0
  993. package/scripts/whatsapp-bridge/package.json +19 -0
  994. package/skills/apple/DESCRIPTION.md +2 -0
  995. package/skills/apple/apple-notes/SKILL.md +90 -0
  996. package/skills/apple/apple-reminders/SKILL.md +98 -0
  997. package/skills/apple/findmy/SKILL.md +131 -0
  998. package/skills/apple/imessage/SKILL.md +102 -0
  999. package/skills/apple/macos-computer-use/SKILL.md +201 -0
  1000. package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
  1001. package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
  1002. package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
  1003. package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
  1004. package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
  1005. package/skills/creative/DESCRIPTION.md +3 -0
  1006. package/skills/creative/architecture-diagram/SKILL.md +148 -0
  1007. package/skills/creative/architecture-diagram/templates/template.html +319 -0
  1008. package/skills/creative/ascii-art/SKILL.md +322 -0
  1009. package/skills/creative/ascii-video/README.md +290 -0
  1010. package/skills/creative/ascii-video/SKILL.md +241 -0
  1011. package/skills/creative/ascii-video/references/architecture.md +802 -0
  1012. package/skills/creative/ascii-video/references/composition.md +892 -0
  1013. package/skills/creative/ascii-video/references/effects.md +1865 -0
  1014. package/skills/creative/ascii-video/references/inputs.md +685 -0
  1015. package/skills/creative/ascii-video/references/optimization.md +688 -0
  1016. package/skills/creative/ascii-video/references/scenes.md +1011 -0
  1017. package/skills/creative/ascii-video/references/shaders.md +1385 -0
  1018. package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
  1019. package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
  1020. package/skills/creative/baoyu-comic/SKILL.md +247 -0
  1021. package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
  1022. package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
  1023. package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
  1024. package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
  1025. package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
  1026. package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
  1027. package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
  1028. package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
  1029. package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
  1030. package/skills/creative/baoyu-comic/references/character-template.md +180 -0
  1031. package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
  1032. package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
  1033. package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
  1034. package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
  1035. package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
  1036. package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
  1037. package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
  1038. package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
  1039. package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
  1040. package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
  1041. package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
  1042. package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
  1043. package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
  1044. package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
  1045. package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
  1046. package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
  1047. package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
  1048. package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
  1049. package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
  1050. package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
  1051. package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
  1052. package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
  1053. package/skills/creative/baoyu-comic/references/workflow.md +401 -0
  1054. package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
  1055. package/skills/creative/baoyu-infographic/SKILL.md +237 -0
  1056. package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
  1057. package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
  1058. package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
  1059. package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
  1060. package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
  1061. package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
  1062. package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
  1063. package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
  1064. package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
  1065. package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
  1066. package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
  1067. package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
  1068. package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
  1069. package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
  1070. package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
  1071. package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
  1072. package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
  1073. package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
  1074. package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
  1075. package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
  1076. package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
  1077. package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
  1078. package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
  1079. package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
  1080. package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
  1081. package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
  1082. package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
  1083. package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
  1084. package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
  1085. package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
  1086. package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
  1087. package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
  1088. package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
  1089. package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
  1090. package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
  1091. package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
  1092. package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
  1093. package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
  1094. package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
  1095. package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
  1096. package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
  1097. package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
  1098. package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
  1099. package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
  1100. package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
  1101. package/skills/creative/claude-design/SKILL.md +591 -0
  1102. package/skills/creative/comfyui/SKILL.md +612 -0
  1103. package/skills/creative/comfyui/references/official-cli.md +255 -0
  1104. package/skills/creative/comfyui/references/rest-api.md +312 -0
  1105. package/skills/creative/comfyui/references/template-integrity.md +243 -0
  1106. package/skills/creative/comfyui/references/workflow-format.md +226 -0
  1107. package/skills/creative/comfyui/scripts/_common.py +835 -0
  1108. package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
  1109. package/skills/creative/comfyui/scripts/check_deps.py +437 -0
  1110. package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
  1111. package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
  1112. package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
  1113. package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
  1114. package/skills/creative/comfyui/scripts/health_check.py +223 -0
  1115. package/skills/creative/comfyui/scripts/run_batch.py +243 -0
  1116. package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
  1117. package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
  1118. package/skills/creative/comfyui/tests/README.md +50 -0
  1119. package/skills/creative/comfyui/tests/conftest.py +64 -0
  1120. package/skills/creative/comfyui/tests/pytest.ini +5 -0
  1121. package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
  1122. package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
  1123. package/skills/creative/comfyui/tests/test_common.py +447 -0
  1124. package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
  1125. package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
  1126. package/skills/creative/comfyui/workflows/README.md +86 -0
  1127. package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
  1128. package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
  1129. package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
  1130. package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
  1131. package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
  1132. package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
  1133. package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
  1134. package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
  1135. package/skills/creative/creative-ideation/SKILL.md +152 -0
  1136. package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
  1137. package/skills/creative/design-md/SKILL.md +199 -0
  1138. package/skills/creative/design-md/templates/starter.md +99 -0
  1139. package/skills/creative/excalidraw/SKILL.md +199 -0
  1140. package/skills/creative/excalidraw/references/colors.md +44 -0
  1141. package/skills/creative/excalidraw/references/dark-mode.md +68 -0
  1142. package/skills/creative/excalidraw/references/examples.md +141 -0
  1143. package/skills/creative/excalidraw/scripts/upload.py +133 -0
  1144. package/skills/creative/humanizer/LICENSE +21 -0
  1145. package/skills/creative/humanizer/SKILL.md +578 -0
  1146. package/skills/creative/manim-video/README.md +23 -0
  1147. package/skills/creative/manim-video/SKILL.md +269 -0
  1148. package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
  1149. package/skills/creative/manim-video/references/animations.md +282 -0
  1150. package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
  1151. package/skills/creative/manim-video/references/decorations.md +202 -0
  1152. package/skills/creative/manim-video/references/equations.md +216 -0
  1153. package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
  1154. package/skills/creative/manim-video/references/mobjects.md +333 -0
  1155. package/skills/creative/manim-video/references/paper-explainer.md +255 -0
  1156. package/skills/creative/manim-video/references/production-quality.md +190 -0
  1157. package/skills/creative/manim-video/references/rendering.md +185 -0
  1158. package/skills/creative/manim-video/references/scene-planning.md +118 -0
  1159. package/skills/creative/manim-video/references/troubleshooting.md +135 -0
  1160. package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
  1161. package/skills/creative/manim-video/references/visual-design.md +124 -0
  1162. package/skills/creative/manim-video/scripts/setup.sh +14 -0
  1163. package/skills/creative/p5js/README.md +64 -0
  1164. package/skills/creative/p5js/SKILL.md +556 -0
  1165. package/skills/creative/p5js/references/animation.md +439 -0
  1166. package/skills/creative/p5js/references/color-systems.md +352 -0
  1167. package/skills/creative/p5js/references/core-api.md +410 -0
  1168. package/skills/creative/p5js/references/export-pipeline.md +566 -0
  1169. package/skills/creative/p5js/references/interaction.md +398 -0
  1170. package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
  1171. package/skills/creative/p5js/references/troubleshooting.md +532 -0
  1172. package/skills/creative/p5js/references/typography.md +302 -0
  1173. package/skills/creative/p5js/references/visual-effects.md +895 -0
  1174. package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
  1175. package/skills/creative/p5js/scripts/export-frames.js +179 -0
  1176. package/skills/creative/p5js/scripts/render.sh +108 -0
  1177. package/skills/creative/p5js/scripts/serve.sh +28 -0
  1178. package/skills/creative/p5js/scripts/setup.sh +87 -0
  1179. package/skills/creative/p5js/templates/viewer.html +395 -0
  1180. package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
  1181. package/skills/creative/pixel-art/SKILL.md +218 -0
  1182. package/skills/creative/pixel-art/references/palettes.md +49 -0
  1183. package/skills/creative/pixel-art/scripts/__init__.py +0 -0
  1184. package/skills/creative/pixel-art/scripts/palettes.py +167 -0
  1185. package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
  1186. package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
  1187. package/skills/creative/popular-web-designs/SKILL.md +214 -0
  1188. package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
  1189. package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
  1190. package/skills/creative/popular-web-designs/templates/apple.md +326 -0
  1191. package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
  1192. package/skills/creative/popular-web-designs/templates/cal.md +272 -0
  1193. package/skills/creative/popular-web-designs/templates/claude.md +325 -0
  1194. package/skills/creative/popular-web-designs/templates/clay.md +317 -0
  1195. package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
  1196. package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
  1197. package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
  1198. package/skills/creative/popular-web-designs/templates/composio.md +320 -0
  1199. package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
  1200. package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
  1201. package/skills/creative/popular-web-designs/templates/expo.md +294 -0
  1202. package/skills/creative/popular-web-designs/templates/figma.md +233 -0
  1203. package/skills/creative/popular-web-designs/templates/framer.md +259 -0
  1204. package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
  1205. package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
  1206. package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
  1207. package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
  1208. package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
  1209. package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
  1210. package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
  1211. package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
  1212. package/skills/creative/popular-web-designs/templates/miro.md +121 -0
  1213. package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
  1214. package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
  1215. package/skills/creative/popular-web-designs/templates/notion.md +322 -0
  1216. package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
  1217. package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
  1218. package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
  1219. package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
  1220. package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
  1221. package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
  1222. package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
  1223. package/skills/creative/popular-web-designs/templates/resend.md +316 -0
  1224. package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
  1225. package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
  1226. package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
  1227. package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
  1228. package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
  1229. package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
  1230. package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
  1231. package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
  1232. package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
  1233. package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
  1234. package/skills/creative/popular-web-designs/templates/uber.md +308 -0
  1235. package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
  1236. package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
  1237. package/skills/creative/popular-web-designs/templates/warp.md +266 -0
  1238. package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
  1239. package/skills/creative/popular-web-designs/templates/wise.md +186 -0
  1240. package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
  1241. package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
  1242. package/skills/creative/pretext/SKILL.md +220 -0
  1243. package/skills/creative/pretext/references/patterns.md +258 -0
  1244. package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
  1245. package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
  1246. package/skills/creative/sketch/SKILL.md +218 -0
  1247. package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
  1248. package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
  1249. package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
  1250. package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
  1251. package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
  1252. package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
  1253. package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
  1254. package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
  1255. package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
  1256. package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
  1257. package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
  1258. package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
  1259. package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
  1260. package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
  1261. package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
  1262. package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
  1263. package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
  1264. package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
  1265. package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
  1266. package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
  1267. package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
  1268. package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
  1269. package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
  1270. package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
  1271. package/skills/data-science/DESCRIPTION.md +3 -0
  1272. package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
  1273. package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
  1274. package/skills/devops/kanban-worker/SKILL.md +184 -0
  1275. package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
  1276. package/skills/diagramming/DESCRIPTION.md +3 -0
  1277. package/skills/dogfood/SKILL.md +162 -0
  1278. package/skills/dogfood/references/issue-taxonomy.md +109 -0
  1279. package/skills/dogfood/templates/dogfood-report-template.md +86 -0
  1280. package/skills/domain/DESCRIPTION.md +24 -0
  1281. package/skills/email/DESCRIPTION.md +3 -0
  1282. package/skills/email/himalaya/SKILL.md +299 -0
  1283. package/skills/email/himalaya/references/configuration.md +227 -0
  1284. package/skills/email/himalaya/references/message-composition.md +199 -0
  1285. package/skills/gaming/DESCRIPTION.md +3 -0
  1286. package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
  1287. package/skills/gaming/pokemon-player/SKILL.md +216 -0
  1288. package/skills/gifs/DESCRIPTION.md +3 -0
  1289. package/skills/github/DESCRIPTION.md +3 -0
  1290. package/skills/github/codebase-inspection/SKILL.md +116 -0
  1291. package/skills/github/github-auth/SKILL.md +247 -0
  1292. package/skills/github/github-auth/scripts/gh-env.sh +66 -0
  1293. package/skills/github/github-code-review/SKILL.md +481 -0
  1294. package/skills/github/github-code-review/references/review-output-template.md +74 -0
  1295. package/skills/github/github-issues/SKILL.md +370 -0
  1296. package/skills/github/github-issues/templates/bug-report.md +35 -0
  1297. package/skills/github/github-issues/templates/feature-request.md +31 -0
  1298. package/skills/github/github-pr-workflow/SKILL.md +367 -0
  1299. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
  1300. package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
  1301. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
  1302. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
  1303. package/skills/github/github-repo-management/SKILL.md +516 -0
  1304. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
  1305. package/skills/index-cache/anthropics_skills_skills_.json +1 -0
  1306. package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
  1307. package/skills/index-cache/lobehub_index.json +1 -0
  1308. package/skills/index-cache/openai_skills_skills_.json +1 -0
  1309. package/skills/inference-sh/DESCRIPTION.md +19 -0
  1310. package/skills/mcp/DESCRIPTION.md +3 -0
  1311. package/skills/mcp/native-mcp/SKILL.md +357 -0
  1312. package/skills/media/DESCRIPTION.md +3 -0
  1313. package/skills/media/gif-search/SKILL.md +91 -0
  1314. package/skills/media/heartmula/SKILL.md +171 -0
  1315. package/skills/media/songsee/SKILL.md +83 -0
  1316. package/skills/media/spotify/SKILL.md +135 -0
  1317. package/skills/media/youtube-content/SKILL.md +73 -0
  1318. package/skills/media/youtube-content/references/output-formats.md +56 -0
  1319. package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
  1320. package/skills/mlops/DESCRIPTION.md +3 -0
  1321. package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
  1322. package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
  1323. package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
  1324. package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
  1325. package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
  1326. package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
  1327. package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
  1328. package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
  1329. package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
  1330. package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
  1331. package/skills/mlops/huggingface-hub/SKILL.md +81 -0
  1332. package/skills/mlops/inference/DESCRIPTION.md +3 -0
  1333. package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
  1334. package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
  1335. package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
  1336. package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
  1337. package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
  1338. package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
  1339. package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
  1340. package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
  1341. package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
  1342. package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
  1343. package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
  1344. package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
  1345. package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
  1346. package/skills/mlops/inference/vllm/SKILL.md +372 -0
  1347. package/skills/mlops/inference/vllm/references/optimization.md +226 -0
  1348. package/skills/mlops/inference/vllm/references/quantization.md +284 -0
  1349. package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
  1350. package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
  1351. package/skills/mlops/models/DESCRIPTION.md +3 -0
  1352. package/skills/mlops/models/audiocraft/SKILL.md +568 -0
  1353. package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
  1354. package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
  1355. package/skills/mlops/models/segment-anything/SKILL.md +506 -0
  1356. package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
  1357. package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
  1358. package/skills/mlops/research/DESCRIPTION.md +3 -0
  1359. package/skills/mlops/research/dspy/SKILL.md +594 -0
  1360. package/skills/mlops/research/dspy/references/examples.md +663 -0
  1361. package/skills/mlops/research/dspy/references/modules.md +475 -0
  1362. package/skills/mlops/research/dspy/references/optimizers.md +566 -0
  1363. package/skills/mlops/training/DESCRIPTION.md +3 -0
  1364. package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
  1365. package/skills/note-taking/DESCRIPTION.md +3 -0
  1366. package/skills/note-taking/obsidian/SKILL.md +61 -0
  1367. package/skills/productivity/DESCRIPTION.md +3 -0
  1368. package/skills/productivity/airtable/SKILL.md +229 -0
  1369. package/skills/productivity/google-workspace/SKILL.md +335 -0
  1370. package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
  1371. package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
  1372. package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
  1373. package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
  1374. package/skills/productivity/google-workspace/scripts/setup.py +454 -0
  1375. package/skills/productivity/linear/SKILL.md +380 -0
  1376. package/skills/productivity/linear/scripts/linear_api.py +445 -0
  1377. package/skills/productivity/maps/SKILL.md +195 -0
  1378. package/skills/productivity/maps/scripts/maps_client.py +1298 -0
  1379. package/skills/productivity/nano-pdf/SKILL.md +52 -0
  1380. package/skills/productivity/notion/SKILL.md +448 -0
  1381. package/skills/productivity/notion/references/block-types.md +112 -0
  1382. package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
  1383. package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
  1384. package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
  1385. package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
  1386. package/skills/productivity/powerpoint/LICENSE.txt +30 -0
  1387. package/skills/productivity/powerpoint/SKILL.md +237 -0
  1388. package/skills/productivity/powerpoint/editing.md +205 -0
  1389. package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
  1390. package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
  1391. package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
  1392. package/skills/productivity/powerpoint/scripts/clean.py +286 -0
  1393. package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
  1394. package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
  1395. package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
  1396. package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
  1397. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  1398. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  1399. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  1400. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  1401. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  1402. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  1403. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  1404. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  1405. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  1406. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  1407. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  1408. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  1409. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  1410. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  1411. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  1412. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  1413. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  1414. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  1415. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  1416. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  1417. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  1418. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  1419. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  1420. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  1421. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  1422. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  1423. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  1424. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
  1425. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
  1426. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
  1427. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
  1428. package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
  1429. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  1430. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  1431. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  1432. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  1433. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  1434. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  1435. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  1436. package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
  1437. package/skills/red-teaming/godmode/SKILL.md +404 -0
  1438. package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
  1439. package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
  1440. package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
  1441. package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
  1442. package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
  1443. package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
  1444. package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
  1445. package/skills/red-teaming/godmode/templates/prefill.json +18 -0
  1446. package/skills/research/DESCRIPTION.md +3 -0
  1447. package/skills/research/arxiv/SKILL.md +282 -0
  1448. package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
  1449. package/skills/research/blogwatcher/SKILL.md +137 -0
  1450. package/skills/research/llm-wiki/SKILL.md +507 -0
  1451. package/skills/research/polymarket/SKILL.md +77 -0
  1452. package/skills/research/polymarket/references/api-endpoints.md +220 -0
  1453. package/skills/research/polymarket/scripts/polymarket.py +284 -0
  1454. package/skills/research/research-paper-writing/SKILL.md +2377 -0
  1455. package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
  1456. package/skills/research/research-paper-writing/references/checklists.md +434 -0
  1457. package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
  1458. package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
  1459. package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
  1460. package/skills/research/research-paper-writing/references/paper-types.md +481 -0
  1461. package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
  1462. package/skills/research/research-paper-writing/references/sources.md +191 -0
  1463. package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
  1464. package/skills/research/research-paper-writing/templates/README.md +251 -0
  1465. package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
  1466. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  1467. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  1468. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
  1469. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
  1470. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
  1471. package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
  1472. package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
  1473. package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
  1474. package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
  1475. package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
  1476. package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
  1477. package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
  1478. package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
  1479. package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
  1480. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
  1481. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
  1482. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
  1483. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
  1484. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
  1485. package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
  1486. package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
  1487. package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
  1488. package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
  1489. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
  1490. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
  1491. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
  1492. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
  1493. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
  1494. package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
  1495. package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
  1496. package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
  1497. package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
  1498. package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
  1499. package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
  1500. package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
  1501. package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
  1502. package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
  1503. package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
  1504. package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
  1505. package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
  1506. package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
  1507. package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
  1508. package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
  1509. package/skills/smart-home/DESCRIPTION.md +3 -0
  1510. package/skills/smart-home/openhue/SKILL.md +109 -0
  1511. package/skills/social-media/DESCRIPTION.md +3 -0
  1512. package/skills/social-media/xurl/SKILL.md +414 -0
  1513. package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
  1514. package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
  1515. package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
  1516. package/skills/software-development/plan/SKILL.md +58 -0
  1517. package/skills/software-development/python-debugpy/SKILL.md +375 -0
  1518. package/skills/software-development/requesting-code-review/SKILL.md +280 -0
  1519. package/skills/software-development/spike/SKILL.md +197 -0
  1520. package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
  1521. package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
  1522. package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
  1523. package/skills/software-development/systematic-debugging/SKILL.md +367 -0
  1524. package/skills/software-development/test-driven-development/SKILL.md +343 -0
  1525. package/skills/software-development/writing-plans/SKILL.md +297 -0
  1526. package/skills/yuanbao/SKILL.md +108 -0
  1527. package/tools/__init__.py +25 -0
  1528. package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  1529. package/tools/__pycache__/approval.cpython-312.pyc +0 -0
  1530. package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
  1531. package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
  1532. package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
  1533. package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
  1534. package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
  1535. package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
  1536. package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
  1537. package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
  1538. package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
  1539. package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
  1540. package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
  1541. package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
  1542. package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
  1543. package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
  1544. package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
  1545. package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
  1546. package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
  1547. package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
  1548. package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
  1549. package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
  1550. package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
  1551. package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  1552. package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
  1553. package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
  1554. package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
  1555. package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
  1556. package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
  1557. package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
  1558. package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
  1559. package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
  1560. package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
  1561. package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
  1562. package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
  1563. package/tools/__pycache__/registry.cpython-312.pyc +0 -0
  1564. package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
  1565. package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
  1566. package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
  1567. package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
  1568. package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
  1569. package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
  1570. package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
  1571. package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
  1572. package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
  1573. package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
  1574. package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
  1575. package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
  1576. package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
  1577. package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
  1578. package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
  1579. package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
  1580. package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
  1581. package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
  1582. package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
  1583. package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
  1584. package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  1585. package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
  1586. package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
  1587. package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
  1588. package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
  1589. package/tools/ansi_strip.py +44 -0
  1590. package/tools/approval.py +1392 -0
  1591. package/tools/binary_extensions.py +42 -0
  1592. package/tools/browser_camofox.py +700 -0
  1593. package/tools/browser_camofox_state.py +48 -0
  1594. package/tools/browser_cdp_tool.py +569 -0
  1595. package/tools/browser_dialog_tool.py +148 -0
  1596. package/tools/browser_providers/__init__.py +10 -0
  1597. package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
  1598. package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
  1599. package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
  1600. package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
  1601. package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
  1602. package/tools/browser_providers/base.py +59 -0
  1603. package/tools/browser_providers/browser_use.py +225 -0
  1604. package/tools/browser_providers/browserbase.py +222 -0
  1605. package/tools/browser_providers/firecrawl.py +112 -0
  1606. package/tools/browser_supervisor.py +1457 -0
  1607. package/tools/browser_tool.py +3676 -0
  1608. package/tools/budget_config.py +51 -0
  1609. package/tools/checkpoint_manager.py +1639 -0
  1610. package/tools/clarify_gateway.py +278 -0
  1611. package/tools/clarify_tool.py +141 -0
  1612. package/tools/code_execution_tool.py +1782 -0
  1613. package/tools/computer_use/__init__.py +43 -0
  1614. package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
  1615. package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
  1616. package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
  1617. package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
  1618. package/tools/computer_use/backend.py +150 -0
  1619. package/tools/computer_use/cua_backend.py +682 -0
  1620. package/tools/computer_use/schema.py +191 -0
  1621. package/tools/computer_use/tool.py +521 -0
  1622. package/tools/computer_use_tool.py +39 -0
  1623. package/tools/credential_files.py +437 -0
  1624. package/tools/cronjob_tools.py +719 -0
  1625. package/tools/debug_helpers.py +106 -0
  1626. package/tools/delegate_tool.py +2797 -0
  1627. package/tools/discord_tool.py +959 -0
  1628. package/tools/env_passthrough.py +145 -0
  1629. package/tools/environments/__init__.py +14 -0
  1630. package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
  1631. package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
  1632. package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
  1633. package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
  1634. package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
  1635. package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
  1636. package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
  1637. package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
  1638. package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
  1639. package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
  1640. package/tools/environments/base.py +844 -0
  1641. package/tools/environments/daytona.py +270 -0
  1642. package/tools/environments/docker.py +656 -0
  1643. package/tools/environments/file_sync.py +400 -0
  1644. package/tools/environments/local.py +658 -0
  1645. package/tools/environments/managed_modal.py +282 -0
  1646. package/tools/environments/modal.py +479 -0
  1647. package/tools/environments/modal_utils.py +199 -0
  1648. package/tools/environments/singularity.py +263 -0
  1649. package/tools/environments/ssh.py +295 -0
  1650. package/tools/environments/vercel_sandbox.py +655 -0
  1651. package/tools/feishu_doc_tool.py +138 -0
  1652. package/tools/feishu_drive_tool.py +431 -0
  1653. package/tools/file_operations.py +1825 -0
  1654. package/tools/file_state.py +332 -0
  1655. package/tools/file_tools.py +1172 -0
  1656. package/tools/fuzzy_match.py +703 -0
  1657. package/tools/homeassistant_tool.py +513 -0
  1658. package/tools/image_generation_tool.py +1098 -0
  1659. package/tools/interrupt.py +98 -0
  1660. package/tools/kanban_tools.py +1139 -0
  1661. package/tools/lazy_deps.py +608 -0
  1662. package/tools/managed_tool_gateway.py +168 -0
  1663. package/tools/mcp_oauth.py +633 -0
  1664. package/tools/mcp_oauth_manager.py +607 -0
  1665. package/tools/mcp_tool.py +3483 -0
  1666. package/tools/memory_tool.py +584 -0
  1667. package/tools/microsoft_graph_auth.py +245 -0
  1668. package/tools/microsoft_graph_client.py +408 -0
  1669. package/tools/mixture_of_agents_tool.py +542 -0
  1670. package/tools/neutts_samples/jo.txt +1 -0
  1671. package/tools/neutts_samples/jo.wav +0 -0
  1672. package/tools/neutts_synth.py +104 -0
  1673. package/tools/openrouter_client.py +33 -0
  1674. package/tools/osv_check.py +155 -0
  1675. package/tools/patch_parser.py +592 -0
  1676. package/tools/path_security.py +43 -0
  1677. package/tools/process_registry.py +1534 -0
  1678. package/tools/registry.py +589 -0
  1679. package/tools/schema_sanitizer.py +370 -0
  1680. package/tools/send_message_tool.py +1900 -0
  1681. package/tools/session_search_tool.py +613 -0
  1682. package/tools/skill_manager_tool.py +932 -0
  1683. package/tools/skill_provenance.py +78 -0
  1684. package/tools/skill_usage.py +610 -0
  1685. package/tools/skills_guard.py +932 -0
  1686. package/tools/skills_hub.py +3263 -0
  1687. package/tools/skills_sync.py +432 -0
  1688. package/tools/skills_tool.py +1569 -0
  1689. package/tools/slash_confirm.py +167 -0
  1690. package/tools/terminal_tool.py +2376 -0
  1691. package/tools/tirith_security.py +775 -0
  1692. package/tools/todo_tool.py +277 -0
  1693. package/tools/tool_backend_helpers.py +144 -0
  1694. package/tools/tool_output_limits.py +92 -0
  1695. package/tools/tool_result_storage.py +232 -0
  1696. package/tools/transcription_tools.py +936 -0
  1697. package/tools/tts_tool.py +2285 -0
  1698. package/tools/url_safety.py +330 -0
  1699. package/tools/video_generation_tool.py +561 -0
  1700. package/tools/vision_tools.py +1422 -0
  1701. package/tools/voice_mode.py +1019 -0
  1702. package/tools/web_tools.py +1551 -0
  1703. package/tools/website_policy.py +283 -0
  1704. package/tools/x_search_tool.py +424 -0
  1705. package/tools/xai_http.py +83 -0
  1706. package/tools/yuanbao_tools.py +736 -0
  1707. package/toolset_distributions.py +364 -0
  1708. package/toolsets.py +866 -0
  1709. package/trajectory_compressor.py +1509 -0
  1710. package/tui_gateway/__init__.py +0 -0
  1711. package/tui_gateway/entry.py +251 -0
  1712. package/tui_gateway/event_publisher.py +126 -0
  1713. package/tui_gateway/render.py +49 -0
  1714. package/tui_gateway/server.py +6623 -0
  1715. package/tui_gateway/slash_worker.py +76 -0
  1716. package/tui_gateway/transport.py +219 -0
  1717. package/tui_gateway/ws.py +178 -0
  1718. package/utils.py +361 -0
@@ -0,0 +1,4873 @@
1
+ """
2
+ Yuanbao platform adapter.
3
+
4
+ Connects to the Yuanbao WebSocket gateway, handles authentication (AUTH_BIND),
5
+ heartbeat, reconnection, message receive (T05) and send (T06).
6
+
7
+ Configuration in config.yaml (or via env vars):
8
+ platforms:
9
+ yuanbao:
10
+ extra:
11
+ app_id: "..." # or YUANBAO_APP_ID
12
+ app_secret: "..." # or YUANBAO_APP_SECRET
13
+ bot_id: "..." # or YUANBAO_BOT_ID (optional, returned by sign-token)
14
+ ws_url: "wss://..." # or YUANBAO_WS_URL
15
+ api_domain: "https://..." # or YUANBAO_API_DOMAIN
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import collections
22
+ import dataclasses
23
+ import hashlib
24
+ import hmac
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ import secrets
30
+ import time
31
+ import urllib.parse
32
+ import uuid
33
+ from datetime import datetime, timezone, timedelta
34
+ from pathlib import Path
35
+ from abc import ABC, abstractmethod
36
+ from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
37
+
38
+ import sys
39
+
40
+ import httpx
41
+
42
+ try:
43
+ import websockets
44
+ import websockets.exceptions
45
+ WEBSOCKETS_AVAILABLE = True
46
+ except ImportError:
47
+ WEBSOCKETS_AVAILABLE = False
48
+ websockets = None # type: ignore[assignment]
49
+
50
+ from gateway.config import Platform, PlatformConfig
51
+ from gateway.platforms.base import (
52
+ BasePlatformAdapter,
53
+ MessageEvent,
54
+ MessageType,
55
+ SendResult,
56
+ cache_document_from_bytes,
57
+ cache_image_from_bytes,
58
+ )
59
+ from gateway.platforms.helpers import MessageDeduplicator
60
+ from gateway.platforms.yuanbao_media import (
61
+ download_url as media_download_url,
62
+ get_cos_credentials,
63
+ upload_to_cos,
64
+ build_image_msg_body,
65
+ build_file_msg_body,
66
+ guess_mime_type,
67
+ md5_hex,
68
+ )
69
+ from gateway.platforms.yuanbao_proto import (
70
+ CMD_TYPE,
71
+ _fields_to_dict,
72
+ _get_string,
73
+ _get_varint,
74
+ _parse_fields,
75
+ WS_HEARTBEAT_RUNNING,
76
+ WS_HEARTBEAT_FINISH,
77
+ HERMES_INSTANCE_ID,
78
+ decode_conn_msg,
79
+ decode_inbound_push,
80
+ decode_query_group_info_rsp,
81
+ decode_get_group_member_list_rsp,
82
+ encode_auth_bind,
83
+ encode_ping,
84
+ encode_push_ack,
85
+ encode_send_c2c_message,
86
+ encode_send_group_message,
87
+ encode_send_private_heartbeat,
88
+ encode_send_group_heartbeat,
89
+ encode_query_group_info,
90
+ encode_get_group_member_list,
91
+ next_seq_no,
92
+ )
93
+ from gateway.session import build_session_key
94
+
95
+ logger = logging.getLogger(__name__)
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Version / platform constants (used in AUTH_BIND and sign-token headers)
99
+ # ---------------------------------------------------------------------------
100
+ try:
101
+ from hermes_cli import __version__ as _HERMES_VERSION
102
+ except ImportError:
103
+ _HERMES_VERSION = "0.0.0"
104
+
105
+ _APP_VERSION = _HERMES_VERSION
106
+ _BOT_VERSION = _HERMES_VERSION
107
+ _YUANBAO_INSTANCE_ID = str(HERMES_INSTANCE_ID) # single source: yuanbao_proto.HERMES_INSTANCE_ID
108
+ _OPERATION_SYSTEM = sys.platform
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Module-level constants
112
+ # ---------------------------------------------------------------------------
113
+
114
+ DEFAULT_WS_GATEWAY_URL = "wss://bot-wss.yuanbao.tencent.com/wss/connection"
115
+ DEFAULT_API_DOMAIN = "https://bot.yuanbao.tencent.com"
116
+
117
+ HEARTBEAT_INTERVAL_SECONDS = 30.0
118
+ CONNECT_TIMEOUT_SECONDS = 15.0
119
+ AUTH_TIMEOUT_SECONDS = 10.0
120
+ MAX_RECONNECT_ATTEMPTS = 100
121
+ DEFAULT_SEND_TIMEOUT = 30.0 # WS biz request timeout
122
+
123
+ # Close codes that indicate permanent errors — do NOT reconnect.
124
+ NO_RECONNECT_CLOSE_CODES = {4012, 4013, 4014, 4018, 4019, 4021}
125
+
126
+ # Heartbeat timeout threshold — N consecutive missed pongs trigger reconnect.
127
+ HEARTBEAT_TIMEOUT_THRESHOLD = 2
128
+
129
+ # Auth error code classification
130
+ AUTH_FAILED_CODES = {4001, 4002, 4003} # permanent auth failure, re-sign token
131
+ AUTH_RETRYABLE_CODES = {4010, 4011, 4099} # transient, can retry with same token
132
+
133
+ # Reply Heartbeat configuration
134
+ REPLY_HEARTBEAT_INTERVAL_S = 2.0 # Send RUNNING every 2 seconds
135
+ REPLY_HEARTBEAT_TIMEOUT_S = 30.0 # Auto-stop after 30 seconds of inactivity
136
+
137
+ # Reply-to reference configuration
138
+ REPLY_REF_TTL_S = 300.0 # Reference dedup TTL (5 minutes)
139
+
140
+ # Slow-response hint: push a waiting message when agent produces no data for this duration (seconds)
141
+ SLOW_RESPONSE_TIMEOUT_S = 120.0
142
+ SLOW_RESPONSE_MESSAGE = "任务有点复杂,正在努力处理中,请耐心等待..."
143
+
144
+ # Regex matching Yuanbao resource reference anchors in transcript text:
145
+ # [image|ybres:abc123] [file:report.pdf|ybres:xyz789] [voice|ybres:...]
146
+ _YB_RES_REF_RE = re.compile(
147
+ r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
148
+ )
149
+
150
+ # Media kinds that can be resolved and injected into the model context
151
+ _RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
152
+
153
+ # Strip page indicators like (1/3) appended by BasePlatformAdapter
154
+ _INDICATOR_RE = re.compile(r'\s*\(\d+/\d+\)$')
155
+
156
+ # Observed-media backfill: how many recent transcript messages to scan
157
+ OBSERVED_MEDIA_BACKFILL_LOOKBACK = 50
158
+ # Max number of resource references to resolve per inbound turn
159
+ OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN = 12
160
+
161
+ class MarkdownProcessor:
162
+ """Encapsulates all Markdown-related utilities for the Yuanbao platform.
163
+
164
+ Provides static methods for:
165
+ - Fence detection and streaming merge
166
+ - Table row detection and sanitization
167
+ - Paragraph-boundary splitting
168
+ - Atomic-block extraction and chunk splitting
169
+ - Outer markdown fence stripping
170
+ - Markdown hint prompt generation
171
+ """
172
+
173
+ # -- Fence detection ---------------------------------------------------
174
+
175
+ @staticmethod
176
+ def has_unclosed_fence(text: str) -> bool:
177
+ """
178
+ Detect whether the text has unclosed code block fences.
179
+
180
+ Scan line by line, toggling in/out state when encountering a line starting with ```.
181
+ An odd number of toggles indicates an unclosed fence.
182
+
183
+ Args:
184
+ text: Markdown text to check
185
+
186
+ Returns:
187
+ Returns True if the text ends with an unclosed fence, otherwise False
188
+ """
189
+ in_fence = False
190
+ for line in text.split('\n'):
191
+ if line.startswith('```'):
192
+ in_fence = not in_fence
193
+ return in_fence
194
+
195
+ # -- Table detection ---------------------------------------------------
196
+
197
+ @staticmethod
198
+ def ends_with_table_row(text: str) -> bool:
199
+ """
200
+ Detect whether the text ends with a table row (last non-empty line starts and ends with |).
201
+
202
+ Args:
203
+ text: Text to check
204
+
205
+ Returns:
206
+ Returns True if the last non-empty line is a table row
207
+ """
208
+ trimmed = text.rstrip()
209
+ if not trimmed:
210
+ return False
211
+ last_line = trimmed.split('\n')[-1].strip()
212
+ return last_line.startswith('|') and last_line.endswith('|')
213
+
214
+ # -- Paragraph boundary splitting --------------------------------------
215
+
216
+ @staticmethod
217
+ def split_at_paragraph_boundary(
218
+ text: str,
219
+ max_chars: int,
220
+ len_fn: Optional[Callable[[str], int]] = None,
221
+ ) -> tuple[str, str]:
222
+ """
223
+ Find the nearest paragraph boundary split point within max_chars, return (head, tail).
224
+
225
+ Split priority:
226
+ 1. Blank line (paragraph boundary)
227
+ 2. Newline after period/question mark/exclamation mark (Chinese and English)
228
+ 3. Last newline
229
+ 4. Force split at max_chars
230
+
231
+ Args:
232
+ text: Text to split
233
+ max_chars: Maximum character count limit
234
+ len_fn: Optional custom length function (e.g. UTF-16 length); defaults to built-in len
235
+
236
+ Returns:
237
+ (head, tail) tuple, head is the front part, tail is the back part, satisfying head + tail == text
238
+ """
239
+ _len = len_fn or len
240
+ if _len(text) <= max_chars:
241
+ return text, ''
242
+
243
+ # Build a character-index window that fits within max_chars.
244
+ # When len_fn != len we cannot simply slice [:max_chars], so we
245
+ # binary-search for the largest prefix that fits.
246
+ if _len is len:
247
+ window = text[:max_chars]
248
+ else:
249
+ lo, hi = 0, len(text)
250
+ while lo < hi:
251
+ mid = (lo + hi + 1) // 2
252
+ if _len(text[:mid]) <= max_chars:
253
+ lo = mid
254
+ else:
255
+ hi = mid - 1
256
+ window = text[:lo]
257
+
258
+ # 1. Prefer the last blank line (\n\n) as paragraph boundary
259
+ pos = window.rfind('\n\n')
260
+ if pos > 0:
261
+ return text[:pos + 2], text[pos + 2:]
262
+
263
+ # 2. Then find the last newline after a sentence-ending punctuation
264
+ sentence_end_re = re.compile(r'[。!?.!?]\n')
265
+ best_pos = -1
266
+ for m in sentence_end_re.finditer(window):
267
+ best_pos = m.end()
268
+ if best_pos > 0:
269
+ return text[:best_pos], text[best_pos:]
270
+
271
+ # 3. Fallback: find the last newline
272
+ pos = window.rfind('\n')
273
+ if pos > 0:
274
+ return text[:pos + 1], text[pos + 1:]
275
+
276
+ # 4. No valid split point found, force split at window boundary
277
+ cut = len(window)
278
+ return text[:cut], text[cut:]
279
+
280
+ # -- Atomic block helpers (private) ------------------------------------
281
+
282
+ @staticmethod
283
+ def is_fence_atom(text: str) -> bool:
284
+ """Determine whether an atomic block is a code block (starts with ```)."""
285
+ return text.lstrip().startswith('```')
286
+
287
+ @staticmethod
288
+ def is_table_atom(text: str) -> bool:
289
+ """Determine whether an atomic block is a table (first line starts with |)."""
290
+ first_line = text.split('\n')[0].strip()
291
+ return first_line.startswith('|') and first_line.endswith('|')
292
+
293
+ @staticmethod
294
+ def split_into_atoms(text: str) -> list[str]:
295
+ """
296
+ Split text into a list of "atomic blocks", each being an indivisible logical unit:
297
+
298
+ - Code block (fence): from opening ``` to closing ``` (including fence lines)
299
+ - Table: consecutive |...| lines forming a whole segment
300
+ - Normal paragraph: plain text segments separated by blank lines
301
+
302
+ Blank lines serve as separators and are not included in any atomic block.
303
+
304
+ Args:
305
+ text: Markdown text to split
306
+
307
+ Returns:
308
+ List of atomic block strings (all non-empty)
309
+ """
310
+ lines = text.split('\n')
311
+ atoms: list[str] = []
312
+
313
+ current_lines: list[str] = []
314
+ in_fence = False
315
+
316
+ def _is_table_line(line: str) -> bool:
317
+ stripped = line.strip()
318
+ return stripped.startswith('|') and stripped.endswith('|')
319
+
320
+ def _flush_current() -> None:
321
+ if current_lines:
322
+ atom = '\n'.join(current_lines)
323
+ if atom.strip():
324
+ atoms.append(atom)
325
+ current_lines.clear()
326
+
327
+ for line in lines:
328
+ if in_fence:
329
+ current_lines.append(line)
330
+ if line.startswith('```') and len(current_lines) > 1:
331
+ in_fence = False
332
+ _flush_current()
333
+ elif line.startswith('```'):
334
+ _flush_current()
335
+ in_fence = True
336
+ current_lines.append(line)
337
+ elif _is_table_line(line):
338
+ if current_lines and not _is_table_line(current_lines[-1]):
339
+ _flush_current()
340
+ current_lines.append(line)
341
+ elif line.strip() == '':
342
+ _flush_current()
343
+ else:
344
+ if current_lines and _is_table_line(current_lines[-1]):
345
+ _flush_current()
346
+ current_lines.append(line)
347
+
348
+ _flush_current()
349
+
350
+ return atoms
351
+
352
+ # -- Core: chunk splitting ---------------------------------------------
353
+
354
+ @classmethod
355
+ def chunk_markdown_text(
356
+ cls,
357
+ text: str,
358
+ max_chars: int = 4000,
359
+ len_fn: Optional[Callable[[str], int]] = None,
360
+ ) -> list[str]:
361
+ """
362
+ Split Markdown text into multiple chunks by max_chars.
363
+
364
+ Guarantees:
365
+ - Each chunk <= max_chars characters (unless a single code block/table itself exceeds the limit)
366
+ - Code blocks (```...```) are not split in the middle
367
+ - Table rows are not split in the middle (tables output as atomic blocks)
368
+ - Split at paragraph boundaries (blank lines, after periods, etc.)
369
+ - Small trailing/leading chunks are merged with neighbours when possible
370
+
371
+ Args:
372
+ text: Markdown text to split
373
+ max_chars: Max characters per chunk, default 4000
374
+ len_fn: Optional custom length function (e.g. UTF-16 length); defaults to built-in len
375
+
376
+ Returns:
377
+ List of text chunks after splitting (non-empty)
378
+ """
379
+ _len = len_fn or len
380
+
381
+ if not text:
382
+ return []
383
+
384
+ if _len(text) <= max_chars:
385
+ return [text]
386
+
387
+ # Phase 1: Extract atomic blocks
388
+ atoms = cls.split_into_atoms(text)
389
+
390
+ # Phase 2: Greedy merge
391
+ chunks: list[str] = []
392
+ indivisible_set: set[int] = set()
393
+ current_parts: list[str] = []
394
+ current_len = 0
395
+
396
+ def _flush_parts() -> None:
397
+ if current_parts:
398
+ chunks.append('\n\n'.join(current_parts))
399
+
400
+ for atom in atoms:
401
+ atom_len = _len(atom)
402
+ sep_len = 2 if current_parts else 0
403
+ projected_len = current_len + sep_len + atom_len
404
+
405
+ if projected_len > max_chars and current_parts:
406
+ _flush_parts()
407
+ current_parts = []
408
+ current_len = 0
409
+ sep_len = 0
410
+
411
+ if (not current_parts
412
+ and atom_len > max_chars
413
+ and (cls.is_fence_atom(atom) or cls.is_table_atom(atom))):
414
+ indivisible_set.add(len(chunks))
415
+ chunks.append(atom)
416
+ continue
417
+
418
+ current_parts.append(atom)
419
+ current_len += sep_len + atom_len
420
+
421
+ _flush_parts()
422
+
423
+ # Phase 3: Post-processing — split still-oversized chunks at paragraph boundaries
424
+ result: list[str] = []
425
+ for idx, chunk in enumerate(chunks):
426
+ if _len(chunk) <= max_chars:
427
+ result.append(chunk)
428
+ continue
429
+
430
+ if idx in indivisible_set:
431
+ result.append(chunk)
432
+ continue
433
+
434
+ if cls.has_unclosed_fence(chunk):
435
+ result.append(chunk)
436
+ continue
437
+
438
+ remaining = chunk
439
+ while _len(remaining) > max_chars:
440
+ head, remaining = cls.split_at_paragraph_boundary(
441
+ remaining, max_chars, len_fn=len_fn,
442
+ )
443
+ if not head:
444
+ head, remaining = remaining[:max_chars], remaining[max_chars:]
445
+ if head:
446
+ result.append(head)
447
+ if remaining:
448
+ result.append(remaining)
449
+
450
+ # Phase 4: Merge small trailing/leading chunks with neighbours
451
+ if len(result) > 1:
452
+ merged: list[str] = [result[0]]
453
+ for chunk in result[1:]:
454
+ prev = merged[-1]
455
+ combined = prev + '\n\n' + chunk
456
+ if _len(combined) <= max_chars:
457
+ merged[-1] = combined
458
+ else:
459
+ merged.append(chunk)
460
+ result = merged
461
+
462
+ return [c for c in result if c]
463
+
464
+ # -- Block separator inference -----------------------------------------
465
+
466
+ @classmethod
467
+ def infer_block_separator(cls, prev_chunk: str, next_chunk: str) -> str:
468
+ """
469
+ Infer the separator to use between two split chunks.
470
+
471
+ Rules (aligned with TS markdown-stream.ts):
472
+ - Previous chunk ends with code fence or next chunk starts with fence → single newline '\\n'
473
+ - Previous chunk ends with table row and next chunk starts with table row → single newline '\\n' (continued table)
474
+ - Otherwise → double newline '\\n\\n' (paragraph separator)
475
+
476
+ Args:
477
+ prev_chunk: Previous chunk
478
+ next_chunk: Next chunk
479
+
480
+ Returns:
481
+ '\\n' or '\\n\\n'
482
+ """
483
+ prev_trimmed = prev_chunk.rstrip()
484
+ next_trimmed = next_chunk.lstrip()
485
+
486
+ # Previous chunk ends with fence or next chunk starts with fence
487
+ if prev_trimmed.endswith('```') or next_trimmed.startswith('```'):
488
+ return '\n'
489
+
490
+ # Table continuation
491
+ if cls.ends_with_table_row(prev_chunk):
492
+ first_line = next_trimmed.split('\n')[0].strip() if next_trimmed else ''
493
+ if first_line.startswith('|') and first_line.endswith('|'):
494
+ return '\n'
495
+
496
+ return '\n\n'
497
+
498
+ # -- Streaming fence merge ---------------------------------------------
499
+
500
+ @classmethod
501
+ def merge_block_streaming_fences(cls, chunks: list[str]) -> list[str]:
502
+ """
503
+ Stream-aware fence-conscious chunk merging.
504
+
505
+ When streaming output produces multiple chunks truncated in the middle of a fence,
506
+ attempt to merge adjacent chunks to complete the fence.
507
+
508
+ Rules:
509
+ - If chunk i has an unclosed fence and chunk i+1 starts with ```,
510
+ merge i+1 into i (until the fence is closed or no more chunks).
511
+ - Use infer_block_separator to infer the separator during merging.
512
+
513
+ Args:
514
+ chunks: Original chunk list
515
+
516
+ Returns:
517
+ Merged chunk list (length <= original length)
518
+ """
519
+ if not chunks:
520
+ return []
521
+
522
+ result: list[str] = []
523
+ i = 0
524
+ while i < len(chunks):
525
+ current = chunks[i]
526
+ # If current chunk has unclosed fence, try merging subsequent chunks
527
+ while cls.has_unclosed_fence(current) and i + 1 < len(chunks):
528
+ sep = cls.infer_block_separator(current, chunks[i + 1])
529
+ current = current + sep + chunks[i + 1]
530
+ i += 1
531
+ result.append(current)
532
+ i += 1
533
+
534
+ return result
535
+
536
+ # -- Outer fence stripping ---------------------------------------------
537
+
538
+ @staticmethod
539
+ def strip_outer_markdown_fence(text: str) -> str:
540
+ """
541
+ Strip outer Markdown fence.
542
+
543
+ When AI reply is entirely wrapped in ```markdown\\n...\\n```, remove the outer fence,
544
+ keeping the content. Only strip when the first line is ```markdown (case-insensitive) and the last line is ```.
545
+
546
+ Args:
547
+ text: Text to process
548
+
549
+ Returns:
550
+ Text with outer fence stripped (returns original if no match)
551
+ """
552
+ if not text:
553
+ return text
554
+
555
+ lines = text.split('\n')
556
+ if len(lines) < 3:
557
+ return text
558
+
559
+ first_line = lines[0].strip()
560
+ last_line = lines[-1].strip()
561
+
562
+ # First line must be ```markdown (optional language tag md/markdown)
563
+ if not re.match(r'^```(?:markdown|md)?\s*$', first_line, re.IGNORECASE):
564
+ return text
565
+
566
+ # Last line must be plain ```
567
+ if last_line != '```':
568
+ return text
569
+
570
+ # Strip first and last lines
571
+ inner = '\n'.join(lines[1:-1])
572
+ return inner
573
+
574
+ # -- Table sanitization ------------------------------------------------
575
+
576
+ @staticmethod
577
+ def sanitize_markdown_table(text: str) -> str:
578
+ """
579
+ Table output sanitization.
580
+
581
+ Handle common formatting issues in AI-generated Markdown tables:
582
+ 1. Remove extra whitespace before/after table rows
583
+ 2. Ensure separator rows (|---|---|) are correctly formatted
584
+ 3. Remove empty table rows
585
+
586
+ Args:
587
+ text: Markdown text containing tables
588
+
589
+ Returns:
590
+ Sanitized text
591
+ """
592
+ if '|' not in text:
593
+ return text
594
+
595
+ lines = text.split('\n')
596
+ result_lines: list[str] = []
597
+
598
+ for line in lines:
599
+ stripped = line.strip()
600
+
601
+ # Table row processing
602
+ if stripped.startswith('|') and stripped.endswith('|'):
603
+ # Separator row normalization: | --- | --- | → |---|---|
604
+ if re.match(r'^\|[\s\-:]+(\|[\s\-:]+)+\|$', stripped):
605
+ cells = stripped.split('|')
606
+ normalized = '|'.join(
607
+ cell.strip() if cell.strip() else cell
608
+ for cell in cells
609
+ )
610
+ result_lines.append(normalized)
611
+ elif stripped == '||' or stripped.replace('|', '').strip() == '':
612
+ # Empty table row → skip
613
+ continue
614
+ else:
615
+ result_lines.append(stripped)
616
+ else:
617
+ result_lines.append(line)
618
+
619
+ return '\n'.join(result_lines)
620
+
621
+ # -- Markdown hint prompt ----------------------------------------------
622
+
623
+ @staticmethod
624
+ def markdown_hint_system_prompt() -> str:
625
+ """
626
+ Markdown rendering hint (appended to system prompt).
627
+
628
+ Tell AI that Yuanbao platform supports Markdown rendering, including:
629
+ - Code blocks (```lang)
630
+ - Tables (| col | col |)
631
+ - Bold/italic
632
+ """
633
+ return (
634
+ "The current platform supports Markdown rendering. You can use the following formats:\n"
635
+ "- Code blocks: ```language\\ncode\\n```\n"
636
+ "- Tables: | col1 | col2 |\\n|---|---|\\n| val1 | val2 |\n"
637
+ "- Bold: **text** / Italic: *text*\n"
638
+ "Please use Markdown formatting when appropriate to improve readability."
639
+ )
640
+
641
+ class SignManager:
642
+ """Encapsulates all sign-token related logic for the Yuanbao platform.
643
+
644
+ Manages token acquisition, caching, signature computation, and
645
+ automatic retry. All state (cache, locks) is kept as class-level
646
+ attributes so that a single shared client serves the whole process.
647
+ """
648
+
649
+ # -- Constants ---------------------------------------------------------
650
+
651
+ TOKEN_PATH = "/api/v5/robotLogic/sign-token"
652
+
653
+ RETRYABLE_CODE = 10099
654
+ MAX_RETRIES = 3
655
+ RETRY_DELAY_S = 1.0
656
+
657
+ #: Early refresh margin (seconds), treat as expiring 60s before actual expiry
658
+ CACHE_REFRESH_MARGIN_S = 60
659
+
660
+ #: HTTP timeout (seconds)
661
+ HTTP_TIMEOUT_S = 10.0
662
+
663
+ # -- Class-level shared state ------------------------------------------
664
+
665
+ # key: app_key → {"token", "bot_id", "expire_ts", ...}
666
+ _cache: dict[str, dict[str, Any]] = {}
667
+
668
+ # Per-app_key refresh locks — prevents concurrent duplicate sign-token
669
+ # requests. Created lazily inside get_refresh_lock() which is only called
670
+ # from async context, so the Lock is always bound to the correct loop.
671
+ # disconnect() clears this dict to prevent stale locks across reconnects.
672
+ _locks: dict[str, asyncio.Lock] = {}
673
+
674
+ # -- Internal helpers --------------------------------------------------
675
+
676
+ @classmethod
677
+ def get_refresh_lock(cls, app_key: str) -> asyncio.Lock:
678
+ """Return (creating if needed) the per-app_key refresh lock.
679
+
680
+ Must only be called from within a running event loop (async context).
681
+ """
682
+ if app_key not in cls._locks:
683
+ cls._locks[app_key] = asyncio.Lock()
684
+ return cls._locks[app_key]
685
+
686
+ @staticmethod
687
+ def compute_signature(nonce: str, timestamp: str, app_key: str, app_secret: str) -> str:
688
+ """Compute HMAC-SHA256 signature (aligned with TypeScript original).
689
+
690
+ plain = nonce + timestamp + app_key + app_secret
691
+ signature = HMAC-SHA256(key=app_secret, msg=plain).hexdigest()
692
+ """
693
+ plain = nonce + timestamp + app_key + app_secret
694
+ return hmac.new(app_secret.encode(), plain.encode(), hashlib.sha256).hexdigest()
695
+
696
+ @staticmethod
697
+ def build_timestamp() -> str:
698
+ """Build Beijing-time ISO-8601 timestamp (no milliseconds).
699
+
700
+ Format: 2006-01-02T15:04:05+08:00
701
+ """
702
+ bjtime = datetime.now(tz=timezone(timedelta(hours=8)))
703
+ return bjtime.strftime("%Y-%m-%dT%H:%M:%S+08:00")
704
+
705
+ @classmethod
706
+ def is_cache_valid(cls, entry: dict[str, Any]) -> bool:
707
+ """Determine whether the cache entry is valid (not expired with margin)."""
708
+ return entry["expire_ts"] - time.time() > cls.CACHE_REFRESH_MARGIN_S
709
+
710
+ @classmethod
711
+ def clear_locks(cls) -> None:
712
+ """Clear all per-app_key refresh locks (called on disconnect)."""
713
+ cls._locks.clear()
714
+
715
+ @classmethod
716
+ def purge_expired(cls) -> int:
717
+ """Remove all expired entries from the token cache.
718
+
719
+ Returns the number of entries purged. Called lazily from
720
+ ``get_token()`` so that stale app_key entries don't accumulate
721
+ indefinitely in long-running processes.
722
+ """
723
+ now = time.time()
724
+ expired_keys = [
725
+ k for k, v in cls._cache.items()
726
+ if now - v.get("expire_ts", 0) > 0
727
+ ]
728
+ for k in expired_keys:
729
+ cls._cache.pop(k, None)
730
+ return len(expired_keys)
731
+
732
+ # -- Core: fetch -------------------------------------------------------
733
+
734
+ @classmethod
735
+ async def fetch(
736
+ cls,
737
+ app_key: str,
738
+ app_secret: str,
739
+ api_domain: str,
740
+ route_env: str = "",
741
+ ) -> dict[str, Any]:
742
+ """Send sign-ticket HTTP request with auto-retry (up to MAX_RETRIES times)."""
743
+ url = f"{api_domain.rstrip('/')}{cls.TOKEN_PATH}"
744
+ async with httpx.AsyncClient(timeout=cls.HTTP_TIMEOUT_S) as client:
745
+ for attempt in range(cls.MAX_RETRIES + 1):
746
+ nonce = secrets.token_hex(16)
747
+ timestamp = cls.build_timestamp()
748
+ signature = cls.compute_signature(nonce, timestamp, app_key, app_secret)
749
+
750
+ payload = {
751
+ "app_key": app_key,
752
+ "nonce": nonce,
753
+ "signature": signature,
754
+ "timestamp": timestamp,
755
+ }
756
+
757
+ headers = {
758
+ "Content-Type": "application/json",
759
+ "X-AppVersion": _APP_VERSION,
760
+ "X-OperationSystem": _OPERATION_SYSTEM,
761
+ "X-Instance-Id": _YUANBAO_INSTANCE_ID,
762
+ "X-Bot-Version": _BOT_VERSION,
763
+ }
764
+ if route_env:
765
+ headers["X-Route-Env"] = route_env
766
+
767
+ logger.info(
768
+ "Sign token request: url=%s%s",
769
+ url,
770
+ f" (retry {attempt}/{cls.MAX_RETRIES})" if attempt > 0 else "",
771
+ )
772
+
773
+ response = await client.post(url, json=payload, headers=headers)
774
+
775
+ if response.status_code != 200:
776
+ body = response.text
777
+ raise RuntimeError(f"Sign token API returned {response.status_code}: {body[:200]}")
778
+
779
+ try:
780
+ result_data: dict[str, Any] = response.json()
781
+ except Exception as exc:
782
+ raise ValueError(f"Sign token response parse error: {exc}") from exc
783
+
784
+ code = result_data.get("code")
785
+ if code == 0:
786
+ data = result_data.get("data")
787
+ if not isinstance(data, dict):
788
+ raise ValueError(f"Sign token response missing 'data' field: {result_data}")
789
+ logger.info("Sign token success: bot_id=%s", data.get("bot_id"))
790
+ return data
791
+
792
+ if code == cls.RETRYABLE_CODE and attempt < cls.MAX_RETRIES:
793
+ logger.warning(
794
+ "Sign token retryable: code=%s, retrying in %ss (attempt=%d/%d)",
795
+ code,
796
+ cls.RETRY_DELAY_S,
797
+ attempt + 1,
798
+ cls.MAX_RETRIES,
799
+ )
800
+ await asyncio.sleep(cls.RETRY_DELAY_S)
801
+ continue
802
+
803
+ msg = result_data.get("msg", "")
804
+ raise RuntimeError(f"Sign token error: code={code}, msg={msg}")
805
+
806
+ raise RuntimeError("Sign token failed: max retries exceeded")
807
+
808
+ # -- Public API: get (with cache) --------------------------------------
809
+
810
+ @classmethod
811
+ async def get_token(
812
+ cls,
813
+ app_key: str,
814
+ app_secret: str,
815
+ api_domain: str,
816
+ route_env: str = "",
817
+ ) -> dict[str, Any]:
818
+ """Get WS auth token (with cache).
819
+
820
+ Return directly on cache hit without re-requesting; treat as expiring
821
+ 60 seconds before actual expiry, triggering refresh.
822
+ """
823
+ # Lazily evict stale entries from other app_keys
824
+ cls.purge_expired()
825
+
826
+ cached = cls._cache.get(app_key)
827
+ if cached and cls.is_cache_valid(cached):
828
+ remain = int(cached["expire_ts"] - time.time())
829
+ logger.info("Using cached token (%ds remaining)", remain)
830
+ return dict(cached)
831
+
832
+ async with cls.get_refresh_lock(app_key):
833
+ cached = cls._cache.get(app_key)
834
+ if cached and cls.is_cache_valid(cached):
835
+ return dict(cached)
836
+
837
+ data = await cls.fetch(app_key, app_secret, api_domain, route_env)
838
+
839
+ duration: int = data.get("duration", 0)
840
+ expire_ts = time.time() + duration if duration > 0 else time.time() + 3600
841
+
842
+ cls._cache[app_key] = {
843
+ "token": data.get("token", ""),
844
+ "bot_id": data.get("bot_id", ""),
845
+ "duration": duration,
846
+ "product": data.get("product", ""),
847
+ "source": data.get("source", ""),
848
+ "expire_ts": expire_ts,
849
+ }
850
+
851
+ return dict(cls._cache[app_key])
852
+
853
+ # -- Public API: force refresh -----------------------------------------
854
+
855
+ @classmethod
856
+ async def force_refresh(
857
+ cls,
858
+ app_key: str,
859
+ app_secret: str,
860
+ api_domain: str,
861
+ route_env: str = "",
862
+ ) -> dict[str, Any]:
863
+ """Force refresh token (clear cache and re-sign)."""
864
+ logger.warning("[force-refresh] Clearing cache and re-signing token: app_key=****%s", app_key[-4:])
865
+ async with cls.get_refresh_lock(app_key):
866
+ cls._cache.pop(app_key, None)
867
+ data = await cls.fetch(app_key, app_secret, api_domain, route_env)
868
+
869
+ duration: int = data.get("duration", 0)
870
+ expire_ts = time.time() + duration if duration > 0 else time.time() + 3600
871
+
872
+ cls._cache[app_key] = {
873
+ "token": data.get("token", ""),
874
+ "bot_id": data.get("bot_id", ""),
875
+ "duration": duration,
876
+ "product": data.get("product", ""),
877
+ "source": data.get("source", ""),
878
+ "expire_ts": expire_ts,
879
+ }
880
+
881
+ return dict(cls._cache[app_key])
882
+
883
+
884
+ from dataclasses import dataclass, field as dc_field
885
+
886
+ @dataclass
887
+ class InboundContext:
888
+ """Mutable context flowing through the inbound middleware pipeline.
889
+
890
+ Each middleware reads/writes fields on this context. The pipeline
891
+ engine passes it to every middleware in registration order.
892
+ """
893
+
894
+ adapter: Any # YuanbaoAdapter (forward-ref avoids circular import)
895
+ raw_frames: list = dc_field(default_factory=list) # Raw bytes frames (debounce-aggregated)
896
+
897
+ # Populated by DecodeMiddleware
898
+ push: Optional[dict] = None
899
+ decoded_via: str = "" # "json" | "protobuf"
900
+
901
+ # Extracted from push by FieldExtractMiddleware
902
+ from_account: str = ""
903
+ group_code: str = ""
904
+ group_name: str = ""
905
+ sender_nickname: str = ""
906
+ msg_body: list = dc_field(default_factory=list)
907
+ msg_id: str = ""
908
+ cloud_custom_data: str = ""
909
+
910
+ # Derived by ChatRoutingMiddleware
911
+ chat_id: str = ""
912
+ chat_type: str = "" # "dm" | "group"
913
+ chat_name: str = ""
914
+
915
+ # Populated by ContentExtractMiddleware
916
+ raw_text: str = ""
917
+ media_refs: list = dc_field(default_factory=list)
918
+
919
+ # Owner command detection
920
+ owner_command: Optional[str] = None
921
+
922
+ # Source built by BuildSourceMiddleware
923
+ source: Optional[Any] = None # SessionSource
924
+
925
+ # Populated by ClassifyMessageTypeMiddleware
926
+ msg_type: Optional[Any] = None # MessageType
927
+
928
+ # Populated by QuoteContextMiddleware
929
+ reply_to_message_id: Optional[str] = None
930
+ reply_to_text: Optional[str] = None
931
+ quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
932
+
933
+ # Populated by MediaResolveMiddleware
934
+ media_urls: list = dc_field(default_factory=list)
935
+ media_types: list = dc_field(default_factory=list)
936
+
937
+ # Populated by ExtractContentMiddleware
938
+ link_urls: list = dc_field(default_factory=list)
939
+
940
+ # Populated by GroupAttributionMiddleware
941
+ channel_prompt: Optional[str] = None
942
+
943
+
944
+ class InboundMiddleware(ABC):
945
+ """Abstract base class for all inbound pipeline middlewares.
946
+
947
+ Subclasses must:
948
+ - Set ``name`` as a class-level attribute (used for pipeline registration
949
+ and dynamic insertion/removal).
950
+ - Implement ``async handle(ctx, next_fn)`` containing the middleware logic.
951
+
952
+ Convention:
953
+ - Call ``await next_fn()`` to pass control to the next middleware.
954
+ - Return without calling ``next_fn`` to **stop** the pipeline.
955
+ """
956
+
957
+ name: str = "" # Override in each subclass
958
+
959
+ @abstractmethod
960
+ async def handle(self, ctx: InboundContext, next_fn: Callable) -> None:
961
+ """Process *ctx* and optionally call *next_fn* to continue the pipeline."""
962
+
963
+ async def __call__(self, ctx: InboundContext, next_fn: Callable) -> None:
964
+ """Allow middleware instances to be called directly (duck-typing compat)."""
965
+ return await self.handle(ctx, next_fn)
966
+
967
+ def __repr__(self) -> str:
968
+ return f"<{self.__class__.__name__} name={self.name!r}>"
969
+
970
+
971
+ class InboundPipeline:
972
+ """Onion-model middleware pipeline engine for inbound message processing.
973
+
974
+ Inspired by OpenClaw's MessagePipeline (extensions/yuanbao/src/business/
975
+ pipeline/engine.ts). Supports named middlewares, conditional guards
976
+ (``when``), and ``use_before`` / ``use_after`` / ``remove`` for dynamic
977
+ composition.
978
+
979
+ Accepts both ``InboundMiddleware`` instances (OOP style) and plain
980
+ ``async def(ctx, next_fn)`` callables (functional style) for flexibility.
981
+ """
982
+
983
+ def __init__(self) -> None:
984
+ self._middlewares: list = [] # list of (name, handler, when_fn | None)
985
+
986
+ # -- Internal helpers --------------------------------------------------
987
+
988
+ @staticmethod
989
+ def _normalize(name_or_mw, handler=None):
990
+ """Normalize (name, handler) or (InboundMiddleware,) into (name, callable)."""
991
+ if isinstance(name_or_mw, InboundMiddleware):
992
+ return name_or_mw.name, name_or_mw
993
+ # Functional style: name is a str, handler is a callable
994
+ return name_or_mw, handler
995
+
996
+ # -- Registration API --------------------------------------------------
997
+
998
+ def use(self, name_or_mw, handler=None, when=None) -> "InboundPipeline":
999
+ """Append a middleware to the end of the pipeline.
1000
+
1001
+ Accepts either:
1002
+ - ``pipeline.use(SomeMiddleware())`` — OOP style
1003
+ - ``pipeline.use("name", some_fn)`` — functional style
1004
+ """
1005
+ name, h = self._normalize(name_or_mw, handler)
1006
+ self._middlewares.append((name, h, when))
1007
+ return self
1008
+
1009
+ def use_before(self, target: str, name_or_mw, handler=None, when=None) -> "InboundPipeline":
1010
+ """Insert a middleware before *target* (by name). Appends if not found."""
1011
+ name, h = self._normalize(name_or_mw, handler)
1012
+ idx = next((i for i, (n, _, _) in enumerate(self._middlewares) if n == target), None)
1013
+ entry = (name, h, when)
1014
+ if idx is None:
1015
+ self._middlewares.append(entry)
1016
+ else:
1017
+ self._middlewares.insert(idx, entry)
1018
+ return self
1019
+
1020
+ def use_after(self, target: str, name_or_mw, handler=None, when=None) -> "InboundPipeline":
1021
+ """Insert a middleware after *target* (by name). Appends if not found."""
1022
+ name, h = self._normalize(name_or_mw, handler)
1023
+ idx = next((i for i, (n, _, _) in enumerate(self._middlewares) if n == target), None)
1024
+ entry = (name, h, when)
1025
+ if idx is None:
1026
+ self._middlewares.append(entry)
1027
+ else:
1028
+ self._middlewares.insert(idx + 1, entry)
1029
+ return self
1030
+
1031
+ def remove(self, name: str) -> "InboundPipeline":
1032
+ """Remove a middleware by name."""
1033
+ self._middlewares = [(n, h, w) for n, h, w in self._middlewares if n != name]
1034
+ return self
1035
+
1036
+ @property
1037
+ def middleware_names(self) -> list:
1038
+ """Return ordered list of registered middleware names (for testing)."""
1039
+ return [n for n, _, _ in self._middlewares]
1040
+
1041
+ # -- Execution ---------------------------------------------------------
1042
+
1043
+ async def execute(self, ctx: InboundContext) -> None:
1044
+ """Run all middlewares in order. Each middleware receives ``(ctx, next_fn)``."""
1045
+ chain = self._middlewares
1046
+ index = 0
1047
+
1048
+ async def next_fn() -> None:
1049
+ nonlocal index
1050
+ while index < len(chain):
1051
+ name, handler, when_fn = chain[index]
1052
+ index += 1
1053
+ # Conditional guard: skip when returns False
1054
+ if when_fn is not None and not when_fn(ctx):
1055
+ continue
1056
+ try:
1057
+ await handler(ctx, next_fn)
1058
+ except Exception:
1059
+ logger.error("[InboundPipeline] middleware [%s] error", name, exc_info=True)
1060
+ raise
1061
+ return
1062
+ # End of chain — nothing more to do
1063
+
1064
+ await next_fn()
1065
+ class DecodeMiddleware(InboundMiddleware):
1066
+ """Decode raw inbound frames from JSON or Protobuf into ctx.push.
1067
+
1068
+ Encapsulates JSON push parsing (aligned with TS decodeFromContent)
1069
+ and Protobuf decoding via ``decode_inbound_push``.
1070
+ """
1071
+
1072
+ name = "decode"
1073
+
1074
+ # -- JSON push parsing -------------------------------------------------
1075
+
1076
+ @staticmethod
1077
+ def convert_json_msg_body(raw_body: list) -> list:
1078
+ """Normalize raw JSON msg_body array to [{"msg_type": str, "msg_content": dict}].
1079
+
1080
+ Compatible with both PascalCase (MsgType/MsgContent) and
1081
+ snake_case (msg_type/msg_content) naming.
1082
+ """
1083
+ result = []
1084
+ for item in raw_body or []:
1085
+ if not isinstance(item, dict):
1086
+ continue
1087
+ msg_type = item.get("msg_type") or item.get("MsgType", "")
1088
+ msg_content = item.get("msg_content") or item.get("MsgContent", {})
1089
+ if isinstance(msg_content, str):
1090
+ try:
1091
+ msg_content = json.loads(msg_content)
1092
+ except Exception:
1093
+ msg_content = {"text": msg_content}
1094
+ result.append({"msg_type": msg_type, "msg_content": msg_content or {}})
1095
+ return result
1096
+
1097
+ @staticmethod
1098
+ def parse_json_push(raw_json: dict) -> dict | None:
1099
+ """Convert JSON-format push to a dict with the same structure as
1100
+ ``decode_inbound_push``.
1101
+
1102
+ Supports standard callback format (callback_command + from_account +
1103
+ msg_body) and legacy format fields (GroupId, MsgSeq, MsgKey, MsgBody,
1104
+ etc.).
1105
+ """
1106
+ if not raw_json:
1107
+ return None
1108
+
1109
+ # Tencent IM callback format uses PascalCase (From_Account, To_Account, MsgBody).
1110
+ # Internal format uses snake_case (from_account, to_account, msg_body).
1111
+ # Support both.
1112
+ from_account = (
1113
+ raw_json.get("from_account", "")
1114
+ or raw_json.get("From_Account", "")
1115
+ )
1116
+ group_code = (
1117
+ raw_json.get("group_code", "")
1118
+ or raw_json.get("GroupId", "")
1119
+ or raw_json.get("group_id", "")
1120
+ )
1121
+ msg_body_raw = (
1122
+ raw_json.get("msg_body", [])
1123
+ or raw_json.get("MsgBody", [])
1124
+ )
1125
+ msg_body = DecodeMiddleware.convert_json_msg_body(msg_body_raw)
1126
+
1127
+ # Recall callbacks may have neither from_account nor msg_body.
1128
+ if not from_account and not msg_body and not raw_json.get("callback_command"):
1129
+ return None
1130
+
1131
+ return {
1132
+ "callback_command": raw_json.get("callback_command", ""),
1133
+ "from_account": from_account,
1134
+ "to_account": raw_json.get("to_account", "") or raw_json.get("To_Account", ""),
1135
+ "sender_nickname": raw_json.get("sender_nickname", "") or raw_json.get("nick_name", ""),
1136
+ "group_code": group_code,
1137
+ "group_name": raw_json.get("group_name", ""),
1138
+ "msg_seq": raw_json.get("msg_seq", 0) or raw_json.get("MsgSeq", 0),
1139
+ "msg_id": raw_json.get("msg_id", "") or raw_json.get("msg_key", "") or raw_json.get("MsgKey", ""),
1140
+ "msg_body": msg_body,
1141
+ "cloud_custom_data": raw_json.get("cloud_custom_data", "") or raw_json.get("CloudCustomData", ""),
1142
+ "bot_owner_id": raw_json.get("bot_owner_id", "") or raw_json.get("botOwnerId", ""),
1143
+ "recall_msg_seq_list": raw_json.get("recall_msg_seq_list") or None,
1144
+ "trace_id": (raw_json.get("log_ext") or {}).get("trace_id", "") if isinstance(raw_json.get("log_ext"), dict) else "",
1145
+ }
1146
+
1147
+ # -- Pipeline handler --------------------------------------------------
1148
+
1149
+ def _decode_single(self, adapter, data: bytes) -> tuple:
1150
+ """Decode a single raw frame into (push_dict, decoded_via) or (None, '')."""
1151
+ try:
1152
+ conn_json = json.loads(data.decode("utf-8"))
1153
+ except Exception:
1154
+ conn_json = None
1155
+
1156
+ if isinstance(conn_json, dict):
1157
+ push = self.parse_json_push(conn_json)
1158
+ if push:
1159
+ return push, "json"
1160
+ else:
1161
+ try:
1162
+ push = decode_inbound_push(data)
1163
+ except Exception:
1164
+ push = None
1165
+ if push:
1166
+ return push, "protobuf"
1167
+
1168
+ return None, ""
1169
+
1170
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1171
+ data_list = ctx.raw_frames
1172
+ if not data_list:
1173
+ return # Stop pipeline — nothing to decode
1174
+
1175
+ merged_push = None
1176
+ decoded_via = ""
1177
+
1178
+ for data in data_list:
1179
+ push, via = self._decode_single(ctx.adapter, data)
1180
+ if not push:
1181
+ logger.info(
1182
+ "[%s] Push decoded but no valid message. raw hex(first64)=%s",
1183
+ ctx.adapter.name, data.hex()[:128] if data else "(empty)",
1184
+ )
1185
+ continue
1186
+
1187
+ if merged_push is None:
1188
+ # First valid push becomes the base
1189
+ merged_push = push
1190
+ decoded_via = via
1191
+ logger.info(
1192
+ "[%s] Frame decoded (via=%s): len=%d",
1193
+ ctx.adapter.name, via, len(data),
1194
+ )
1195
+ else:
1196
+ # Subsequent pushes: merge msg_body into the base with a
1197
+ extra_body = push.get("msg_body", [])
1198
+ if extra_body:
1199
+ _sep = {"msg_type": "TIMTextElem", "msg_content": {"text": "\n"}}
1200
+ merged_push["msg_body"] = merged_push.get("msg_body", []) + [_sep] + extra_body
1201
+ logger.info(
1202
+ "[%s] Merged %d extra msg_body elements from aggregated push",
1203
+ ctx.adapter.name, len(extra_body),
1204
+ )
1205
+
1206
+ if not merged_push:
1207
+ return # Stop pipeline
1208
+
1209
+ ctx.push = merged_push
1210
+ ctx.decoded_via = decoded_via
1211
+
1212
+ logger.info(
1213
+ "[%s] Push decoded (via=%s): from=%s group=%s msg_id=%s msg_types=%s",
1214
+ ctx.adapter.name, ctx.decoded_via,
1215
+ ctx.push.get("from_account", ""),
1216
+ ctx.push.get("group_code", ""),
1217
+ ctx.push.get("msg_id", ""),
1218
+ [e.get("msg_type", "") for e in ctx.push.get("msg_body", [])],
1219
+ )
1220
+ logger.debug("[%s] Push payload: %s", ctx.adapter.name, ctx.push)
1221
+
1222
+ await next_fn()
1223
+
1224
+
1225
+ class ExtractFieldsMiddleware(InboundMiddleware):
1226
+ """Extract common fields from ctx.push into ctx attributes."""
1227
+
1228
+ name = "extract-fields"
1229
+
1230
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1231
+ push = ctx.push
1232
+ ctx.from_account = push.get("from_account", "")
1233
+ ctx.group_code = push.get("group_code", "")
1234
+ ctx.group_name = push.get("group_name", "")
1235
+ ctx.sender_nickname = push.get("sender_nickname", "")
1236
+ ctx.msg_body = push.get("msg_body", [])
1237
+ ctx.msg_id = push.get("msg_id", "")
1238
+ ctx.cloud_custom_data = push.get("cloud_custom_data", "")
1239
+ await next_fn()
1240
+
1241
+
1242
+ class DedupMiddleware(InboundMiddleware):
1243
+ """Inbound message deduplication."""
1244
+
1245
+ name = "dedup"
1246
+
1247
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1248
+ if ctx.msg_id and ctx.adapter._dedup.is_duplicate(ctx.msg_id):
1249
+ logger.debug("[%s] Duplicate message ignored: msg_id=%s", ctx.adapter.name, ctx.msg_id)
1250
+ return # Stop pipeline
1251
+ await next_fn()
1252
+
1253
+
1254
+ class RecallGuardMiddleware(InboundMiddleware):
1255
+ """Intercept Group.CallbackAfterRecallMsg / C2C.CallbackAfterMsgWithDraw.
1256
+
1257
+ Branch A: message in transcript (observed, not yet consumed) → redact content
1258
+ Branch B: message not in transcript → append system note
1259
+ Branch C: message currently being processed → silent interrupt + delayed redact
1260
+ """
1261
+
1262
+ name = "recall_guard"
1263
+
1264
+ _RECALL_COMMANDS = frozenset({
1265
+ "Group.CallbackAfterRecallMsg",
1266
+ "C2C.CallbackAfterMsgWithDraw",
1267
+ })
1268
+ _REDACTED = "[This message was recalled/withdrawn by the sender; original content removed]"
1269
+
1270
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1271
+ cmd = (ctx.push or {}).get("callback_command", "")
1272
+ if cmd not in self._RECALL_COMMANDS:
1273
+ await next_fn()
1274
+ return
1275
+ self._handle_recall(ctx, cmd)
1276
+
1277
+ @staticmethod
1278
+ def _build_source(adapter, group_code: str, from_account: str):
1279
+ return adapter.build_source(
1280
+ chat_id=(f"group:{group_code}" if group_code else f"direct:{from_account}"),
1281
+ chat_type="group" if group_code else "dm",
1282
+ user_id=from_account or None,
1283
+ thread_id="main" if group_code else None,
1284
+ )
1285
+
1286
+ def _handle_recall(self, ctx: InboundContext, cmd: str) -> None:
1287
+ adapter = ctx.adapter
1288
+ push = ctx.push or {}
1289
+
1290
+ if cmd == "Group.CallbackAfterRecallMsg":
1291
+ seq_list = push.get("recall_msg_seq_list") or []
1292
+ else:
1293
+ mid = push.get("msg_id") or ""
1294
+ seq = push.get("msg_seq")
1295
+ seq_list = [{"msg_id": mid, "msg_seq": seq}] if (mid or seq) else []
1296
+
1297
+ if not seq_list:
1298
+ logger.debug("[%s] Recall callback with empty seq_list, skipping", adapter.name)
1299
+ return
1300
+
1301
+ group_code = (push.get("group_code") or "").strip()
1302
+ from_account = (push.get("from_account") or "").strip()
1303
+
1304
+ for seq_entry in seq_list:
1305
+ recalled_id = seq_entry.get("msg_id") or str(seq_entry.get("msg_seq") or "")
1306
+ if not recalled_id:
1307
+ continue
1308
+
1309
+ matched_sk = self._find_processing_session(adapter, recalled_id)
1310
+ if matched_sk is not None:
1311
+ self._interrupt_for_recall(adapter, matched_sk, recalled_id, group_code, from_account)
1312
+ else:
1313
+ recalled_content = adapter._msg_content_cache.get(recalled_id)
1314
+ self._patch_transcript(adapter, recalled_id, group_code, from_account, recalled_content)
1315
+
1316
+ # -- Branch C: interrupt currently-processing message ---------------
1317
+
1318
+ @staticmethod
1319
+ def _find_processing_session(adapter, recalled_id: str) -> Optional[str]:
1320
+ for sk, mid in adapter._processing_msg_ids.items():
1321
+ if mid == recalled_id and sk in adapter._active_sessions:
1322
+ return sk
1323
+ return None
1324
+
1325
+ @classmethod
1326
+ def _interrupt_for_recall(cls, adapter, session_key: str, recalled_id: str,
1327
+ group_code: str, from_account: str) -> None:
1328
+ where = f"group {group_code}" if group_code else f"direct chat with {from_account}"
1329
+ recall_text = (
1330
+ f"[CRITICAL — MESSAGE RECALLED] The user message that triggered "
1331
+ f"your current task (message_id=\"{recalled_id}\") in {where} has "
1332
+ f"been recalled/withdrawn by the sender. "
1333
+ f"IGNORE any prior system note asking you to finish processing "
1334
+ f"tool results — the original request is void. "
1335
+ f"Do NOT continue the task, do NOT call more tools, do NOT "
1336
+ f"reference the recalled content. "
1337
+ f"Reply only with a brief acknowledgment such as "
1338
+ f"\"The message has been recalled.\" in the "
1339
+ f"language the user was using."
1340
+ )
1341
+
1342
+ synth_event = MessageEvent(
1343
+ text=recall_text,
1344
+ message_type=MessageType.TEXT,
1345
+ source=cls._build_source(adapter, group_code, from_account),
1346
+ internal=True,
1347
+ )
1348
+ # Set pending + signal directly (bypass handle_message to avoid busy-ack).
1349
+ # May overwrite a user message pending in the same ~200ms window — acceptable.
1350
+ adapter._pending_messages[session_key] = synth_event
1351
+ active_event = adapter._active_sessions.get(session_key)
1352
+ if active_event is not None:
1353
+ active_event.set()
1354
+
1355
+ logger.info("[%s] Recall interrupt: msg_id=%s session=%s", adapter.name, recalled_id, session_key[:30])
1356
+
1357
+ # The interrupted turn will persist the recalled content *after* our
1358
+ # interrupt — schedule a delayed redaction to clean it up.
1359
+ recalled_text = adapter._processing_msg_texts.get(session_key, "")
1360
+ if recalled_text:
1361
+ cls._schedule_content_redact(adapter, session_key, recalled_text, group_code, from_account)
1362
+
1363
+ @classmethod
1364
+ def _schedule_content_redact(cls, adapter, session_key: str, recalled_text: str,
1365
+ group_code: str, from_account: str) -> None:
1366
+ async def _redact() -> None:
1367
+ store = getattr(adapter, "_session_store", None)
1368
+ if not store:
1369
+ return
1370
+ try:
1371
+ sid = store.get_or_create_session(
1372
+ cls._build_source(adapter, group_code, from_account),
1373
+ ).session_id
1374
+ except Exception:
1375
+ return
1376
+ # Poll until the recalled content appears in transcript — the
1377
+ # interrupted turn hasn't finished writing yet when scheduled.
1378
+ for _ in range(30):
1379
+ await asyncio.sleep(0.5)
1380
+ try:
1381
+ transcript = store.load_transcript(sid)
1382
+ except Exception:
1383
+ continue
1384
+ for entry in transcript:
1385
+ if entry.get("role") == "user" and entry.get("content") == recalled_text:
1386
+ entry["content"] = cls._REDACTED
1387
+ try:
1388
+ store.rewrite_transcript(sid, transcript)
1389
+ logger.info("[%s] Recall redact: session %s", adapter.name, session_key[:30])
1390
+ except Exception as exc:
1391
+ logger.warning("[%s] Recall redact failed: %s", adapter.name, exc)
1392
+ return
1393
+ logger.debug("[%s] Recall redact: content not found after polling, session %s", adapter.name, session_key[:30])
1394
+
1395
+ task = asyncio.create_task(_redact())
1396
+ adapter._background_tasks.add(task)
1397
+ task.add_done_callback(adapter._background_tasks.discard)
1398
+
1399
+ # -- Branch A/B: patch transcript (session idle) --------------------
1400
+
1401
+ @classmethod
1402
+ def _patch_transcript(cls, adapter, recalled_id: str, group_code: str,
1403
+ from_account: str, recalled_content: Optional[str] = None) -> None:
1404
+ store = getattr(adapter, "_session_store", None)
1405
+ if not store:
1406
+ return
1407
+ try:
1408
+ sid = store.get_or_create_session(cls._build_source(adapter, group_code, from_account)).session_id
1409
+ except Exception as exc:
1410
+ logger.warning("[%s] Recall: failed to resolve session: %s", adapter.name, exc)
1411
+ return
1412
+
1413
+ # Read JSONL directly — SQLite doesn't preserve message_id field.
1414
+ transcript: list = []
1415
+ try:
1416
+ path = store.get_transcript_path(sid)
1417
+ if path.exists():
1418
+ with open(path, "r", encoding="utf-8") as f:
1419
+ for line in f:
1420
+ line = line.strip()
1421
+ if line:
1422
+ try:
1423
+ transcript.append(json.loads(line))
1424
+ except json.JSONDecodeError:
1425
+ pass
1426
+ except Exception as exc:
1427
+ logger.warning("[%s] Recall: failed to load transcript: %s", adapter.name, exc)
1428
+ return
1429
+
1430
+ # Branch A: redact — try message_id first, then content fallback.
1431
+ # Observed messages have message_id; agent-processed @bot messages
1432
+ # only have content (run.py doesn't write message_id to transcript).
1433
+ target = None
1434
+ for entry in transcript:
1435
+ if entry.get("message_id") == recalled_id:
1436
+ target = entry
1437
+ break
1438
+ if target is None and recalled_content:
1439
+ for entry in transcript:
1440
+ if entry.get("role") == "user" and entry.get("content") == recalled_content:
1441
+ target = entry
1442
+ break
1443
+ if target is not None:
1444
+ target["content"] = cls._REDACTED
1445
+ try:
1446
+ store.rewrite_transcript(sid, transcript)
1447
+ logger.info("[%s] Recall: redacted msg_id=%s (branch A)", adapter.name, recalled_id)
1448
+ except Exception as exc:
1449
+ logger.warning("[%s] Recall: rewrite_transcript failed: %s", adapter.name, exc)
1450
+ return
1451
+
1452
+ # Branch B: not found in transcript → append system note
1453
+ store.append_to_transcript(sid, {
1454
+ "role": "system",
1455
+ "content": f'[recall] message_id="{recalled_id}" has been recalled; do not quote or reference it.',
1456
+ "timestamp": datetime.now(tz=timezone.utc).isoformat(),
1457
+ })
1458
+ logger.info("[%s] Recall: system note for msg_id=%s (branch B)", adapter.name, recalled_id)
1459
+
1460
+
1461
+ class SkipSelfMiddleware(InboundMiddleware):
1462
+ """Filter out bot's own messages."""
1463
+
1464
+ name = "skip-self"
1465
+
1466
+ @staticmethod
1467
+ def _is_self_reference(from_account: str, bot_id: Optional[str]) -> bool:
1468
+ """Detect whether the message is from the bot itself."""
1469
+ if not from_account or not bot_id:
1470
+ return False
1471
+ return from_account == bot_id
1472
+
1473
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1474
+ if self._is_self_reference(ctx.from_account, ctx.adapter._bot_id):
1475
+ logger.debug("[%s] Ignoring self-sent message from %s", ctx.adapter.name, ctx.from_account)
1476
+ return # Stop pipeline
1477
+ await next_fn()
1478
+
1479
+
1480
+ class ChatRoutingMiddleware(InboundMiddleware):
1481
+ """Determine chat_id, chat_type, chat_name from push fields."""
1482
+
1483
+ name = "chat-routing"
1484
+
1485
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1486
+ if ctx.group_code:
1487
+ ctx.chat_id = f"group:{ctx.group_code}"
1488
+ ctx.chat_type = "group"
1489
+ ctx.chat_name = ctx.group_name or ctx.group_code
1490
+ else:
1491
+ ctx.chat_id = f"direct:{ctx.from_account}"
1492
+ ctx.chat_type = "dm"
1493
+ ctx.chat_name = ctx.sender_nickname or ctx.from_account
1494
+ await next_fn()
1495
+
1496
+
1497
+ class AccessPolicy:
1498
+ """Platform-level DM / Group access control policy.
1499
+
1500
+ Encapsulates the allow/deny logic so that both inbound middleware
1501
+ and outbound ``send_dm`` can share the same rules without reaching
1502
+ into adapter internals.
1503
+ """
1504
+
1505
+ def __init__(
1506
+ self,
1507
+ dm_policy: str,
1508
+ dm_allow_from: list[str],
1509
+ group_policy: str,
1510
+ group_allow_from: list[str],
1511
+ ) -> None:
1512
+ self._dm_policy = dm_policy
1513
+ self._dm_allow_from = dm_allow_from
1514
+ self._group_policy = group_policy
1515
+ self._group_allow_from = group_allow_from
1516
+
1517
+ def is_dm_allowed(self, sender_id: str) -> bool:
1518
+ """Platform-level DM inbound filter (open / allowlist / disabled)."""
1519
+ if self._dm_policy == "disabled":
1520
+ return False
1521
+ if self._dm_policy == "allowlist":
1522
+ return sender_id.strip() in self._dm_allow_from
1523
+ return True
1524
+
1525
+ def is_group_allowed(self, group_code: str) -> bool:
1526
+ """Platform-level group chat inbound filter (open / allowlist / disabled)."""
1527
+ if self._group_policy == "disabled":
1528
+ return False
1529
+ if self._group_policy == "allowlist":
1530
+ return group_code.strip() in self._group_allow_from
1531
+ return True
1532
+
1533
+ @property
1534
+ def dm_policy(self) -> str:
1535
+ return self._dm_policy
1536
+
1537
+ @property
1538
+ def group_policy(self) -> str:
1539
+ return self._group_policy
1540
+
1541
+
1542
+ class AccessGuardMiddleware(InboundMiddleware):
1543
+ """Platform-level DM/Group access control filter."""
1544
+
1545
+ name = "access-guard"
1546
+
1547
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1548
+ adapter = ctx.adapter
1549
+ policy: AccessPolicy = adapter._access_policy
1550
+ if ctx.chat_type == "dm":
1551
+ if not policy.is_dm_allowed(ctx.from_account):
1552
+ logger.debug(
1553
+ "[%s] DM from %s blocked by dm_policy=%s",
1554
+ adapter.name, ctx.from_account, policy.dm_policy,
1555
+ )
1556
+ return # Stop pipeline
1557
+ elif ctx.chat_type == "group":
1558
+ if not policy.is_group_allowed(ctx.group_code):
1559
+ logger.debug(
1560
+ "[%s] Group %s blocked by group_policy=%s",
1561
+ adapter.name, ctx.group_code, policy.group_policy,
1562
+ )
1563
+ return # Stop pipeline
1564
+ await next_fn()
1565
+
1566
+
1567
+ class AutoSetHomeMiddleware(InboundMiddleware):
1568
+ """Auto-designate the first inbound conversation as Yuanbao home channel.
1569
+
1570
+ Triggers when no home channel is configured, or when an existing group-chat
1571
+ home is superseded by the first DM (direct > group upgrade).
1572
+ Silent: writes config.yaml and env, no user-facing message.
1573
+ """
1574
+
1575
+ name = "auto-sethome"
1576
+
1577
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1578
+ adapter = ctx.adapter
1579
+ if not adapter._auto_sethome_done:
1580
+ _cur_home = os.getenv("YUANBAO_HOME_CHANNEL", "")
1581
+ _should_set = (
1582
+ not _cur_home
1583
+ or (_cur_home.startswith("group:") and ctx.chat_type == "dm")
1584
+ )
1585
+ if ctx.chat_type == "dm":
1586
+ adapter._auto_sethome_done = True # DM seen — no further upgrades needed
1587
+ if _should_set:
1588
+ try:
1589
+ from calvyn_constants import get_hermes_home
1590
+ from utils import atomic_yaml_write
1591
+ import yaml
1592
+
1593
+ _home = get_hermes_home()
1594
+ config_path = _home / "config.yaml"
1595
+ user_config: dict = {}
1596
+ if config_path.exists():
1597
+ with open(config_path, encoding="utf-8") as f:
1598
+ user_config = yaml.safe_load(f) or {}
1599
+ user_config["YUANBAO_HOME_CHANNEL"] = ctx.chat_id
1600
+ atomic_yaml_write(config_path, user_config)
1601
+ os.environ["YUANBAO_HOME_CHANNEL"] = str(ctx.chat_id)
1602
+ logger.info(
1603
+ "[%s] Auto-sethome: designated %s (%s) as Yuanbao home channel",
1604
+ adapter.name, ctx.chat_id, ctx.chat_name,
1605
+ )
1606
+ # Silent auto-sethome: no user-facing message, only log
1607
+ except Exception as e:
1608
+ logger.warning("[%s] Auto-sethome failed: %s", adapter.name, e)
1609
+ await next_fn()
1610
+
1611
+
1612
+ class ExtractContentMiddleware(InboundMiddleware):
1613
+ """Extract raw text and media refs from msg_body."""
1614
+
1615
+ name = "extract-content"
1616
+
1617
+ _CARD_CONTENT_MAX_LENGTH = 1000
1618
+
1619
+ @staticmethod
1620
+ def _format_shared_link(custom: dict) -> str:
1621
+ """Format elem_type 1010 (share card) into bracket-placeholder text."""
1622
+ title = custom.get("title", "")
1623
+ link = custom.get("link", "")
1624
+ header = f"[share_card: {title} | {link}]" if link else f"[share_card: {title}]"
1625
+ lines = [header]
1626
+ max_len = ExtractContentMiddleware._CARD_CONTENT_MAX_LENGTH
1627
+ for field in ("card_content", "wechat_des"):
1628
+ val = custom.get(field)
1629
+ if val and isinstance(val, str):
1630
+ preview = val[:max_len] + "...(truncated)" if len(val) > max_len else val
1631
+ lines.append(f"Preview: {preview}")
1632
+ break
1633
+ if link:
1634
+ lines.append("[visit link for full content]")
1635
+ return "\n".join(lines)
1636
+
1637
+ @staticmethod
1638
+ def _format_link_understanding(custom: dict) -> Optional[str]:
1639
+ """Format elem_type 1007 (link understanding card) into bracket-placeholder text."""
1640
+ content = custom.get("content")
1641
+ if not content:
1642
+ return None
1643
+ try:
1644
+ parsed = json.loads(content)
1645
+ link = parsed.get("link") if isinstance(parsed, dict) else None
1646
+ except (json.JSONDecodeError, TypeError):
1647
+ link = None
1648
+ if not link or not isinstance(link, str):
1649
+ return None
1650
+ return f"[link: {link} | visit link for full content]"
1651
+
1652
+ @staticmethod
1653
+ def _parse_resource_id(url: str) -> str:
1654
+ """Extract resourceId from Yuanbao resource URL query parameters.
1655
+
1656
+ Args:
1657
+ url: Resource URL (e.g., https://...?resourceId=abc123)
1658
+
1659
+ Returns:
1660
+ Resource ID string, or empty string if not found
1661
+ """
1662
+ if not url:
1663
+ return ""
1664
+ try:
1665
+ query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
1666
+ ids = query.get("resourceId") or query.get("resourceid") or []
1667
+ return str(ids[0]).strip() if ids else ""
1668
+ except Exception:
1669
+ return ""
1670
+
1671
+ @classmethod
1672
+ def _extract_text(cls, msg_body: list) -> str:
1673
+ """Extract plain text content from MsgBody.
1674
+
1675
+ - TIMTextElem -> text field
1676
+ - TIMImageElem -> "[image]"
1677
+ - TIMFileElem -> "[file: {filename}]"
1678
+ - TIMSoundElem -> "[voice]"
1679
+ - TIMVideoFileElem -> "[video]"
1680
+ - TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
1681
+ - TIMCustomElem -> try to extract data field, otherwise "[custom message]"
1682
+ - Multiple elems joined with spaces
1683
+ """
1684
+ parts: list[str] = []
1685
+ for elem in msg_body:
1686
+ elem_type: str = elem.get("msg_type", "")
1687
+ content: dict = elem.get("msg_content", {})
1688
+
1689
+ if elem_type == "TIMTextElem":
1690
+ text = content.get("text", "")
1691
+ if text:
1692
+ parts.append(text)
1693
+ elif elem_type == "TIMImageElem":
1694
+ # Extract resourceId from image_info_array URL
1695
+ image_info_array = content.get("image_info_array")
1696
+ if not isinstance(image_info_array, list):
1697
+ image_info_array = []
1698
+ image_info = None
1699
+ # Prefer medium image (index 1), fallback to index 0
1700
+ if len(image_info_array) > 1 and isinstance(image_info_array[1], dict):
1701
+ image_info = image_info_array[1]
1702
+ elif len(image_info_array) > 0 and isinstance(image_info_array[0], dict):
1703
+ image_info = image_info_array[0]
1704
+ image_url = str((image_info or {}).get("url") or "").strip()
1705
+ rid = cls._parse_resource_id(image_url)
1706
+ parts.append(f"[image|ybres:{rid}]" if rid else "[image]")
1707
+ elif elem_type == "TIMFileElem":
1708
+ filename = content.get("file_name", content.get("fileName", content.get("filename", "")))
1709
+ file_url = str(content.get("url") or "").strip()
1710
+ rid = cls._parse_resource_id(file_url)
1711
+ if rid:
1712
+ parts.append(f"[file:{filename}|ybres:{rid}]" if filename else f"[file|ybres:{rid}]")
1713
+ else:
1714
+ parts.append(f"[file: {filename}]" if filename else "[file]")
1715
+ elif elem_type == "TIMSoundElem":
1716
+ sound_url = str(content.get("url") or "").strip()
1717
+ rid = cls._parse_resource_id(sound_url)
1718
+ parts.append(f"[voice|ybres:{rid}]" if rid else "[voice]")
1719
+ elif elem_type == "TIMVideoFileElem":
1720
+ video_url = str(content.get("url") or "").strip()
1721
+ rid = cls._parse_resource_id(video_url)
1722
+ parts.append(f"[video|ybres:{rid}]" if rid else "[video]")
1723
+ elif elem_type == "TIMCustomElem":
1724
+ data_val = content.get("data", "")
1725
+ if data_val:
1726
+ try:
1727
+ custom = json.loads(data_val)
1728
+ if not isinstance(custom, dict):
1729
+ parts.append("[unsupported message type]")
1730
+ continue
1731
+ ctype = custom.get("elem_type")
1732
+ if ctype == 1002:
1733
+ parts.append(custom.get("text", "[mention]"))
1734
+ elif ctype == 1010:
1735
+ parts.append(cls._format_shared_link(custom))
1736
+ elif ctype == 1007:
1737
+ text = cls._format_link_understanding(custom)
1738
+ if text:
1739
+ parts.append(text)
1740
+ else:
1741
+ parts.append("[unsupported message type]")
1742
+ else:
1743
+ parts.append("[unsupported message type]")
1744
+ except (json.JSONDecodeError, TypeError):
1745
+ parts.append(data_val)
1746
+ else:
1747
+ parts.append("[unsupported message type]")
1748
+ elif elem_type == "TIMFaceElem":
1749
+ # Sticker/emoji: extract name from data JSON
1750
+ raw_data = content.get("data", "")
1751
+ face_name = ""
1752
+ if raw_data:
1753
+ try:
1754
+ face_data = json.loads(raw_data)
1755
+ face_name = (face_data.get("name") or "").strip()
1756
+ except (json.JSONDecodeError, TypeError, AttributeError):
1757
+ pass
1758
+ parts.append(f"[emoji: {face_name}]" if face_name else "[emoji]")
1759
+ elif elem_type:
1760
+ # Unknown element type — include type as placeholder
1761
+ parts.append(f"[{elem_type}]")
1762
+
1763
+ return " ".join(parts) if parts else ""
1764
+
1765
+ @staticmethod
1766
+ def _rewrite_slash_command(text: str) -> str:
1767
+ """Normalize input text: strip whitespace and convert full-width slash
1768
+ (Chinese input method) to ASCII slash so commands are recognized correctly.
1769
+ """
1770
+ text = text.strip()
1771
+ if text.startswith('\uff0f'): # Full-width slash
1772
+ text = '/' + text[1:]
1773
+ return text
1774
+
1775
+ @staticmethod
1776
+ def _extract_inbound_media_refs(msg_body: list) -> List[Dict[str, str]]:
1777
+ """Extract inbound image/file references from TIM msg_body.
1778
+
1779
+ Return example:
1780
+ [{"kind": "image", "url": "https://..."}, {"kind": "file", "url": "...", "name": "a.pdf"}]
1781
+ """
1782
+ refs: List[Dict[str, str]] = []
1783
+ for elem in msg_body or []:
1784
+ if not isinstance(elem, dict):
1785
+ continue
1786
+ msg_type = elem.get("msg_type", "")
1787
+ content = elem.get("msg_content", {}) or {}
1788
+ if not isinstance(content, dict):
1789
+ continue
1790
+
1791
+ if msg_type == "TIMImageElem":
1792
+ # Prefer medium image (index 1), fallback to index 0.
1793
+ image_info_array = content.get("image_info_array")
1794
+ if not isinstance(image_info_array, list):
1795
+ image_info_array = []
1796
+ image_info = None
1797
+ if len(image_info_array) > 1 and isinstance(image_info_array[1], dict):
1798
+ image_info = image_info_array[1]
1799
+ elif len(image_info_array) > 0 and isinstance(image_info_array[0], dict):
1800
+ image_info = image_info_array[0]
1801
+ image_url = str((image_info or {}).get("url") or "").strip()
1802
+ if image_url:
1803
+ refs.append({"kind": "image", "url": image_url})
1804
+ continue
1805
+
1806
+ if msg_type == "TIMFileElem":
1807
+ file_url = str(content.get("url") or "").strip()
1808
+ file_name = (
1809
+ str(content.get("file_name") or "").strip()
1810
+ or str(content.get("fileName") or "").strip()
1811
+ or str(content.get("filename") or "").strip()
1812
+ )
1813
+ if file_url:
1814
+ ref: Dict[str, str] = {"kind": "file", "url": file_url}
1815
+ if file_name:
1816
+ ref["name"] = file_name
1817
+ refs.append(ref)
1818
+ return refs
1819
+
1820
+ @staticmethod
1821
+ def _extract_link_urls(msg_body: list) -> list:
1822
+ """Extract link URLs from share-card (1010) and link-understanding (1007) custom elems."""
1823
+ urls: list[str] = []
1824
+ for elem in msg_body or []:
1825
+ if not isinstance(elem, dict) or elem.get("msg_type") != "TIMCustomElem":
1826
+ continue
1827
+ data_str = (elem.get("msg_content") or {}).get("data", "")
1828
+ if not data_str:
1829
+ continue
1830
+ try:
1831
+ custom = json.loads(data_str)
1832
+ except (json.JSONDecodeError, TypeError):
1833
+ continue
1834
+ if not isinstance(custom, dict):
1835
+ continue
1836
+ ctype = custom.get("elem_type")
1837
+ if ctype == 1010:
1838
+ link = custom.get("link")
1839
+ if link and isinstance(link, str):
1840
+ urls.append(link)
1841
+ elif ctype == 1007:
1842
+ content = custom.get("content")
1843
+ if content:
1844
+ try:
1845
+ parsed = json.loads(content)
1846
+ link = parsed.get("link") if isinstance(parsed, dict) else None
1847
+ if link and isinstance(link, str):
1848
+ urls.append(link)
1849
+ except (json.JSONDecodeError, TypeError):
1850
+ pass
1851
+ return urls
1852
+
1853
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1854
+ ctx.raw_text = self._rewrite_slash_command(self._extract_text(ctx.msg_body))
1855
+ ctx.media_refs = self._extract_inbound_media_refs(ctx.msg_body)
1856
+ ctx.link_urls = self._extract_link_urls(ctx.msg_body)
1857
+ await next_fn()
1858
+
1859
+ class PlaceholderFilterMiddleware(InboundMiddleware):
1860
+ """Skip pure placeholder messages (e.g. '[image]' with no media)."""
1861
+
1862
+ name = "placeholder-filter"
1863
+
1864
+ SKIPPABLE_PLACEHOLDERS: frozenset = frozenset({
1865
+ "[image]", "[图片]", "[file]", "[文件]",
1866
+ "[video]", "[视频]", "[voice]", "[语音]",
1867
+ })
1868
+
1869
+ @classmethod
1870
+ def is_skippable_placeholder(cls, text: str, media_count: int = 0) -> bool:
1871
+ """Detect whether the message is a pure placeholder (should be skipped)."""
1872
+ if media_count > 0:
1873
+ return False
1874
+ stripped = text.strip()
1875
+ return stripped in cls.SKIPPABLE_PLACEHOLDERS
1876
+
1877
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1878
+ if self.is_skippable_placeholder(ctx.raw_text, len(ctx.media_refs)):
1879
+ logger.debug("[%s] Skipping placeholder message: %r", ctx.adapter.name, ctx.raw_text)
1880
+ return # Stop pipeline
1881
+ await next_fn()
1882
+
1883
+
1884
+ class OwnerCommandMiddleware(InboundMiddleware):
1885
+ """Detect bot-owner slash commands in group chat.
1886
+
1887
+ Identifies in-group allowlisted slash commands and determines sender identity.
1888
+ Owner commands skip @Bot detection; non-owner attempts are rejected.
1889
+ """
1890
+
1891
+ name = "owner-command"
1892
+
1893
+ # Slash command allowlist that bot owner can execute in group without @Bot
1894
+ ALLOWLIST: frozenset = frozenset({
1895
+ "/new", "/reset", "/retry", "/undo", "/stop",
1896
+ "/approve", "/deny", "/background", "/bg",
1897
+ "/btw", "/queue", "/q",
1898
+ })
1899
+
1900
+ @staticmethod
1901
+ def _rewrite_slash_command(text: str) -> str:
1902
+ """Normalize full-width slash to ASCII slash and strip whitespace."""
1903
+ text = text.strip()
1904
+ if text.startswith('\uff0f'): # Full-width slash
1905
+ text = '/' + text[1:]
1906
+ return text
1907
+
1908
+ @classmethod
1909
+ def _detect_owner_command(
1910
+ cls,
1911
+ *,
1912
+ push: dict,
1913
+ msg_body: list,
1914
+ chat_type: str,
1915
+ from_account: str,
1916
+ ) -> Tuple[Optional[str], Optional[str], bool]:
1917
+ """Identify allowlisted slash commands and determine sender identity.
1918
+
1919
+ Returns (cmd, cmd_line, is_owner):
1920
+ - (None, None, False): Not an allowlisted command
1921
+ - (cmd, cmd_line, True): Owner match
1922
+ - (cmd, cmd_line, False): Allowlisted command but sender is not owner
1923
+ """
1924
+ if chat_type != "group" or not cls.ALLOWLIST:
1925
+ return None, None, False
1926
+
1927
+ # Extract TIMTextElem: only do command recognition with exactly one text segment
1928
+ text_elems = [
1929
+ e for e in (msg_body or [])
1930
+ if e.get("msg_type") == "TIMTextElem"
1931
+ ]
1932
+ if len(text_elems) != 1:
1933
+ return None, None, False
1934
+
1935
+ text = (text_elems[0].get("msg_content") or {}).get("text", "")
1936
+ cmd_line = cls._rewrite_slash_command(text)
1937
+ if not cmd_line.startswith("/"):
1938
+ return None, None, False
1939
+ cmd = cmd_line.split(maxsplit=1)[0].lower()
1940
+ if cmd not in cls.ALLOWLIST:
1941
+ return None, None, False
1942
+
1943
+ # Sender identity check: bot owner <-> push.from_account == push.bot_owner_id.
1944
+ # The allowlisted commands (/approve, /deny, /stop, /reset, ...) are
1945
+ # privileged — leaking them to non-owners lets any group member approve
1946
+ # a dangerous tool call, kill the owner's task, or wipe session state.
1947
+ owner_id = str((push or {}).get("bot_owner_id") or "").strip()
1948
+ is_owner = bool(owner_id) and owner_id == from_account
1949
+ return cmd, cmd_line, is_owner
1950
+
1951
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1952
+ adapter = ctx.adapter
1953
+ matched_cmd, cmd_line, is_owner = self._detect_owner_command(
1954
+ push=ctx.push,
1955
+ msg_body=ctx.msg_body,
1956
+ chat_type=ctx.chat_type,
1957
+ from_account=ctx.from_account,
1958
+ )
1959
+ if matched_cmd and not is_owner:
1960
+ # Non-owner tried an owner-only command — reject and stop
1961
+ logger.info(
1962
+ "[%s] Reject non-owner slash command: chat=%s from=%s cmd=%s",
1963
+ adapter.name, ctx.chat_id, ctx.from_account, matched_cmd,
1964
+ )
1965
+ adapter._track_task(asyncio.create_task(
1966
+ adapter.send(ctx.chat_id, f"⚠️ {matched_cmd} is only available to the creator in private chat mode"),
1967
+ name=f"yuanbao-owner-cmd-denial-{matched_cmd}",
1968
+ ))
1969
+ return # Stop pipeline
1970
+
1971
+ if matched_cmd and is_owner and cmd_line:
1972
+ logger.info(
1973
+ "[%s] Bot owner slash command: chat=%s from=%s cmd=%s",
1974
+ adapter.name, ctx.chat_id, ctx.from_account, matched_cmd,
1975
+ )
1976
+ ctx.owner_command = matched_cmd
1977
+ ctx.raw_text = cmd_line # Override with clean command text
1978
+ await next_fn()
1979
+
1980
+
1981
+ class BuildSourceMiddleware(InboundMiddleware):
1982
+ """Build SessionSource from context fields."""
1983
+
1984
+ name = "build-source"
1985
+
1986
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
1987
+ adapter = ctx.adapter
1988
+ ctx.source = adapter.build_source(
1989
+ chat_id=ctx.chat_id,
1990
+ chat_type=ctx.chat_type,
1991
+ chat_name=ctx.chat_name,
1992
+ user_id=ctx.from_account or None,
1993
+ user_name=ctx.sender_nickname or ctx.from_account,
1994
+ thread_id="main" if ctx.chat_type == "group" else None,
1995
+ )
1996
+ await next_fn()
1997
+
1998
+
1999
+ class GroupAtGuardMiddleware(InboundMiddleware):
2000
+ """In group chat, observe non-@bot messages; only reply on @Bot.
2001
+
2002
+ Owner commands skip @Bot detection (owner doesn't need to @Bot).
2003
+ """
2004
+
2005
+ name = "group-at-guard"
2006
+
2007
+ @staticmethod
2008
+ def _is_at_bot(msg_body: list, bot_id: Optional[str]) -> bool:
2009
+ """Detect whether the message @Bot.
2010
+
2011
+ AT element format: TIMCustomElem, msg_content.data is a JSON string:
2012
+ {"elem_type": 1002, "text": "@xxx", "user_id": "<botId>"}
2013
+ Considered @Bot when elem_type == 1002 and user_id == bot_id.
2014
+ """
2015
+ if not bot_id:
2016
+ return False
2017
+ for elem in msg_body:
2018
+ if elem.get("msg_type") != "TIMCustomElem":
2019
+ continue
2020
+ data_str = elem.get("msg_content", {}).get("data", "")
2021
+ if not data_str:
2022
+ continue
2023
+ try:
2024
+ custom = json.loads(data_str)
2025
+ except (json.JSONDecodeError, TypeError):
2026
+ continue
2027
+ if custom.get("elem_type") == 1002 and custom.get("user_id") == bot_id:
2028
+ return True
2029
+ return False
2030
+
2031
+ @staticmethod
2032
+ def _extract_bot_mention_text(msg_body: list, bot_id: Optional[str]) -> str:
2033
+ """Extract the display text used to @-mention this bot (e.g. ``@yuanbao-bot``)."""
2034
+ if not bot_id:
2035
+ return ""
2036
+ for elem in msg_body:
2037
+ if elem.get("msg_type") != "TIMCustomElem":
2038
+ continue
2039
+ data_str = elem.get("msg_content", {}).get("data", "")
2040
+ if not data_str:
2041
+ continue
2042
+ try:
2043
+ custom = json.loads(data_str)
2044
+ except (json.JSONDecodeError, TypeError):
2045
+ continue
2046
+ if custom.get("elem_type") == 1002 and custom.get("user_id") == bot_id:
2047
+ mention_text = str(custom.get("text") or "").strip()
2048
+ if mention_text:
2049
+ return mention_text
2050
+ return ""
2051
+
2052
+ @staticmethod
2053
+ def _build_group_channel_prompt(msg_body: list, bot_id: Optional[str]) -> str:
2054
+ """Build a per-turn group-chat prompt that highlights which message to respond to."""
2055
+ bid = str(bot_id or "unknown")
2056
+ bot_mention = GroupAtGuardMiddleware._extract_bot_mention_text(msg_body, bot_id) or "unknown"
2057
+ return (
2058
+ "You are handling a Yuanbao group chat message.\n"
2059
+ f"- Your identity: user_id={bid}, @-mention name in this group={bot_mention}\n"
2060
+ "- Lines in history prefixed with `[nickname|user_id]` are observed group context "
2061
+ "and are not necessarily addressed to you.\n"
2062
+ "- Treat only the current new message as a request explicitly directed at you, "
2063
+ "and answer it directly."
2064
+ )
2065
+
2066
+ @staticmethod
2067
+ def _observe_group_message(
2068
+ adapter, source, sender_display: str, text: str,
2069
+ *, msg_id: Optional[str] = None,
2070
+ ) -> None:
2071
+ """Write a group message into the session transcript without triggering the agent.
2072
+
2073
+ This allows the model to see the full group conversation when it is
2074
+ eventually invoked via @bot. Messages are stored with ``role: "user"``
2075
+ in the format ``[nickname|user_id]\\n<content>`` so the model
2076
+ can distinguish participants and their user ids.
2077
+ """
2078
+ store = getattr(adapter, "_session_store", None)
2079
+ if not store:
2080
+ return
2081
+ try:
2082
+ session_entry = store.get_or_create_session(source)
2083
+ user_id = source.user_id or "unknown"
2084
+ attributed = f"[{sender_display}|{user_id}]\n{text}"
2085
+ entry: dict = {
2086
+ "role": "user",
2087
+ "content": attributed,
2088
+ "timestamp": datetime.now(tz=timezone.utc).isoformat(),
2089
+ "observed": True,
2090
+ }
2091
+ if msg_id:
2092
+ entry["message_id"] = msg_id
2093
+ store.append_to_transcript(
2094
+ session_entry.session_id,
2095
+ entry,
2096
+ )
2097
+ except Exception as exc:
2098
+ logger.warning("[%s] Failed to observe group message: %s", adapter.name, exc)
2099
+
2100
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
2101
+ adapter = ctx.adapter
2102
+ if ctx.chat_type == "group" and not ctx.owner_command and not self._is_at_bot(ctx.msg_body, adapter._bot_id):
2103
+ self._observe_group_message(
2104
+ adapter, ctx.source, ctx.sender_nickname or ctx.from_account, ctx.raw_text,
2105
+ msg_id=ctx.msg_id or None,
2106
+ )
2107
+ logger.info(
2108
+ "[%s] Group message observed (no @bot): chat=%s from=%s",
2109
+ adapter.name, ctx.chat_id, ctx.from_account,
2110
+ )
2111
+ return # Stop pipeline — message observed but not dispatched
2112
+ await next_fn()
2113
+
2114
+
2115
+ class GroupAttributionMiddleware(InboundMiddleware):
2116
+ """Tag group @bot messages with [nickname|user_id] attribution and channel_prompt.
2117
+
2118
+ For group messages that pass the @bot guard (i.e. the bot is mentioned),
2119
+ this middleware:
2120
+ - Builds a per-turn channel_prompt so the model knows its identity and
2121
+ the attribution scheme.
2122
+ - Rewrites ctx.raw_text to ``[nickname|user_id]\\n<content>`` to match
2123
+ the observed-history format.
2124
+ - Suppresses the runner's default ``[user_name]`` shared-thread prefix
2125
+ by clearing ``source.user_name``.
2126
+ """
2127
+
2128
+ name = "group-attribution"
2129
+
2130
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
2131
+ if ctx.chat_type == "group" and not ctx.owner_command:
2132
+ adapter = ctx.adapter
2133
+ ctx.channel_prompt = GroupAtGuardMiddleware._build_group_channel_prompt(
2134
+ ctx.msg_body, adapter._bot_id,
2135
+ )
2136
+ user_id_label = ctx.from_account or "unknown"
2137
+ nickname_label = ctx.sender_nickname or ctx.from_account or "unknown"
2138
+ ctx.raw_text = f"[{nickname_label}|{user_id_label}]\n{ctx.raw_text}"
2139
+ # Suppress runner's default ``[user_name]`` shared-thread prefix so
2140
+ # the text the model sees matches the observed-history format.
2141
+ if ctx.source is not None:
2142
+ ctx.source = dataclasses.replace(ctx.source, user_name=None)
2143
+ await next_fn()
2144
+
2145
+
2146
+ class ClassifyMessageTypeMiddleware(InboundMiddleware):
2147
+ """Determine MessageType from text content and msg_body elements."""
2148
+
2149
+ name = "classify-msg-type"
2150
+
2151
+ @staticmethod
2152
+ def _classify(text: str, msg_body: list) -> MessageType:
2153
+ """Classify message type based on text and msg_body."""
2154
+ if text.startswith("/"):
2155
+ return MessageType.COMMAND
2156
+ for elem in msg_body:
2157
+ etype = elem.get("msg_type", "")
2158
+ if etype == "TIMImageElem":
2159
+ return MessageType.PHOTO
2160
+ if etype == "TIMSoundElem":
2161
+ return MessageType.VOICE
2162
+ if etype == "TIMVideoFileElem":
2163
+ return MessageType.VIDEO
2164
+ if etype == "TIMFileElem":
2165
+ return MessageType.DOCUMENT
2166
+ return MessageType.TEXT
2167
+
2168
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
2169
+ ctx.msg_type = self._classify(ctx.raw_text, ctx.msg_body)
2170
+ await next_fn()
2171
+
2172
+
2173
+ class QuoteContextMiddleware(InboundMiddleware):
2174
+ """Extract quote/reply context from cloud_custom_data."""
2175
+
2176
+ name = "quote-context"
2177
+
2178
+ @staticmethod
2179
+ def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
2180
+ """Extract quote context, mapping to MessageEvent.reply_to_*.
2181
+
2182
+ Returns:
2183
+ (reply_to_message_id, reply_to_text, quote_media_refs)
2184
+ where quote_media_refs is a list of (rid, kind, filename) tuples
2185
+ """
2186
+ if not cloud_custom_data:
2187
+ return None, None, []
2188
+ try:
2189
+ parsed = json.loads(cloud_custom_data)
2190
+ except (json.JSONDecodeError, TypeError):
2191
+ return None, None, []
2192
+
2193
+ quote = parsed.get("quote") if isinstance(parsed, dict) else None
2194
+ if not isinstance(quote, dict):
2195
+ return None, None, []
2196
+
2197
+ # type=2 corresponds to image reference; desc may be empty, provide a placeholder.
2198
+ quote_type = int(quote.get("type") or 0)
2199
+ desc = str(quote.get("desc") or "").strip()
2200
+ if quote_type == 2 and not desc:
2201
+ desc = "[image]"
2202
+ if not desc:
2203
+ return None, None, []
2204
+
2205
+ quote_id = str(quote.get("id") or "").strip() or None
2206
+ sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
2207
+ quote_text = f"{sender}: {desc}" if sender else desc
2208
+
2209
+ # Extract media references from desc using _YB_RES_REF_RE regex
2210
+ media_refs: list = []
2211
+ for m in _YB_RES_REF_RE.finditer(desc):
2212
+ head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
2213
+ rid = m.group(2)
2214
+ kind, _, filename = head.partition(":")
2215
+ kind = kind.strip()
2216
+ media_refs.append((rid, kind, filename.strip()))
2217
+
2218
+ return quote_id, quote_text, media_refs
2219
+
2220
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
2221
+ ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
2222
+
2223
+ await next_fn()
2224
+
2225
+
2226
+ class MediaResolveMiddleware(InboundMiddleware):
2227
+ """Resolve inbound media references to downloadable URLs."""
2228
+
2229
+ name = "media-resolve"
2230
+
2231
+ @staticmethod
2232
+ def _guess_image_ext_from_url(url: str) -> str:
2233
+ """Guess image extension from URL path."""
2234
+ path = urllib.parse.urlparse(url).path
2235
+ ext = os.path.splitext(path)[1].lower()
2236
+ if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff"}:
2237
+ return ext
2238
+ return ".jpg"
2239
+
2240
+ @staticmethod
2241
+ async def _fetch_resource_url(adapter, resource_id: str) -> str:
2242
+ """Low-level helper: exchange a ``resourceId`` for a direct download URL.
2243
+
2244
+ Handles token retrieval, the ``/api/resource/v1/download`` API call,
2245
+ and a single 401-retry with token force-refresh. Raises on failure.
2246
+ """
2247
+ resource_id = resource_id.strip()
2248
+ if not resource_id:
2249
+ raise RuntimeError("missing resource_id")
2250
+
2251
+ token_data = await adapter._get_cached_token()
2252
+ token = str(token_data.get("token") or "").strip()
2253
+ source = str(token_data.get("source") or "web").strip() or "web"
2254
+ bot_id = str(token_data.get("bot_id") or adapter._bot_id or adapter._app_key).strip()
2255
+ if not token or not bot_id:
2256
+ raise RuntimeError("missing token or bot_id for resource download")
2257
+
2258
+ api_url = f"{adapter._api_domain}/api/resource/v1/download"
2259
+ headers = {
2260
+ "Content-Type": "application/json",
2261
+ "X-ID": bot_id,
2262
+ "X-Token": token,
2263
+ "X-Source": source,
2264
+ }
2265
+
2266
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
2267
+ for attempt in range(2):
2268
+ resp = await client.get(api_url, params={"resourceId": resource_id}, headers=headers)
2269
+ if resp.status_code == 401 and attempt == 0:
2270
+ # Force refresh token once on expiry and retry
2271
+ token_data = await SignManager.force_refresh(
2272
+ adapter._app_key, adapter._app_secret, adapter._api_domain,
2273
+ )
2274
+ token = str(token_data.get("token") or "").strip()
2275
+ source = str(token_data.get("source") or source or "web").strip() or "web"
2276
+ bot_id = str(token_data.get("bot_id") or adapter._bot_id or adapter._app_key).strip()
2277
+ if not token or not bot_id:
2278
+ break
2279
+ headers["X-ID"] = bot_id
2280
+ headers["X-Token"] = token
2281
+ headers["X-Source"] = source
2282
+ continue
2283
+
2284
+ resp.raise_for_status()
2285
+ payload = resp.json()
2286
+ code = payload.get("code")
2287
+ if code not in {None, 0}:
2288
+ raise RuntimeError(
2289
+ f"resource/v1/download failed: code={code}, msg={payload.get('msg', '')}"
2290
+ )
2291
+ data = payload.get("data") if isinstance(payload.get("data"), dict) else payload
2292
+ real_url = str((data or {}).get("url") or (data or {}).get("realUrl") or "").strip()
2293
+ if real_url:
2294
+ return real_url
2295
+ raise RuntimeError("resource/v1/download missing url/realUrl")
2296
+
2297
+ raise RuntimeError("resource/v1/download did not return a URL")
2298
+
2299
+ @staticmethod
2300
+ async def _resolve_download_url(adapter, url: str) -> str:
2301
+ """Resolve Yuanbao resource placeholder to a directly fetchable real URL.
2302
+
2303
+ Common URL patterns:
2304
+ https://hunyuan.tencent.com/api/resource/download?resourceId=...
2305
+ Direct GET returns 401; need business API:
2306
+ GET /api/resource/v1/download?resourceId=...
2307
+ """
2308
+ try:
2309
+ parsed = urllib.parse.urlparse(url)
2310
+ except Exception:
2311
+ return url
2312
+
2313
+ query = urllib.parse.parse_qs(parsed.query)
2314
+ resource_ids = query.get("resourceId") or query.get("resourceid") or []
2315
+ resource_id = str(resource_ids[0]).strip() if resource_ids else ""
2316
+ if not resource_id:
2317
+ return url
2318
+
2319
+ try:
2320
+ return await MediaResolveMiddleware._fetch_resource_url(adapter, resource_id)
2321
+ except Exception:
2322
+ return url
2323
+
2324
+ @classmethod
2325
+ async def _download_and_cache(
2326
+ cls, adapter, *, fetch_url: str, kind: str,
2327
+ file_name: Optional[str] = None, log_tag: str = "",
2328
+ ) -> Optional[Tuple[str, str]]:
2329
+ """Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``."""
2330
+ try:
2331
+ file_bytes, content_type = await media_download_url(
2332
+ fetch_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
2333
+ )
2334
+ except Exception as exc:
2335
+ logger.warning(
2336
+ "[%s] inbound media download failed: kind=%s %s err=%s",
2337
+ adapter.name, kind, log_tag, exc,
2338
+ )
2339
+ return None
2340
+
2341
+ if kind == "image":
2342
+ ext = cls._guess_image_ext_from_url(fetch_url)
2343
+ try:
2344
+ local_path = cache_image_from_bytes(file_bytes, ext=ext)
2345
+ except ValueError as exc:
2346
+ logger.warning(
2347
+ "[%s] inbound image cache rejected: %s err=%s",
2348
+ adapter.name, log_tag, exc,
2349
+ )
2350
+ return None
2351
+ mime = guess_mime_type(f"image{ext}")
2352
+ if not mime.startswith("image/"):
2353
+ mime = content_type if content_type.startswith("image/") else "image/jpeg"
2354
+ return local_path, mime
2355
+
2356
+ # kind == "file"
2357
+ if not file_name:
2358
+ parsed = urllib.parse.urlparse(fetch_url)
2359
+ file_name = os.path.basename(parsed.path) or "file"
2360
+ try:
2361
+ local_path = cache_document_from_bytes(file_bytes, file_name)
2362
+ except Exception as exc:
2363
+ logger.warning(
2364
+ "[%s] inbound file cache failed: %s err=%s",
2365
+ adapter.name, log_tag, exc,
2366
+ )
2367
+ return None
2368
+ mime = guess_mime_type(file_name) or content_type or "application/octet-stream"
2369
+ return local_path, mime
2370
+
2371
+ @classmethod
2372
+ async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
2373
+ """Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
2374
+ return await cls._fetch_resource_url(adapter, resource_id)
2375
+
2376
+ @classmethod
2377
+ async def _resolve_media_urls(
2378
+ cls, adapter, media_refs: List[Dict[str, str]]
2379
+ ) -> Tuple[List[str], List[str]]:
2380
+ """Resolve inbound media refs: download to local cache, return (local_paths, mime_types).
2381
+
2382
+ Yuanbao COS hostnames resolve to private IPs, tripping the SSRF guard
2383
+ in vision_tools. We download ourselves and return local cache paths.
2384
+ """
2385
+ media_urls: List[str] = []
2386
+ media_types: List[str] = []
2387
+
2388
+ for ref in media_refs:
2389
+ kind = str(ref.get("kind") or "").strip().lower()
2390
+ url = str(ref.get("url") or "").strip()
2391
+ if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
2392
+ continue
2393
+
2394
+ try:
2395
+ fetch_url = await cls._resolve_download_url(adapter, url)
2396
+ except Exception as exc:
2397
+ logger.warning(
2398
+ "[%s] inbound media resolve failed: kind=%s url=%s err=%s",
2399
+ adapter.name, kind, url, exc,
2400
+ )
2401
+ continue
2402
+
2403
+ cached = await cls._download_and_cache(
2404
+ adapter,
2405
+ fetch_url=fetch_url,
2406
+ kind=kind,
2407
+ file_name=str(ref.get("name") or "").strip() or None,
2408
+ log_tag=f"placeholder_url={url[:80]}",
2409
+ )
2410
+ if cached is None:
2411
+ continue
2412
+ local_path, mime = cached
2413
+ media_urls.append(local_path)
2414
+ media_types.append(mime)
2415
+
2416
+ return media_urls, media_types
2417
+
2418
+ @classmethod
2419
+ async def _collect_observed_media(
2420
+ cls, adapter, source,
2421
+ ) -> Tuple[List[str], List[str]]:
2422
+ """Resolve recent observed image/file anchors from transcript into ``(local_paths, mimes)``."""
2423
+ store = getattr(adapter, "_session_store", None)
2424
+ if not store:
2425
+ return [], []
2426
+ try:
2427
+ session_entry = store.get_or_create_session(source)
2428
+ history = store.load_transcript(session_entry.session_id)
2429
+ except Exception as exc:
2430
+ logger.warning(
2431
+ "[%s] Observed-media hydration setup failed: %s",
2432
+ adapter.name, exc,
2433
+ )
2434
+ return [], []
2435
+ if not history:
2436
+ return [], []
2437
+
2438
+ start = max(0, len(history) - OBSERVED_MEDIA_BACKFILL_LOOKBACK)
2439
+ order: List[Tuple[str, str, str]] = [] # (rid, kind, filename)
2440
+ seen: set = set()
2441
+ for msg in history[start:]:
2442
+ content = msg.get("content")
2443
+ if not isinstance(content, str) or "|ybres:" not in content:
2444
+ continue
2445
+ for m in _YB_RES_REF_RE.finditer(content):
2446
+ head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
2447
+ rid = m.group(2)
2448
+ kind, _, filename = head.partition(":")
2449
+ kind = kind.strip()
2450
+ if kind not in _RESOLVABLE_MEDIA_KINDS:
2451
+ continue
2452
+ if rid in seen:
2453
+ continue
2454
+ seen.add(rid)
2455
+ order.append((rid, kind, filename.strip()))
2456
+ if len(order) >= OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN:
2457
+ break
2458
+ if len(order) >= OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN:
2459
+ break
2460
+
2461
+ if not order:
2462
+ return [], []
2463
+
2464
+ media_paths: List[str] = []
2465
+ mimes: List[str] = []
2466
+ for rid, kind, filename in order:
2467
+ try:
2468
+ fresh_url = await cls._resolve_by_resource_id(adapter, rid)
2469
+ except Exception as exc:
2470
+ logger.warning(
2471
+ "[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
2472
+ adapter.name, rid, kind, exc,
2473
+ )
2474
+ continue
2475
+ cached = await cls._download_and_cache(
2476
+ adapter,
2477
+ fetch_url=fresh_url,
2478
+ kind=kind,
2479
+ file_name=filename or None,
2480
+ log_tag=f"rid={rid}",
2481
+ )
2482
+ if cached is None:
2483
+ continue
2484
+ path, mime = cached
2485
+ media_paths.append(path)
2486
+ mimes.append(mime)
2487
+ return media_paths, mimes
2488
+
2489
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
2490
+ adapter = ctx.adapter
2491
+ ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
2492
+ # Re-check placeholder after media resolution
2493
+ if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
2494
+ logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
2495
+ return # Stop pipeline
2496
+ await next_fn()
2497
+
2498
+
2499
+ class DispatchMiddleware(InboundMiddleware):
2500
+ """Build MessageEvent and dispatch to AI handler."""
2501
+
2502
+ name = "dispatch"
2503
+
2504
+ async def handle(self, ctx: InboundContext, next_fn) -> None:
2505
+ adapter = ctx.adapter
2506
+
2507
+ _sk = build_session_key(
2508
+ ctx.source,
2509
+ group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True),
2510
+ thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False),
2511
+ )
2512
+
2513
+ async def _dispatch_inbound_event() -> None:
2514
+ media_urls = list(ctx.media_urls)
2515
+ media_types = list(ctx.media_types)
2516
+
2517
+ # If user quoted a message (reply_to_message_id is set), resolve only
2518
+ # quote_media_refs to avoid injecting unrelated history media.
2519
+ # Otherwise, backfill observed media from recent transcript history.
2520
+ if ctx.reply_to_message_id is not None:
2521
+ # Fallback: if desc didn't contain ybres refs, look up transcript
2522
+ if not ctx.quote_media_refs:
2523
+ try:
2524
+ store = getattr(adapter, "_session_store", None)
2525
+ if store:
2526
+ session_entry = store.get_or_create_session(ctx.source)
2527
+ history = store.load_transcript(session_entry.session_id)
2528
+ for msg in reversed(history or []):
2529
+ mid = msg.get("message_id", "")
2530
+ if mid and mid == ctx.reply_to_message_id:
2531
+ _content = msg.get("content", "")
2532
+ if isinstance(_content, str) and "|ybres:" in _content:
2533
+ for m in _YB_RES_REF_RE.finditer(_content):
2534
+ head = m.group(1)
2535
+ rid = m.group(2)
2536
+ kind, _, filename = head.partition(":")
2537
+ kind = kind.strip()
2538
+ if kind in _RESOLVABLE_MEDIA_KINDS:
2539
+ ctx.quote_media_refs.append((rid, kind, filename.strip()))
2540
+ break
2541
+ except Exception as exc:
2542
+ logger.warning(
2543
+ "[%s] quote transcript lookup failed: %s",
2544
+ adapter.name, exc,
2545
+ )
2546
+ # User quoted a message — resolve only media from the quote
2547
+ for rid, kind, filename in ctx.quote_media_refs:
2548
+ if kind not in _RESOLVABLE_MEDIA_KINDS:
2549
+ continue
2550
+ try:
2551
+ fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
2552
+ except Exception as exc:
2553
+ logger.warning(
2554
+ "[%s] quote media resolve failed: rid=%s kind=%s err=%s",
2555
+ adapter.name, rid, kind, exc,
2556
+ )
2557
+ continue
2558
+ cached = await MediaResolveMiddleware._download_and_cache(
2559
+ adapter,
2560
+ fetch_url=fresh_url,
2561
+ kind=kind,
2562
+ file_name=filename or None,
2563
+ log_tag=f"quote rid={rid}",
2564
+ )
2565
+ if cached is None:
2566
+ continue
2567
+ path, mime = cached
2568
+ # Avoid duplicates
2569
+ if path not in media_urls:
2570
+ media_urls.append(path)
2571
+ media_types.append(mime)
2572
+ else:
2573
+ # No quote — backfill observed media from recent transcript history
2574
+ extra_img_urls: List[str] = []
2575
+ extra_img_mimes: List[str] = []
2576
+ try:
2577
+ extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
2578
+ adapter, ctx.source,
2579
+ )
2580
+ except Exception as exc:
2581
+ logger.warning(
2582
+ "[%s] observed-image hydration raised, continuing anyway: %s",
2583
+ adapter.name, exc,
2584
+ )
2585
+ if extra_img_urls:
2586
+ current = set(media_urls)
2587
+ for u, m in zip(extra_img_urls, extra_img_mimes):
2588
+ if u in current:
2589
+ continue
2590
+ media_urls.append(u)
2591
+ media_types.append(m)
2592
+ current.add(u)
2593
+
2594
+ # Replace [kind|ybres:xxx] anchors with local cache paths so
2595
+ # the transcript records usable paths for the model.
2596
+ _patched_event_text = ctx.raw_text
2597
+ for u, m in zip(media_urls, media_types):
2598
+ if not u.startswith("/"):
2599
+ continue
2600
+ anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
2601
+ if not anchor_match:
2602
+ continue
2603
+ head = anchor_match.group(1)
2604
+ kind, _, filename = head.partition(":")
2605
+ kind = kind.strip()
2606
+ if kind == "image" and m.startswith("image/"):
2607
+ replacement = f"[image: {u}]"
2608
+ elif kind == "file":
2609
+ label = filename.strip() or os.path.basename(u)
2610
+ replacement = f"[file: {label} → {u}]"
2611
+ else:
2612
+ continue
2613
+ _patched_event_text = (
2614
+ _patched_event_text[:anchor_match.start()]
2615
+ + replacement
2616
+ + _patched_event_text[anchor_match.end():]
2617
+ )
2618
+
2619
+ event = MessageEvent(
2620
+ text=_patched_event_text,
2621
+ message_type=(
2622
+ MessageType.DOCUMENT
2623
+ if any(mt.startswith(("application/", "text/")) for mt in media_types)
2624
+ else ctx.msg_type
2625
+ ),
2626
+ source=ctx.source,
2627
+ message_id=ctx.msg_id or None,
2628
+ raw_message=ctx.push,
2629
+ media_urls=media_urls,
2630
+ media_types=media_types,
2631
+ reply_to_message_id=ctx.reply_to_message_id,
2632
+ reply_to_text=ctx.reply_to_text,
2633
+ channel_prompt=ctx.channel_prompt,
2634
+ )
2635
+ if _sk and ctx.msg_id:
2636
+ adapter._processing_msg_ids[_sk] = ctx.msg_id
2637
+ adapter._processing_msg_texts[_sk] = ctx.raw_text or ""
2638
+ if ctx.msg_id and ctx.raw_text:
2639
+ cache = adapter._msg_content_cache
2640
+ cache[ctx.msg_id] = ctx.raw_text
2641
+ if len(cache) > 200:
2642
+ for k in list(cache)[:len(cache) - 200]:
2643
+ del cache[k]
2644
+ await adapter.handle_message(event)
2645
+
2646
+ if ctx.chat_type == "group":
2647
+ is_new = _sk not in adapter._group_queues
2648
+ queue = adapter._group_queues.setdefault(_sk, asyncio.Queue())
2649
+ queue.put_nowait(_dispatch_inbound_event)
2650
+ logger.info(
2651
+ "[%s] Group message enqueued (qsize=%d) for %s",
2652
+ adapter.name, queue.qsize(), (_sk or "")[:50],
2653
+ )
2654
+ if is_new:
2655
+ consumer = asyncio.create_task(
2656
+ self._consume_group_queue(adapter, _sk),
2657
+ name=f"yuanbao-group-consumer-{(_sk or '')[:30]}",
2658
+ )
2659
+ adapter._inbound_tasks.add(consumer)
2660
+ consumer.add_done_callback(adapter._inbound_tasks.discard)
2661
+ else:
2662
+ task = asyncio.create_task(
2663
+ _dispatch_inbound_event(),
2664
+ name=f"yuanbao-inbound-{ctx.msg_id or 'unknown'}",
2665
+ )
2666
+ adapter._inbound_tasks.add(task)
2667
+ task.add_done_callback(adapter._inbound_tasks.discard)
2668
+
2669
+ await next_fn()
2670
+
2671
+ @staticmethod
2672
+ async def _consume_group_queue(adapter: "YuanbaoAdapter", session_key: str) -> None:
2673
+ """Drain the group queue one dispatch at a time, waiting for each to finish."""
2674
+ _IDLE_TIMEOUT = 2.0
2675
+ queue = adapter._group_queues.get(session_key)
2676
+ if not queue:
2677
+ return
2678
+ try:
2679
+ while True:
2680
+ try:
2681
+ dispatch_fn = await asyncio.wait_for(queue.get(), timeout=_IDLE_TIMEOUT)
2682
+ except asyncio.TimeoutError:
2683
+ break
2684
+ logger.debug(
2685
+ "[%s] Group queue: dispatching for %s (remaining=%d)",
2686
+ adapter.name, (session_key or "")[:50], queue.qsize(),
2687
+ )
2688
+ try:
2689
+ await dispatch_fn()
2690
+ while session_key in adapter._active_sessions:
2691
+ await asyncio.sleep(0.1)
2692
+ except Exception:
2693
+ logger.exception("[%s] Group queue consumer error", adapter.name)
2694
+ finally:
2695
+ adapter._group_queues.pop(session_key, None)
2696
+
2697
+
2698
+ class InboundPipelineBuilder:
2699
+ """Factory for building InboundPipeline instances.
2700
+
2701
+ Separates pipeline assembly (business knowledge) from the pipeline engine
2702
+ (InboundPipeline) so the engine stays generic and reusable.
2703
+ """
2704
+
2705
+ # Default middleware sequence for Yuanbao inbound message processing.
2706
+ _DEFAULT_MIDDLEWARES: list[type] = [
2707
+ DecodeMiddleware,
2708
+ ExtractFieldsMiddleware,
2709
+ RecallGuardMiddleware,
2710
+ DedupMiddleware,
2711
+ SkipSelfMiddleware,
2712
+ ChatRoutingMiddleware,
2713
+ AccessGuardMiddleware,
2714
+ AutoSetHomeMiddleware,
2715
+ ExtractContentMiddleware,
2716
+ PlaceholderFilterMiddleware,
2717
+ OwnerCommandMiddleware,
2718
+ BuildSourceMiddleware,
2719
+ GroupAtGuardMiddleware,
2720
+ GroupAttributionMiddleware,
2721
+ ClassifyMessageTypeMiddleware,
2722
+ QuoteContextMiddleware,
2723
+ MediaResolveMiddleware,
2724
+ DispatchMiddleware,
2725
+ ]
2726
+
2727
+ @classmethod
2728
+ def build(cls) -> InboundPipeline:
2729
+ """Build the default inbound message processing pipeline."""
2730
+ pipeline = InboundPipeline()
2731
+ for mw_cls in cls._DEFAULT_MIDDLEWARES:
2732
+ pipeline.use(mw_cls())
2733
+ return pipeline
2734
+
2735
+ class ConnectionManager:
2736
+ """Manages the WebSocket connection lifecycle for YuanbaoAdapter.
2737
+
2738
+ Responsibilities:
2739
+ - Opening and closing the WebSocket
2740
+ - AUTH_BIND handshake
2741
+ - Heartbeat (ping/pong) loop
2742
+ - Receive loop (frame dispatch)
2743
+ - Reconnect with exponential backoff
2744
+ """
2745
+
2746
+ def __init__(self, adapter: "YuanbaoAdapter") -> None:
2747
+ self._adapter = adapter
2748
+ self._ws = None # websockets connection
2749
+ self._connect_id: Optional[str] = None
2750
+ self._heartbeat_task: Optional[asyncio.Task] = None
2751
+ self._recv_task: Optional[asyncio.Task] = None
2752
+ self._pending_acks: Dict[str, asyncio.Future] = {}
2753
+ self._pending_pong: Optional[asyncio.Future] = None
2754
+ self._consecutive_hb_timeouts: int = 0
2755
+ self._reconnect_attempts: int = 0
2756
+ self._reconnecting: bool = False
2757
+ # Debounce buffer for aggregating multi-part inbound messages
2758
+ self._inbound_buffer: Dict[str, list] = {} # key -> [raw_data_frames, ...]
2759
+ self._inbound_timers: Dict[str, asyncio.TimerHandle] = {} # key -> timer
2760
+
2761
+ # -- Properties --------------------------------------------------------
2762
+
2763
+ @property
2764
+ def ws(self):
2765
+ return self._ws
2766
+
2767
+ @property
2768
+ def connect_id(self) -> Optional[str]:
2769
+ return self._connect_id
2770
+
2771
+ @property
2772
+ def reconnect_attempts(self) -> int:
2773
+ return self._reconnect_attempts
2774
+
2775
+ @property
2776
+ def is_connected(self) -> bool:
2777
+ if self._ws is None:
2778
+ return False
2779
+ open_attr = getattr(self._ws, "open", None)
2780
+ if open_attr is True:
2781
+ return True
2782
+ if callable(open_attr):
2783
+ try:
2784
+ return bool(open_attr())
2785
+ except Exception:
2786
+ return False
2787
+ return False
2788
+
2789
+ # -- Open / Close ------------------------------------------------------
2790
+
2791
+ async def open(self) -> bool:
2792
+ """Open WebSocket connection: sign-token → WS connect → AUTH_BIND → start loops.
2793
+
2794
+ Returns True on success, False on failure.
2795
+ """
2796
+ adapter = self._adapter
2797
+
2798
+ if not WEBSOCKETS_AVAILABLE:
2799
+ msg = "Yuanbao startup failed: 'websockets' package not installed"
2800
+ adapter._set_fatal_error("yuanbao_missing_dependency", msg, retryable=True)
2801
+ logger.warning("[%s] %s. Run: pip install websockets", adapter.name, msg)
2802
+ return False
2803
+
2804
+ if not adapter._app_key or not adapter._app_secret:
2805
+ msg = (
2806
+ "Yuanbao startup failed: "
2807
+ "YUANBAO_APP_ID and YUANBAO_APP_SECRET are required"
2808
+ )
2809
+ adapter._set_fatal_error("yuanbao_missing_credentials", msg, retryable=False)
2810
+ logger.error("[%s] %s", adapter.name, msg)
2811
+ return False
2812
+
2813
+ # Idempotency guard
2814
+ if self._ws is not None:
2815
+ try:
2816
+ open_attr = getattr(self._ws, "open", None)
2817
+ if open_attr is True or (callable(open_attr) and open_attr()):
2818
+ logger.debug("[%s] Already connected, skipping connect()", adapter.name)
2819
+ return True
2820
+ except Exception:
2821
+ pass
2822
+
2823
+ # Acquire platform-scoped lock to prevent duplicate connections
2824
+ if not adapter._acquire_platform_lock(
2825
+ 'yuanbao-app-key', adapter._app_key, 'Yuanbao app key'
2826
+ ):
2827
+ return False
2828
+
2829
+ try:
2830
+ # Step 1: Get sign token
2831
+ logger.info("[%s] Fetching sign token from %s", adapter.name, adapter._api_domain)
2832
+ token_data = await SignManager.get_token(
2833
+ adapter._app_key, adapter._app_secret, adapter._api_domain,
2834
+ route_env=adapter._route_env,
2835
+ )
2836
+
2837
+ # Update bot_id if returned by sign-token API
2838
+ if token_data.get("bot_id"):
2839
+ adapter._bot_id = str(token_data["bot_id"])
2840
+
2841
+ # Step 2: Open WebSocket connection (disable built-in ping/pong)
2842
+ logger.info("[%s] Connecting to %s", adapter.name, adapter._ws_url)
2843
+ self._ws = await asyncio.wait_for(
2844
+ websockets.connect( # type: ignore[attr-defined]
2845
+ adapter._ws_url,
2846
+ ping_interval=None,
2847
+ ping_timeout=None,
2848
+ close_timeout=5,
2849
+ ),
2850
+ timeout=CONNECT_TIMEOUT_SECONDS,
2851
+ )
2852
+
2853
+ # Step 3: Authenticate (AUTH_BIND + wait for BIND_ACK)
2854
+ authed = await self._authenticate(token_data)
2855
+ if not authed:
2856
+ await self._cleanup_ws()
2857
+ return False
2858
+
2859
+ # Step 4: Start background tasks
2860
+ self._reconnect_attempts = 0
2861
+ adapter._mark_connected()
2862
+ adapter._loop = asyncio.get_running_loop()
2863
+ self._heartbeat_task = asyncio.create_task(
2864
+ self._heartbeat_loop(), name=f"yuanbao-heartbeat-{self._connect_id}"
2865
+ )
2866
+ self._recv_task = asyncio.create_task(
2867
+ self._receive_loop(), name=f"yuanbao-recv-{self._connect_id}"
2868
+ )
2869
+ logger.info(
2870
+ "[%s] Connected. connectId=%s botId=%s",
2871
+ adapter.name, self._connect_id, adapter._bot_id,
2872
+ )
2873
+
2874
+ YuanbaoAdapter.set_active(adapter)
2875
+
2876
+ return True
2877
+
2878
+ except asyncio.TimeoutError:
2879
+ logger.error("[%s] Connection timed out", adapter.name)
2880
+ await self._cleanup_ws()
2881
+ adapter._release_platform_lock()
2882
+ return False
2883
+ except Exception as exc:
2884
+ logger.error("[%s] connect() failed: %s", adapter.name, exc, exc_info=True)
2885
+ await self._cleanup_ws()
2886
+ adapter._release_platform_lock()
2887
+ return False
2888
+
2889
+ async def close(self) -> None:
2890
+ """Cancel background tasks, fail pending futures, and close the WebSocket."""
2891
+
2892
+ if self._heartbeat_task:
2893
+ self._heartbeat_task.cancel()
2894
+ try:
2895
+ await self._heartbeat_task
2896
+ except asyncio.CancelledError:
2897
+ pass
2898
+ self._heartbeat_task = None
2899
+
2900
+ if self._recv_task:
2901
+ self._recv_task.cancel()
2902
+ try:
2903
+ await self._recv_task
2904
+ except asyncio.CancelledError:
2905
+ pass
2906
+ self._recv_task = None
2907
+
2908
+ # Fail any pending ACK futures
2909
+ disc_exc = RuntimeError("YuanbaoAdapter disconnected")
2910
+ for fut in self._pending_acks.values():
2911
+ if not fut.done():
2912
+ fut.set_exception(disc_exc)
2913
+ self._pending_acks.clear()
2914
+
2915
+ # Clear refresh locks to avoid stale locks from a previous event loop
2916
+ SignManager.clear_locks()
2917
+
2918
+ await self._cleanup_ws()
2919
+
2920
+ # -- Authentication ----------------------------------------------------
2921
+
2922
+ async def _authenticate(self, token_data: dict) -> bool:
2923
+ """Send AUTH_BIND and read frames until BIND_ACK is received.
2924
+
2925
+ Returns True on success, False on failure/timeout.
2926
+ """
2927
+ adapter = self._adapter
2928
+ if self._ws is None:
2929
+ return False
2930
+
2931
+ token = token_data.get("token", "")
2932
+ uid = adapter._bot_id or token_data.get("bot_id", "")
2933
+ source = token_data.get("source") or "bot"
2934
+ route_env = adapter._route_env or token_data.get("route_env", "") or ""
2935
+
2936
+ msg_id = str(uuid.uuid4())
2937
+
2938
+ auth_bytes = encode_auth_bind(
2939
+ biz_id="ybBot",
2940
+ uid=uid,
2941
+ source=source,
2942
+ token=token,
2943
+ msg_id=msg_id,
2944
+ app_version=_APP_VERSION,
2945
+ operation_system=_OPERATION_SYSTEM,
2946
+ bot_version=_BOT_VERSION,
2947
+ route_env=route_env,
2948
+ )
2949
+ await self._ws.send(auth_bytes)
2950
+ logger.debug("[%s] AUTH_BIND sent (msg_id=%s uid=%s)", adapter.name, msg_id, uid)
2951
+
2952
+ try:
2953
+ _loop = asyncio.get_running_loop()
2954
+ deadline = _loop.time() + AUTH_TIMEOUT_SECONDS
2955
+ while True:
2956
+ remaining = deadline - _loop.time()
2957
+ if remaining <= 0:
2958
+ logger.error("[%s] AUTH_BIND timeout waiting for BIND_ACK", adapter.name)
2959
+ return False
2960
+
2961
+ raw = await asyncio.wait_for(self._ws.recv(), timeout=remaining)
2962
+ if not isinstance(raw, (bytes, bytearray)):
2963
+ continue
2964
+
2965
+ try:
2966
+ msg = decode_conn_msg(bytes(raw))
2967
+ except Exception:
2968
+ continue
2969
+
2970
+ head = msg.get("head", {})
2971
+ cmd_type = head.get("cmd_type", -1)
2972
+ cmd = head.get("cmd", "")
2973
+
2974
+ if cmd_type == CMD_TYPE["Response"] and cmd == "auth-bind":
2975
+ connect_id = self._extract_connect_id(msg)
2976
+ if connect_id:
2977
+ self._connect_id = connect_id
2978
+ logger.info("[%s] BIND_ACK received: connectId=%s", adapter.name, connect_id)
2979
+ return True
2980
+ else:
2981
+ logger.error("[%s] BIND_ACK missing connectId", adapter.name)
2982
+ return False
2983
+
2984
+ except asyncio.TimeoutError:
2985
+ logger.error("[%s] AUTH_BIND timeout", adapter.name)
2986
+ return False
2987
+ except Exception as exc:
2988
+ logger.error("[%s] AUTH_BIND error: %s", adapter.name, exc, exc_info=True)
2989
+ return False
2990
+
2991
+ def _extract_connect_id(self, decoded_msg: dict) -> Optional[str]:
2992
+ """Extract connectId from decoded BIND_ACK message."""
2993
+ data: bytes = decoded_msg.get("data", b"")
2994
+ if not data:
2995
+ return None
2996
+ try:
2997
+ fdict = _fields_to_dict(_parse_fields(data))
2998
+ code = _get_varint(fdict, 1)
2999
+ if code != 0:
3000
+ message = _get_string(fdict, 2)
3001
+ logger.error(
3002
+ "[%s] AuthBindRsp error: code=%d message=%r",
3003
+ self._adapter.name, code, message,
3004
+ )
3005
+ return None
3006
+ connect_id = _get_string(fdict, 3)
3007
+ return connect_id if connect_id else None
3008
+ except Exception as exc:
3009
+ logger.warning("[%s] Failed to extract connectId: %s", self._adapter.name, exc)
3010
+ return None
3011
+
3012
+ # -- Heartbeat ---------------------------------------------------------
3013
+
3014
+ async def _heartbeat_loop(self) -> None:
3015
+ """Send HEARTBEAT (ping) every 30s; trigger reconnect after threshold misses."""
3016
+ adapter = self._adapter
3017
+ try:
3018
+ while adapter._running:
3019
+ await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
3020
+ if self._ws is None:
3021
+ continue
3022
+ try:
3023
+ msg_id = str(uuid.uuid4())
3024
+ ping_bytes = encode_ping(msg_id)
3025
+ loop = asyncio.get_running_loop()
3026
+ pong_future: asyncio.Future = loop.create_future()
3027
+ self._pending_pong = pong_future
3028
+ self._pending_acks[msg_id] = pong_future
3029
+ await self._ws.send(ping_bytes)
3030
+ logger.debug("[%s] PING sent (msg_id=%s)", adapter.name, msg_id)
3031
+ try:
3032
+ await asyncio.wait_for(pong_future, timeout=10.0)
3033
+ self._consecutive_hb_timeouts = 0
3034
+ except asyncio.TimeoutError:
3035
+ self._pending_acks.pop(msg_id, None)
3036
+ self._consecutive_hb_timeouts += 1
3037
+ logger.warning(
3038
+ "[%s] PONG timeout (%d/%d)",
3039
+ adapter.name, self._consecutive_hb_timeouts, HEARTBEAT_TIMEOUT_THRESHOLD,
3040
+ )
3041
+ if self._consecutive_hb_timeouts >= HEARTBEAT_TIMEOUT_THRESHOLD:
3042
+ logger.warning("[%s] Heartbeat threshold exceeded, triggering reconnect", adapter.name)
3043
+ self.schedule_reconnect()
3044
+ return
3045
+ finally:
3046
+ self._pending_acks.pop(msg_id, None)
3047
+ self._pending_pong = None
3048
+ except Exception as exc:
3049
+ logger.debug("[%s] Heartbeat send failed: %s", adapter.name, exc)
3050
+ except asyncio.CancelledError:
3051
+ pass
3052
+
3053
+ # -- Receive loop ------------------------------------------------------
3054
+
3055
+ async def _receive_loop(self) -> None:
3056
+ """Read WS frames and dispatch by cmd_type."""
3057
+ adapter = self._adapter
3058
+ try:
3059
+ async for raw in self._ws: # type: ignore[union-attr]
3060
+ if not isinstance(raw, (bytes, bytearray)):
3061
+ continue
3062
+ await self._handle_frame(bytes(raw))
3063
+ except asyncio.CancelledError:
3064
+ pass
3065
+ except websockets.exceptions.ConnectionClosed as close_exc: # type: ignore[union-attr]
3066
+ close_code = getattr(close_exc, 'code', None)
3067
+ logger.warning(
3068
+ "[%s] WebSocket connection closed: code=%s reason=%s",
3069
+ adapter.name, close_code, getattr(close_exc, 'reason', ''),
3070
+ )
3071
+ if close_code and close_code in NO_RECONNECT_CLOSE_CODES:
3072
+ logger.error(
3073
+ "[%s] Close code %d is non-recoverable, NOT reconnecting",
3074
+ adapter.name, close_code,
3075
+ )
3076
+ adapter._mark_disconnected()
3077
+ else:
3078
+ self.schedule_reconnect()
3079
+ except Exception as exc:
3080
+ logger.warning("[%s] receive_loop exited: %s", adapter.name, exc)
3081
+ self.schedule_reconnect()
3082
+
3083
+ async def _handle_frame(self, raw: bytes) -> None:
3084
+ """Handle a single WebSocket frame."""
3085
+ adapter = self._adapter
3086
+ try:
3087
+ msg = decode_conn_msg(raw)
3088
+ except Exception as exc:
3089
+ logger.debug("[%s] Failed to decode frame: %s", adapter.name, exc)
3090
+ return
3091
+
3092
+ head = msg.get("head", {})
3093
+ cmd_type = head.get("cmd_type", -1)
3094
+ cmd = head.get("cmd", "")
3095
+ msg_id = head.get("msg_id", "")
3096
+ need_ack = head.get("need_ack", False)
3097
+ data: bytes = msg.get("data", b"")
3098
+
3099
+ # HEARTBEAT_ACK
3100
+ if cmd_type == CMD_TYPE["Response"] and cmd == "ping":
3101
+ logger.debug("[%s] HEARTBEAT_ACK received (msg_id=%s)", adapter.name, msg_id)
3102
+ if self._pending_pong is not None and not self._pending_pong.done():
3103
+ self._pending_pong.set_result(True)
3104
+ elif msg_id and msg_id in self._pending_acks:
3105
+ fut = self._pending_acks.pop(msg_id)
3106
+ if not fut.done():
3107
+ fut.set_result(True)
3108
+ return
3109
+
3110
+ # Fire-and-forget heartbeat ACKs — server always responds but callers don't
3111
+ # wait on these; silently discard to avoid "Unmatched Response" noise.
3112
+ if cmd_type == CMD_TYPE["Response"] and cmd in {
3113
+ "send_group_heartbeat",
3114
+ "send_private_heartbeat",
3115
+ }:
3116
+ logger.debug("[%s] Heartbeat ACK received: cmd=%s msg_id=%s", adapter.name, cmd, msg_id)
3117
+ return
3118
+
3119
+ # Response to an outbound RPC call
3120
+ if cmd_type == CMD_TYPE["Response"]:
3121
+ if msg_id and msg_id in self._pending_acks:
3122
+ fut = self._pending_acks.pop(msg_id)
3123
+ if not fut.done():
3124
+ result = {"head": head}
3125
+ if data:
3126
+ result["data"] = data
3127
+ fut.set_result(result)
3128
+ else:
3129
+ logger.debug(
3130
+ "[%s] Unmatched Response: cmd=%s msg_id=%s",
3131
+ adapter.name, cmd, msg_id,
3132
+ )
3133
+ return
3134
+
3135
+ # Server-initiated Push
3136
+ if cmd_type == CMD_TYPE["Push"]:
3137
+ logger.info("[%s] Push received: cmd=%s msg_id=%s data_len=%d", adapter.name, cmd, msg_id, len(data))
3138
+ if need_ack and self._ws is not None:
3139
+ try:
3140
+ ack_bytes = encode_push_ack(head)
3141
+ await self._ws.send(ack_bytes)
3142
+ except Exception as ack_exc:
3143
+ logger.debug("[%s] Failed to send PushAck: %s", adapter.name, ack_exc)
3144
+
3145
+ if msg_id and msg_id in self._pending_acks:
3146
+ fut = self._pending_acks.pop(msg_id)
3147
+ if not fut.done():
3148
+ try:
3149
+ decoded = decode_inbound_push(data) if data else {"head": head}
3150
+ fut.set_result(decoded)
3151
+ except Exception as exc:
3152
+ fut.set_exception(exc)
3153
+ return
3154
+
3155
+ # Genuine inbound message — dispatch to AI
3156
+ if data:
3157
+ logger.info(
3158
+ "[%s] WS received inbound push, decoding and dispatching: cmd=%s, data_len=%d",
3159
+ adapter.name, cmd, len(data),
3160
+ )
3161
+ self._push_to_inbound(data)
3162
+ return
3163
+
3164
+ logger.debug(
3165
+ "[%s] Ignoring frame: cmd_type=%d cmd=%s msg_id=%s",
3166
+ adapter.name, cmd_type, cmd, msg_id,
3167
+ )
3168
+
3169
+ # -- Inbound dispatch ---------------------------------------------------
3170
+
3171
+ _DEBOUNCE_WINDOW: float = 1.5 # seconds to wait for companion messages
3172
+
3173
+ def _extract_sender_key(self, raw_data: bytes) -> str:
3174
+ """Lightweight decode to extract sender key for debounce grouping.
3175
+
3176
+ Returns 'from_account:group_code' or a fallback unique key.
3177
+ """
3178
+ try:
3179
+ parsed = json.loads(raw_data.decode("utf-8"))
3180
+ if isinstance(parsed, dict):
3181
+ from_account = (
3182
+ parsed.get("from_account", "")
3183
+ or parsed.get("From_Account", "")
3184
+ )
3185
+ group_code = (
3186
+ parsed.get("group_code", "")
3187
+ or parsed.get("GroupId", "")
3188
+ or parsed.get("group_id", "")
3189
+ )
3190
+ if from_account:
3191
+ return f"{from_account}:{group_code}"
3192
+ except Exception:
3193
+ pass
3194
+ # Protobuf: try decode_inbound_push for sender info
3195
+ try:
3196
+ push = decode_inbound_push(raw_data)
3197
+ if push:
3198
+ return f"{push.get('from_account', '')}:{push.get('group_code', '')}"
3199
+ except Exception:
3200
+ pass
3201
+ # Fallback: unique key (no aggregation)
3202
+ return f"__unknown_{id(raw_data)}"
3203
+
3204
+ def _push_to_inbound(self, raw_data: bytes) -> None:
3205
+ """Debounced inbound dispatch.
3206
+
3207
+ Buffers raw frames from the same sender within a short time window,
3208
+ then dispatches all buffered data as a single aggregated pipeline
3209
+ execution. This merges multi-part messages (e.g. image + text sent
3210
+ as separate WS pushes) into one pipeline run.
3211
+ """
3212
+ key = self._extract_sender_key(raw_data)
3213
+
3214
+ # Cancel existing timer for this key (reset debounce window)
3215
+ existing_timer = self._inbound_timers.pop(key, None)
3216
+ if existing_timer:
3217
+ existing_timer.cancel()
3218
+
3219
+ # Append to buffer
3220
+ if key not in self._inbound_buffer:
3221
+ self._inbound_buffer[key] = []
3222
+ self._inbound_buffer[key].append(raw_data)
3223
+
3224
+ logger.debug(
3225
+ "[%s] Debounce: buffered frame for key=%s, count=%d",
3226
+ self._adapter.name, key, len(self._inbound_buffer[key]),
3227
+ )
3228
+
3229
+ # Schedule flush after debounce window
3230
+ loop = asyncio.get_running_loop()
3231
+ timer = loop.call_later(
3232
+ self._DEBOUNCE_WINDOW,
3233
+ self._flush_inbound_buffer,
3234
+ key,
3235
+ )
3236
+ self._inbound_timers[key] = timer
3237
+
3238
+ def _flush_inbound_buffer(self, key: str) -> None:
3239
+ """Flush the debounce buffer for a given key — execute the pipeline."""
3240
+ self._inbound_timers.pop(key, None)
3241
+ data_list = self._inbound_buffer.pop(key, [])
3242
+ if not data_list:
3243
+ return
3244
+
3245
+ adapter = self._adapter
3246
+ logger.info(
3247
+ "[%s] Debounce flush: key=%s, aggregated %d frames",
3248
+ adapter.name, key, len(data_list),
3249
+ )
3250
+
3251
+ ctx = InboundContext(adapter=adapter, raw_frames=data_list)
3252
+
3253
+ adapter._track_task(asyncio.create_task(
3254
+ adapter._inbound_pipeline.execute(ctx),
3255
+ name=f"yuanbao-pipeline-{key}",
3256
+ ))
3257
+
3258
+ # -- Send business request ---------------------------------------------
3259
+
3260
+ async def send_biz_request(
3261
+ self,
3262
+ encoded_conn_msg: bytes,
3263
+ req_id: str,
3264
+ timeout: float = DEFAULT_SEND_TIMEOUT,
3265
+ ) -> dict:
3266
+ """Send a business-layer request and wait for the response.
3267
+
3268
+ 1. Register a Future in pending_acks[req_id]
3269
+ 2. Send encoded_conn_msg (bytes) to WS
3270
+ 3. asyncio.wait_for(future, timeout)
3271
+ 4. Clean up pending_acks on timeout/exception
3272
+ """
3273
+ if self._ws is None:
3274
+ raise RuntimeError("Not connected")
3275
+
3276
+ loop = asyncio.get_running_loop()
3277
+ future: asyncio.Future = loop.create_future()
3278
+ self._pending_acks[req_id] = future
3279
+ try:
3280
+ await self._ws.send(encoded_conn_msg)
3281
+ result = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
3282
+ return result
3283
+ except asyncio.TimeoutError:
3284
+ raise
3285
+ except Exception:
3286
+ raise
3287
+ finally:
3288
+ self._pending_acks.pop(req_id, None)
3289
+
3290
+ # -- Reconnect ---------------------------------------------------------
3291
+
3292
+ def schedule_reconnect(self) -> None:
3293
+ """Schedule a reconnect only if running and not already reconnecting."""
3294
+ if self._adapter._running and not self._reconnecting:
3295
+ asyncio.create_task(self._reconnect_with_backoff())
3296
+
3297
+ async def _reconnect_with_backoff(self) -> bool:
3298
+ """Reconnect with exponential backoff (1s, 2s, 4s, … up to 60s)."""
3299
+ if self._reconnecting:
3300
+ logger.debug("[%s] Reconnect already in progress, skipping", self._adapter.name)
3301
+ return False
3302
+ self._reconnecting = True
3303
+ try:
3304
+ return await self._do_reconnect()
3305
+ finally:
3306
+ self._reconnecting = False
3307
+
3308
+ async def _do_reconnect(self) -> bool:
3309
+ """Internal reconnect loop, called under the _reconnecting guard."""
3310
+ adapter = self._adapter
3311
+ for attempt in range(MAX_RECONNECT_ATTEMPTS):
3312
+ self._reconnect_attempts = attempt + 1
3313
+ wait = min(2 ** attempt, 60)
3314
+ logger.info(
3315
+ "[%s] Reconnect attempt %d/%d in %ds",
3316
+ adapter.name, attempt + 1, MAX_RECONNECT_ATTEMPTS, wait,
3317
+ )
3318
+ await asyncio.sleep(wait)
3319
+
3320
+ await self._cleanup_ws()
3321
+
3322
+ try:
3323
+ token_data = await SignManager.force_refresh(
3324
+ adapter._app_key, adapter._app_secret, adapter._api_domain,
3325
+ route_env=adapter._route_env,
3326
+ )
3327
+ if token_data.get("bot_id"):
3328
+ adapter._bot_id = str(token_data["bot_id"])
3329
+
3330
+ self._ws = await asyncio.wait_for(
3331
+ websockets.connect( # type: ignore[attr-defined]
3332
+ adapter._ws_url,
3333
+ ping_interval=None,
3334
+ ping_timeout=None,
3335
+ close_timeout=5,
3336
+ ),
3337
+ timeout=CONNECT_TIMEOUT_SECONDS,
3338
+ )
3339
+
3340
+ authed = await self._authenticate(token_data)
3341
+ if not authed:
3342
+ logger.warning("[%s] Re-auth failed on attempt %d", adapter.name, attempt + 1)
3343
+ await self._cleanup_ws()
3344
+ continue
3345
+
3346
+ self._reconnect_attempts = 0
3347
+ self._consecutive_hb_timeouts = 0
3348
+ adapter._mark_connected()
3349
+
3350
+ if self._heartbeat_task and not self._heartbeat_task.done():
3351
+ self._heartbeat_task.cancel()
3352
+ self._heartbeat_task = asyncio.create_task(
3353
+ self._heartbeat_loop(),
3354
+ name=f"yuanbao-heartbeat-{self._connect_id}",
3355
+ )
3356
+
3357
+ if self._recv_task and not self._recv_task.done():
3358
+ self._recv_task.cancel()
3359
+ self._recv_task = asyncio.create_task(
3360
+ self._receive_loop(),
3361
+ name=f"yuanbao-recv-{self._connect_id}",
3362
+ )
3363
+
3364
+ logger.info(
3365
+ "[%s] Reconnected on attempt %d. connectId=%s",
3366
+ adapter.name, attempt + 1, self._connect_id,
3367
+ )
3368
+ return True
3369
+
3370
+ except asyncio.TimeoutError:
3371
+ logger.warning("[%s] Reconnect attempt %d timed out", adapter.name, attempt + 1)
3372
+ except Exception as exc:
3373
+ logger.warning(
3374
+ "[%s] Reconnect attempt %d failed: %s", adapter.name, attempt + 1, exc
3375
+ )
3376
+
3377
+ logger.error(
3378
+ "[%s] Giving up after %d reconnect attempts", adapter.name, MAX_RECONNECT_ATTEMPTS
3379
+ )
3380
+ adapter._mark_disconnected()
3381
+ return False
3382
+
3383
+ async def _cleanup_ws(self) -> None:
3384
+ """Close and clear the WebSocket connection."""
3385
+ ws = self._ws
3386
+ self._ws = None
3387
+ if ws is not None:
3388
+ try:
3389
+ await ws.close()
3390
+ except Exception:
3391
+ pass
3392
+
3393
+ class MediaSendHandler(ABC):
3394
+ """Abstract base class for media send strategies.
3395
+
3396
+ Subclasses implement:
3397
+ - acquire_file(): how to obtain file bytes (download URL / read local)
3398
+ - build_msg_body(): how to build TIMxxxElem from upload result
3399
+
3400
+ The shared flow (check ws → cancel notifier → validate → COS upload
3401
+ → lock → dispatch) is handled by the base handle() template method.
3402
+ """
3403
+
3404
+ @abstractmethod
3405
+ async def acquire_file(
3406
+ self, adapter: "YuanbaoAdapter", **kwargs: Any,
3407
+ ) -> Tuple[bytes, str, str]:
3408
+ """Return (file_bytes, filename, content_type).
3409
+
3410
+ Raises:
3411
+ ValueError: when file cannot be acquired (not found, empty, etc.)
3412
+ """
3413
+
3414
+ @abstractmethod
3415
+ def build_msg_body(self, upload_result: dict, **kwargs: Any) -> list:
3416
+ """Build platform-specific MsgBody list from COS upload result."""
3417
+
3418
+ def needs_cos_upload(self) -> bool:
3419
+ """Override to return False for non-COS media (e.g. sticker)."""
3420
+ return True
3421
+
3422
+ async def handle(
3423
+ self,
3424
+ adapter: "YuanbaoAdapter",
3425
+ chat_id: str,
3426
+ reply_to: Optional[str] = None,
3427
+ caption: Optional[str] = None,
3428
+ **kwargs: Any,
3429
+ ) -> "SendResult":
3430
+ """Template method: shared media send flow."""
3431
+ conn = adapter._connection
3432
+ sender = adapter._outbound.sender
3433
+
3434
+ if conn.ws is None:
3435
+ return SendResult(success=False, error="Not connected", retryable=True)
3436
+
3437
+ adapter._outbound.cancel_slow_notifier(chat_id)
3438
+
3439
+ try:
3440
+ # 1. Acquire file bytes
3441
+ file_bytes, filename, content_type = await self.acquire_file(
3442
+ adapter, **kwargs,
3443
+ )
3444
+
3445
+ # 2. Validate (only for handlers that upload to COS; stickers use
3446
+ # TIMFaceElem and legitimately carry no file bytes, so skipping
3447
+ # validate_media here avoids a spurious "Empty file: sticker").
3448
+ if self.needs_cos_upload():
3449
+ validation_err = MessageSender.validate_media(
3450
+ file_bytes, filename, adapter.MEDIA_MAX_SIZE_MB,
3451
+ )
3452
+ if validation_err:
3453
+ return SendResult(success=False, error=validation_err)
3454
+
3455
+ if self.needs_cos_upload():
3456
+ file_uuid = md5_hex(file_bytes)
3457
+
3458
+ # 3. Get COS upload credentials
3459
+ token_data = await adapter._get_cached_token()
3460
+ token: str = token_data.get("token", "")
3461
+ bot_id: str = (
3462
+ token_data.get("bot_id", "") or adapter._bot_id or ""
3463
+ )
3464
+
3465
+ credentials = await get_cos_credentials(
3466
+ app_key=adapter._app_key,
3467
+ api_domain=adapter._api_domain,
3468
+ token=token,
3469
+ filename=filename,
3470
+ bot_id=bot_id,
3471
+ route_env=adapter._route_env,
3472
+ )
3473
+
3474
+ # 4. Upload to COS
3475
+ upload_result = await upload_to_cos(
3476
+ file_bytes=file_bytes,
3477
+ filename=filename,
3478
+ content_type=content_type,
3479
+ credentials=credentials,
3480
+ bucket=credentials["bucketName"],
3481
+ region=credentials["region"],
3482
+ )
3483
+
3484
+ # 5. Build MsgBody
3485
+ # Remove keys already passed explicitly to avoid "multiple values" TypeError
3486
+ fwd_kwargs = {
3487
+ k: v for k, v in kwargs.items()
3488
+ if k not in {"file_uuid", "filename", "content_type"}
3489
+ }
3490
+ msg_body = self.build_msg_body(
3491
+ upload_result,
3492
+ file_uuid=file_uuid,
3493
+ filename=filename,
3494
+ content_type=content_type,
3495
+ **fwd_kwargs,
3496
+ )
3497
+ else:
3498
+ # Non-COS media (e.g. sticker): build MsgBody directly
3499
+ msg_body = self.build_msg_body({}, **kwargs)
3500
+
3501
+ # 6. Append caption if provided
3502
+ if caption:
3503
+ msg_body.append(
3504
+ {"msg_type": "TIMTextElem", "msg_content": {"text": caption}},
3505
+ )
3506
+
3507
+ # 7. Lock + dispatch
3508
+ gc = kwargs.get("group_code", "")
3509
+ return await sender.dispatch_msg_body(chat_id, msg_body, reply_to, group_code=gc)
3510
+
3511
+ except ValueError as ve:
3512
+ return SendResult(success=False, error=str(ve))
3513
+ except Exception as exc:
3514
+ handler_name = type(self).__name__
3515
+ logger.error(
3516
+ "[%s] %s.handle() failed: %s",
3517
+ adapter.name, handler_name, exc, exc_info=True,
3518
+ )
3519
+ return SendResult(success=False, error=str(exc))
3520
+
3521
+
3522
+ class ImageUrlHandler(MediaSendHandler):
3523
+ """Strategy: send image from a URL (download → COS → TIMImageElem)."""
3524
+
3525
+ async def acquire_file(self, adapter, **kwargs):
3526
+ image_url: str = kwargs["image_url"]
3527
+ logger.info("[%s] ImageUrlHandler: downloading %s", adapter.name, image_url)
3528
+ file_bytes, content_type = await media_download_url(
3529
+ image_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
3530
+ )
3531
+ if not content_type or content_type == "application/octet-stream":
3532
+ path_part = image_url.split("?")[0]
3533
+ content_type = guess_mime_type(path_part) or "image/jpeg"
3534
+ filename = os.path.basename(image_url.split("?")[0]) or "image.jpg"
3535
+ return file_bytes, filename, content_type
3536
+
3537
+ def build_msg_body(self, upload_result, **kwargs):
3538
+ return build_image_msg_body(
3539
+ url=upload_result["url"],
3540
+ uuid=kwargs["file_uuid"],
3541
+ filename=kwargs["filename"],
3542
+ size=upload_result["size"],
3543
+ width=upload_result.get("width", 0),
3544
+ height=upload_result.get("height", 0),
3545
+ mime_type=kwargs["content_type"],
3546
+ )
3547
+
3548
+
3549
+ class ImageFileHandler(MediaSendHandler):
3550
+ """Strategy: send image from a local file path (read → COS → TIMImageElem)."""
3551
+
3552
+ async def acquire_file(self, adapter, **kwargs):
3553
+ image_path: str = kwargs["image_path"]
3554
+ if not os.path.isfile(image_path):
3555
+ raise ValueError(f"File not found: {image_path}")
3556
+ logger.info("[%s] ImageFileHandler: reading %s", adapter.name, image_path)
3557
+ with open(image_path, "rb") as f:
3558
+ file_bytes = f.read()
3559
+ filename = os.path.basename(image_path) or "image.jpg"
3560
+ content_type = guess_mime_type(filename) or "image/jpeg"
3561
+ return file_bytes, filename, content_type
3562
+
3563
+ def build_msg_body(self, upload_result, **kwargs):
3564
+ return build_image_msg_body(
3565
+ url=upload_result["url"],
3566
+ uuid=kwargs["file_uuid"],
3567
+ filename=kwargs["filename"],
3568
+ size=upload_result["size"],
3569
+ width=upload_result.get("width", 0),
3570
+ height=upload_result.get("height", 0),
3571
+ mime_type=kwargs["content_type"],
3572
+ )
3573
+
3574
+
3575
+ class FileUrlHandler(MediaSendHandler):
3576
+ """Strategy: send file from a URL (download → COS → TIMFileElem)."""
3577
+
3578
+ async def acquire_file(self, adapter, **kwargs):
3579
+ file_url: str = kwargs["file_url"]
3580
+ logger.info("[%s] FileUrlHandler: downloading %s", adapter.name, file_url)
3581
+ file_bytes, content_type = await media_download_url(
3582
+ file_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
3583
+ )
3584
+ filename = kwargs.get("filename")
3585
+ if not filename:
3586
+ path_part = file_url.split("?")[0]
3587
+ filename = os.path.basename(path_part) or "file"
3588
+ if not content_type or content_type == "application/octet-stream":
3589
+ content_type = guess_mime_type(filename) or "application/octet-stream"
3590
+ return file_bytes, filename, content_type
3591
+
3592
+ def build_msg_body(self, upload_result, **kwargs):
3593
+ return build_file_msg_body(
3594
+ url=upload_result["url"],
3595
+ filename=kwargs["filename"],
3596
+ uuid=kwargs["file_uuid"],
3597
+ size=upload_result["size"],
3598
+ )
3599
+
3600
+
3601
+ class DocumentHandler(MediaSendHandler):
3602
+ """Strategy: send local file/document (read → COS → TIMFileElem)."""
3603
+
3604
+ async def acquire_file(self, adapter, **kwargs):
3605
+ file_path: str = kwargs["file_path"]
3606
+ if not os.path.isfile(file_path):
3607
+ raise ValueError(f"File not found: {file_path}")
3608
+ logger.info("[%s] DocumentHandler: reading %s", adapter.name, file_path)
3609
+ with open(file_path, "rb") as f:
3610
+ file_bytes = f.read()
3611
+ filename = kwargs.get("filename") or os.path.basename(file_path) or "document"
3612
+ content_type = guess_mime_type(filename) or "application/octet-stream"
3613
+ return file_bytes, filename, content_type
3614
+
3615
+ def build_msg_body(self, upload_result, **kwargs):
3616
+ return build_file_msg_body(
3617
+ url=upload_result["url"],
3618
+ filename=kwargs["filename"],
3619
+ uuid=kwargs["file_uuid"],
3620
+ size=upload_result["size"],
3621
+ )
3622
+
3623
+
3624
+ class StickerHandler(MediaSendHandler):
3625
+ """Strategy: send sticker/emoji (TIMFaceElem, no COS upload needed)."""
3626
+
3627
+ def needs_cos_upload(self) -> bool:
3628
+ return False
3629
+
3630
+ async def acquire_file(self, adapter, **kwargs):
3631
+ # Sticker does not need file bytes; return dummy values
3632
+ return b"", "sticker", "application/octet-stream"
3633
+
3634
+ def build_msg_body(self, upload_result, **kwargs):
3635
+ from gateway.platforms.yuanbao_sticker import (
3636
+ get_sticker_by_name,
3637
+ get_random_sticker,
3638
+ build_face_msg_body,
3639
+ build_sticker_msg_body,
3640
+ )
3641
+ sticker_name = kwargs.get("sticker_name")
3642
+ face_index = kwargs.get("face_index")
3643
+
3644
+ if sticker_name is not None:
3645
+ sticker = get_sticker_by_name(sticker_name)
3646
+ if sticker is None:
3647
+ raise ValueError(f"Sticker not found: {sticker_name!r}")
3648
+ return build_sticker_msg_body(sticker)
3649
+ elif face_index is not None:
3650
+ return build_face_msg_body(face_index=face_index)
3651
+ else:
3652
+ sticker = get_random_sticker()
3653
+ return build_sticker_msg_body(sticker)
3654
+
3655
+ class GroupQueryService:
3656
+ """Encapsulates all group query operations (both low-level WS calls and
3657
+ higher-level AI-tool-facing wrappers).
3658
+
3659
+ Responsibilities:
3660
+ - Low-level WS encode/decode for group info and member list queries
3661
+ - Chat-id parsing, error wrapping and result filtering for AI tools
3662
+ - Member cache population on the adapter
3663
+ """
3664
+
3665
+ def __init__(self, adapter: "YuanbaoAdapter") -> None:
3666
+ self._adapter = adapter
3667
+
3668
+ # ------------------------------------------------------------------
3669
+ # Low-level WS query methods
3670
+ # ------------------------------------------------------------------
3671
+
3672
+ async def query_group_info_raw(self, group_code: str) -> Optional[dict]:
3673
+ """Query group info via WS (group name, owner, member count, etc.).
3674
+
3675
+ Returns:
3676
+ Decoded dict or None on failure.
3677
+ """
3678
+ adapter = self._adapter
3679
+ if adapter._connection.ws is None:
3680
+ return None
3681
+ encoded = encode_query_group_info(group_code)
3682
+ from gateway.platforms.yuanbao_proto import decode_conn_msg as _decode
3683
+ decoded = _decode(encoded)
3684
+ req_id = decoded["head"]["msg_id"]
3685
+ try:
3686
+ response = await adapter._connection.send_biz_request(encoded, req_id=req_id)
3687
+ head = response.get("head", {})
3688
+ status = head.get("status", 0)
3689
+ if status != 0:
3690
+ logger.warning("[%s] query_group_info failed: status=%d", adapter.name, status)
3691
+ return None
3692
+ biz_data = response.get("data", b"") or response.get("body", b"")
3693
+ if biz_data and isinstance(biz_data, bytes):
3694
+ return decode_query_group_info_rsp(biz_data)
3695
+ return {"group_code": group_code}
3696
+ except asyncio.TimeoutError:
3697
+ logger.warning("[%s] query_group_info timeout: group=%s", adapter.name, group_code)
3698
+ return None
3699
+ except Exception as exc:
3700
+ logger.warning("[%s] query_group_info failed: %s", adapter.name, exc)
3701
+ return None
3702
+
3703
+ async def get_group_member_list_raw(
3704
+ self, group_code: str, offset: int = 0, limit: int = 200
3705
+ ) -> Optional[dict]:
3706
+ """Query group member list via WS.
3707
+
3708
+ Returns:
3709
+ Decoded dict or None on failure. Also populates adapter._member_cache.
3710
+ """
3711
+ adapter = self._adapter
3712
+ if adapter._connection.ws is None:
3713
+ return None
3714
+ encoded = encode_get_group_member_list(group_code, offset=offset, limit=limit)
3715
+ from gateway.platforms.yuanbao_proto import decode_conn_msg as _decode
3716
+ decoded = _decode(encoded)
3717
+ req_id = decoded["head"]["msg_id"]
3718
+ try:
3719
+ response = await adapter._connection.send_biz_request(encoded, req_id=req_id)
3720
+ head = response.get("head", {})
3721
+ status = head.get("status", 0)
3722
+ if status != 0:
3723
+ logger.warning("[%s] get_group_member_list failed: status=%d", adapter.name, status)
3724
+ return None
3725
+ biz_data = response.get("data", b"") or response.get("body", b"")
3726
+ if biz_data and isinstance(biz_data, bytes):
3727
+ result = decode_get_group_member_list_rsp(biz_data)
3728
+ else:
3729
+ result = {"members": [], "next_offset": 0, "is_complete": True}
3730
+ if result and result.get("members"):
3731
+ adapter._member_cache[group_code] = (time.time(), result["members"])
3732
+ return result
3733
+ except asyncio.TimeoutError:
3734
+ logger.warning("[%s] get_group_member_list timeout: group=%s", adapter.name, group_code)
3735
+ return None
3736
+ except Exception as exc:
3737
+ logger.warning("[%s] get_group_member_list failed: %s", adapter.name, exc)
3738
+ return None
3739
+
3740
+ # ------------------------------------------------------------------
3741
+ # AI-tool-facing wrappers (chat_id parsing + filtering)
3742
+ # ------------------------------------------------------------------
3743
+
3744
+ async def query_group_info(self, chat_id: str) -> dict:
3745
+ """AI tool: Query current group info.
3746
+
3747
+ No parameters needed (group_code extracted from session context).
3748
+ Returns group name, owner, member count, etc.
3749
+ """
3750
+ if not chat_id.startswith("group:"):
3751
+ return {"error": "This command is only available in group chats"}
3752
+ group_code = chat_id[len("group:"):]
3753
+ result = await self.query_group_info_raw(group_code)
3754
+ if result is None:
3755
+ return {"error": "Failed to query group info"}
3756
+ return result
3757
+
3758
+ async def query_session_members(
3759
+ self,
3760
+ chat_id: str,
3761
+ action: str = "list_all",
3762
+ name: Optional[str] = None,
3763
+ ) -> dict:
3764
+ """AI tool: Query group member list.
3765
+
3766
+ Args:
3767
+ chat_id: Chat ID (extracted from session context)
3768
+ action: 'find' (search by name) | 'list_bots' (list bots) | 'list_all' (list all)
3769
+ name: Search keyword when action='find'
3770
+
3771
+ Returns:
3772
+ {"members": [...], "total": int, "mentionHint": str}
3773
+ """
3774
+ if not chat_id.startswith("group:"):
3775
+ return {"error": "This command is only available in group chats"}
3776
+ group_code = chat_id[len("group:"):]
3777
+ result = await self.get_group_member_list_raw(group_code)
3778
+ if result is None:
3779
+ return {"error": "Failed to query group members"}
3780
+
3781
+ members = result.get("members", [])
3782
+
3783
+ if action == "find" and name:
3784
+ query = name.lower()
3785
+ members = [
3786
+ m for m in members
3787
+ if query in (m.get("nickname", "") or "").lower()
3788
+ or query in (m.get("name_card", "") or "").lower()
3789
+ or query in (m.get("user_id", "") or "").lower()
3790
+ ]
3791
+ elif action == "list_bots":
3792
+ members = [m for m in members if "bot" in (m.get("nickname", "") or "").lower()]
3793
+
3794
+ # Construct mentionHint
3795
+ mention_hint = ""
3796
+ if members and len(members) <= 10:
3797
+ names = [m.get("name_card") or m.get("nickname") or m.get("user_id", "") for m in members]
3798
+ mention_hint = "Mention with @name: " + ", ".join(names)
3799
+
3800
+ return {
3801
+ "members": members[:50], # Limit return count
3802
+ "total": len(members),
3803
+ "mentionHint": mention_hint,
3804
+ }
3805
+
3806
+
3807
+ class HeartbeatManager:
3808
+ """Manages reply heartbeat (RUNNING / FINISH) lifecycle.
3809
+
3810
+ Responsibilities:
3811
+ - Periodic RUNNING heartbeat sender (every 2s)
3812
+ - Auto-FINISH after 30s inactivity
3813
+ - Explicit stop with optional FINISH signal
3814
+ """
3815
+
3816
+ def __init__(self, adapter: "YuanbaoAdapter") -> None:
3817
+ self._adapter = adapter
3818
+ self._reply_heartbeat_tasks: Dict[str, asyncio.Task] = {}
3819
+ self._reply_hb_last_active: Dict[str, float] = {}
3820
+
3821
+ async def send_heartbeat_once(self, chat_id: str, heartbeat_val: int) -> None:
3822
+ """Send a single heartbeat (RUNNING or FINISH), best effort."""
3823
+ adapter = self._adapter
3824
+ conn = adapter._connection
3825
+ if conn.ws is None or not adapter._bot_id:
3826
+ return
3827
+ try:
3828
+ if chat_id.startswith("group:"):
3829
+ group_code = chat_id[len("group:"):]
3830
+ encoded = encode_send_group_heartbeat(
3831
+ from_account=adapter._bot_id,
3832
+ group_code=group_code,
3833
+ heartbeat=heartbeat_val,
3834
+ )
3835
+ else:
3836
+ to_account = chat_id.removeprefix("direct:")
3837
+ encoded = encode_send_private_heartbeat(
3838
+ from_account=adapter._bot_id,
3839
+ to_account=to_account,
3840
+ heartbeat=heartbeat_val,
3841
+ )
3842
+ await conn.ws.send(encoded)
3843
+ status_name = "RUNNING" if heartbeat_val == WS_HEARTBEAT_RUNNING else "FINISH"
3844
+ logger.debug(
3845
+ "[%s] Reply heartbeat %s sent: chat=%s",
3846
+ adapter.name, status_name, chat_id,
3847
+ )
3848
+ except Exception as exc:
3849
+ logger.debug("[%s] send_heartbeat_once failed: %s", adapter.name, exc)
3850
+
3851
+ async def start(self, chat_id: str) -> None:
3852
+ """Start or renew the Reply Heartbeat periodic sender (RUNNING, every 2s)."""
3853
+ adapter = self._adapter
3854
+ conn = adapter._connection
3855
+ if conn.ws is None or not adapter._bot_id:
3856
+ return
3857
+
3858
+ existing = self._reply_heartbeat_tasks.get(chat_id)
3859
+ if existing and not existing.done():
3860
+ self._reply_hb_last_active[chat_id] = time.time()
3861
+ return
3862
+
3863
+ self._reply_hb_last_active[chat_id] = time.time()
3864
+
3865
+ task = asyncio.create_task(
3866
+ self._worker(chat_id),
3867
+ name=f"yuanbao-reply-hb-{chat_id}",
3868
+ )
3869
+ self._reply_heartbeat_tasks[chat_id] = task
3870
+
3871
+ async def _worker(self, chat_id: str) -> None:
3872
+ """Background coroutine: send RUNNING heartbeat every 2s.
3873
+ 30s without renewal -> send FINISH and exit.
3874
+ """
3875
+ try:
3876
+ await self.send_heartbeat_once(chat_id, WS_HEARTBEAT_RUNNING)
3877
+
3878
+ while True:
3879
+ await asyncio.sleep(REPLY_HEARTBEAT_INTERVAL_S)
3880
+
3881
+ last_active = self._reply_hb_last_active.get(chat_id, 0)
3882
+ if time.time() - last_active > REPLY_HEARTBEAT_TIMEOUT_S:
3883
+ break
3884
+
3885
+ conn = self._adapter._connection
3886
+ if conn.ws is None:
3887
+ break
3888
+
3889
+ await self.send_heartbeat_once(chat_id, WS_HEARTBEAT_RUNNING)
3890
+
3891
+ except asyncio.CancelledError:
3892
+ cancelled = True
3893
+ except Exception:
3894
+ cancelled = False
3895
+ else:
3896
+ cancelled = False
3897
+ finally:
3898
+ if not cancelled:
3899
+ try:
3900
+ await self.send_heartbeat_once(chat_id, WS_HEARTBEAT_FINISH)
3901
+ except Exception:
3902
+ pass
3903
+ self._reply_heartbeat_tasks.pop(chat_id, None)
3904
+ self._reply_hb_last_active.pop(chat_id, None)
3905
+
3906
+ async def stop(self, chat_id: str, send_finish: bool = True) -> None:
3907
+ """Stop Reply Heartbeat and optionally send FINISH."""
3908
+ task = self._reply_heartbeat_tasks.pop(chat_id, None)
3909
+ if task and not task.done():
3910
+ task.cancel()
3911
+ try:
3912
+ await task
3913
+ except asyncio.CancelledError:
3914
+ pass
3915
+ if send_finish:
3916
+ try:
3917
+ await self.send_heartbeat_once(chat_id, WS_HEARTBEAT_FINISH)
3918
+ except Exception:
3919
+ pass
3920
+
3921
+ async def close(self) -> None:
3922
+ """Cancel all reply heartbeat tasks."""
3923
+ for task in list(self._reply_heartbeat_tasks.values()):
3924
+ if not task.done():
3925
+ task.cancel()
3926
+ self._reply_heartbeat_tasks.clear()
3927
+ self._reply_hb_last_active.clear()
3928
+
3929
+
3930
+ class SlowResponseNotifier:
3931
+ """Manages delayed 'please wait' notifications for slow agent responses.
3932
+
3933
+ Starts a timer per chat_id; if the agent hasn't replied within
3934
+ SLOW_RESPONSE_TIMEOUT_S seconds, sends a courtesy message.
3935
+ """
3936
+
3937
+ def __init__(self, adapter: "YuanbaoAdapter", sender: "MessageSender") -> None:
3938
+ self._adapter = adapter
3939
+ self._sender = sender
3940
+ self._tasks: Dict[str, asyncio.Task] = {}
3941
+
3942
+ async def start(self, chat_id: str) -> None:
3943
+ """Start a delayed task that notifies the user when the agent is slow."""
3944
+ self.cancel(chat_id)
3945
+ task = asyncio.create_task(
3946
+ self._notifier(chat_id),
3947
+ name=f"yuanbao-slow-resp-{chat_id}",
3948
+ )
3949
+ self._tasks[chat_id] = task
3950
+
3951
+ async def _notifier(self, chat_id: str) -> None:
3952
+ """Wait SLOW_RESPONSE_TIMEOUT_S, then push a 'please wait' message."""
3953
+ try:
3954
+ await asyncio.sleep(SLOW_RESPONSE_TIMEOUT_S)
3955
+ logger.info(
3956
+ "[%s] Agent response exceeded %ds for %s, sending wait notice",
3957
+ self._adapter.name, int(SLOW_RESPONSE_TIMEOUT_S), chat_id,
3958
+ )
3959
+ await self._sender.send_text_chunk(chat_id, SLOW_RESPONSE_MESSAGE)
3960
+ except asyncio.CancelledError:
3961
+ pass
3962
+ except Exception as exc:
3963
+ logger.debug("[%s] Slow-response notifier failed: %s", self._adapter.name, exc)
3964
+
3965
+ def cancel(self, chat_id: str) -> None:
3966
+ """Cancel the pending slow-response notifier for *chat_id*, if any."""
3967
+ task = self._tasks.pop(chat_id, None)
3968
+ if task and not task.done():
3969
+ task.cancel()
3970
+
3971
+ async def close(self) -> None:
3972
+ """Cancel all slow-response tasks."""
3973
+ for task in list(self._tasks.values()):
3974
+ if not task.done():
3975
+ task.cancel()
3976
+ self._tasks.clear()
3977
+
3978
+
3979
+ class MessageSender:
3980
+ """Core message sending dispatcher for YuanbaoAdapter.
3981
+
3982
+ Responsibilities:
3983
+ - Per-chat-id lock management (serial send ordering)
3984
+ - Text chunk sending with retry
3985
+ - C2C / Group message encoding and dispatch
3986
+ - Media send helpers (image, file, sticker, document)
3987
+ - Direct send helper (text + media, used by send_message tool)
3988
+ """
3989
+
3990
+ IMAGE_EXTS: ClassVar[frozenset] = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
3991
+ CHAT_DICT_MAX_SIZE: ClassVar[int] = 1000 # Max distinct chat IDs in _chat_locks
3992
+
3993
+ def __init__(self, adapter: "YuanbaoAdapter") -> None:
3994
+ self._adapter = adapter
3995
+ self._chat_locks: collections.OrderedDict[str, asyncio.Lock] = collections.OrderedDict()
3996
+
3997
+ # Optional hooks injected by OutboundManager for coordination
3998
+ self._on_send_start: Optional[Callable[[str], Any]] = None # cancel slow-notifier
3999
+ self._on_send_finish: Optional[Callable[[str], Any]] = None # send FINISH heartbeat
4000
+
4001
+ # Media send handlers (strategy pattern)
4002
+ self._media_handlers: Dict[str, MediaSendHandler] = {
4003
+ "image_url": ImageUrlHandler(),
4004
+ "image_file": ImageFileHandler(),
4005
+ "file_url": FileUrlHandler(),
4006
+ "document": DocumentHandler(),
4007
+ "sticker": StickerHandler(),
4008
+ }
4009
+
4010
+ # -- Media handler registry ---------------------------------------------
4011
+
4012
+ def register_handler(self, name: str, handler: MediaSendHandler) -> None:
4013
+ """Register (or replace) a named media send handler."""
4014
+ self._media_handlers[name] = handler
4015
+
4016
+ # -- Chat lock ---------------------------------------------------------
4017
+
4018
+ def get_chat_lock(self, chat_id: str) -> asyncio.Lock:
4019
+ """Return (or create) a per-chat-id lock with safe LRU eviction."""
4020
+ if chat_id in self._chat_locks:
4021
+ self._chat_locks.move_to_end(chat_id)
4022
+ return self._chat_locks[chat_id]
4023
+ if len(self._chat_locks) >= self.CHAT_DICT_MAX_SIZE:
4024
+ evicted = False
4025
+ for key in list(self._chat_locks):
4026
+ if not self._chat_locks[key].locked():
4027
+ self._chat_locks.pop(key)
4028
+ evicted = True
4029
+ break
4030
+ if not evicted:
4031
+ self._chat_locks.pop(next(iter(self._chat_locks)))
4032
+ self._chat_locks[chat_id] = asyncio.Lock()
4033
+ return self._chat_locks[chat_id]
4034
+
4035
+ # -- Text send ---------------------------------------------------------
4036
+
4037
+ async def send_text(
4038
+ self,
4039
+ chat_id: str,
4040
+ content: str,
4041
+ reply_to: Optional[str] = None,
4042
+ group_code: str = "",
4043
+ ) -> "SendResult":
4044
+ """Send text message with auto-chunking and per-chat-id ordering guarantee."""
4045
+ adapter = self._adapter
4046
+ conn = adapter._connection
4047
+ if conn.ws is None:
4048
+ return SendResult(success=False, error="Not connected", retryable=True)
4049
+
4050
+ if self._on_send_start:
4051
+ self._on_send_start(chat_id)
4052
+
4053
+ lock = self.get_chat_lock(chat_id)
4054
+ async with lock:
4055
+ content_to_send = self.strip_cron_wrapper(content)
4056
+ chunks = self.truncate_message(content_to_send, adapter.MAX_TEXT_CHUNK)
4057
+ logger.info(
4058
+ "[%s] truncate_message: input=%d chars, max=%d, output=%d chunk(s) sizes=%s",
4059
+ adapter.name, len(content_to_send), adapter.MAX_TEXT_CHUNK,
4060
+ len(chunks), [len(c) for c in chunks],
4061
+ )
4062
+ for i, chunk in enumerate(chunks):
4063
+ r_to = reply_to if i == 0 else None
4064
+ result = await self.send_text_chunk(chat_id, chunk, r_to, group_code=group_code)
4065
+ if not result.success:
4066
+ return result
4067
+
4068
+ # Notify outbound coordinator that send is complete (e.g. FINISH heartbeat)
4069
+ if self._on_send_finish:
4070
+ try:
4071
+ await self._on_send_finish(chat_id)
4072
+ except Exception:
4073
+ pass
4074
+ return SendResult(success=True)
4075
+
4076
+ async def send_media(
4077
+ self,
4078
+ chat_id: str,
4079
+ handler_name: str,
4080
+ reply_to: Optional[str] = None,
4081
+ caption: Optional[str] = None,
4082
+ **kwargs: Any,
4083
+ ) -> "SendResult":
4084
+ """Dispatch media send to the named handler strategy."""
4085
+ handler = self._media_handlers.get(handler_name)
4086
+ if handler is None:
4087
+ return SendResult(
4088
+ success=False,
4089
+ error=f"Unknown media handler: {handler_name!r}",
4090
+ )
4091
+ return await handler.handle(
4092
+ self._adapter, chat_id,
4093
+ reply_to=reply_to, caption=caption, **kwargs,
4094
+ )
4095
+
4096
+ # -- Direct send (text + media, used by send_message tool) -------------
4097
+
4098
+ async def send_direct(
4099
+ self,
4100
+ chat_id: str,
4101
+ message: str,
4102
+ media_files: Optional[List[Tuple[str, bool]]] = None,
4103
+ ) -> Dict[str, Any]:
4104
+ """Send text + media via Yuanbao (used by the ``send_message`` tool).
4105
+
4106
+ Unlike Weixin which creates a fresh adapter per call, Yuanbao reuses
4107
+ the running gateway adapter (persistent WebSocket). Logic mirrors
4108
+ send_weixin_direct: send text first, then iterate media_files by
4109
+ extension.
4110
+ """
4111
+ adapter = self._adapter
4112
+ last_result: Optional["SendResult"] = None
4113
+
4114
+ # 1. Send text
4115
+ if message.strip():
4116
+ last_result = await adapter.send(chat_id, message)
4117
+ if not last_result.success:
4118
+ return {"error": f"Yuanbao send failed: {last_result.error}"}
4119
+
4120
+ # 2. Iterate media_files, dispatch by file extension
4121
+ for media_path, _is_voice in media_files or []:
4122
+ ext = Path(media_path).suffix.lower()
4123
+ if ext in self.IMAGE_EXTS:
4124
+ last_result = await adapter.send_image_file(chat_id, media_path)
4125
+ else:
4126
+ last_result = await adapter.send_document(chat_id, media_path)
4127
+
4128
+ if not last_result.success:
4129
+ return {"error": f"Yuanbao media send failed: {last_result.error}"}
4130
+
4131
+ if last_result is None:
4132
+ return {"error": "No deliverable text or media remained after processing"}
4133
+
4134
+ return {
4135
+ "success": True,
4136
+ "platform": "yuanbao",
4137
+ "chat_id": chat_id,
4138
+ "message_id": last_result.message_id if last_result else None,
4139
+ }
4140
+
4141
+ async def dispatch_msg_body(
4142
+ self,
4143
+ chat_id: str,
4144
+ msg_body: list,
4145
+ reply_to: Optional[str] = None,
4146
+ group_code: str = "",
4147
+ ) -> "SendResult":
4148
+ """Lock + dispatch an arbitrary MsgBody to C2C or group."""
4149
+ lock = self.get_chat_lock(chat_id)
4150
+ async with lock:
4151
+ if chat_id.startswith("group:"):
4152
+ grp = chat_id[len("group:"):]
4153
+ result = await self.send_group_msg_body(grp, msg_body, reply_to)
4154
+ else:
4155
+ to_account = chat_id.removeprefix("direct:")
4156
+ result = await self.send_c2c_msg_body(to_account, msg_body, group_code=group_code)
4157
+
4158
+ if result.get("success"):
4159
+ return SendResult(success=True, message_id=result.get("msg_key"))
4160
+ return SendResult(success=False, error=result.get("error", "Unknown error"))
4161
+
4162
+ async def send_text_chunk(
4163
+ self,
4164
+ chat_id: str,
4165
+ text: str,
4166
+ reply_to: Optional[str] = None,
4167
+ retry: int = 3,
4168
+ group_code: str = "",
4169
+ ) -> "SendResult":
4170
+ """Send a single text chunk with retry (exponential backoff: 1s, 2s, 4s)."""
4171
+ adapter = self._adapter
4172
+ last_error: str = "Unknown error"
4173
+ for attempt in range(retry):
4174
+ try:
4175
+ if chat_id.startswith("group:"):
4176
+ grp = chat_id[len("group:"):]
4177
+ raw = await self.send_group_message(grp, text, reply_to)
4178
+ else:
4179
+ to_account = chat_id.removeprefix("direct:")
4180
+ raw = await self.send_c2c_message(to_account, text, group_code=group_code)
4181
+
4182
+ if raw.get("success"):
4183
+ return SendResult(success=True, message_id=raw.get("msg_key"))
4184
+
4185
+ last_error = raw.get("error", "Unknown error")
4186
+ logger.warning(
4187
+ "[%s] send_text_chunk attempt %d/%d failed: %s",
4188
+ adapter.name, attempt + 1, retry, last_error,
4189
+ )
4190
+ except Exception as exc:
4191
+ last_error = str(exc)
4192
+ logger.warning(
4193
+ "[%s] send_text_chunk attempt %d/%d exception: %s",
4194
+ adapter.name, attempt + 1, retry, last_error,
4195
+ )
4196
+
4197
+ if attempt < retry - 1:
4198
+ await asyncio.sleep(2 ** attempt)
4199
+
4200
+ logger.error(
4201
+ "[%s] send_text_chunk max retries (%d) exceeded. Last error: %s",
4202
+ adapter.name, retry, last_error,
4203
+ )
4204
+ return SendResult(success=False, error=f"Max retries exceeded: {last_error}")
4205
+
4206
+ # -- C2C / Group message -----------------------------------------------
4207
+
4208
+ async def send_c2c_message(self, to_account: str, text: str, group_code: str = "") -> dict:
4209
+ """Send C2C text message, return {success: bool, msg_key: str}."""
4210
+ msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": text}}]
4211
+ return await self.send_c2c_msg_body(to_account, msg_body, group_code=group_code)
4212
+
4213
+ async def send_group_message(
4214
+ self,
4215
+ group_code: str,
4216
+ text: str,
4217
+ reply_to: Optional[str] = None,
4218
+ ) -> dict:
4219
+ """Send group text message, auto-converting @nickname to TIMCustomElem."""
4220
+ msg_body = self._build_msg_body_with_mentions(text, group_code)
4221
+ return await self.send_group_msg_body(group_code, msg_body, reply_to)
4222
+
4223
+ # @mention pattern: (whitespace or start) + @ + nickname + (whitespace or end)
4224
+ _AT_USER_RE = re.compile(r'(?:(?<=\s)|(?<=^))@(\S+?)(?=\s|$)', re.MULTILINE)
4225
+
4226
+ def _build_msg_body_with_mentions(self, text: str, group_code: str) -> list:
4227
+ """Parse @nickname patterns and build mixed TIMTextElem + TIMCustomElem msg_body."""
4228
+ cached = self._adapter._member_cache.get(group_code)
4229
+ if cached:
4230
+ ts, member_list = cached
4231
+ members = member_list if (time.time() - ts < self._adapter.MEMBER_CACHE_TTL_S) else []
4232
+ else:
4233
+ members = []
4234
+ if not members:
4235
+ return [{"msg_type": "TIMTextElem", "msg_content": {"text": text}}]
4236
+
4237
+ nickname_to_uid = {}
4238
+ for m in members:
4239
+ nick = m.get("nickname") or m.get("nick_name") or ""
4240
+ uid = m.get("user_id") or ""
4241
+ if nick and uid:
4242
+ nickname_to_uid[nick.lower()] = (nick, uid)
4243
+
4244
+ msg_body: list = []
4245
+ last_idx = 0
4246
+ for match in self._AT_USER_RE.finditer(text):
4247
+ start = match.start()
4248
+ if start > last_idx:
4249
+ seg = text[last_idx:start].strip()
4250
+ if seg:
4251
+ msg_body.append({"msg_type": "TIMTextElem", "msg_content": {"text": seg}})
4252
+
4253
+ nickname = match.group(1)
4254
+ entry = nickname_to_uid.get(nickname.lower())
4255
+ if entry:
4256
+ real_nick, uid = entry
4257
+ msg_body.append({
4258
+ "msg_type": "TIMCustomElem",
4259
+ "msg_content": {
4260
+ "data": json.dumps({"elem_type": 1002, "text": f"@{real_nick}", "user_id": uid}),
4261
+ },
4262
+ })
4263
+ else:
4264
+ msg_body.append({"msg_type": "TIMTextElem", "msg_content": {"text": f"@{nickname}"}})
4265
+
4266
+ last_idx = match.end()
4267
+
4268
+ if last_idx < len(text):
4269
+ tail = text[last_idx:].strip()
4270
+ if tail:
4271
+ msg_body.append({"msg_type": "TIMTextElem", "msg_content": {"text": tail}})
4272
+
4273
+ if not msg_body:
4274
+ msg_body.append({"msg_type": "TIMTextElem", "msg_content": {"text": text}})
4275
+
4276
+ return msg_body
4277
+
4278
+ async def send_c2c_msg_body(self, to_account: str, msg_body: list, group_code: str = "") -> dict:
4279
+ """Send C2C message with arbitrary MsgBody."""
4280
+ adapter = self._adapter
4281
+ req_id = f"c2c_{next_seq_no()}"
4282
+ encoded = encode_send_c2c_message(
4283
+ to_account=to_account,
4284
+ msg_body=msg_body,
4285
+ from_account=adapter._bot_id or "",
4286
+ msg_id=req_id,
4287
+ group_code=group_code,
4288
+ )
4289
+ return await self._dispatch_encoded(adapter, encoded, req_id)
4290
+
4291
+ async def send_group_msg_body(
4292
+ self,
4293
+ group_code: str,
4294
+ msg_body: list,
4295
+ reply_to: Optional[str] = None,
4296
+ ) -> dict:
4297
+ """Send group message with arbitrary MsgBody."""
4298
+ adapter = self._adapter
4299
+ req_id = f"grp_{next_seq_no()}"
4300
+ encoded = encode_send_group_message(
4301
+ group_code=group_code,
4302
+ msg_body=msg_body,
4303
+ from_account=adapter._bot_id or "",
4304
+ msg_id=req_id,
4305
+ ref_msg_id=reply_to or "",
4306
+ )
4307
+ return await self._dispatch_encoded(adapter, encoded, req_id)
4308
+
4309
+ # -- Common dispatch helper --------------------------------------------
4310
+
4311
+ @staticmethod
4312
+ async def _dispatch_encoded(
4313
+ adapter: "YuanbaoAdapter", encoded: bytes, req_id: str,
4314
+ ) -> dict:
4315
+ """Send pre-encoded bytes via WS and return a normalised result dict."""
4316
+ try:
4317
+ response = await adapter._connection.send_biz_request(encoded, req_id=req_id)
4318
+ return {"success": True, "msg_key": response.get("msg_id", "")}
4319
+ except asyncio.TimeoutError:
4320
+ return {"success": False, "error": f"Request timeout after {DEFAULT_SEND_TIMEOUT}s"}
4321
+ except Exception as exc:
4322
+ return {"success": False, "error": str(exc)}
4323
+
4324
+ # -- Media validation ---------------------------------------------------
4325
+
4326
+ @staticmethod
4327
+ def validate_media(
4328
+ file_bytes: Optional[bytes], filename: str, max_size_mb: int = 20
4329
+ ) -> Optional[str]:
4330
+ """Media pre-validation: check file validity before sending/uploading.
4331
+
4332
+ Returns:
4333
+ Error description (str) if validation fails, otherwise None.
4334
+ """
4335
+ if file_bytes is None or len(file_bytes) == 0:
4336
+ return f"Empty file: {filename}"
4337
+ max_bytes = max_size_mb * 1024 * 1024
4338
+ if len(file_bytes) > max_bytes:
4339
+ size_mb = len(file_bytes) / 1024 / 1024
4340
+ return f"File too large: {filename} ({size_mb:.1f}MB > {max_size_mb}MB)"
4341
+ return None
4342
+
4343
+ # -- Text truncation (table-aware) --------------------------------------
4344
+
4345
+ @staticmethod
4346
+ def truncate_message(
4347
+ content: str,
4348
+ max_length: int = 4000,
4349
+ len_fn: Optional[Callable[[str], int]] = None,
4350
+ ) -> List[str]:
4351
+ """
4352
+ Split a long message into chunks with table-awareness.
4353
+
4354
+ Delegates core splitting to ``MarkdownProcessor.chunk_markdown_text``
4355
+ and strips page indicators like ``(1/3)`` from the output.
4356
+
4357
+ Falls back to ``BasePlatformAdapter.truncate_message`` for non-table
4358
+ content and for overall text that fits in a single chunk.
4359
+ """
4360
+ _len = len_fn or len
4361
+ if _len(content) <= max_length:
4362
+ return [content]
4363
+
4364
+ # Delegate to MarkdownProcessor for table/fence-aware chunking
4365
+ chunks = MarkdownProcessor.chunk_markdown_text(
4366
+ content, max_length, len_fn=len_fn,
4367
+ )
4368
+
4369
+ # Strip page indicators like (1/3) that BasePlatformAdapter may add
4370
+ chunks = [_INDICATOR_RE.sub('', c) for c in chunks]
4371
+
4372
+ return chunks if chunks else [content]
4373
+
4374
+ # -- Cron wrapper stripping ---------------------------------------------
4375
+
4376
+ @staticmethod
4377
+ def strip_cron_wrapper(content: str) -> str:
4378
+ """Strip scheduler cron header/footer wrapper for cleaner Yuanbao output."""
4379
+ if not content.startswith("Cronjob Response: "):
4380
+ return content
4381
+
4382
+ divider = "\n-------------\n\n"
4383
+ footer_prefix = '\n\nTo stop or manage this job, send me a new message (e.g. "stop reminder '
4384
+ divider_pos = content.find(divider)
4385
+ footer_pos = content.rfind(footer_prefix)
4386
+ if divider_pos < 0 or footer_pos < 0 or footer_pos <= divider_pos:
4387
+ return content
4388
+
4389
+ header = content[:divider_pos]
4390
+ if "\n(job_id: " not in header:
4391
+ return content
4392
+
4393
+ body_start = divider_pos + len(divider)
4394
+ body = content[body_start:footer_pos].strip()
4395
+ return body or content
4396
+
4397
+ # -- Cleanup on disconnect ---------------------------------------------
4398
+
4399
+ async def close(self) -> None:
4400
+ """Release chat locks (no-op for now; placeholder for future cleanup)."""
4401
+ self._chat_locks.clear()
4402
+
4403
+
4404
+ class OutboundManager:
4405
+ """Outbound coordinator that orchestrates sending, heartbeat and slow-response.
4406
+
4407
+ Composes:
4408
+ - MessageSender — core text/media sending
4409
+ - HeartbeatManager — reply heartbeat (RUNNING / FINISH) lifecycle
4410
+ - SlowResponseNotifier — delayed 'please wait' notifications
4411
+
4412
+ YuanbaoAdapter holds a single ``_outbound: OutboundManager`` and delegates
4413
+ all outbound operations through it.
4414
+ """
4415
+
4416
+ # Expose class-level constants from MessageSender for backward compatibility
4417
+ CHAT_DICT_MAX_SIZE: ClassVar[int] = MessageSender.CHAT_DICT_MAX_SIZE
4418
+
4419
+ def __init__(self, adapter: "YuanbaoAdapter") -> None:
4420
+ self._adapter = adapter
4421
+ self.sender: MessageSender = MessageSender(adapter)
4422
+ self.heartbeat: HeartbeatManager = HeartbeatManager(adapter)
4423
+ self.slow_notifier: SlowResponseNotifier = SlowResponseNotifier(adapter, self.sender)
4424
+
4425
+ # Wire coordination hooks into MessageSender
4426
+ self.sender._on_send_start = self._handle_send_start
4427
+ self.sender._on_send_finish = self._handle_send_finish
4428
+
4429
+ # -- Coordination hooks ------------------------------------------------
4430
+
4431
+ def _handle_send_start(self, chat_id: str) -> None:
4432
+ """Called by MessageSender before sending: cancel slow-response notifier."""
4433
+ self.slow_notifier.cancel(chat_id)
4434
+
4435
+ async def _handle_send_finish(self, chat_id: str) -> None:
4436
+ """Called by MessageSender after sending: send FINISH heartbeat."""
4437
+ await self.heartbeat.send_heartbeat_once(chat_id, WS_HEARTBEAT_FINISH)
4438
+
4439
+ # -- Delegated public API (used by YuanbaoAdapter) ---------------------
4440
+
4441
+ async def send_text(
4442
+ self, chat_id: str, content: str, reply_to: Optional[str] = None,
4443
+ group_code: str = "",
4444
+ ) -> "SendResult":
4445
+ """Send text message with auto-chunking."""
4446
+ return await self.sender.send_text(chat_id, content, reply_to, group_code=group_code)
4447
+
4448
+ async def send_media(
4449
+ self, chat_id: str, handler_name: str, **kwargs: Any,
4450
+ ) -> "SendResult":
4451
+ """Dispatch media send to the named handler strategy."""
4452
+ return await self.sender.send_media(chat_id, handler_name, **kwargs)
4453
+
4454
+ async def send_direct(
4455
+ self, chat_id: str, message: str,
4456
+ media_files: Optional[List[Tuple[str, bool]]] = None,
4457
+ ) -> Dict[str, Any]:
4458
+ """Send text + media (used by send_message tool)."""
4459
+ return await self.sender.send_direct(chat_id, message, media_files)
4460
+
4461
+ async def start_typing(self, chat_id: str) -> None:
4462
+ """Start reply heartbeat (RUNNING)."""
4463
+ await self.heartbeat.start(chat_id)
4464
+
4465
+ async def stop_typing(self, chat_id: str, send_finish: bool = False) -> None:
4466
+ """Stop reply heartbeat."""
4467
+ await self.heartbeat.stop(chat_id, send_finish=send_finish)
4468
+
4469
+ async def start_slow_notifier(self, chat_id: str) -> None:
4470
+ """Start slow-response notifier."""
4471
+ await self.slow_notifier.start(chat_id)
4472
+
4473
+ def cancel_slow_notifier(self, chat_id: str) -> None:
4474
+ """Cancel slow-response notifier."""
4475
+ self.slow_notifier.cancel(chat_id)
4476
+
4477
+ def get_chat_lock(self, chat_id: str) -> asyncio.Lock:
4478
+ """Proxy to MessageSender.get_chat_lock for backward compatibility."""
4479
+ return self.sender.get_chat_lock(chat_id)
4480
+
4481
+ @property
4482
+ def _chat_locks(self) -> collections.OrderedDict:
4483
+ """Proxy to MessageSender._chat_locks for backward compatibility."""
4484
+ return self.sender._chat_locks
4485
+
4486
+ @staticmethod
4487
+ def validate_media(
4488
+ file_bytes: Optional[bytes], filename: str, max_size_mb: int = 20,
4489
+ ) -> Optional[str]:
4490
+ """Proxy to MessageSender.validate_media."""
4491
+ return MessageSender.validate_media(file_bytes, filename, max_size_mb)
4492
+
4493
+ async def close(self) -> None:
4494
+ """Shut down all sub-managers."""
4495
+ await self.sender.close()
4496
+ await self.heartbeat.close()
4497
+ await self.slow_notifier.close()
4498
+
4499
+
4500
+ class YuanbaoAdapter(BasePlatformAdapter):
4501
+ """Yuanbao AI Bot adapter backed by a persistent WebSocket connection."""
4502
+
4503
+ PLATFORM = Platform.YUANBAO
4504
+ MAX_TEXT_CHUNK: int = 4000 # Yuanbao single message character limit
4505
+ MEDIA_MAX_SIZE_MB: int = 50 # Max media file size in MB for upload validation
4506
+ REPLY_REF_MAX_ENTRIES: ClassVar[int] = 500 # Max capacity of reference dedup dict
4507
+
4508
+ # -- Active instance registry (class-level singleton) -------------------
4509
+
4510
+ _active_instance: ClassVar[Optional["YuanbaoAdapter"]] = None
4511
+
4512
+ @classmethod
4513
+ def get_active(cls) -> Optional["YuanbaoAdapter"]:
4514
+ """Return the currently connected YuanbaoAdapter, or None."""
4515
+ return cls._active_instance
4516
+
4517
+ @classmethod
4518
+ def set_active(cls, adapter: Optional["YuanbaoAdapter"]) -> None:
4519
+ """Register (or clear) the active adapter instance."""
4520
+ cls._active_instance = adapter
4521
+
4522
+ def __init__(self, config: PlatformConfig, **kwargs: Any) -> None:
4523
+ super().__init__(config, Platform.YUANBAO)
4524
+
4525
+ # Credentials / endpoints from config.extra (populated by config.py from env/yaml)
4526
+ _extra = config.extra or {}
4527
+ self._app_key: str = (_extra.get("app_id") or "").strip()
4528
+ self._app_secret: str = (_extra.get("app_secret") or "").strip()
4529
+ self._bot_id: Optional[str] = _extra.get("bot_id") or None
4530
+ self._ws_url: str = (_extra.get("ws_url") or DEFAULT_WS_GATEWAY_URL).strip()
4531
+ self._api_domain: str = (_extra.get("api_domain") or DEFAULT_API_DOMAIN).rstrip("/")
4532
+ self._route_env: str = (_extra.get("route_env") or "").strip()
4533
+
4534
+ # Core managers (UML composition)
4535
+ self._connection: ConnectionManager = ConnectionManager(self)
4536
+ self._outbound: OutboundManager = OutboundManager(self)
4537
+
4538
+ # Inbound dispatch tasks — tracked so disconnect() can cancel them
4539
+ self._inbound_tasks: set[asyncio.Task] = set()
4540
+
4541
+ # Set of background tasks — prevent GC from collecting fire-and-forget tasks
4542
+ self._background_tasks: set[asyncio.Task] = set()
4543
+
4544
+ # Member cache: group_code -> (updated_ts, [{"user_id":..., "nickname":..., ...}, ...])
4545
+ # Populated by get_group_member_list(), used by @mention resolution.
4546
+ # Entries older than MEMBER_CACHE_TTL_S are treated as stale.
4547
+ self._member_cache: Dict[str, Tuple[float, list]] = {}
4548
+ self.MEMBER_CACHE_TTL_S: float = 300.0 # 5 minutes
4549
+
4550
+ # Inbound message deduplication (WS reconnect / network jitter)
4551
+ self._dedup = MessageDeduplicator(ttl_seconds=300)
4552
+
4553
+ # Group chat sequential dispatch queue (session_key → asyncio.Queue).
4554
+ self._group_queues: Dict[str, asyncio.Queue] = {}
4555
+
4556
+ # Recall support: track which msg_id is being processed per session_key
4557
+ # so RecallGuardMiddleware can detect "currently processing" messages.
4558
+ self._processing_msg_ids: Dict[str, str] = {}
4559
+ self._processing_msg_texts: Dict[str, str] = {}
4560
+ # Bounded cache of msg_id → attributed content for recent messages.
4561
+ # Used by _patch_transcript as content-match fallback when transcript
4562
+ # entries lack a message_id field (agent-processed @bot messages).
4563
+ self._msg_content_cache: Dict[str, str] = {}
4564
+
4565
+ # Reply-to dedup: inbound_msg_id -> expire_ts
4566
+ # ------------------------------------------------------------------
4567
+ # Access control policy (DM / Group)
4568
+ # ------------------------------------------------------------------
4569
+ dm_policy: str = (
4570
+ _extra.get("dm_policy")
4571
+ or os.getenv("YUANBAO_DM_POLICY", "open")
4572
+ ).strip().lower()
4573
+
4574
+ _dm_allow_from_raw: str = (
4575
+ _extra.get("dm_allow_from")
4576
+ or os.getenv("YUANBAO_DM_ALLOW_FROM", "")
4577
+ )
4578
+ dm_allow_from: list[str] = [x.strip() for x in _dm_allow_from_raw.split(",") if x.strip()]
4579
+
4580
+ group_policy: str = (
4581
+ _extra.get("group_policy")
4582
+ or os.getenv("YUANBAO_GROUP_POLICY", "open")
4583
+ ).strip().lower()
4584
+
4585
+ _group_allow_from_raw: str = (
4586
+ _extra.get("group_allow_from")
4587
+ or os.getenv("YUANBAO_GROUP_ALLOW_FROM", "")
4588
+ )
4589
+ group_allow_from: list[str] = [x.strip() for x in _group_allow_from_raw.split(",") if x.strip()]
4590
+
4591
+ self._access_policy = AccessPolicy(
4592
+ dm_policy=dm_policy,
4593
+ dm_allow_from=dm_allow_from,
4594
+ group_policy=group_policy,
4595
+ group_allow_from=group_allow_from,
4596
+ )
4597
+
4598
+ # Group query service (AI tool backing)
4599
+ self._group_query = GroupQueryService(self)
4600
+
4601
+ # Inbound message processing pipeline (middleware pattern)
4602
+ self._inbound_pipeline: InboundPipeline = InboundPipelineBuilder.build()
4603
+
4604
+ # ------------------------------------------------------------------
4605
+ # Auto-sethome: first user to message the bot becomes the owner.
4606
+ # If no home channel is configured, the first conversation will be
4607
+ # automatically set as the home channel. When the existing home
4608
+ # channel is a group chat (group:xxx), it stays eligible for
4609
+ # upgrade — the first DM will override it with direct:xxx.
4610
+ # ------------------------------------------------------------------
4611
+ _existing_home = os.getenv("YUANBAO_HOME_CHANNEL") or (
4612
+ config.home_channel.chat_id if config.home_channel else ""
4613
+ )
4614
+ self._auto_sethome_done: bool = bool(_existing_home) and not _existing_home.startswith("group:")
4615
+
4616
+ # ------------------------------------------------------------------
4617
+ # Task tracking helper
4618
+ # ------------------------------------------------------------------
4619
+
4620
+ def _track_task(self, task: asyncio.Task) -> asyncio.Task:
4621
+ """Register a fire-and-forget task so it won't be GC'd prematurely."""
4622
+ self._background_tasks.add(task)
4623
+ task.add_done_callback(self._background_tasks.discard)
4624
+ return task
4625
+
4626
+ # ------------------------------------------------------------------
4627
+ # Abstract method implementations
4628
+ # ------------------------------------------------------------------
4629
+
4630
+ async def connect(self) -> bool:
4631
+ """Connect to Yuanbao WS gateway and authenticate.
4632
+
4633
+ Delegates to ConnectionManager.open().
4634
+ """
4635
+ return await self._connection.open()
4636
+
4637
+ async def disconnect(self) -> None:
4638
+ """Cancel background tasks and close the WebSocket connection."""
4639
+ if YuanbaoAdapter._active_instance is self:
4640
+ YuanbaoAdapter.set_active(None)
4641
+
4642
+ self._running = False
4643
+ self._mark_disconnected()
4644
+ self._release_platform_lock()
4645
+
4646
+ # Delegate to managers
4647
+ await self._connection.close()
4648
+ await self._outbound.close()
4649
+
4650
+ # Cancel all in-flight inbound dispatch tasks
4651
+ for task in list(self._inbound_tasks):
4652
+ if not task.done():
4653
+ task.cancel()
4654
+ self._inbound_tasks.clear()
4655
+
4656
+ self._group_queues.clear()
4657
+
4658
+ logger.info("[%s] Disconnected", self.name)
4659
+
4660
+ async def send(
4661
+ self,
4662
+ chat_id: str,
4663
+ content: str,
4664
+ reply_to: Optional[str] = None,
4665
+ metadata: Optional[Dict[str, Any]] = None,
4666
+ group_code: str = "",
4667
+ ) -> SendResult:
4668
+ """Send text message with auto-chunking. Delegates to OutboundManager."""
4669
+ return await self._outbound.send_text(chat_id, content, reply_to, group_code=group_code)
4670
+
4671
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
4672
+ """Return basic chat metadata derived from the chat_id prefix.
4673
+
4674
+ chat_id conventions:
4675
+ "group:<group_code>" → group chat
4676
+ "direct:<account>" → C2C / direct message (default)
4677
+
4678
+ TODO (T06): fetch real chat name/member-count from Yuanbao API.
4679
+ """
4680
+ if chat_id.startswith("group:"):
4681
+ return {"name": chat_id, "type": "group"}
4682
+ return {"name": chat_id, "type": "dm"}
4683
+
4684
+ async def send_typing(self, chat_id: str, metadata: Optional[dict] = None) -> None:
4685
+ """Send "typing" status heartbeat (RUNNING). Delegates to OutboundManager."""
4686
+ try:
4687
+ await self._outbound.start_typing(chat_id)
4688
+ except Exception:
4689
+ pass
4690
+
4691
+ async def stop_typing(self, chat_id: str) -> None:
4692
+ """Stop the RUNNING heartbeat loop without sending FINISH immediately.
4693
+
4694
+ FINISH is sent by send() after actual message delivery to ensure correct ordering:
4695
+ RUNNING... -> message arrives -> FINISH.
4696
+ """
4697
+ try:
4698
+ await self._outbound.stop_typing(chat_id, send_finish=False)
4699
+ except Exception:
4700
+ pass
4701
+
4702
+ async def _process_message_background(self, event, session_key: str) -> None:
4703
+ """Wrap base class processing with a slow-response notifier."""
4704
+ chat_id = event.source.chat_id
4705
+ await self._outbound.start_slow_notifier(chat_id)
4706
+ try:
4707
+ await super()._process_message_background(event, session_key)
4708
+ finally:
4709
+ self._outbound.cancel_slow_notifier(chat_id)
4710
+
4711
+ # ------------------------------------------------------------------
4712
+ # Group query (delegate to GroupQueryService)
4713
+ # ------------------------------------------------------------------
4714
+
4715
+ async def query_group_info(self, group_code: str) -> Optional[dict]:
4716
+ """Query group info (delegates to GroupQueryService)."""
4717
+ return await self._group_query.query_group_info_raw(group_code)
4718
+
4719
+ async def get_group_member_list(
4720
+ self, group_code: str, offset: int = 0, limit: int = 200
4721
+ ) -> Optional[dict]:
4722
+ """Query group member list (delegates to GroupQueryService)."""
4723
+ return await self._group_query.get_group_member_list_raw(group_code, offset=offset, limit=limit)
4724
+
4725
+ # ------------------------------------------------------------------
4726
+ # DM active private chat + access control
4727
+ # ------------------------------------------------------------------
4728
+
4729
+ DM_MAX_CHARS = 10000 # DM text limit
4730
+
4731
+ async def send_dm(self, user_id: str, text: str, group_code: str = "") -> SendResult:
4732
+ """
4733
+ Actively send C2C private chat message.
4734
+
4735
+ Args:
4736
+ user_id: Target user ID
4737
+ text: Message text (limit 10000 characters)
4738
+ group_code: Source group code (for group-originated DM context)
4739
+
4740
+ Returns:
4741
+ SendResult
4742
+ """
4743
+ if not self._access_policy.is_dm_allowed(user_id):
4744
+ return SendResult(success=False, error="DM access denied for this user")
4745
+ if len(text) > self.DM_MAX_CHARS:
4746
+ text = text[:self.DM_MAX_CHARS] + "\n...(truncated)"
4747
+ chat_id = f"direct:{user_id}"
4748
+ return await self.send(chat_id, text, group_code=group_code)
4749
+
4750
+ # ------------------------------------------------------------------
4751
+ # Media send methods
4752
+ # ------------------------------------------------------------------
4753
+
4754
+ async def send_image(
4755
+ self,
4756
+ chat_id: str,
4757
+ image_url: str,
4758
+ caption: Optional[str] = None,
4759
+ reply_to: Optional[str] = None,
4760
+ metadata: Optional[dict] = None,
4761
+ **kwargs: Any,
4762
+ ) -> SendResult:
4763
+ """Send image message (URL). Delegates to OutboundManager via ImageUrlHandler."""
4764
+ return await self._outbound.send_media(
4765
+ chat_id, "image_url",
4766
+ reply_to=reply_to, caption=caption, image_url=image_url,
4767
+ **kwargs,
4768
+ )
4769
+
4770
+ async def send_image_file(
4771
+ self,
4772
+ chat_id: str,
4773
+ image_path: str,
4774
+ caption: Optional[str] = None,
4775
+ reply_to: Optional[str] = None,
4776
+ metadata: Optional[dict] = None,
4777
+ **kwargs: Any,
4778
+ ) -> SendResult:
4779
+ """Send local image file. Delegates to OutboundManager via ImageFileHandler."""
4780
+ return await self._outbound.send_media(
4781
+ chat_id, "image_file",
4782
+ reply_to=reply_to, caption=caption, image_path=image_path,
4783
+ **kwargs,
4784
+ )
4785
+
4786
+ async def send_file(
4787
+ self,
4788
+ chat_id: str,
4789
+ file_url: str,
4790
+ filename: Optional[str] = None,
4791
+ reply_to: Optional[str] = None,
4792
+ metadata: Optional[dict] = None,
4793
+ **kwargs: Any,
4794
+ ) -> SendResult:
4795
+ """Send file message (URL). Delegates to OutboundManager via FileUrlHandler."""
4796
+ return await self._outbound.send_media(
4797
+ chat_id, "file_url",
4798
+ reply_to=reply_to, file_url=file_url, filename=filename,
4799
+ **kwargs,
4800
+ )
4801
+
4802
+ async def send_sticker(
4803
+ self,
4804
+ chat_id: str,
4805
+ sticker_name: Optional[str] = None,
4806
+ face_index: Optional[int] = None,
4807
+ reply_to: Optional[str] = None,
4808
+ **kwargs: Any,
4809
+ ) -> SendResult:
4810
+ """Send sticker/emoji. Delegates to OutboundManager via StickerHandler."""
4811
+ return await self._outbound.send_media(
4812
+ chat_id, "sticker",
4813
+ reply_to=reply_to,
4814
+ sticker_name=sticker_name, face_index=face_index,
4815
+ **kwargs,
4816
+ )
4817
+
4818
+ async def send_document(
4819
+ self,
4820
+ chat_id: str,
4821
+ file_path: str,
4822
+ filename: Optional[str] = None,
4823
+ caption: Optional[str] = None,
4824
+ reply_to: Optional[str] = None,
4825
+ metadata: Optional[dict] = None,
4826
+ **kwargs: Any,
4827
+ ) -> SendResult:
4828
+ """Send local file (document). Delegates to OutboundManager via DocumentHandler."""
4829
+ return await self._outbound.send_media(
4830
+ chat_id, "document",
4831
+ reply_to=reply_to, caption=caption,
4832
+ file_path=file_path, filename=filename,
4833
+ **kwargs,
4834
+ )
4835
+
4836
+ async def _get_cached_token(self) -> dict:
4837
+ """Get the current valid sign token (using module-level cache)."""
4838
+ return await SignManager.get_token(
4839
+ self._app_key, self._app_secret, self._api_domain,
4840
+ route_env=self._route_env,
4841
+ )
4842
+
4843
+ def get_status(self) -> dict:
4844
+ """Return a snapshot of the current connection status."""
4845
+ conn = self._connection
4846
+ return {
4847
+ "connected": conn.is_connected,
4848
+ "bot_id": self._bot_id,
4849
+ "connect_id": conn.connect_id,
4850
+ "reconnect_attempts": conn.reconnect_attempts,
4851
+ "ws_url": self._ws_url,
4852
+ }
4853
+
4854
+
4855
+ # ---------------------------------------------------------------------------
4856
+ # Module-level thin delegates (preserve import compatibility for external callers)
4857
+ # ---------------------------------------------------------------------------
4858
+
4859
+
4860
+ def get_active_adapter() -> Optional["YuanbaoAdapter"]:
4861
+ """Delegate to ``YuanbaoAdapter.get_active()``."""
4862
+ return YuanbaoAdapter.get_active()
4863
+
4864
+
4865
+ async def send_yuanbao_direct(
4866
+ adapter: "YuanbaoAdapter",
4867
+ chat_id: str,
4868
+ message: str,
4869
+ media_files: Optional[List[Tuple[str, bool]]] = None,
4870
+ ) -> Dict[str, Any]:
4871
+ """Delegate to ``OutboundManager.send_direct``."""
4872
+ return await adapter._outbound.send_direct(chat_id, message, media_files)
4873
+