pm-copilot-engine 0.1.0__py3-none-any.whl

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 (656) hide show
  1. cron/__init__.py +42 -0
  2. cron/blueprint_catalog.py +713 -0
  3. cron/jobs.py +1212 -0
  4. cron/scheduler.py +2213 -0
  5. cron/scripts/__init__.py +1 -0
  6. cron/scripts/classify_items.py +226 -0
  7. cron/suggestion_catalog.py +154 -0
  8. cron/suggestions.py +257 -0
  9. plugins/__init__.py +1 -0
  10. plugins/browser/browser_use/__init__.py +14 -0
  11. plugins/browser/browser_use/plugin.yaml +7 -0
  12. plugins/browser/browser_use/provider.py +317 -0
  13. plugins/browser/browserbase/__init__.py +15 -0
  14. plugins/browser/browserbase/plugin.yaml +7 -0
  15. plugins/browser/browserbase/provider.py +297 -0
  16. plugins/browser/firecrawl/__init__.py +16 -0
  17. plugins/browser/firecrawl/plugin.yaml +7 -0
  18. plugins/browser/firecrawl/provider.py +171 -0
  19. plugins/context_engine/__init__.py +285 -0
  20. plugins/dashboard_auth/basic/__init__.py +491 -0
  21. plugins/dashboard_auth/basic/plugin.yaml +7 -0
  22. plugins/dashboard_auth/nous/__init__.py +667 -0
  23. plugins/dashboard_auth/nous/plugin.yaml +7 -0
  24. plugins/dashboard_auth/self_hosted/__init__.py +736 -0
  25. plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
  26. plugins/disk-cleanup/README.md +51 -0
  27. plugins/disk-cleanup/__init__.py +316 -0
  28. plugins/disk-cleanup/disk_cleanup.py +560 -0
  29. plugins/disk-cleanup/plugin.yaml +7 -0
  30. plugins/google_meet/README.md +131 -0
  31. plugins/google_meet/SKILL.md +148 -0
  32. plugins/google_meet/__init__.py +103 -0
  33. plugins/google_meet/audio_bridge.py +248 -0
  34. plugins/google_meet/cli.py +477 -0
  35. plugins/google_meet/meet_bot.py +858 -0
  36. plugins/google_meet/node/__init__.py +54 -0
  37. plugins/google_meet/node/cli.py +125 -0
  38. plugins/google_meet/node/client.py +107 -0
  39. plugins/google_meet/node/protocol.py +124 -0
  40. plugins/google_meet/node/registry.py +112 -0
  41. plugins/google_meet/node/server.py +200 -0
  42. plugins/google_meet/plugin.yaml +16 -0
  43. plugins/google_meet/process_manager.py +323 -0
  44. plugins/google_meet/realtime/__init__.py +10 -0
  45. plugins/google_meet/realtime/openai_client.py +332 -0
  46. plugins/google_meet/tools.py +348 -0
  47. plugins/hermes-achievements/README.md +150 -0
  48. plugins/hermes-achievements/dashboard/manifest.json +11 -0
  49. plugins/hermes-achievements/dashboard/plugin_api.py +1061 -0
  50. plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
  51. plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
  52. plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
  53. plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
  54. plugins/image_gen/fal/__init__.py +182 -0
  55. plugins/image_gen/fal/plugin.yaml +7 -0
  56. plugins/image_gen/krea/__init__.py +548 -0
  57. plugins/image_gen/krea/plugin.yaml +7 -0
  58. plugins/image_gen/openai/__init__.py +316 -0
  59. plugins/image_gen/openai/plugin.yaml +7 -0
  60. plugins/image_gen/openai-codex/__init__.py +442 -0
  61. plugins/image_gen/openai-codex/plugin.yaml +5 -0
  62. plugins/image_gen/xai/__init__.py +334 -0
  63. plugins/image_gen/xai/plugin.yaml +7 -0
  64. plugins/kanban/dashboard/manifest.json +14 -0
  65. plugins/kanban/dashboard/plugin_api.py +2454 -0
  66. plugins/memory/__init__.py +450 -0
  67. plugins/memory/byterover/README.md +41 -0
  68. plugins/memory/byterover/__init__.py +384 -0
  69. plugins/memory/byterover/plugin.yaml +9 -0
  70. plugins/memory/hindsight/README.md +147 -0
  71. plugins/memory/hindsight/__init__.py +1803 -0
  72. plugins/memory/hindsight/plugin.yaml +8 -0
  73. plugins/memory/holographic/README.md +36 -0
  74. plugins/memory/holographic/__init__.py +408 -0
  75. plugins/memory/holographic/holographic.py +203 -0
  76. plugins/memory/holographic/plugin.yaml +5 -0
  77. plugins/memory/holographic/retrieval.py +593 -0
  78. plugins/memory/holographic/store.py +578 -0
  79. plugins/memory/honcho/README.md +368 -0
  80. plugins/memory/honcho/__init__.py +1419 -0
  81. plugins/memory/honcho/cli.py +1698 -0
  82. plugins/memory/honcho/client.py +882 -0
  83. plugins/memory/honcho/plugin.yaml +7 -0
  84. plugins/memory/honcho/session.py +1341 -0
  85. plugins/memory/mem0/README.md +38 -0
  86. plugins/memory/mem0/__init__.py +374 -0
  87. plugins/memory/mem0/plugin.yaml +5 -0
  88. plugins/memory/openviking/README.md +40 -0
  89. plugins/memory/openviking/__init__.py +978 -0
  90. plugins/memory/openviking/plugin.yaml +9 -0
  91. plugins/memory/retaindb/README.md +40 -0
  92. plugins/memory/retaindb/__init__.py +766 -0
  93. plugins/memory/retaindb/plugin.yaml +7 -0
  94. plugins/memory/supermemory/README.md +111 -0
  95. plugins/memory/supermemory/__init__.py +897 -0
  96. plugins/memory/supermemory/plugin.yaml +5 -0
  97. plugins/model-providers/README.md +70 -0
  98. plugins/model-providers/alibaba/__init__.py +13 -0
  99. plugins/model-providers/alibaba/plugin.yaml +5 -0
  100. plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
  101. plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
  102. plugins/model-providers/anthropic/__init__.py +52 -0
  103. plugins/model-providers/anthropic/plugin.yaml +5 -0
  104. plugins/model-providers/arcee/__init__.py +13 -0
  105. plugins/model-providers/arcee/plugin.yaml +5 -0
  106. plugins/model-providers/azure-foundry/__init__.py +21 -0
  107. plugins/model-providers/azure-foundry/plugin.yaml +5 -0
  108. plugins/model-providers/bedrock/__init__.py +29 -0
  109. plugins/model-providers/bedrock/plugin.yaml +5 -0
  110. plugins/model-providers/copilot/__init__.py +58 -0
  111. plugins/model-providers/copilot/plugin.yaml +5 -0
  112. plugins/model-providers/copilot-acp/__init__.py +34 -0
  113. plugins/model-providers/copilot-acp/plugin.yaml +5 -0
  114. plugins/model-providers/custom/__init__.py +73 -0
  115. plugins/model-providers/custom/plugin.yaml +5 -0
  116. plugins/model-providers/deepseek/__init__.py +100 -0
  117. plugins/model-providers/deepseek/plugin.yaml +5 -0
  118. plugins/model-providers/gemini/__init__.py +72 -0
  119. plugins/model-providers/gemini/plugin.yaml +5 -0
  120. plugins/model-providers/gmi/__init__.py +31 -0
  121. plugins/model-providers/gmi/plugin.yaml +5 -0
  122. plugins/model-providers/huggingface/__init__.py +20 -0
  123. plugins/model-providers/huggingface/plugin.yaml +5 -0
  124. plugins/model-providers/kilocode/__init__.py +14 -0
  125. plugins/model-providers/kilocode/plugin.yaml +5 -0
  126. plugins/model-providers/kimi-coding/__init__.py +80 -0
  127. plugins/model-providers/kimi-coding/plugin.yaml +5 -0
  128. plugins/model-providers/minimax/__init__.py +45 -0
  129. plugins/model-providers/minimax/plugin.yaml +5 -0
  130. plugins/model-providers/nous/__init__.py +54 -0
  131. plugins/model-providers/nous/plugin.yaml +5 -0
  132. plugins/model-providers/novita/__init__.py +27 -0
  133. plugins/model-providers/novita/plugin.yaml +5 -0
  134. plugins/model-providers/nvidia/__init__.py +21 -0
  135. plugins/model-providers/nvidia/plugin.yaml +5 -0
  136. plugins/model-providers/ollama-cloud/__init__.py +14 -0
  137. plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
  138. plugins/model-providers/openai-codex/__init__.py +15 -0
  139. plugins/model-providers/openai-codex/plugin.yaml +5 -0
  140. plugins/model-providers/opencode-zen/__init__.py +126 -0
  141. plugins/model-providers/opencode-zen/plugin.yaml +5 -0
  142. plugins/model-providers/openrouter/__init__.py +187 -0
  143. plugins/model-providers/openrouter/plugin.yaml +5 -0
  144. plugins/model-providers/qwen-oauth/__init__.py +82 -0
  145. plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
  146. plugins/model-providers/stepfun/__init__.py +14 -0
  147. plugins/model-providers/stepfun/plugin.yaml +5 -0
  148. plugins/model-providers/xai/__init__.py +15 -0
  149. plugins/model-providers/xai/plugin.yaml +5 -0
  150. plugins/model-providers/xiaomi/__init__.py +16 -0
  151. plugins/model-providers/xiaomi/plugin.yaml +5 -0
  152. plugins/model-providers/zai/__init__.py +21 -0
  153. plugins/model-providers/zai/plugin.yaml +5 -0
  154. plugins/observability/langfuse/README.md +53 -0
  155. plugins/observability/langfuse/__init__.py +1035 -0
  156. plugins/observability/langfuse/plugin.yaml +14 -0
  157. plugins/observability/nemo_relay/README.md +559 -0
  158. plugins/observability/nemo_relay/__init__.py +962 -0
  159. plugins/observability/nemo_relay/plugin.yaml +20 -0
  160. plugins/platforms/discord/__init__.py +3 -0
  161. plugins/platforms/discord/adapter.py +6764 -0
  162. plugins/platforms/discord/plugin.yaml +34 -0
  163. plugins/platforms/discord/voice_mixer.py +379 -0
  164. plugins/platforms/google_chat/__init__.py +3 -0
  165. plugins/platforms/google_chat/adapter.py +3348 -0
  166. plugins/platforms/google_chat/oauth.py +667 -0
  167. plugins/platforms/google_chat/plugin.yaml +39 -0
  168. plugins/platforms/homeassistant/__init__.py +3 -0
  169. plugins/platforms/homeassistant/adapter.py +577 -0
  170. plugins/platforms/homeassistant/plugin.yaml +22 -0
  171. plugins/platforms/irc/__init__.py +3 -0
  172. plugins/platforms/irc/adapter.py +971 -0
  173. plugins/platforms/irc/plugin.yaml +54 -0
  174. plugins/platforms/line/__init__.py +3 -0
  175. plugins/platforms/line/adapter.py +1652 -0
  176. plugins/platforms/line/plugin.yaml +65 -0
  177. plugins/platforms/mattermost/__init__.py +3 -0
  178. plugins/platforms/mattermost/adapter.py +1192 -0
  179. plugins/platforms/mattermost/plugin.yaml +49 -0
  180. plugins/platforms/ntfy/__init__.py +3 -0
  181. plugins/platforms/ntfy/adapter.py +593 -0
  182. plugins/platforms/ntfy/plugin.yaml +56 -0
  183. plugins/platforms/photon/README.md +174 -0
  184. plugins/platforms/photon/__init__.py +4 -0
  185. plugins/platforms/photon/adapter.py +1529 -0
  186. plugins/platforms/photon/auth.py +1065 -0
  187. plugins/platforms/photon/cli.py +441 -0
  188. plugins/platforms/photon/plugin.yaml +88 -0
  189. plugins/platforms/photon/sidecar/README.md +52 -0
  190. plugins/platforms/photon/sidecar/package-lock.json +1729 -0
  191. plugins/platforms/photon/sidecar/package.json +24 -0
  192. plugins/platforms/simplex/__init__.py +3 -0
  193. plugins/platforms/simplex/adapter.py +1313 -0
  194. plugins/platforms/simplex/plugin.yaml +56 -0
  195. plugins/platforms/teams/__init__.py +3 -0
  196. plugins/platforms/teams/adapter.py +1297 -0
  197. plugins/platforms/teams/plugin.yaml +48 -0
  198. plugins/plugin_utils.py +135 -0
  199. plugins/security-guidance/README.md +88 -0
  200. plugins/security-guidance/__init__.py +259 -0
  201. plugins/security-guidance/patterns.py +368 -0
  202. plugins/security-guidance/plugin.yaml +7 -0
  203. plugins/spotify/__init__.py +66 -0
  204. plugins/spotify/client.py +435 -0
  205. plugins/spotify/plugin.yaml +13 -0
  206. plugins/spotify/tools.py +454 -0
  207. plugins/teams_pipeline/__init__.py +23 -0
  208. plugins/teams_pipeline/cli.py +461 -0
  209. plugins/teams_pipeline/meetings.py +333 -0
  210. plugins/teams_pipeline/models.py +350 -0
  211. plugins/teams_pipeline/pipeline.py +689 -0
  212. plugins/teams_pipeline/plugin.yaml +9 -0
  213. plugins/teams_pipeline/runtime.py +135 -0
  214. plugins/teams_pipeline/store.py +193 -0
  215. plugins/teams_pipeline/subscriptions.py +249 -0
  216. plugins/video_gen/fal/__init__.py +620 -0
  217. plugins/video_gen/fal/plugin.yaml +7 -0
  218. plugins/video_gen/xai/__init__.py +504 -0
  219. plugins/video_gen/xai/plugin.yaml +7 -0
  220. plugins/web/__init__.py +7 -0
  221. plugins/web/brave_free/__init__.py +14 -0
  222. plugins/web/brave_free/plugin.yaml +7 -0
  223. plugins/web/brave_free/provider.py +137 -0
  224. plugins/web/ddgs/__init__.py +15 -0
  225. plugins/web/ddgs/plugin.yaml +7 -0
  226. plugins/web/ddgs/provider.py +104 -0
  227. plugins/web/exa/__init__.py +15 -0
  228. plugins/web/exa/plugin.yaml +7 -0
  229. plugins/web/exa/provider.py +212 -0
  230. plugins/web/firecrawl/__init__.py +28 -0
  231. plugins/web/firecrawl/plugin.yaml +7 -0
  232. plugins/web/firecrawl/provider.py +594 -0
  233. plugins/web/parallel/__init__.py +16 -0
  234. plugins/web/parallel/plugin.yaml +7 -0
  235. plugins/web/parallel/provider.py +696 -0
  236. plugins/web/searxng/__init__.py +15 -0
  237. plugins/web/searxng/plugin.yaml +7 -0
  238. plugins/web/searxng/provider.py +153 -0
  239. plugins/web/tavily/__init__.py +10 -0
  240. plugins/web/tavily/plugin.yaml +7 -0
  241. plugins/web/tavily/provider.py +220 -0
  242. plugins/web/xai/__init__.py +14 -0
  243. plugins/web/xai/plugin.yaml +7 -0
  244. plugins/web/xai/provider.py +557 -0
  245. pm_copilot_engine/__init__.py +11 -0
  246. pm_copilot_engine/_internal/__init__.py +6 -0
  247. pm_copilot_engine/_internal/hermes_cli/__init__.py +92 -0
  248. pm_copilot_engine/_internal/hermes_cli/_parser.py +411 -0
  249. pm_copilot_engine/_internal/hermes_cli/_subprocess_compat.py +234 -0
  250. pm_copilot_engine/_internal/hermes_cli/active_sessions.py +320 -0
  251. pm_copilot_engine/_internal/hermes_cli/auth.py +7926 -0
  252. pm_copilot_engine/_internal/hermes_cli/auth_commands.py +802 -0
  253. pm_copilot_engine/_internal/hermes_cli/azure_detect.py +406 -0
  254. pm_copilot_engine/_internal/hermes_cli/backup.py +1064 -0
  255. pm_copilot_engine/_internal/hermes_cli/banner.py +835 -0
  256. pm_copilot_engine/_internal/hermes_cli/blueprint_cmd.py +318 -0
  257. pm_copilot_engine/_internal/hermes_cli/browser_connect.py +217 -0
  258. pm_copilot_engine/_internal/hermes_cli/build_info.py +51 -0
  259. pm_copilot_engine/_internal/hermes_cli/bundles.py +229 -0
  260. pm_copilot_engine/_internal/hermes_cli/callbacks.py +242 -0
  261. pm_copilot_engine/_internal/hermes_cli/checkpoints.py +244 -0
  262. pm_copilot_engine/_internal/hermes_cli/claw.py +809 -0
  263. pm_copilot_engine/_internal/hermes_cli/cli_agent_setup_mixin.py +681 -0
  264. pm_copilot_engine/_internal/hermes_cli/cli_commands_mixin.py +2263 -0
  265. pm_copilot_engine/_internal/hermes_cli/cli_output.py +77 -0
  266. pm_copilot_engine/_internal/hermes_cli/clipboard.py +494 -0
  267. pm_copilot_engine/_internal/hermes_cli/codex_models.py +206 -0
  268. pm_copilot_engine/_internal/hermes_cli/codex_runtime_plugin_migration.py +757 -0
  269. pm_copilot_engine/_internal/hermes_cli/codex_runtime_switch.py +266 -0
  270. pm_copilot_engine/_internal/hermes_cli/colors.py +38 -0
  271. pm_copilot_engine/_internal/hermes_cli/commands.py +1933 -0
  272. pm_copilot_engine/_internal/hermes_cli/completion.py +319 -0
  273. pm_copilot_engine/_internal/hermes_cli/config.py +6530 -0
  274. pm_copilot_engine/_internal/hermes_cli/container_boot.py +395 -0
  275. pm_copilot_engine/_internal/hermes_cli/copilot_auth.py +392 -0
  276. pm_copilot_engine/_internal/hermes_cli/cron.py +357 -0
  277. pm_copilot_engine/_internal/hermes_cli/curator.py +598 -0
  278. pm_copilot_engine/_internal/hermes_cli/curses_ui.py +872 -0
  279. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/__init__.py +42 -0
  280. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/audit.py +87 -0
  281. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/base.py +220 -0
  282. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/cookies.py +247 -0
  283. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/login_page.py +534 -0
  284. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/middleware.py +368 -0
  285. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/prefix.py +201 -0
  286. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/public_paths.py +49 -0
  287. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/registry.py +58 -0
  288. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/routes.py +621 -0
  289. pm_copilot_engine/_internal/hermes_cli/dashboard_auth/ws_tickets.py +161 -0
  290. pm_copilot_engine/_internal/hermes_cli/dashboard_register.py +427 -0
  291. pm_copilot_engine/_internal/hermes_cli/debug.py +829 -0
  292. pm_copilot_engine/_internal/hermes_cli/default_soul.py +11 -0
  293. pm_copilot_engine/_internal/hermes_cli/dep_ensure.py +159 -0
  294. pm_copilot_engine/_internal/hermes_cli/dingtalk_auth.py +293 -0
  295. pm_copilot_engine/_internal/hermes_cli/doctor.py +2269 -0
  296. pm_copilot_engine/_internal/hermes_cli/dump.py +360 -0
  297. pm_copilot_engine/_internal/hermes_cli/env_loader.py +344 -0
  298. pm_copilot_engine/_internal/hermes_cli/fallback_cmd.py +354 -0
  299. pm_copilot_engine/_internal/hermes_cli/fallback_config.py +72 -0
  300. pm_copilot_engine/_internal/hermes_cli/gateway.py +6915 -0
  301. pm_copilot_engine/_internal/hermes_cli/gateway_windows.py +1311 -0
  302. pm_copilot_engine/_internal/hermes_cli/goals.py +912 -0
  303. pm_copilot_engine/_internal/hermes_cli/gui_uninstall.py +285 -0
  304. pm_copilot_engine/_internal/hermes_cli/hooks.py +385 -0
  305. pm_copilot_engine/_internal/hermes_cli/inventory.py +387 -0
  306. pm_copilot_engine/_internal/hermes_cli/kanban.py +2830 -0
  307. pm_copilot_engine/_internal/hermes_cli/kanban_db.py +7750 -0
  308. pm_copilot_engine/_internal/hermes_cli/kanban_decompose.py +477 -0
  309. pm_copilot_engine/_internal/hermes_cli/kanban_diagnostics.py +1107 -0
  310. pm_copilot_engine/_internal/hermes_cli/kanban_specify.py +273 -0
  311. pm_copilot_engine/_internal/hermes_cli/kanban_swarm.py +279 -0
  312. pm_copilot_engine/_internal/hermes_cli/logs.py +394 -0
  313. pm_copilot_engine/_internal/hermes_cli/main.py +12419 -0
  314. pm_copilot_engine/_internal/hermes_cli/managed_uv.py +254 -0
  315. pm_copilot_engine/_internal/hermes_cli/mcp_catalog.py +775 -0
  316. pm_copilot_engine/_internal/hermes_cli/mcp_config.py +892 -0
  317. pm_copilot_engine/_internal/hermes_cli/mcp_picker.py +322 -0
  318. pm_copilot_engine/_internal/hermes_cli/mcp_startup.py +59 -0
  319. pm_copilot_engine/_internal/hermes_cli/memory_setup.py +472 -0
  320. pm_copilot_engine/_internal/hermes_cli/middleware.py +313 -0
  321. pm_copilot_engine/_internal/hermes_cli/migrate.py +115 -0
  322. pm_copilot_engine/_internal/hermes_cli/model_catalog.py +394 -0
  323. pm_copilot_engine/_internal/hermes_cli/model_cost_guard.py +134 -0
  324. pm_copilot_engine/_internal/hermes_cli/model_normalize.py +473 -0
  325. pm_copilot_engine/_internal/hermes_cli/model_setup_flows.py +2736 -0
  326. pm_copilot_engine/_internal/hermes_cli/model_switch.py +2086 -0
  327. pm_copilot_engine/_internal/hermes_cli/models.py +3992 -0
  328. pm_copilot_engine/_internal/hermes_cli/nous_account.py +789 -0
  329. pm_copilot_engine/_internal/hermes_cli/nous_subscription.py +1279 -0
  330. pm_copilot_engine/_internal/hermes_cli/oneshot.py +381 -0
  331. pm_copilot_engine/_internal/hermes_cli/pairing.py +115 -0
  332. pm_copilot_engine/_internal/hermes_cli/partial_compress.py +235 -0
  333. pm_copilot_engine/_internal/hermes_cli/platforms.py +84 -0
  334. pm_copilot_engine/_internal/hermes_cli/plugins.py +2046 -0
  335. pm_copilot_engine/_internal/hermes_cli/plugins_cmd.py +1835 -0
  336. pm_copilot_engine/_internal/hermes_cli/portal_cli.py +245 -0
  337. pm_copilot_engine/_internal/hermes_cli/profile_describer.py +298 -0
  338. pm_copilot_engine/_internal/hermes_cli/profile_distribution.py +726 -0
  339. pm_copilot_engine/_internal/hermes_cli/profiles.py +1776 -0
  340. pm_copilot_engine/_internal/hermes_cli/prompt_size.py +153 -0
  341. pm_copilot_engine/_internal/hermes_cli/providers.py +734 -0
  342. pm_copilot_engine/_internal/hermes_cli/proxy/__init__.py +20 -0
  343. pm_copilot_engine/_internal/hermes_cli/proxy/adapters/__init__.py +37 -0
  344. pm_copilot_engine/_internal/hermes_cli/proxy/adapters/base.py +108 -0
  345. pm_copilot_engine/_internal/hermes_cli/proxy/adapters/nous_portal.py +189 -0
  346. pm_copilot_engine/_internal/hermes_cli/proxy/adapters/xai.py +145 -0
  347. pm_copilot_engine/_internal/hermes_cli/proxy/cli.py +142 -0
  348. pm_copilot_engine/_internal/hermes_cli/proxy/server.py +296 -0
  349. pm_copilot_engine/_internal/hermes_cli/psutil_android.py +108 -0
  350. pm_copilot_engine/_internal/hermes_cli/pt_input_extras.py +120 -0
  351. pm_copilot_engine/_internal/hermes_cli/pty_bridge.py +286 -0
  352. pm_copilot_engine/_internal/hermes_cli/relaunch.py +205 -0
  353. pm_copilot_engine/_internal/hermes_cli/runtime_provider.py +1775 -0
  354. pm_copilot_engine/_internal/hermes_cli/secret_prompt.py +126 -0
  355. pm_copilot_engine/_internal/hermes_cli/secrets_cli.py +600 -0
  356. pm_copilot_engine/_internal/hermes_cli/security_advisories.py +453 -0
  357. pm_copilot_engine/_internal/hermes_cli/security_audit.py +576 -0
  358. pm_copilot_engine/_internal/hermes_cli/send_cmd.py +463 -0
  359. pm_copilot_engine/_internal/hermes_cli/service_manager.py +981 -0
  360. pm_copilot_engine/_internal/hermes_cli/session_recap.py +316 -0
  361. pm_copilot_engine/_internal/hermes_cli/setup.py +3361 -0
  362. pm_copilot_engine/_internal/hermes_cli/setup_whatsapp_cloud.py +541 -0
  363. pm_copilot_engine/_internal/hermes_cli/skills_config.py +177 -0
  364. pm_copilot_engine/_internal/hermes_cli/skills_hub.py +1891 -0
  365. pm_copilot_engine/_internal/hermes_cli/skin_engine.py +926 -0
  366. pm_copilot_engine/_internal/hermes_cli/slack_cli.py +159 -0
  367. pm_copilot_engine/_internal/hermes_cli/status.py +586 -0
  368. pm_copilot_engine/_internal/hermes_cli/stdio.py +251 -0
  369. pm_copilot_engine/_internal/hermes_cli/subcommands/__init__.py +18 -0
  370. pm_copilot_engine/_internal/hermes_cli/subcommands/_shared.py +29 -0
  371. pm_copilot_engine/_internal/hermes_cli/subcommands/acp.py +52 -0
  372. pm_copilot_engine/_internal/hermes_cli/subcommands/auth.py +109 -0
  373. pm_copilot_engine/_internal/hermes_cli/subcommands/backup.py +38 -0
  374. pm_copilot_engine/_internal/hermes_cli/subcommands/claw.py +92 -0
  375. pm_copilot_engine/_internal/hermes_cli/subcommands/config.py +49 -0
  376. pm_copilot_engine/_internal/hermes_cli/subcommands/cron.py +163 -0
  377. pm_copilot_engine/_internal/hermes_cli/subcommands/dashboard.py +143 -0
  378. pm_copilot_engine/_internal/hermes_cli/subcommands/debug.py +77 -0
  379. pm_copilot_engine/_internal/hermes_cli/subcommands/doctor.py +35 -0
  380. pm_copilot_engine/_internal/hermes_cli/subcommands/dump.py +28 -0
  381. pm_copilot_engine/_internal/hermes_cli/subcommands/gateway.py +274 -0
  382. pm_copilot_engine/_internal/hermes_cli/subcommands/gui.py +63 -0
  383. pm_copilot_engine/_internal/hermes_cli/subcommands/hooks.py +77 -0
  384. pm_copilot_engine/_internal/hermes_cli/subcommands/import_cmd.py +31 -0
  385. pm_copilot_engine/_internal/hermes_cli/subcommands/insights.py +25 -0
  386. pm_copilot_engine/_internal/hermes_cli/subcommands/login.py +58 -0
  387. pm_copilot_engine/_internal/hermes_cli/subcommands/logout.py +28 -0
  388. pm_copilot_engine/_internal/hermes_cli/subcommands/logs.py +78 -0
  389. pm_copilot_engine/_internal/hermes_cli/subcommands/mcp.py +108 -0
  390. pm_copilot_engine/_internal/hermes_cli/subcommands/memory.py +53 -0
  391. pm_copilot_engine/_internal/hermes_cli/subcommands/model.py +72 -0
  392. pm_copilot_engine/_internal/hermes_cli/subcommands/pairing.py +36 -0
  393. pm_copilot_engine/_internal/hermes_cli/subcommands/plugins.py +94 -0
  394. pm_copilot_engine/_internal/hermes_cli/subcommands/postinstall.py +23 -0
  395. pm_copilot_engine/_internal/hermes_cli/subcommands/profile.py +203 -0
  396. pm_copilot_engine/_internal/hermes_cli/subcommands/prompt_size.py +36 -0
  397. pm_copilot_engine/_internal/hermes_cli/subcommands/security.py +62 -0
  398. pm_copilot_engine/_internal/hermes_cli/subcommands/setup.py +58 -0
  399. pm_copilot_engine/_internal/hermes_cli/subcommands/skills.py +269 -0
  400. pm_copilot_engine/_internal/hermes_cli/subcommands/slack.py +60 -0
  401. pm_copilot_engine/_internal/hermes_cli/subcommands/status.py +28 -0
  402. pm_copilot_engine/_internal/hermes_cli/subcommands/tools.py +95 -0
  403. pm_copilot_engine/_internal/hermes_cli/subcommands/uninstall.py +41 -0
  404. pm_copilot_engine/_internal/hermes_cli/subcommands/update.py +70 -0
  405. pm_copilot_engine/_internal/hermes_cli/subcommands/version.py +18 -0
  406. pm_copilot_engine/_internal/hermes_cli/subcommands/webhook.py +76 -0
  407. pm_copilot_engine/_internal/hermes_cli/subcommands/whatsapp.py +22 -0
  408. pm_copilot_engine/_internal/hermes_cli/suggestions_cmd.py +153 -0
  409. pm_copilot_engine/_internal/hermes_cli/telegram_managed_bot.py +358 -0
  410. pm_copilot_engine/_internal/hermes_cli/timeouts.py +82 -0
  411. pm_copilot_engine/_internal/hermes_cli/tips.py +486 -0
  412. pm_copilot_engine/_internal/hermes_cli/tools_config.py +3926 -0
  413. pm_copilot_engine/_internal/hermes_cli/uninstall.py +930 -0
  414. pm_copilot_engine/_internal/hermes_cli/voice.py +846 -0
  415. pm_copilot_engine/_internal/hermes_cli/web_server.py +11859 -0
  416. pm_copilot_engine/_internal/hermes_cli/webhook.py +298 -0
  417. pm_copilot_engine/_internal/hermes_cli/win_pty_bridge.py +179 -0
  418. pm_copilot_engine/_internal/hermes_cli/write_approval_commands.py +209 -0
  419. pm_copilot_engine/_internal/hermes_cli/xai_retirement.py +253 -0
  420. pm_copilot_engine/agent/__init__.py +8 -0
  421. pm_copilot_engine/agent/account_usage.py +638 -0
  422. pm_copilot_engine/agent/agent_init.py +1713 -0
  423. pm_copilot_engine/agent/agent_runtime_helpers.py +2595 -0
  424. pm_copilot_engine/agent/anthropic_adapter.py +2513 -0
  425. pm_copilot_engine/agent/async_utils.py +68 -0
  426. pm_copilot_engine/agent/auxiliary_client.py +5949 -0
  427. pm_copilot_engine/agent/azure_identity_adapter.py +555 -0
  428. pm_copilot_engine/agent/background_review.py +608 -0
  429. pm_copilot_engine/agent/bedrock_adapter.py +1325 -0
  430. pm_copilot_engine/agent/browser_provider.py +175 -0
  431. pm_copilot_engine/agent/browser_registry.py +192 -0
  432. pm_copilot_engine/agent/chat_completion_helpers.py +2682 -0
  433. pm_copilot_engine/agent/codex_responses_adapter.py +1271 -0
  434. pm_copilot_engine/agent/codex_runtime.py +686 -0
  435. pm_copilot_engine/agent/coding_context.py +738 -0
  436. pm_copilot_engine/agent/context_compressor.py +2426 -0
  437. pm_copilot_engine/agent/context_engine.py +226 -0
  438. pm_copilot_engine/agent/context_references.py +551 -0
  439. pm_copilot_engine/agent/conversation_compression.py +802 -0
  440. pm_copilot_engine/agent/conversation_loop.py +4255 -0
  441. pm_copilot_engine/agent/copilot_acp_client.py +686 -0
  442. pm_copilot_engine/agent/credential_persistence.py +174 -0
  443. pm_copilot_engine/agent/credential_pool.py +2184 -0
  444. pm_copilot_engine/agent/credential_sources.py +448 -0
  445. pm_copilot_engine/agent/credits_tracker.py +794 -0
  446. pm_copilot_engine/agent/curator.py +1835 -0
  447. pm_copilot_engine/agent/curator_backup.py +695 -0
  448. pm_copilot_engine/agent/display.py +1049 -0
  449. pm_copilot_engine/agent/error_classifier.py +1365 -0
  450. pm_copilot_engine/agent/file_safety.py +640 -0
  451. pm_copilot_engine/agent/gemini_cloudcode_adapter.py +909 -0
  452. pm_copilot_engine/agent/gemini_native_adapter.py +1001 -0
  453. pm_copilot_engine/agent/gemini_schema.py +99 -0
  454. pm_copilot_engine/agent/google_code_assist.py +451 -0
  455. pm_copilot_engine/agent/google_oauth.py +1067 -0
  456. pm_copilot_engine/agent/i18n.py +302 -0
  457. pm_copilot_engine/agent/image_gen_provider.py +324 -0
  458. pm_copilot_engine/agent/image_gen_registry.py +145 -0
  459. pm_copilot_engine/agent/image_routing.py +540 -0
  460. pm_copilot_engine/agent/insights.py +921 -0
  461. pm_copilot_engine/agent/iteration_budget.py +62 -0
  462. pm_copilot_engine/agent/jiter_preload.py +39 -0
  463. pm_copilot_engine/agent/lmstudio_reasoning.py +48 -0
  464. pm_copilot_engine/agent/lsp/__init__.py +106 -0
  465. pm_copilot_engine/agent/lsp/cli.py +299 -0
  466. pm_copilot_engine/agent/lsp/client.py +943 -0
  467. pm_copilot_engine/agent/lsp/eventlog.py +213 -0
  468. pm_copilot_engine/agent/lsp/install.py +403 -0
  469. pm_copilot_engine/agent/lsp/manager.py +639 -0
  470. pm_copilot_engine/agent/lsp/protocol.py +196 -0
  471. pm_copilot_engine/agent/lsp/range_shift.py +149 -0
  472. pm_copilot_engine/agent/lsp/reporter.py +78 -0
  473. pm_copilot_engine/agent/lsp/servers.py +1040 -0
  474. pm_copilot_engine/agent/lsp/workspace.py +223 -0
  475. pm_copilot_engine/agent/manual_compression_feedback.py +49 -0
  476. pm_copilot_engine/agent/markdown_tables.py +309 -0
  477. pm_copilot_engine/agent/memory_manager.py +917 -0
  478. pm_copilot_engine/agent/memory_provider.py +296 -0
  479. pm_copilot_engine/agent/message_sanitization.py +444 -0
  480. pm_copilot_engine/agent/model_metadata.py +2006 -0
  481. pm_copilot_engine/agent/models_dev.py +725 -0
  482. pm_copilot_engine/agent/moonshot_schema.py +238 -0
  483. pm_copilot_engine/agent/nous_rate_guard.py +325 -0
  484. pm_copilot_engine/agent/onboarding.py +253 -0
  485. pm_copilot_engine/agent/plugin_llm.py +1046 -0
  486. pm_copilot_engine/agent/portal_tags.py +64 -0
  487. pm_copilot_engine/agent/process_bootstrap.py +167 -0
  488. pm_copilot_engine/agent/prompt_builder.py +1630 -0
  489. pm_copilot_engine/agent/prompt_caching.py +79 -0
  490. pm_copilot_engine/agent/rate_limit_tracker.py +246 -0
  491. pm_copilot_engine/agent/redact.py +496 -0
  492. pm_copilot_engine/agent/retry_utils.py +57 -0
  493. pm_copilot_engine/agent/runtime_cwd.py +62 -0
  494. pm_copilot_engine/agent/secret_sources/__init__.py +13 -0
  495. pm_copilot_engine/agent/secret_sources/bitwarden.py +692 -0
  496. pm_copilot_engine/agent/shell_hooks.py +847 -0
  497. pm_copilot_engine/agent/skill_bundles.py +410 -0
  498. pm_copilot_engine/agent/skill_commands.py +527 -0
  499. pm_copilot_engine/agent/skill_preprocessing.py +140 -0
  500. pm_copilot_engine/agent/skill_utils.py +666 -0
  501. pm_copilot_engine/agent/stream_diag.py +280 -0
  502. pm_copilot_engine/agent/subdirectory_hints.py +270 -0
  503. pm_copilot_engine/agent/system_prompt.py +446 -0
  504. pm_copilot_engine/agent/think_scrubber.py +386 -0
  505. pm_copilot_engine/agent/title_generator.py +171 -0
  506. pm_copilot_engine/agent/tool_dispatch_helpers.py +417 -0
  507. pm_copilot_engine/agent/tool_executor.py +1428 -0
  508. pm_copilot_engine/agent/tool_guardrails.py +475 -0
  509. pm_copilot_engine/agent/tool_result_classification.py +26 -0
  510. pm_copilot_engine/agent/trajectory.py +56 -0
  511. pm_copilot_engine/agent/transcription_provider.py +193 -0
  512. pm_copilot_engine/agent/transcription_registry.py +122 -0
  513. pm_copilot_engine/agent/transports/__init__.py +68 -0
  514. pm_copilot_engine/agent/transports/anthropic.py +232 -0
  515. pm_copilot_engine/agent/transports/base.py +89 -0
  516. pm_copilot_engine/agent/transports/bedrock.py +154 -0
  517. pm_copilot_engine/agent/transports/chat_completions.py +704 -0
  518. pm_copilot_engine/agent/transports/codex.py +347 -0
  519. pm_copilot_engine/agent/transports/codex_app_server.py +400 -0
  520. pm_copilot_engine/agent/transports/codex_app_server_session.py +876 -0
  521. pm_copilot_engine/agent/transports/codex_event_projector.py +312 -0
  522. pm_copilot_engine/agent/transports/hermes_tools_mcp_server.py +233 -0
  523. pm_copilot_engine/agent/transports/types.py +174 -0
  524. pm_copilot_engine/agent/tts_provider.py +274 -0
  525. pm_copilot_engine/agent/tts_registry.py +133 -0
  526. pm_copilot_engine/agent/turn_context.py +388 -0
  527. pm_copilot_engine/agent/turn_finalizer.py +427 -0
  528. pm_copilot_engine/agent/turn_retry_state.py +68 -0
  529. pm_copilot_engine/agent/usage_pricing.py +908 -0
  530. pm_copilot_engine/agent/video_gen_provider.py +299 -0
  531. pm_copilot_engine/agent/video_gen_registry.py +117 -0
  532. pm_copilot_engine/agent/web_search_provider.py +185 -0
  533. pm_copilot_engine/agent/web_search_registry.py +245 -0
  534. pm_copilot_engine/batch_runner.py +1321 -0
  535. pm_copilot_engine/hermes_bootstrap.py +129 -0
  536. pm_copilot_engine/hermes_constants.py +471 -0
  537. pm_copilot_engine/hermes_logging.py +536 -0
  538. pm_copilot_engine/hermes_state.py +4777 -0
  539. pm_copilot_engine/hermes_time.py +117 -0
  540. pm_copilot_engine/mcp_serve.py +897 -0
  541. pm_copilot_engine/model_tools.py +1229 -0
  542. pm_copilot_engine/providers/__init__.py +191 -0
  543. pm_copilot_engine/providers/base.py +214 -0
  544. pm_copilot_engine/run_agent.py +5424 -0
  545. pm_copilot_engine/tools/__init__.py +25 -0
  546. pm_copilot_engine/tools/ansi_strip.py +44 -0
  547. pm_copilot_engine/tools/approval.py +1812 -0
  548. pm_copilot_engine/tools/binary_extensions.py +42 -0
  549. pm_copilot_engine/tools/blueprints.py +325 -0
  550. pm_copilot_engine/tools/browser_camofox.py +794 -0
  551. pm_copilot_engine/tools/browser_camofox_state.py +47 -0
  552. pm_copilot_engine/tools/browser_cdp_tool.py +569 -0
  553. pm_copilot_engine/tools/browser_dialog_tool.py +148 -0
  554. pm_copilot_engine/tools/browser_supervisor.py +1475 -0
  555. pm_copilot_engine/tools/browser_tool.py +3873 -0
  556. pm_copilot_engine/tools/budget_config.py +51 -0
  557. pm_copilot_engine/tools/checkpoint_manager.py +1642 -0
  558. pm_copilot_engine/tools/clarify_gateway.py +278 -0
  559. pm_copilot_engine/tools/clarify_tool.py +141 -0
  560. pm_copilot_engine/tools/code_execution_tool.py +1832 -0
  561. pm_copilot_engine/tools/computer_use/__init__.py +43 -0
  562. pm_copilot_engine/tools/computer_use/backend.py +158 -0
  563. pm_copilot_engine/tools/computer_use/cua_backend.py +779 -0
  564. pm_copilot_engine/tools/computer_use/schema.py +213 -0
  565. pm_copilot_engine/tools/computer_use/tool.py +823 -0
  566. pm_copilot_engine/tools/computer_use/vision_routing.py +204 -0
  567. pm_copilot_engine/tools/computer_use_tool.py +39 -0
  568. pm_copilot_engine/tools/credential_files.py +455 -0
  569. pm_copilot_engine/tools/cronjob_tools.py +896 -0
  570. pm_copilot_engine/tools/debug_helpers.py +105 -0
  571. pm_copilot_engine/tools/delegate_tool.py +2956 -0
  572. pm_copilot_engine/tools/discord_tool.py +959 -0
  573. pm_copilot_engine/tools/env_passthrough.py +163 -0
  574. pm_copilot_engine/tools/env_probe.py +248 -0
  575. pm_copilot_engine/tools/environments/__init__.py +14 -0
  576. pm_copilot_engine/tools/environments/base.py +895 -0
  577. pm_copilot_engine/tools/environments/daytona.py +270 -0
  578. pm_copilot_engine/tools/environments/docker.py +1312 -0
  579. pm_copilot_engine/tools/environments/file_sync.py +403 -0
  580. pm_copilot_engine/tools/environments/local.py +755 -0
  581. pm_copilot_engine/tools/environments/managed_modal.py +282 -0
  582. pm_copilot_engine/tools/environments/modal.py +478 -0
  583. pm_copilot_engine/tools/environments/modal_utils.py +204 -0
  584. pm_copilot_engine/tools/environments/singularity.py +265 -0
  585. pm_copilot_engine/tools/environments/ssh.py +375 -0
  586. pm_copilot_engine/tools/fal_common.py +163 -0
  587. pm_copilot_engine/tools/feishu_doc_tool.py +138 -0
  588. pm_copilot_engine/tools/feishu_drive_tool.py +431 -0
  589. pm_copilot_engine/tools/file_operations.py +2336 -0
  590. pm_copilot_engine/tools/file_state.py +332 -0
  591. pm_copilot_engine/tools/file_tools.py +1632 -0
  592. pm_copilot_engine/tools/fuzzy_match.py +860 -0
  593. pm_copilot_engine/tools/homeassistant_tool.py +513 -0
  594. pm_copilot_engine/tools/image_generation_tool.py +1180 -0
  595. pm_copilot_engine/tools/interrupt.py +98 -0
  596. pm_copilot_engine/tools/kanban_tools.py +1431 -0
  597. pm_copilot_engine/tools/lazy_deps.py +643 -0
  598. pm_copilot_engine/tools/managed_tool_gateway.py +192 -0
  599. pm_copilot_engine/tools/mcp_oauth.py +776 -0
  600. pm_copilot_engine/tools/mcp_oauth_manager.py +607 -0
  601. pm_copilot_engine/tools/mcp_tool.py +4106 -0
  602. pm_copilot_engine/tools/memory_tool.py +811 -0
  603. pm_copilot_engine/tools/microsoft_graph_auth.py +245 -0
  604. pm_copilot_engine/tools/microsoft_graph_client.py +408 -0
  605. pm_copilot_engine/tools/mixture_of_agents_tool.py +542 -0
  606. pm_copilot_engine/tools/neutts_synth.py +104 -0
  607. pm_copilot_engine/tools/openrouter_client.py +33 -0
  608. pm_copilot_engine/tools/osv_check.py +169 -0
  609. pm_copilot_engine/tools/patch_parser.py +622 -0
  610. pm_copilot_engine/tools/path_security.py +43 -0
  611. pm_copilot_engine/tools/process_registry.py +1621 -0
  612. pm_copilot_engine/tools/read_extract.py +248 -0
  613. pm_copilot_engine/tools/read_terminal_tool.py +93 -0
  614. pm_copilot_engine/tools/registry.py +589 -0
  615. pm_copilot_engine/tools/schema_sanitizer.py +483 -0
  616. pm_copilot_engine/tools/send_message_tool.py +1897 -0
  617. pm_copilot_engine/tools/session_search_tool.py +784 -0
  618. pm_copilot_engine/tools/skill_manager_tool.py +1125 -0
  619. pm_copilot_engine/tools/skill_provenance.py +78 -0
  620. pm_copilot_engine/tools/skill_usage.py +887 -0
  621. pm_copilot_engine/tools/skills_ast_audit.py +133 -0
  622. pm_copilot_engine/tools/skills_guard.py +1086 -0
  623. pm_copilot_engine/tools/skills_hub.py +3888 -0
  624. pm_copilot_engine/tools/skills_sync.py +932 -0
  625. pm_copilot_engine/tools/skills_tool.py +1612 -0
  626. pm_copilot_engine/tools/slash_confirm.py +167 -0
  627. pm_copilot_engine/tools/terminal_tool.py +2684 -0
  628. pm_copilot_engine/tools/thread_context.py +120 -0
  629. pm_copilot_engine/tools/threat_patterns.py +252 -0
  630. pm_copilot_engine/tools/tirith_security.py +822 -0
  631. pm_copilot_engine/tools/todo_tool.py +308 -0
  632. pm_copilot_engine/tools/tool_backend_helpers.py +182 -0
  633. pm_copilot_engine/tools/tool_output_limits.py +110 -0
  634. pm_copilot_engine/tools/tool_result_storage.py +232 -0
  635. pm_copilot_engine/tools/tool_search.py +735 -0
  636. pm_copilot_engine/tools/transcription_tools.py +1798 -0
  637. pm_copilot_engine/tools/tts_tool.py +2731 -0
  638. pm_copilot_engine/tools/url_safety.py +402 -0
  639. pm_copilot_engine/tools/video_generation_tool.py +562 -0
  640. pm_copilot_engine/tools/vision_tools.py +1591 -0
  641. pm_copilot_engine/tools/voice_mode.py +1218 -0
  642. pm_copilot_engine/tools/web_tools.py +1569 -0
  643. pm_copilot_engine/tools/website_policy.py +282 -0
  644. pm_copilot_engine/tools/write_approval.py +493 -0
  645. pm_copilot_engine/tools/x_search_tool.py +525 -0
  646. pm_copilot_engine/tools/xai_http.py +128 -0
  647. pm_copilot_engine/tools/yuanbao_tools.py +737 -0
  648. pm_copilot_engine/toolset_distributions.py +364 -0
  649. pm_copilot_engine/toolsets.py +912 -0
  650. pm_copilot_engine/trajectory_compressor.py +1579 -0
  651. pm_copilot_engine/utils.py +440 -0
  652. pm_copilot_engine-0.1.0.dist-info/METADATA +215 -0
  653. pm_copilot_engine-0.1.0.dist-info/RECORD +656 -0
  654. pm_copilot_engine-0.1.0.dist-info/WHEEL +5 -0
  655. pm_copilot_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  656. pm_copilot_engine-0.1.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,1897 @@
1
+ """Send Message Tool -- cross-channel messaging via platform APIs.
2
+
3
+ Sends a message to a user or channel on any connected messaging platform
4
+ (Telegram, Discord, Slack). Supports listing available targets and resolving
5
+ human-friendly channel names to IDs. Works in both CLI and gateway contexts.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import ssl
14
+ import time
15
+ from email.utils import formatdate
16
+
17
+ from pm_copilot_engine.agent.redact import redact_sensitive_text
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$")
22
+ _FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$")
23
+ # Slack conversation IDs: C (public channel), G (private/group channel), D (DM).
24
+ # Must be uppercase alphanumeric, 9+ chars. User IDs (U...) and workspace IDs
25
+ # (W...) are NOT valid chat.postMessage channel values — posting to them fails
26
+ # because the API requires a conversation ID. To DM a user you must first call
27
+ # conversations.open to obtain a D... ID. Without this gate, Slack IDs fall
28
+ # through to channel-name resolution, which only matches by name and fails.
29
+ _SLACK_TARGET_RE = re.compile(r"^\s*([CGDU][A-Z0-9]{8,})\s*$")
30
+ # Session-derived Slack thread targets use "<conversation_id>:<thread_ts>".
31
+ _SLACK_THREAD_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,}):([^\s:]+)\s*$")
32
+ _WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$")
33
+ _YUANBAO_TARGET_RE = re.compile(r"^\s*((?:group|direct):[^:]+)\s*$")
34
+ # Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.
35
+ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
36
+ # Platforms that address recipients by phone number and accept E.164 format
37
+ # (with a leading '+'). Without this, "+15551234567" fails the isdigit() check
38
+ # below and falls through to channel-name resolution, which has no way to
39
+ # resolve a raw phone number. Keeping the '+' preserves the E.164 form that
40
+ # downstream adapters (signal, etc.) expect.
41
+ _PHONE_PLATFORMS = frozenset({"photon", "signal", "sms", "whatsapp"})
42
+ _E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
43
+ # Email addresses — a valid email like "user@domain.com" should be treated as
44
+ # an explicit target for the email platform, not fall through to channel-name
45
+ # resolution which has no way to resolve a raw address.
46
+ _EMAIL_TARGET_RE = re.compile(r"^\s*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\s*$")
47
+ # Most platforms read their home channel from "<PLATFORM>_HOME_CHANNEL", but a
48
+ # few diverge. Email reads EMAIL_HOME_ADDRESS (see gateway/config.py), so the
49
+ # generic "<PLATFORM>_HOME_CHANNEL" hint would point users at a variable that is
50
+ # never read. Map the exceptions so the error guidance is actually actionable.
51
+ _HOME_CHANNEL_ENV_OVERRIDES = {"email": "EMAIL_HOME_ADDRESS"}
52
+ _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
53
+ _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
54
+ _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
55
+ _VOICE_EXTS = {".ogg", ".opus"}
56
+ # Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
57
+ # formats either route through sendVoice (Opus/OGG) or fall back to
58
+ # document delivery.
59
+ _TELEGRAM_SEND_AUDIO_EXTS = {".mp3", ".m4a"}
60
+ _URL_SECRET_QUERY_RE = re.compile(
61
+ r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)",
62
+ re.IGNORECASE,
63
+ )
64
+ _GENERIC_SECRET_ASSIGN_RE = re.compile(
65
+ r"\b(access_token|api[_-]?key|auth[_-]?token|signature|sig)\s*=\s*([^\s,;]+)",
66
+ re.IGNORECASE,
67
+ )
68
+
69
+
70
+ def _sanitize_error_text(text) -> str:
71
+ """Redact secrets from error text before surfacing it to users/models."""
72
+ redacted = redact_sensitive_text(text)
73
+ redacted = _URL_SECRET_QUERY_RE.sub(lambda m: f"{m.group(1)}***", redacted)
74
+ redacted = _GENERIC_SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", redacted)
75
+ return redacted
76
+
77
+
78
+ def _error(message: str) -> dict:
79
+ """Build a standardized error payload with redacted content."""
80
+ return {"error": _sanitize_error_text(message)}
81
+
82
+
83
+ def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None:
84
+ retry_after = getattr(exc, "retry_after", None)
85
+ if retry_after is not None:
86
+ try:
87
+ return max(float(retry_after), 0.0)
88
+ except (TypeError, ValueError):
89
+ return 1.0
90
+
91
+ text = str(exc).lower()
92
+ if "timed out" in text or "timeout" in text:
93
+ return None
94
+ if (
95
+ "bad gateway" in text
96
+ or "502" in text
97
+ or "too many requests" in text
98
+ or "429" in text
99
+ or "service unavailable" in text
100
+ or "503" in text
101
+ or "gateway timeout" in text
102
+ or "504" in text
103
+ ):
104
+ return float(2 ** attempt)
105
+ return None
106
+
107
+
108
+ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs):
109
+ for attempt in range(attempts):
110
+ try:
111
+ return await bot.send_message(**kwargs)
112
+ except Exception as exc:
113
+ delay = _telegram_retry_delay(exc, attempt)
114
+ if delay is None or attempt >= attempts - 1:
115
+ raise
116
+ logger.warning(
117
+ "Transient Telegram send failure (attempt %d/%d), retrying in %.1fs: %s",
118
+ attempt + 1,
119
+ attempts,
120
+ delay,
121
+ _sanitize_error_text(exc),
122
+ )
123
+ await asyncio.sleep(delay)
124
+
125
+
126
+ SEND_MESSAGE_SCHEMA = {
127
+ "name": "send_message",
128
+ "description": (
129
+ "Send a message to a connected messaging platform, or list available targets.\n\n"
130
+ "IMPORTANT: When the user asks to send to a specific channel or person "
131
+ "(not just a bare platform name), call send_message(action='list') FIRST to see "
132
+ "available targets, then send to the correct one.\n"
133
+ "If the user just says a platform name like 'send to telegram', send directly "
134
+ "to the home channel without listing first."
135
+ ),
136
+ "parameters": {
137
+ "type": "object",
138
+ "properties": {
139
+ "action": {
140
+ "type": "string",
141
+ "enum": ["send", "list", "react", "unreact"],
142
+ "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms. 'react' attaches an emoji reaction to a message (platforms that support it, e.g. photon/iMessage tapbacks). 'unreact' retracts a previously-added reaction."
143
+ },
144
+ "target": {
145
+ "type": "string",
146
+ "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'ntfy:alerts-channel' (explicit ntfy topic), 'yuanbao:direct:<account_id>' (DM), 'yuanbao:group:<group_code>' (group chat)"
147
+ },
148
+ "message": {
149
+ "type": "string",
150
+ "description": "The message text to send. To send an image or file, include MEDIA:<local_path> (e.g. 'MEDIA:/tmp/report.pdf') in the message — the platform will deliver it as a native media attachment."
151
+ },
152
+ "emoji": {
153
+ "type": "string",
154
+ "description": "For action='react': the emoji to react with (e.g. '❤️'). On iMessage, ❤️👍👎😂‼️❓ render as native tapbacks; other emoji use custom-emoji reactions."
155
+ },
156
+ "message_id": {
157
+ "type": "string",
158
+ "description": "For action='react'/'unreact': id of the message to react to. Omit to target the most recent message received in that chat (usually the one being replied to)."
159
+ }
160
+ },
161
+ "required": []
162
+ }
163
+ }
164
+
165
+
166
+ def send_message_tool(args, **kw):
167
+ """Handle cross-channel send_message tool calls."""
168
+ action = args.get("action", "send")
169
+
170
+ if action == "list":
171
+ return _handle_list()
172
+
173
+ if action == "react":
174
+ return _handle_react(args)
175
+
176
+ if action == "unreact":
177
+ return _handle_react(args, remove=True)
178
+
179
+ return _handle_send(args)
180
+
181
+
182
+ def _handle_list():
183
+ """Return formatted list of available messaging targets."""
184
+ try:
185
+ from gateway.channel_directory import format_directory_for_display
186
+ return json.dumps({"targets": format_directory_for_display()})
187
+ except Exception as e:
188
+ return json.dumps(_error(f"Failed to load channel directory: {e}"))
189
+
190
+
191
+ def _handle_react(args, remove=False):
192
+ """Attach (or with ``remove=True`` retract) an emoji reaction on a message
193
+ via a live gateway adapter.
194
+
195
+ Only adapters that expose ``add_reaction(chat_id, emoji, message_id)`` /
196
+ ``remove_reaction(chat_id, message_id)`` coroutines support this (e.g.
197
+ photon/iMessage tapbacks). Requires the gateway to be running in this
198
+ process — there is no standalone fallback, since reacting needs the
199
+ adapter's live message-id state.
200
+ """
201
+ target = args.get("target", "")
202
+ emoji = (args.get("emoji") or "").strip()
203
+ message_id = (args.get("message_id") or "").strip() or None
204
+ if not target or (not remove and not emoji):
205
+ return tool_error(
206
+ "Both 'target' and 'emoji' are required when action='react'"
207
+ if not remove
208
+ else "'target' is required when action='unreact'"
209
+ )
210
+
211
+ parts = target.split(":", 1)
212
+ platform_name = parts[0].strip().lower()
213
+ target_ref = parts[1].strip() if len(parts) > 1 else None
214
+ chat_id = None
215
+ if target_ref:
216
+ chat_id, _thread_id, _ = _parse_target_ref(platform_name, target_ref)
217
+ if not chat_id:
218
+ try:
219
+ from gateway.channel_directory import resolve_channel_name
220
+ resolved = resolve_channel_name(platform_name, target_ref)
221
+ except Exception:
222
+ resolved = None
223
+ # Opaque platform-native ids (e.g. photon space GUIDs like
224
+ # 'any;-;+1555...') match no parser pattern and no directory
225
+ # entry — pass them through verbatim; the adapter validates.
226
+ chat_id = resolved or target_ref
227
+
228
+ try:
229
+ from gateway.config import Platform, load_gateway_config
230
+ platform = Platform(platform_name)
231
+ except (ValueError, KeyError):
232
+ return tool_error(f"Unknown platform: {platform_name}")
233
+
234
+ if not chat_id:
235
+ try:
236
+ config = load_gateway_config()
237
+ home = config.get_home_channel(platform)
238
+ except Exception:
239
+ home = None
240
+ if not home:
241
+ return tool_error(
242
+ f"No chat specified and no home channel set for {platform_name}. "
243
+ f"Use '{platform_name}:chat_id'."
244
+ )
245
+ chat_id = home.chat_id
246
+
247
+ runner = None
248
+ try:
249
+ from gateway.run import _gateway_runner_ref
250
+ runner = _gateway_runner_ref()
251
+ except Exception:
252
+ runner = None
253
+ adapter = runner.adapters.get(platform) if runner is not None else None
254
+ if adapter is None:
255
+ return tool_error(
256
+ f"Reactions require a live {platform_name} adapter in the running "
257
+ "gateway (not available from cron/standalone contexts)."
258
+ )
259
+ fn_name = "remove_reaction" if remove else "add_reaction"
260
+ react_fn = getattr(adapter, fn_name, None)
261
+ if not callable(react_fn):
262
+ return tool_error(
263
+ f"Platform '{platform_name}' does not support message reactions."
264
+ )
265
+
266
+ try:
267
+ from pm_copilot_engine.model_tools import _run_async
268
+ if remove:
269
+ result = _run_async(
270
+ react_fn(chat_id=chat_id, message_id=message_id)
271
+ )
272
+ else:
273
+ result = _run_async(
274
+ react_fn(chat_id=chat_id, emoji=emoji, message_id=message_id)
275
+ )
276
+ except Exception as e:
277
+ return json.dumps(_error(f"Reaction failed: {e}"))
278
+ if isinstance(result, dict):
279
+ return json.dumps(result)
280
+ return json.dumps({"success": bool(result)})
281
+
282
+
283
+ def _handle_send(args):
284
+ """Send a message to a platform target."""
285
+ target = args.get("target", "")
286
+ message = args.get("message", "")
287
+ if not target or not message:
288
+ return tool_error("Both 'target' and 'message' are required when action='send'")
289
+
290
+ parts = target.split(":", 1)
291
+ platform_name = parts[0].strip().lower()
292
+ target_ref = parts[1].strip() if len(parts) > 1 else None
293
+ chat_id = None
294
+ thread_id = None
295
+
296
+ if target_ref:
297
+ chat_id, thread_id, is_explicit = _parse_target_ref(platform_name, target_ref)
298
+ else:
299
+ is_explicit = False
300
+
301
+ # Resolve human-friendly channel names to numeric IDs
302
+ if target_ref and not is_explicit:
303
+ try:
304
+ from gateway.channel_directory import resolve_channel_name
305
+ resolved = resolve_channel_name(platform_name, target_ref)
306
+ if resolved:
307
+ chat_id, thread_id, _ = _parse_target_ref(platform_name, resolved)
308
+ else:
309
+ return json.dumps({
310
+ "error": f"Could not resolve '{target_ref}' on {platform_name}. "
311
+ f"Use send_message(action='list') to see available targets."
312
+ })
313
+ except Exception:
314
+ return json.dumps({
315
+ "error": f"Could not resolve '{target_ref}' on {platform_name}. "
316
+ f"Try using a numeric channel ID instead."
317
+ })
318
+
319
+ from pm_copilot_engine.tools.interrupt import is_interrupted
320
+ if is_interrupted():
321
+ return tool_error("Interrupted")
322
+
323
+ try:
324
+ from gateway.config import load_gateway_config, Platform
325
+ config = load_gateway_config()
326
+ except Exception as e:
327
+ return json.dumps(_error(f"Failed to load gateway config: {e}"))
328
+
329
+ # Accept any platform name — built-in names resolve to their enum
330
+ # member, plugin platform names create dynamic members via _missing_().
331
+ try:
332
+ platform = Platform(platform_name)
333
+ except (ValueError, KeyError):
334
+ return tool_error(f"Unknown platform: {platform_name}")
335
+
336
+ pconfig = config.platforms.get(platform)
337
+ if not pconfig or not pconfig.enabled:
338
+ # Weixin can be configured purely via .env; synthesize a pconfig so
339
+ # send_message and cron delivery work without a gateway.yaml entry.
340
+ if platform_name == "weixin":
341
+ wx_token = os.getenv("WEIXIN_TOKEN", "").strip()
342
+ wx_account = os.getenv("WEIXIN_ACCOUNT_ID", "").strip()
343
+ if wx_token and wx_account:
344
+ from gateway.config import PlatformConfig
345
+ pconfig = PlatformConfig(
346
+ enabled=True,
347
+ token=wx_token,
348
+ extra={
349
+ "account_id": wx_account,
350
+ "base_url": os.getenv("WEIXIN_BASE_URL", "").strip(),
351
+ "cdn_base_url": os.getenv("WEIXIN_CDN_BASE_URL", "").strip(),
352
+ },
353
+ )
354
+ else:
355
+ return tool_error(f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.")
356
+ else:
357
+ return tool_error(f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.")
358
+
359
+ from gateway.platforms.base import BasePlatformAdapter
360
+
361
+ # Capture [[as_document]] directive before extract_media strips it.
362
+ # Image-extension files in this batch will route through send_document
363
+ # instead of send_photo so the original bytes survive (e.g. info-graph
364
+ # JPGs where Telegram's sendPhoto recompresses to 1280px).
365
+ force_document_attachments = "[[as_document]]" in message
366
+
367
+ media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
368
+ media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
369
+ mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
370
+
371
+ used_home_channel = False
372
+ if not chat_id:
373
+ home = config.get_home_channel(platform)
374
+ if not home and platform_name == "weixin":
375
+ wx_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip()
376
+ if wx_home:
377
+ from gateway.config import HomeChannel
378
+ home = HomeChannel(platform=platform, chat_id=wx_home, name="Weixin Home")
379
+ if home:
380
+ chat_id = home.chat_id
381
+ used_home_channel = True
382
+ else:
383
+ home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(
384
+ platform_name, f"{platform_name.upper()}_HOME_CHANNEL"
385
+ )
386
+ return json.dumps({
387
+ "error": f"No home channel set for {platform_name} to determine where to send the message. "
388
+ f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', "
389
+ f"or set a home channel via: hermes config set {home_env} <channel_id>"
390
+ })
391
+
392
+ duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)
393
+ if duplicate_skip:
394
+ return json.dumps(duplicate_skip)
395
+
396
+ # Slack: resolve user IDs (U...) to DM channel IDs via conversations.open
397
+ if platform_name == "slack" and chat_id and chat_id.startswith("U"):
398
+ try:
399
+ import aiohttp
400
+ async def _open_slack_dm(token, user_id):
401
+ url = "https://slack.com/api/conversations.open"
402
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
403
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
404
+ async with session.post(url, headers=headers, json={"users": [user_id]}) as resp:
405
+ data = await resp.json()
406
+ if data.get("ok"):
407
+ return data["channel"]["id"]
408
+ return None
409
+ from pm_copilot_engine.model_tools import _run_async
410
+ dm_channel = _run_async(_open_slack_dm(pconfig.token, chat_id))
411
+ if dm_channel:
412
+ chat_id = dm_channel
413
+ else:
414
+ return json.dumps({"error": f"Could not open DM with Slack user {chat_id}. Check bot permissions (im:write)."})
415
+ except Exception as e:
416
+ return json.dumps({"error": f"Failed to open Slack DM: {e}"})
417
+
418
+ try:
419
+ from pm_copilot_engine.model_tools import _run_async
420
+ result = _run_async(
421
+ _send_to_platform(
422
+ platform,
423
+ pconfig,
424
+ chat_id,
425
+ cleaned_message,
426
+ thread_id=thread_id,
427
+ media_files=media_files,
428
+ force_document=force_document_attachments,
429
+ )
430
+ )
431
+ if used_home_channel and isinstance(result, dict) and result.get("success"):
432
+ result["note"] = f"Sent to {platform_name} home channel (chat_id: {chat_id})"
433
+
434
+ # Mirror the sent message into the target's gateway session
435
+ if isinstance(result, dict) and result.get("success") and mirror_text:
436
+ try:
437
+ from gateway.mirror import mirror_to_session
438
+ from gateway.session_context import get_session_env
439
+ source_label = get_session_env("HERMES_SESSION_PLATFORM", "cli")
440
+ user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None
441
+ if mirror_to_session(
442
+ platform_name,
443
+ chat_id,
444
+ mirror_text,
445
+ source_label=source_label,
446
+ thread_id=thread_id,
447
+ user_id=user_id,
448
+ ):
449
+ result["mirrored"] = True
450
+ except Exception:
451
+ pass
452
+
453
+ if isinstance(result, dict) and "error" in result:
454
+ result["error"] = _sanitize_error_text(result["error"])
455
+ return json.dumps(result)
456
+ except Exception as e:
457
+ return json.dumps(_error(f"Send failed: {e}"))
458
+
459
+
460
+ def _parse_target_ref(platform_name: str, target_ref: str):
461
+ """Parse a tool target into chat_id/thread_id and whether it is explicit."""
462
+ if platform_name == "telegram":
463
+ match = _TELEGRAM_TOPIC_TARGET_RE.fullmatch(target_ref)
464
+ if match:
465
+ return match.group(1), match.group(2), True
466
+ if platform_name == "feishu":
467
+ match = _FEISHU_TARGET_RE.fullmatch(target_ref)
468
+ if match:
469
+ return match.group(1), match.group(2), True
470
+ if platform_name == "discord":
471
+ match = _NUMERIC_TOPIC_RE.fullmatch(target_ref)
472
+ if match:
473
+ return match.group(1), match.group(2), True
474
+ if platform_name == "slack":
475
+ match = _SLACK_THREAD_TARGET_RE.fullmatch(target_ref)
476
+ if match:
477
+ return match.group(1), match.group(2), True
478
+ match = _SLACK_TARGET_RE.fullmatch(target_ref)
479
+ if match:
480
+ chat_id = match.group(1)
481
+ # Slack user IDs (U...) and workspace IDs (W...) are NOT valid
482
+ # explicit send targets — chat.postMessage rejects them. A DM
483
+ # must be opened first via conversations.open to get a D...
484
+ # conversation ID. Caller still gets the chat_id so the U→D
485
+ # resolution path in send_message() can run.
486
+ is_explicit = chat_id[0] not in {"U", "W"}
487
+ return chat_id, None, is_explicit
488
+ if platform_name == "matrix":
489
+ trimmed = target_ref.strip()
490
+ split_idx = trimmed.rfind(":$")
491
+ if split_idx > 0:
492
+ return trimmed[:split_idx], trimmed[split_idx + 1 :], True
493
+ if platform_name == "weixin":
494
+ match = _WEIXIN_TARGET_RE.fullmatch(target_ref)
495
+ if match:
496
+ return match.group(1), None, True
497
+ if platform_name == "yuanbao":
498
+ match = _YUANBAO_TARGET_RE.fullmatch(target_ref)
499
+ if match:
500
+ return match.group(1), None, True
501
+ if target_ref.strip().isdigit():
502
+ return f"group:{target_ref.strip()}", None, True
503
+ return None, None, False
504
+ if platform_name == "ntfy":
505
+ topic = target_ref.strip()
506
+ if topic:
507
+ return topic, None, True
508
+ if platform_name == "email":
509
+ match = _EMAIL_TARGET_RE.fullmatch(target_ref)
510
+ if match:
511
+ return target_ref.strip(), None, True
512
+ if platform_name in _PHONE_PLATFORMS:
513
+ match = _E164_TARGET_RE.fullmatch(target_ref)
514
+ if match:
515
+ # Preserve the leading '+' — signal-cli and sms/whatsapp adapters
516
+ # expect E.164 format for direct recipients.
517
+ return target_ref.strip(), None, True
518
+ if target_ref.lstrip("-").isdigit():
519
+ return target_ref, None, True
520
+ # Matrix room IDs (start with !) and user IDs (start with @) are explicit
521
+ if platform_name == "matrix" and (target_ref.startswith("!") or target_ref.startswith("@")):
522
+ return target_ref, None, True
523
+ # XMPP JIDs (user@server or room@conference.server) are explicit
524
+ if platform_name == "xmpp" and "@" in target_ref:
525
+ return target_ref, None, True
526
+ return None, None, False
527
+
528
+
529
+ def _describe_media_for_mirror(media_files):
530
+ """Return a human-readable mirror summary when a message only contains media."""
531
+ if not media_files:
532
+ return ""
533
+ if len(media_files) == 1:
534
+ media_path, is_voice = media_files[0]
535
+ ext = os.path.splitext(media_path)[1].lower()
536
+ if is_voice and ext in _VOICE_EXTS:
537
+ return "[Sent voice message]"
538
+ if ext in _IMAGE_EXTS:
539
+ return "[Sent image attachment]"
540
+ if ext in _VIDEO_EXTS:
541
+ return "[Sent video attachment]"
542
+ if ext in _AUDIO_EXTS:
543
+ return "[Sent audio attachment]"
544
+ return "[Sent document attachment]"
545
+ return f"[Sent {len(media_files)} media attachments]"
546
+
547
+
548
+ def _get_cron_auto_delivery_target():
549
+ """Return the cron scheduler's auto-delivery target for the current run, if any."""
550
+ from gateway.session_context import get_session_env
551
+ platform = get_session_env("HERMES_CRON_AUTO_DELIVER_PLATFORM", "").strip().lower()
552
+ chat_id = get_session_env("HERMES_CRON_AUTO_DELIVER_CHAT_ID", "").strip()
553
+ if not platform or not chat_id:
554
+ return None
555
+ thread_id = get_session_env("HERMES_CRON_AUTO_DELIVER_THREAD_ID", "").strip() or None
556
+ return {
557
+ "platform": platform,
558
+ "chat_id": chat_id,
559
+ "thread_id": thread_id,
560
+ }
561
+
562
+
563
+ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id: str | None):
564
+ """Skip redundant cron send_message calls when the scheduler will auto-deliver there."""
565
+ auto_target = _get_cron_auto_delivery_target()
566
+ if not auto_target:
567
+ return None
568
+
569
+ same_target = (
570
+ auto_target["platform"] == platform_name
571
+ and str(auto_target["chat_id"]) == str(chat_id)
572
+ and auto_target.get("thread_id") == thread_id
573
+ )
574
+ if not same_target:
575
+ return None
576
+
577
+ target_label = f"{platform_name}:{chat_id}"
578
+ if thread_id is not None:
579
+ target_label += f":{thread_id}"
580
+
581
+ return {
582
+ "success": True,
583
+ "skipped": True,
584
+ "reason": "cron_auto_delivery_duplicate_target",
585
+ "target": target_label,
586
+ "note": (
587
+ f"Skipped send_message to {target_label}. This cron job will already auto-deliver "
588
+ "its final response to that same target. Put the intended user-facing content in "
589
+ "your final response instead, or use a different target if you want an additional message."
590
+ ),
591
+ }
592
+
593
+
594
+ async def _send_via_adapter(
595
+ platform,
596
+ pconfig,
597
+ chat_id,
598
+ chunk,
599
+ *,
600
+ thread_id=None,
601
+ media_files=None,
602
+ force_document=False,
603
+ ):
604
+ """Send a message via a live gateway adapter, with a standalone fallback
605
+ for out-of-process callers (e.g. cron running separately from the gateway).
606
+
607
+ Order of attempts:
608
+ 1. Live in-process adapter via ``_gateway_runner_ref()`` (the path that
609
+ existed before this change).
610
+ 2. The plugin's ``standalone_sender_fn`` registered on its
611
+ ``PlatformEntry`` (used when the gateway is not in this process, so
612
+ the runner weakref is ``None``).
613
+ 3. A descriptive error explaining both options.
614
+ """
615
+ platform_name = platform.value if hasattr(platform, "value") else str(platform)
616
+ runner = None
617
+ try:
618
+ from gateway.run import _gateway_runner_ref
619
+ runner = _gateway_runner_ref()
620
+ except Exception:
621
+ runner = None
622
+
623
+ if runner is not None:
624
+ try:
625
+ adapter = runner.adapters.get(platform)
626
+ except Exception:
627
+ adapter = None
628
+ if adapter is not None:
629
+ try:
630
+ metadata = {}
631
+ if thread_id:
632
+ metadata["thread_id"] = thread_id
633
+ if platform_name == "ntfy" and chat_id:
634
+ metadata["publish_topic"] = chat_id
635
+ if not metadata:
636
+ metadata = None
637
+ result = await adapter.send(chat_id=chat_id, content=chunk, metadata=metadata)
638
+ except asyncio.CancelledError:
639
+ raise
640
+ except Exception as e:
641
+ return {"error": f"Plugin platform send failed: {e}"}
642
+ if result.success:
643
+ return {"success": True, "message_id": result.message_id}
644
+ return {"error": f"Adapter send failed: {result.error}"}
645
+
646
+ entry = None
647
+ try:
648
+ from gateway.platform_registry import platform_registry
649
+ entry = platform_registry.get(platform_name)
650
+ except Exception:
651
+ entry = None
652
+
653
+ if entry is not None and entry.standalone_sender_fn is not None:
654
+ try:
655
+ result = await entry.standalone_sender_fn(
656
+ pconfig,
657
+ chat_id,
658
+ chunk,
659
+ thread_id=thread_id,
660
+ media_files=media_files,
661
+ force_document=force_document,
662
+ )
663
+ except asyncio.CancelledError:
664
+ raise
665
+ except Exception as e:
666
+ logger.debug("Plugin standalone send for %s raised", platform_name, exc_info=True)
667
+ return {"error": f"Plugin standalone send failed: {e}"}
668
+
669
+ if isinstance(result, dict) and (result.get("success") or result.get("error")):
670
+ return result
671
+ return {
672
+ "error": (
673
+ f"Plugin standalone send for '{platform_name}' returned an "
674
+ f"invalid result: expected a dict with 'success' or 'error' "
675
+ f"keys, got {type(result).__name__}"
676
+ )
677
+ }
678
+
679
+ return {
680
+ "error": (
681
+ f"No live adapter for platform '{platform_name}'. Is the gateway "
682
+ f"running with this platform connected? For out-of-process delivery "
683
+ f"(e.g. cron in a separate process), the platform plugin must "
684
+ f"register a standalone_sender_fn on its PlatformEntry."
685
+ )
686
+ }
687
+
688
+
689
+ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False):
690
+ """Route a message to the appropriate platform sender.
691
+
692
+ Long messages are automatically chunked to fit within platform limits
693
+ using the same smart-splitting algorithm as the gateway adapters
694
+ (preserves code-block boundaries, adds part indicators).
695
+ """
696
+ from gateway.config import Platform
697
+
698
+ media_files = media_files or []
699
+
700
+ # Weixin handles text/media delivery inside its native helper and does not
701
+ # need the optional platform adapter imports below. Keep this branch early
702
+ # so a Weixin send is not blocked by unrelated optional dependencies (for
703
+ # example lark-oapi's heavy Feishu import path).
704
+ if platform == Platform.WEIXIN:
705
+ return await _send_weixin(pconfig, chat_id, message, media_files=media_files)
706
+
707
+ from gateway.platforms.base import BasePlatformAdapter, utf16_len
708
+ from gateway.platforms.slack import SlackAdapter
709
+
710
+ # Telegram adapter import is optional (requires python-telegram-bot)
711
+ try:
712
+ from gateway.platforms.telegram import TelegramAdapter
713
+ _telegram_available = True
714
+ except ImportError:
715
+ _telegram_available = False
716
+
717
+ # Feishu adapter import is optional (requires lark-oapi)
718
+ try:
719
+ from gateway.platforms.feishu import FeishuAdapter
720
+ _feishu_available = True
721
+ except ImportError:
722
+ _feishu_available = False
723
+
724
+ if platform == Platform.SLACK and message:
725
+ try:
726
+ slack_adapter = SlackAdapter.__new__(SlackAdapter)
727
+ message = slack_adapter.format_message(message)
728
+ except Exception:
729
+ logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
730
+
731
+ # Platform message length limits (from adapter class attributes for
732
+ # built-in platforms; from PlatformEntry.max_message_length for plugins).
733
+ _MAX_LENGTHS = {
734
+ Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096,
735
+ Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH,
736
+ }
737
+ if _feishu_available:
738
+ _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
739
+
740
+ # Check plugin registry for max_message_length
741
+ if platform not in _MAX_LENGTHS:
742
+ try:
743
+ from gateway.platform_registry import platform_registry
744
+ entry = platform_registry.get(platform.value)
745
+ if entry and entry.max_message_length > 0:
746
+ _MAX_LENGTHS[platform] = entry.max_message_length
747
+ except Exception:
748
+ pass
749
+
750
+ # Smart-chunk the message to fit within platform limits.
751
+ # For short messages or platforms without a known limit this is a no-op.
752
+ # Telegram measures length in UTF-16 code units, not Unicode codepoints.
753
+ max_len = _MAX_LENGTHS.get(platform)
754
+ if max_len:
755
+ _len_fn = utf16_len if platform == Platform.TELEGRAM else None
756
+ chunks = BasePlatformAdapter.truncate_message(message, max_len, len_fn=_len_fn)
757
+ else:
758
+ chunks = [message]
759
+
760
+ # --- Telegram: special handling for media attachments ---
761
+ if platform == Platform.TELEGRAM:
762
+ last_result = None
763
+ disable_link_previews = bool(getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews"))
764
+ for i, chunk in enumerate(chunks):
765
+ is_last = (i == len(chunks) - 1)
766
+ result = await _send_telegram(
767
+ pconfig.token,
768
+ chat_id,
769
+ chunk,
770
+ media_files=media_files if is_last else [],
771
+ thread_id=thread_id,
772
+ disable_link_previews=disable_link_previews,
773
+ force_document=force_document,
774
+ )
775
+ if isinstance(result, dict) and result.get("error"):
776
+ return result
777
+ last_result = result
778
+ return last_result
779
+
780
+ # --- Discord: chunked delivery via the registry's standalone_sender_fn.
781
+ # The plugin's ``_standalone_send`` (registered in
782
+ # plugins/platforms/discord/adapter.py) handles forum channels, threads,
783
+ # and multipart media uploads. ``_send_via_adapter`` tries the live
784
+ # in-process adapter first via ``adapter.send()``, but Discord's elif
785
+ # historically went straight to the HTTP path; we preserve that by
786
+ # explicitly invoking the registry hook here so behavior is unchanged.
787
+ if platform == Platform.DISCORD:
788
+ from gateway.platform_registry import platform_registry
789
+ entry = platform_registry.get("discord")
790
+ if entry is None or entry.standalone_sender_fn is None:
791
+ return {"error": "Discord plugin not registered or missing standalone_sender_fn"}
792
+ last_result = None
793
+ for i, chunk in enumerate(chunks):
794
+ is_last = (i == len(chunks) - 1)
795
+ result = await entry.standalone_sender_fn(
796
+ pconfig,
797
+ chat_id,
798
+ chunk,
799
+ thread_id=thread_id,
800
+ media_files=media_files if is_last else [],
801
+ )
802
+ if isinstance(result, dict) and result.get("error"):
803
+ return result
804
+ last_result = result
805
+ return last_result
806
+
807
+ # --- Matrix: use the native adapter helper when media is present ---
808
+ if platform == Platform.MATRIX and media_files:
809
+ last_result = None
810
+ for i, chunk in enumerate(chunks):
811
+ is_last = (i == len(chunks) - 1)
812
+ result = await _send_matrix_via_adapter(
813
+ pconfig,
814
+ chat_id,
815
+ chunk,
816
+ media_files=media_files if is_last else [],
817
+ thread_id=thread_id,
818
+ )
819
+ if isinstance(result, dict) and result.get("error"):
820
+ return result
821
+ last_result = result
822
+ return last_result
823
+
824
+ # --- Signal: native attachment support via JSON-RPC attachments param ---
825
+ if platform == Platform.SIGNAL and media_files:
826
+ last_result = None
827
+ for i, chunk in enumerate(chunks):
828
+ is_last = (i == len(chunks) - 1)
829
+ result = await _send_signal(
830
+ pconfig.extra,
831
+ chat_id,
832
+ chunk,
833
+ media_files=media_files if is_last else [],
834
+ )
835
+ if isinstance(result, dict) and result.get("error"):
836
+ return result
837
+ last_result = result
838
+ return last_result
839
+
840
+ # --- Yuanbao: native media attachment support via running gateway adapter ---
841
+ if platform == Platform.YUANBAO and media_files:
842
+ last_result = None
843
+ for i, chunk in enumerate(chunks):
844
+ is_last = (i == len(chunks) - 1)
845
+ result = await _send_yuanbao(
846
+ chat_id,
847
+ chunk,
848
+ media_files=media_files if is_last else None,
849
+ )
850
+ if isinstance(result, dict) and result.get("error"):
851
+ return result
852
+ last_result = result
853
+ return last_result
854
+
855
+ # --- Feishu: native media attachment support via adapter ---
856
+ if platform == Platform.FEISHU and media_files:
857
+ last_result = None
858
+ for i, chunk in enumerate(chunks):
859
+ is_last = (i == len(chunks) - 1)
860
+ result = await _send_feishu(
861
+ pconfig,
862
+ chat_id,
863
+ chunk,
864
+ media_files=media_files if is_last else None,
865
+ thread_id=thread_id,
866
+ )
867
+ if isinstance(result, dict) and result.get("error"):
868
+ return result
869
+ last_result = result
870
+ return last_result
871
+
872
+ # --- Non-media platforms ---
873
+ if media_files and not message.strip():
874
+ return {
875
+ "error": (
876
+ f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu; "
877
+ f"target {platform.value} had only media attachments"
878
+ )
879
+ }
880
+ warning = None
881
+ if media_files:
882
+ warning = (
883
+ f"MEDIA attachments were omitted for {platform.value}; "
884
+ "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu"
885
+ )
886
+
887
+ last_result = None
888
+ for chunk in chunks:
889
+ if platform == Platform.SLACK:
890
+ result = await _send_slack(pconfig.token, chat_id, chunk, thread_ts=thread_id)
891
+ elif platform == Platform.WHATSAPP:
892
+ result = await _send_whatsapp(pconfig.extra, chat_id, chunk)
893
+ elif platform == Platform.SIGNAL:
894
+ result = await _send_signal(pconfig.extra, chat_id, chunk)
895
+ elif platform == Platform.EMAIL:
896
+ result = await _send_email(pconfig.extra, chat_id, chunk)
897
+ elif platform == Platform.SMS:
898
+ result = await _send_sms(pconfig.api_key, chat_id, chunk)
899
+ elif platform == Platform.MATRIX:
900
+ result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk)
901
+ elif platform == Platform.DINGTALK:
902
+ result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
903
+ elif platform == Platform.FEISHU:
904
+ result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id)
905
+ elif platform == Platform.WECOM:
906
+ result = await _send_wecom(pconfig.extra, chat_id, chunk)
907
+ elif platform == Platform.BLUEBUBBLES:
908
+ result = await _send_bluebubbles(pconfig.extra, chat_id, chunk)
909
+ elif platform == Platform.QQBOT:
910
+ result = await _send_qqbot(pconfig, chat_id, chunk)
911
+ elif platform == Platform.YUANBAO:
912
+ result = await _send_yuanbao(chat_id, chunk)
913
+ else:
914
+ # Plugin platform: route through the gateway's live adapter if
915
+ # available, otherwise the plugin's standalone_sender_fn.
916
+ result = await _send_via_adapter(
917
+ platform,
918
+ pconfig,
919
+ chat_id,
920
+ chunk,
921
+ thread_id=thread_id,
922
+ media_files=media_files,
923
+ force_document=force_document,
924
+ )
925
+
926
+ if isinstance(result, dict) and result.get("error"):
927
+ return result
928
+ last_result = result
929
+
930
+ if warning and isinstance(last_result, dict) and last_result.get("success"):
931
+ warnings = list(last_result.get("warnings", []))
932
+ warnings.append(warning)
933
+ last_result["warnings"] = warnings
934
+ return last_result
935
+
936
+
937
+ def _is_telegram_thread_not_found(error: Exception) -> bool:
938
+ """Check if a Telegram error is a thread-not-found failure.
939
+
940
+ Matches the gateway adapter's ``_is_thread_not_found_error`` for
941
+ the standalone ``_send_telegram`` path (issue #27012).
942
+ """
943
+ return "thread not found" in str(error).lower()
944
+
945
+
946
+ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False):
947
+ """Send via Telegram Bot API (one-shot, no polling needed).
948
+
949
+ Applies markdown→MarkdownV2 formatting (same as the gateway adapter)
950
+ so that bold, links, and headers render correctly. If the message
951
+ already contains HTML tags, it is sent with ``parse_mode='HTML'``
952
+ instead, bypassing MarkdownV2 conversion.
953
+ """
954
+ try:
955
+ from telegram import Bot
956
+ from telegram.constants import ParseMode
957
+
958
+ # Auto-detect HTML tags — if present, skip MarkdownV2 and send as HTML.
959
+ # Inspired by github.com/ashaney — PR #1568.
960
+ _has_html = bool(re.search(r'<[a-zA-Z/][^>]*>', message))
961
+
962
+ if _has_html:
963
+ formatted = message
964
+ send_parse_mode = ParseMode.HTML
965
+ else:
966
+ # Reuse the gateway adapter's format_message for markdown→MarkdownV2
967
+ try:
968
+ from gateway.platforms.telegram import TelegramAdapter
969
+ _adapter = TelegramAdapter.__new__(TelegramAdapter)
970
+ formatted = _adapter.format_message(message)
971
+ except Exception:
972
+ # Fallback: send as-is if formatting unavailable
973
+ formatted = message
974
+ send_parse_mode = ParseMode.MARKDOWN_V2
975
+
976
+ # Honour a configured proxy (telegram.proxy_url in config.yaml, exported
977
+ # as TELEGRAM_PROXY env var by load_gateway_config). Without this, the
978
+ # standalone send path bypasses the proxy and times out in regions
979
+ # where api.telegram.org is blocked. The in-gateway adapter does the
980
+ # same thing in gateway/platforms/telegram.py.
981
+ try:
982
+ from gateway.platforms.base import resolve_proxy_url
983
+ _tg_proxy = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=["api.telegram.org"])
984
+ except Exception:
985
+ _tg_proxy = None
986
+ if _tg_proxy:
987
+ try:
988
+ from telegram.request import HTTPXRequest
989
+ logger.info("send_message: standalone Telegram send routed through proxy %s", _tg_proxy)
990
+ bot = Bot(
991
+ token=token,
992
+ request=HTTPXRequest(proxy=_tg_proxy),
993
+ get_updates_request=HTTPXRequest(proxy=_tg_proxy),
994
+ )
995
+ except Exception as _proxy_err:
996
+ logger.warning("send_message: failed to attach Telegram proxy (%s), falling back to direct connection", _proxy_err)
997
+ bot = Bot(token=token)
998
+ else:
999
+ bot = Bot(token=token)
1000
+ int_chat_id = int(chat_id)
1001
+ media_files = media_files or []
1002
+ thread_kwargs = {}
1003
+ if thread_id is not None:
1004
+ # Reuse the gateway adapter's General-topic mapping: in Telegram
1005
+ # forum supergroups, the General topic is addressed as
1006
+ # message_thread_id="1" on incoming updates, but Bot API
1007
+ # sendMessage rejects message_thread_id=1 with "Message thread
1008
+ # not found". The adapter's helper maps "1" to None for that
1009
+ # reason; the send_message tool needs the same mapping or a
1010
+ # send to a forum group's General topic always errors out
1011
+ # (see issue #22267).
1012
+ try:
1013
+ from gateway.platforms.telegram import TelegramAdapter
1014
+ effective_thread_id = TelegramAdapter._message_thread_id_for_send(
1015
+ str(thread_id)
1016
+ )
1017
+ except Exception:
1018
+ # Fallback: explicit mapping in case the adapter import
1019
+ # fails (e.g. python-telegram-bot missing in this venv).
1020
+ effective_thread_id = (
1021
+ None if str(thread_id) == "1" else int(thread_id)
1022
+ )
1023
+ if effective_thread_id is not None:
1024
+ thread_kwargs["message_thread_id"] = effective_thread_id
1025
+ # disable_web_page_preview is only valid for send_message, not
1026
+ # send_photo/send_video/etc. Keep it separate so media sends
1027
+ # don't inherit an invalid parameter (issue #27012).
1028
+ text_kwargs = dict(thread_kwargs)
1029
+ if disable_link_previews:
1030
+ text_kwargs["disable_web_page_preview"] = True
1031
+
1032
+ last_msg = None
1033
+ warnings = []
1034
+
1035
+ if formatted.strip():
1036
+ try:
1037
+ last_msg = await _send_telegram_message_with_retry(
1038
+ bot,
1039
+ chat_id=int_chat_id, text=formatted,
1040
+ parse_mode=send_parse_mode, **text_kwargs
1041
+ )
1042
+ except Exception as md_error:
1043
+ # Thread not found — retry without message_thread_id so the
1044
+ # message still delivers (matching the gateway adapter's
1045
+ # fallback behaviour, issue #27012).
1046
+ if _is_telegram_thread_not_found(md_error) and thread_kwargs:
1047
+ logger.warning(
1048
+ "Thread %s not found in _send_telegram, retrying without message_thread_id",
1049
+ thread_kwargs.get("message_thread_id"),
1050
+ )
1051
+ text_kwargs.pop("message_thread_id", None)
1052
+ last_msg = await _send_telegram_message_with_retry(
1053
+ bot,
1054
+ chat_id=int_chat_id, text=formatted,
1055
+ parse_mode=send_parse_mode, **text_kwargs
1056
+ )
1057
+ elif "parse" in str(md_error).lower() or "markdown" in str(md_error).lower() or "html" in str(md_error).lower():
1058
+ logger.warning(
1059
+ "Parse mode %s failed in _send_telegram, falling back to plain text: %s",
1060
+ send_parse_mode,
1061
+ _sanitize_error_text(md_error),
1062
+ )
1063
+ if not _has_html:
1064
+ try:
1065
+ from gateway.platforms.telegram import _strip_mdv2
1066
+ plain = _strip_mdv2(formatted)
1067
+ except Exception:
1068
+ plain = message
1069
+ else:
1070
+ plain = message
1071
+ last_msg = await _send_telegram_message_with_retry(
1072
+ bot,
1073
+ chat_id=int_chat_id, text=plain,
1074
+ parse_mode=None, **text_kwargs
1075
+ )
1076
+ else:
1077
+ raise
1078
+
1079
+ for media_path, is_voice in media_files:
1080
+ if not os.path.exists(media_path):
1081
+ warning = f"Media file not found, skipping: {media_path}"
1082
+ logger.warning(warning)
1083
+ warnings.append(warning)
1084
+ continue
1085
+
1086
+ ext = os.path.splitext(media_path)[1].lower()
1087
+ try:
1088
+ with open(media_path, "rb") as f:
1089
+ media_kwargs = dict(thread_kwargs)
1090
+ try:
1091
+ if ext in _IMAGE_EXTS and not force_document:
1092
+ last_msg = await bot.send_photo(
1093
+ chat_id=int_chat_id, photo=f, **media_kwargs
1094
+ )
1095
+ elif ext in _VIDEO_EXTS:
1096
+ last_msg = await bot.send_video(
1097
+ chat_id=int_chat_id, video=f, **media_kwargs
1098
+ )
1099
+ elif ext in _VOICE_EXTS and is_voice:
1100
+ last_msg = await bot.send_voice(
1101
+ chat_id=int_chat_id, voice=f, **media_kwargs
1102
+ )
1103
+ elif ext in _TELEGRAM_SEND_AUDIO_EXTS:
1104
+ last_msg = await bot.send_audio(
1105
+ chat_id=int_chat_id, audio=f, **media_kwargs
1106
+ )
1107
+ else:
1108
+ last_msg = await bot.send_document(
1109
+ chat_id=int_chat_id, document=f, **media_kwargs
1110
+ )
1111
+ except Exception as media_err:
1112
+ if _is_telegram_thread_not_found(media_err) and media_kwargs.get("message_thread_id"):
1113
+ # Thread not found for media — retry without
1114
+ # message_thread_id (issue #27012).
1115
+ logger.warning(
1116
+ "Thread %s not found for media send, retrying without message_thread_id",
1117
+ media_kwargs["message_thread_id"],
1118
+ )
1119
+ # Re-seek the file since the first attempt consumed it
1120
+ f.seek(0)
1121
+ media_kwargs.pop("message_thread_id", None)
1122
+ if ext in _IMAGE_EXTS and not force_document:
1123
+ last_msg = await bot.send_photo(
1124
+ chat_id=int_chat_id, photo=f, **media_kwargs
1125
+ )
1126
+ elif ext in _VIDEO_EXTS:
1127
+ last_msg = await bot.send_video(
1128
+ chat_id=int_chat_id, video=f, **media_kwargs
1129
+ )
1130
+ elif ext in _VOICE_EXTS and is_voice:
1131
+ last_msg = await bot.send_voice(
1132
+ chat_id=int_chat_id, voice=f, **media_kwargs
1133
+ )
1134
+ elif ext in _TELEGRAM_SEND_AUDIO_EXTS:
1135
+ last_msg = await bot.send_audio(
1136
+ chat_id=int_chat_id, audio=f, **media_kwargs
1137
+ )
1138
+ else:
1139
+ last_msg = await bot.send_document(
1140
+ chat_id=int_chat_id, document=f, **media_kwargs
1141
+ )
1142
+ else:
1143
+ raise
1144
+ except Exception as e:
1145
+ warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}")
1146
+ logger.error(warning)
1147
+ warnings.append(warning)
1148
+
1149
+ if last_msg is None:
1150
+ error = "No deliverable text or media remained after processing MEDIA tags"
1151
+ if warnings:
1152
+ return {"error": error, "warnings": warnings}
1153
+ return {"error": error}
1154
+
1155
+ result = {
1156
+ "success": True,
1157
+ "platform": "telegram",
1158
+ "chat_id": chat_id,
1159
+ "message_id": str(last_msg.message_id),
1160
+ }
1161
+ if warnings:
1162
+ result["warnings"] = warnings
1163
+ return result
1164
+ except ImportError:
1165
+ return {"error": "python-telegram-bot not installed. Run: pip install python-telegram-bot"}
1166
+ except Exception as e:
1167
+ return _error(f"Telegram send failed: {e}")
1168
+
1169
+
1170
+ async def _send_slack(token, chat_id, message, thread_ts=None):
1171
+ """Send via Slack Web API."""
1172
+ try:
1173
+ import aiohttp
1174
+ except ImportError:
1175
+ return {"error": "aiohttp not installed. Run: pip install aiohttp"}
1176
+ try:
1177
+ from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
1178
+ _proxy = resolve_proxy_url()
1179
+ _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
1180
+ url = "https://slack.com/api/chat.postMessage"
1181
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
1182
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
1183
+ payload = {"channel": chat_id, "text": message, "mrkdwn": True}
1184
+ if thread_ts:
1185
+ payload["thread_ts"] = thread_ts
1186
+ async with session.post(url, headers=headers, json=payload, **_req_kw) as resp:
1187
+ data = await resp.json()
1188
+ if data.get("ok"):
1189
+ return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
1190
+ return _error(f"Slack API error: {data.get('error', 'unknown')}")
1191
+ except Exception as e:
1192
+ return _error(f"Slack send failed: {e}")
1193
+
1194
+
1195
+ async def _send_whatsapp(extra, chat_id, message):
1196
+ """Send via the local WhatsApp bridge HTTP API."""
1197
+ try:
1198
+ import aiohttp
1199
+ except ImportError:
1200
+ return {"error": "aiohttp not installed. Run: pip install aiohttp"}
1201
+ try:
1202
+ bridge_port = extra.get("bridge_port", 3000)
1203
+ async with aiohttp.ClientSession() as session:
1204
+ async with session.post(
1205
+ f"http://localhost:{bridge_port}/send",
1206
+ json={"chatId": chat_id, "message": message},
1207
+ timeout=aiohttp.ClientTimeout(total=30),
1208
+ ) as resp:
1209
+ if resp.status == 200:
1210
+ data = await resp.json()
1211
+ return {
1212
+ "success": True,
1213
+ "platform": "whatsapp",
1214
+ "chat_id": chat_id,
1215
+ "message_id": data.get("messageId"),
1216
+ }
1217
+ body = await resp.text()
1218
+ return _error(f"WhatsApp bridge error ({resp.status}): {body}")
1219
+ except Exception as e:
1220
+ return _error(f"WhatsApp send failed: {e}")
1221
+
1222
+
1223
+ async def _send_signal(extra, chat_id, message, media_files=None):
1224
+ """Send via signal-cli JSON-RPC API.
1225
+
1226
+ Supports both text-only and text-with-attachments (images/audio/documents).
1227
+ Multi-attachment sends are chunked into batches of
1228
+ SIGNAL_MAX_ATTACHMENTS_PER_MSG and metered by the process-wide
1229
+ SignalAttachmentScheduler — same bucket the gateway adapter uses, so
1230
+ sends from this tool and inbound-driven replies share rate-limit state.
1231
+ """
1232
+ try:
1233
+ import httpx
1234
+ except ImportError:
1235
+ return {"error": "httpx not installed"}
1236
+
1237
+ from gateway.platforms.signal_rate_limit import (
1238
+ SIGNAL_BATCH_PACING_NOTICE_THRESHOLD,
1239
+ SIGNAL_MAX_ATTACHMENTS_PER_MSG,
1240
+ SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
1241
+ _extract_retry_after_seconds,
1242
+ _format_wait,
1243
+ _is_signal_rate_limit_error,
1244
+ _signal_send_timeout,
1245
+ get_scheduler,
1246
+ )
1247
+
1248
+ try:
1249
+ http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/")
1250
+ account = extra.get("account", "")
1251
+ if not account:
1252
+ return {"error": "Signal account not configured"}
1253
+
1254
+ valid_media = media_files or []
1255
+ attachment_paths = []
1256
+ for media_path, _is_voice in valid_media:
1257
+ if os.path.exists(media_path):
1258
+ attachment_paths.append(media_path)
1259
+ else:
1260
+ logger.warning("Signal media file not found, skipping: %s", media_path)
1261
+
1262
+ # Chunk attachments. With no attachments we still emit one batch
1263
+ # (text only). With attachments, the text rides on batch #0 so the
1264
+ # caption isn't repeated across every chunk.
1265
+ if attachment_paths:
1266
+ att_batches = [
1267
+ attachment_paths[i:i + SIGNAL_MAX_ATTACHMENTS_PER_MSG]
1268
+ for i in range(0, len(attachment_paths), SIGNAL_MAX_ATTACHMENTS_PER_MSG)
1269
+ ]
1270
+ else:
1271
+ att_batches = [[]]
1272
+
1273
+ async def _post(batch_attachments, batch_message):
1274
+ params = {"account": account, "message": batch_message}
1275
+ if chat_id.startswith("group:"):
1276
+ params["groupId"] = chat_id[6:]
1277
+ else:
1278
+ params["recipient"] = [chat_id]
1279
+ if batch_attachments:
1280
+ params["attachments"] = batch_attachments
1281
+
1282
+ payload = {
1283
+ "jsonrpc": "2.0",
1284
+ "method": "send",
1285
+ "params": params,
1286
+ "id": f"send_{int(time.time() * 1000)}",
1287
+ }
1288
+ timeout = _signal_send_timeout(len(batch_attachments) if batch_attachments else 0)
1289
+ async with httpx.AsyncClient(timeout=timeout) as client:
1290
+ resp = await client.post(f"{http_url}/api/v1/rpc", json=payload)
1291
+ resp.raise_for_status()
1292
+ return resp.json()
1293
+
1294
+ async def _send_inline_notice(text: str) -> None:
1295
+ """Best-effort one-shot RPC for a user-facing pacing notice."""
1296
+ notice_params = {"account": account, "message": text}
1297
+ if chat_id.startswith("group:"):
1298
+ notice_params["groupId"] = chat_id[6:]
1299
+ else:
1300
+ notice_params["recipient"] = [chat_id]
1301
+ try:
1302
+ async with httpx.AsyncClient(timeout=30.0) as _client:
1303
+ await _client.post(
1304
+ f"{http_url}/api/v1/rpc",
1305
+ json={
1306
+ "jsonrpc": "2.0",
1307
+ "method": "send",
1308
+ "params": notice_params,
1309
+ "id": f"notice_{int(time.time() * 1000)}",
1310
+ },
1311
+ )
1312
+ except Exception as _e:
1313
+ logger.warning("Signal: inline notice failed: %s", _e)
1314
+
1315
+ scheduler = get_scheduler()
1316
+ logger.info(
1317
+ "send_message Signal: scheduler state=%s, %d attachment(s) in %d batch(es)",
1318
+ scheduler.state(), len(attachment_paths), len(att_batches),
1319
+ )
1320
+ failed_batches: list[int] = []
1321
+ for idx, att_batch in enumerate(att_batches):
1322
+ n = len(att_batch)
1323
+ if n > 0:
1324
+ estimated = scheduler.estimate_wait(n)
1325
+ if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
1326
+ await _send_inline_notice(
1327
+ f"(More images coming — pausing ~{_format_wait(estimated)} "
1328
+ f"for Signal rate limit, batch {idx + 1}/{len(att_batches)}.)"
1329
+ )
1330
+
1331
+ batch_message = message if idx == 0 else ""
1332
+
1333
+ for attempt in range(1, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + 1):
1334
+ try:
1335
+ await scheduler.acquire(n)
1336
+ _rpc_t0 = time.monotonic()
1337
+ data = await _post(att_batch, batch_message)
1338
+ _rpc_duration = time.monotonic() - _rpc_t0
1339
+ if "error" not in data:
1340
+ await scheduler.report_rpc_duration(_rpc_duration, n)
1341
+ break
1342
+
1343
+ err = data["error"]
1344
+
1345
+ if not _is_signal_rate_limit_error(err):
1346
+ return _error(f"Signal RPC error on batch {idx + 1}/{len(att_batches)}: {err}")
1347
+
1348
+ server_retry_after = _extract_retry_after_seconds(err)
1349
+ scheduler.feedback(server_retry_after, n)
1350
+
1351
+ if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
1352
+ failed_batches.append(idx + 1)
1353
+ logger.error(
1354
+ "Signal: rate-limit retries exhausted on batch %d/%d "
1355
+ "(%d attachments lost, server retry_after=%s)",
1356
+ idx + 1, len(att_batches), n,
1357
+ f"{server_retry_after:.0f}s" if server_retry_after else "unknown",
1358
+ )
1359
+ break
1360
+ logger.warning(
1361
+ "Signal: rate-limited on batch %d/%d "
1362
+ "(attempt %d/%d, server retry_after=%s); "
1363
+ "scheduler will pace the retry",
1364
+ idx + 1, len(att_batches),
1365
+ attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
1366
+ f"{server_retry_after:.0f}s" if server_retry_after else "unknown",
1367
+ )
1368
+ except Exception as e:
1369
+ if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
1370
+ failed_batches.append(idx + 1)
1371
+ logger.error(
1372
+ "Signal: send error on batch %d/%d after %d attempts: %s",
1373
+ idx + 1, len(att_batches), attempt, str(e)
1374
+ )
1375
+ break
1376
+ logger.warning(
1377
+ "Signal: transient error on batch %d/%d (attempt %d/%d): %s; will retry",
1378
+ idx + 1, len(att_batches), attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS, str(e)
1379
+ )
1380
+
1381
+ warnings = []
1382
+ if len(attachment_paths) < len(valid_media):
1383
+ warnings.append("Some media files were skipped (not found on disk)")
1384
+ if failed_batches:
1385
+ warnings.append(
1386
+ f"Signal rate-limited {len(failed_batches)} batch(es) "
1387
+ f"(#{', #'.join(str(b) for b in failed_batches)})"
1388
+ )
1389
+
1390
+ if failed_batches and len(failed_batches) == len(att_batches):
1391
+ return _error(
1392
+ f"Signal: every batch ({len(att_batches)}) hit rate limit; "
1393
+ f"no attachments delivered"
1394
+ )
1395
+
1396
+ result = {"success": True, "platform": "signal", "chat_id": chat_id}
1397
+ if warnings:
1398
+ result["warnings"] = warnings
1399
+ return result
1400
+ except Exception as e:
1401
+ return _error(f"Signal send failed: {e}")
1402
+
1403
+
1404
+ async def _send_email(extra, chat_id, message):
1405
+ """Send via SMTP (one-shot, no persistent connection needed)."""
1406
+ import smtplib
1407
+ from email.mime.text import MIMEText
1408
+
1409
+ address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "")
1410
+ password = os.getenv("EMAIL_PASSWORD", "")
1411
+ smtp_host = extra.get("smtp_host") or os.getenv("EMAIL_SMTP_HOST", "")
1412
+ try:
1413
+ smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
1414
+ except (ValueError, TypeError):
1415
+ smtp_port = 587
1416
+
1417
+ if not all([address, password, smtp_host]):
1418
+ return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}
1419
+
1420
+ try:
1421
+ msg = MIMEText(message, "plain", "utf-8")
1422
+ msg["From"] = address
1423
+ msg["To"] = chat_id
1424
+ msg["Subject"] = "Hermes Agent"
1425
+ msg["Date"] = formatdate(localtime=True)
1426
+
1427
+ server = smtplib.SMTP(smtp_host, smtp_port)
1428
+ server.starttls(context=ssl.create_default_context())
1429
+ server.login(address, password)
1430
+ server.send_message(msg)
1431
+ server.quit()
1432
+ return {"success": True, "platform": "email", "chat_id": chat_id}
1433
+ except Exception as e:
1434
+ return _error(f"Email send failed: {e}")
1435
+
1436
+
1437
+ async def _send_sms(auth_token, chat_id, message):
1438
+ """Send a single SMS via Twilio REST API.
1439
+
1440
+ Uses HTTP Basic auth (Account SID : Auth Token) and form-encoded POST.
1441
+ Chunking is handled by _send_to_platform() before this is called.
1442
+ """
1443
+ try:
1444
+ import aiohttp
1445
+ except ImportError:
1446
+ return {"error": "aiohttp not installed. Run: pip install aiohttp"}
1447
+
1448
+ import base64
1449
+
1450
+ account_sid = os.getenv("TWILIO_ACCOUNT_SID", "")
1451
+ from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
1452
+ if not account_sid or not auth_token or not from_number:
1453
+ return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}
1454
+
1455
+ # Strip markdown — SMS renders it as literal characters
1456
+ message = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL)
1457
+ message = re.sub(r"\*(.+?)\*", r"\1", message, flags=re.DOTALL)
1458
+ message = re.sub(r"__(.+?)__", r"\1", message, flags=re.DOTALL)
1459
+ message = re.sub(r"_(.+?)_", r"\1", message, flags=re.DOTALL)
1460
+ message = re.sub(r"```[a-z]*\n?", "", message)
1461
+ message = re.sub(r"`(.+?)`", r"\1", message)
1462
+ message = re.sub(r"^#{1,6}\s+", "", message, flags=re.MULTILINE)
1463
+ message = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", message)
1464
+ message = re.sub(r"\n{3,}", "\n\n", message)
1465
+ message = message.strip()
1466
+
1467
+ try:
1468
+ from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
1469
+ _proxy = resolve_proxy_url()
1470
+ _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
1471
+ creds = f"{account_sid}:{auth_token}"
1472
+ encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
1473
+ url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
1474
+ headers = {"Authorization": f"Basic {encoded}"}
1475
+
1476
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
1477
+ form_data = aiohttp.FormData()
1478
+ form_data.add_field("From", from_number)
1479
+ form_data.add_field("To", chat_id)
1480
+ form_data.add_field("Body", message)
1481
+
1482
+ async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp:
1483
+ body = await resp.json()
1484
+ if resp.status >= 400:
1485
+ error_msg = body.get("message", str(body))
1486
+ return _error(f"Twilio API error ({resp.status}): {error_msg}")
1487
+ msg_sid = body.get("sid", "")
1488
+ return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": msg_sid}
1489
+ except Exception as e:
1490
+ return _error(f"SMS send failed: {e}")
1491
+
1492
+
1493
+ async def _send_matrix(token, extra, chat_id, message):
1494
+ """Send via Matrix Client-Server API.
1495
+
1496
+ Converts markdown to HTML for rich rendering in Matrix clients.
1497
+ Falls back to plain text if the ``markdown`` library is not installed.
1498
+ """
1499
+ try:
1500
+ import aiohttp
1501
+ except ImportError:
1502
+ return {"error": "aiohttp not installed. Run: pip install aiohttp"}
1503
+ try:
1504
+ homeserver = (extra.get("homeserver") or os.getenv("MATRIX_HOMESERVER", "")).rstrip("/")
1505
+ token = token or os.getenv("MATRIX_ACCESS_TOKEN", "")
1506
+ if not homeserver or not token:
1507
+ return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"}
1508
+ txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}"
1509
+ from urllib.parse import quote
1510
+ encoded_room = quote(chat_id, safe="")
1511
+ url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
1512
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
1513
+
1514
+ # Build message payload with optional HTML formatted_body.
1515
+ payload = {"msgtype": "m.text", "body": message}
1516
+ try:
1517
+ import markdown as _md
1518
+ html = _md.markdown(message, extensions=["fenced_code", "tables"])
1519
+ # Convert h1-h6 to bold for Element X compatibility.
1520
+ html = re.sub(r"<h[1-6]>(.*?)</h[1-6]>", r"<strong>\1</strong>", html)
1521
+ payload["format"] = "org.matrix.custom.html"
1522
+ payload["formatted_body"] = html
1523
+ except ImportError:
1524
+ pass
1525
+
1526
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
1527
+ async with session.put(url, headers=headers, json=payload) as resp:
1528
+ if resp.status not in {200, 201}:
1529
+ body = await resp.text()
1530
+ return _error(f"Matrix API error ({resp.status}): {body}")
1531
+ data = await resp.json()
1532
+ return {"success": True, "platform": "matrix", "chat_id": chat_id, "message_id": data.get("event_id")}
1533
+ except Exception as e:
1534
+ return _error(f"Matrix send failed: {e}")
1535
+
1536
+
1537
+ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None):
1538
+ """Send via the Matrix adapter so native Matrix media uploads are preserved."""
1539
+ try:
1540
+ from gateway.platforms.matrix import MatrixAdapter
1541
+ except ImportError:
1542
+ return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"}
1543
+
1544
+ media_files = media_files or []
1545
+
1546
+ try:
1547
+ adapter = MatrixAdapter(pconfig)
1548
+ connected = await adapter.connect()
1549
+ if not connected:
1550
+ return _error("Matrix connect failed")
1551
+
1552
+ metadata = {"thread_id": thread_id} if thread_id else None
1553
+ last_result = None
1554
+
1555
+ if message.strip():
1556
+ last_result = await adapter.send(chat_id, message, metadata=metadata)
1557
+ if not last_result.success:
1558
+ return _error(f"Matrix send failed: {last_result.error}")
1559
+
1560
+ for media_path, is_voice in media_files:
1561
+ if not os.path.exists(media_path):
1562
+ return _error(f"Media file not found: {media_path}")
1563
+
1564
+ ext = os.path.splitext(media_path)[1].lower()
1565
+ if ext in _IMAGE_EXTS:
1566
+ last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
1567
+ elif ext in _VIDEO_EXTS:
1568
+ last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
1569
+ elif ext in _VOICE_EXTS and is_voice:
1570
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
1571
+ elif ext in _AUDIO_EXTS:
1572
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
1573
+ else:
1574
+ last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
1575
+
1576
+ if not last_result.success:
1577
+ return _error(f"Matrix media send failed: {last_result.error}")
1578
+
1579
+ if last_result is None:
1580
+ return {"error": "No deliverable text or media remained after processing MEDIA tags"}
1581
+
1582
+ return {
1583
+ "success": True,
1584
+ "platform": "matrix",
1585
+ "chat_id": chat_id,
1586
+ "message_id": last_result.message_id,
1587
+ }
1588
+ except Exception as e:
1589
+ return _error(f"Matrix send failed: {e}")
1590
+ finally:
1591
+ try:
1592
+ await adapter.disconnect()
1593
+ except Exception:
1594
+ pass
1595
+
1596
+
1597
+ async def _send_dingtalk(extra, chat_id, message):
1598
+ """Send via DingTalk robot webhook.
1599
+
1600
+ Note: The gateway's DingTalk adapter uses per-session webhook URLs from
1601
+ incoming messages (dingtalk-stream SDK). For cross-platform send_message
1602
+ delivery we use a static robot webhook URL instead, which must be
1603
+ configured via ``DINGTALK_WEBHOOK_URL`` env var or ``webhook_url`` in the
1604
+ platform's extra config.
1605
+ """
1606
+ try:
1607
+ import httpx
1608
+ except ImportError:
1609
+ return {"error": "httpx not installed"}
1610
+ try:
1611
+ webhook_url = extra.get("webhook_url") or os.getenv("DINGTALK_WEBHOOK_URL", "")
1612
+ if not webhook_url:
1613
+ return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."}
1614
+ async with httpx.AsyncClient(timeout=30.0) as client:
1615
+ resp = await client.post(
1616
+ webhook_url,
1617
+ json={"msgtype": "text", "text": {"content": message}},
1618
+ )
1619
+ resp.raise_for_status()
1620
+ data = resp.json()
1621
+ if data.get("errcode", 0) != 0:
1622
+ return _error(f"DingTalk API error: {data.get('errmsg', 'unknown')}")
1623
+ return {"success": True, "platform": "dingtalk", "chat_id": chat_id}
1624
+ except Exception as e:
1625
+ return _error(f"DingTalk send failed: {e}")
1626
+
1627
+
1628
+ async def _send_wecom(extra, chat_id, message):
1629
+ """Send via WeCom using the adapter's WebSocket send pipeline."""
1630
+ try:
1631
+ from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
1632
+ if not check_wecom_requirements():
1633
+ return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
1634
+ except ImportError:
1635
+ return {"error": "WeCom adapter not available."}
1636
+
1637
+ try:
1638
+ from gateway.config import PlatformConfig
1639
+ pconfig = PlatformConfig(extra=extra)
1640
+ adapter = WeComAdapter(pconfig)
1641
+ connected = await adapter.connect()
1642
+ if not connected:
1643
+ return _error(f"WeCom: failed to connect - {adapter.fatal_error_message or 'unknown error'}")
1644
+ try:
1645
+ result = await adapter.send(chat_id, message)
1646
+ if not result.success:
1647
+ return _error(f"WeCom send failed: {result.error}")
1648
+ return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
1649
+ finally:
1650
+ await adapter.disconnect()
1651
+ except Exception as e:
1652
+ return _error(f"WeCom send failed: {e}")
1653
+
1654
+
1655
+ async def _send_weixin(pconfig, chat_id, message, media_files=None):
1656
+ """Send via Weixin iLink using the native adapter helper."""
1657
+ try:
1658
+ from gateway.platforms.weixin import check_weixin_requirements, send_weixin_direct
1659
+ if not check_weixin_requirements():
1660
+ return {"error": "Weixin requirements not met. Need aiohttp + cryptography."}
1661
+ except ImportError:
1662
+ return {"error": "Weixin adapter not available."}
1663
+
1664
+ try:
1665
+ return await send_weixin_direct(
1666
+ extra=pconfig.extra,
1667
+ token=pconfig.token,
1668
+ chat_id=chat_id,
1669
+ message=message,
1670
+ media_files=media_files,
1671
+ )
1672
+ except Exception as e:
1673
+ return _error(f"Weixin send failed: {e}")
1674
+
1675
+
1676
+ async def _send_bluebubbles(extra, chat_id, message):
1677
+ """Send via BlueBubbles iMessage server using the adapter's REST API."""
1678
+ try:
1679
+ from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements
1680
+ if not check_bluebubbles_requirements():
1681
+ return {"error": "BlueBubbles requirements not met (need aiohttp + httpx)."}
1682
+ except ImportError:
1683
+ return {"error": "BlueBubbles adapter not available."}
1684
+
1685
+ try:
1686
+ from gateway.config import PlatformConfig
1687
+ pconfig = PlatformConfig(extra=extra)
1688
+ adapter = BlueBubblesAdapter(pconfig)
1689
+ connected = await adapter.connect()
1690
+ if not connected:
1691
+ return _error("BlueBubbles: failed to connect to server")
1692
+ try:
1693
+ result = await adapter.send(chat_id, message)
1694
+ if not result.success:
1695
+ return _error(f"BlueBubbles send failed: {result.error}")
1696
+ return {"success": True, "platform": "bluebubbles", "chat_id": chat_id, "message_id": result.message_id}
1697
+ finally:
1698
+ await adapter.disconnect()
1699
+ except Exception as e:
1700
+ return _error(f"BlueBubbles send failed: {e}")
1701
+
1702
+
1703
+ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None):
1704
+ """Send via Feishu/Lark using the adapter's send pipeline."""
1705
+ try:
1706
+ from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE
1707
+ if not FEISHU_AVAILABLE:
1708
+ return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
1709
+ from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN
1710
+ except ImportError:
1711
+ return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
1712
+
1713
+ media_files = media_files or []
1714
+
1715
+ try:
1716
+ adapter = FeishuAdapter(pconfig)
1717
+ domain_name = getattr(adapter, "_domain_name", "feishu")
1718
+ domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
1719
+ adapter._client = adapter._build_lark_client(domain)
1720
+ metadata = {"thread_id": thread_id} if thread_id else None
1721
+
1722
+ last_result = None
1723
+ if message.strip():
1724
+ last_result = await adapter.send(chat_id, message, metadata=metadata)
1725
+ if not last_result.success:
1726
+ return _error(f"Feishu send failed: {last_result.error}")
1727
+
1728
+ for media_path, is_voice in media_files:
1729
+ if not os.path.exists(media_path):
1730
+ return _error(f"Media file not found: {media_path}")
1731
+
1732
+ ext = os.path.splitext(media_path)[1].lower()
1733
+ if ext in _IMAGE_EXTS:
1734
+ last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
1735
+ elif ext in _VIDEO_EXTS:
1736
+ last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
1737
+ elif ext in _VOICE_EXTS and is_voice:
1738
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
1739
+ elif ext in _AUDIO_EXTS:
1740
+ last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
1741
+ else:
1742
+ last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
1743
+
1744
+ if not last_result.success:
1745
+ return _error(f"Feishu media send failed: {last_result.error}")
1746
+
1747
+ if last_result is None:
1748
+ return {"error": "No deliverable text or media remained after processing MEDIA tags"}
1749
+
1750
+ return {
1751
+ "success": True,
1752
+ "platform": "feishu",
1753
+ "chat_id": chat_id,
1754
+ "message_id": last_result.message_id,
1755
+ }
1756
+ except Exception as e:
1757
+ return _error(f"Feishu send failed: {e}")
1758
+
1759
+
1760
+ def _check_send_message():
1761
+ """Gate send_message on gateway running (always available on messaging platforms).
1762
+
1763
+ Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK``
1764
+ on every spawned worker, but those workers run with the assignee profile's
1765
+ ``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check
1766
+ would fail even though the parent gateway is alive. Honoring the env var
1767
+ lets workers call ``send_message`` to deliver rich content directly to the
1768
+ originating chat (paired with ``kanban_complete`` for the short notifier
1769
+ summary), which is the canonical pattern for any worker that needs to
1770
+ reply with more than the ~200-char first-line truncation the kanban
1771
+ notifier applies.
1772
+ """
1773
+ if os.environ.get("HERMES_KANBAN_TASK"):
1774
+ return True
1775
+ from gateway.session_context import get_session_env
1776
+ platform = get_session_env("HERMES_SESSION_PLATFORM", "")
1777
+ if platform and platform != "local":
1778
+ return True
1779
+ try:
1780
+ from gateway.status import is_gateway_running
1781
+ return is_gateway_running()
1782
+ except Exception:
1783
+ return False
1784
+
1785
+
1786
+ async def _send_qqbot(pconfig, chat_id, message):
1787
+ """Send via QQBot using the REST API directly (no WebSocket needed).
1788
+
1789
+ Uses the QQ Bot Open Platform REST endpoints to get an access token
1790
+ and post a message. Supports guild channels, C2C (private) chats,
1791
+ and group chats by trying the appropriate endpoints.
1792
+ """
1793
+ try:
1794
+ import httpx
1795
+ except ImportError:
1796
+ return _error("QQBot direct send requires httpx. Run: pip install httpx")
1797
+
1798
+ extra = pconfig.extra or {}
1799
+ appid = extra.get("app_id") or os.getenv("QQ_APP_ID", "")
1800
+ secret = (pconfig.token or extra.get("client_secret")
1801
+ or os.getenv("QQ_CLIENT_SECRET", ""))
1802
+ if not appid or not secret:
1803
+ return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.")
1804
+
1805
+ try:
1806
+ async with httpx.AsyncClient(timeout=15) as client:
1807
+ # Step 1: Get access token
1808
+ token_resp = await client.post(
1809
+ "https://bots.qq.com/app/getAppAccessToken",
1810
+ json={"appId": str(appid), "clientSecret": str(secret)},
1811
+ )
1812
+ if token_resp.status_code != 200:
1813
+ return _error(f"QQBot token request failed: {token_resp.status_code}")
1814
+ token_data = token_resp.json()
1815
+ access_token = token_data.get("access_token")
1816
+ if not access_token:
1817
+ return _error(f"QQBot: no access_token in response")
1818
+
1819
+ # Step 2: Send message via REST
1820
+ # QQ Bot API has separate endpoints for channels, C2C, and groups.
1821
+ # We try them in order: channel first, then fallback to C2C.
1822
+ headers = {
1823
+ "Authorization": f"QQBot {access_token}",
1824
+ "Content-Type": "application/json",
1825
+ }
1826
+ payload = {"content": message[:4000], "msg_type": 0}
1827
+
1828
+ # Try channel endpoint first (works for guild channels)
1829
+ url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages"
1830
+ resp = await client.post(url, json=payload, headers=headers)
1831
+ if resp.status_code in {200, 201}:
1832
+ data = resp.json()
1833
+ return {"success": True, "platform": "qqbot", "chat_id": chat_id,
1834
+ "message_id": data.get("id")}
1835
+
1836
+ # If channel endpoint failed (likely "频道不存在"), try C2C endpoint
1837
+ url_c2c = f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages"
1838
+ resp_c2c = await client.post(url_c2c, json=payload, headers=headers)
1839
+ if resp_c2c.status_code in {200, 201}:
1840
+ data = resp_c2c.json()
1841
+ return {"success": True, "platform": "qqbot", "chat_id": chat_id,
1842
+ "message_id": data.get("id")}
1843
+
1844
+ # If C2C also failed, try group endpoint
1845
+ url_group = f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages"
1846
+ resp_group = await client.post(url_group, json=payload, headers=headers)
1847
+ if resp_group.status_code in {200, 201}:
1848
+ data = resp_group.json()
1849
+ return {"success": True, "platform": "qqbot", "chat_id": chat_id,
1850
+ "message_id": data.get("id")}
1851
+
1852
+ # All endpoints failed — return the most informative error
1853
+ return _error(f"QQBot send failed: channel={resp.status_code} c2c={resp_c2c.status_code} group={resp_group.status_code}")
1854
+ except Exception as e:
1855
+ return _error(f"QQBot send failed: {e}")
1856
+
1857
+
1858
+ async def _send_yuanbao(chat_id, message, media_files=None):
1859
+ """Send via Yuanbao using the running gateway adapter's WebSocket connection.
1860
+
1861
+ Yuanbao uses a persistent WebSocket — unlike HTTP-based platforms, we
1862
+ cannot create a throwaway client. We obtain the running singleton from
1863
+ the adapter module itself (``get_active_adapter``).
1864
+
1865
+ chat_id format:
1866
+ - Group: "group:<group_code>"
1867
+ - DM: "direct:<account_id>" or just "<account_id>"
1868
+ """
1869
+ try:
1870
+ from gateway.platforms.yuanbao import get_active_adapter, send_yuanbao_direct
1871
+ except ImportError:
1872
+ return _error("Yuanbao adapter module not available.")
1873
+
1874
+ adapter = get_active_adapter()
1875
+ if adapter is None:
1876
+ return _error(
1877
+ "Yuanbao adapter is not running. "
1878
+ "Start the gateway with yuanbao platform enabled first."
1879
+ )
1880
+
1881
+ try:
1882
+ return await send_yuanbao_direct(adapter, chat_id, message, media_files=media_files)
1883
+ except Exception as e:
1884
+ return _error(f"Yuanbao send failed: {e}")
1885
+
1886
+
1887
+ # --- Registry ---
1888
+ from pm_copilot_engine.tools.registry import registry, tool_error
1889
+
1890
+ registry.register(
1891
+ name="send_message",
1892
+ toolset="messaging",
1893
+ schema=SEND_MESSAGE_SCHEMA,
1894
+ handler=send_message_tool,
1895
+ check_fn=_check_send_message,
1896
+ emoji="📨",
1897
+ )