calvyn-code 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1718) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/README.zh-CN.md +180 -0
  4. package/acp_adapter/__init__.py +1 -0
  5. package/acp_adapter/__main__.py +5 -0
  6. package/acp_adapter/auth.py +68 -0
  7. package/acp_adapter/bootstrap/__init__.py +0 -0
  8. package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
  9. package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
  10. package/acp_adapter/entry.py +292 -0
  11. package/acp_adapter/events.py +265 -0
  12. package/acp_adapter/permissions.py +148 -0
  13. package/acp_adapter/server.py +1713 -0
  14. package/acp_adapter/session.py +629 -0
  15. package/acp_adapter/tools.py +1180 -0
  16. package/agent/__init__.py +6 -0
  17. package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
  19. package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
  20. package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
  21. package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
  22. package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
  23. package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
  24. package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
  25. package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
  26. package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
  27. package/agent/__pycache__/curator.cpython-312.pyc +0 -0
  28. package/agent/__pycache__/display.cpython-312.pyc +0 -0
  29. package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
  30. package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
  31. package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
  32. package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
  33. package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
  34. package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
  35. package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
  36. package/agent/__pycache__/insights.cpython-312.pyc +0 -0
  37. package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
  38. package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
  39. package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
  40. package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
  41. package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
  42. package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
  43. package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
  44. package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
  45. package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
  46. package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
  47. package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
  48. package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
  49. package/agent/__pycache__/redact.cpython-312.pyc +0 -0
  50. package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
  51. package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
  52. package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
  53. package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
  54. package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
  55. package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
  56. package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
  57. package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
  58. package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
  59. package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
  60. package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
  61. package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
  62. package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
  63. package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
  64. package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
  65. package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
  66. package/agent/account_usage.py +326 -0
  67. package/agent/anthropic_adapter.py +2087 -0
  68. package/agent/async_utils.py +68 -0
  69. package/agent/auxiliary_client.py +4893 -0
  70. package/agent/bedrock_adapter.py +1276 -0
  71. package/agent/codex_responses_adapter.py +1084 -0
  72. package/agent/context_compressor.py +1583 -0
  73. package/agent/context_engine.py +211 -0
  74. package/agent/context_references.py +519 -0
  75. package/agent/copilot_acp_client.py +684 -0
  76. package/agent/credential_pool.py +1780 -0
  77. package/agent/credential_sources.py +449 -0
  78. package/agent/curator.py +1782 -0
  79. package/agent/curator_backup.py +694 -0
  80. package/agent/display.py +987 -0
  81. package/agent/error_classifier.py +1058 -0
  82. package/agent/file_safety.py +112 -0
  83. package/agent/gemini_cloudcode_adapter.py +909 -0
  84. package/agent/gemini_native_adapter.py +971 -0
  85. package/agent/gemini_schema.py +99 -0
  86. package/agent/google_code_assist.py +452 -0
  87. package/agent/google_oauth.py +1062 -0
  88. package/agent/i18n.py +258 -0
  89. package/agent/image_gen_provider.py +243 -0
  90. package/agent/image_gen_registry.py +145 -0
  91. package/agent/image_routing.py +301 -0
  92. package/agent/insights.py +931 -0
  93. package/agent/lmstudio_reasoning.py +48 -0
  94. package/agent/lsp/__init__.py +106 -0
  95. package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
  97. package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
  98. package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
  99. package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
  100. package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
  101. package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
  102. package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
  103. package/agent/lsp/cli.py +308 -0
  104. package/agent/lsp/client.py +930 -0
  105. package/agent/lsp/eventlog.py +213 -0
  106. package/agent/lsp/install.py +376 -0
  107. package/agent/lsp/manager.py +644 -0
  108. package/agent/lsp/protocol.py +196 -0
  109. package/agent/lsp/range_shift.py +149 -0
  110. package/agent/lsp/reporter.py +78 -0
  111. package/agent/lsp/servers.py +1040 -0
  112. package/agent/lsp/workspace.py +223 -0
  113. package/agent/manual_compression_feedback.py +49 -0
  114. package/agent/markdown_tables.py +309 -0
  115. package/agent/memory_manager.py +556 -0
  116. package/agent/memory_provider.py +279 -0
  117. package/agent/model_metadata.py +1827 -0
  118. package/agent/models_dev.py +724 -0
  119. package/agent/moonshot_schema.py +231 -0
  120. package/agent/nous_rate_guard.py +326 -0
  121. package/agent/onboarding.py +193 -0
  122. package/agent/plugin_llm.py +1046 -0
  123. package/agent/portal_tags.py +64 -0
  124. package/agent/prompt_builder.py +1457 -0
  125. package/agent/prompt_caching.py +79 -0
  126. package/agent/rate_limit_tracker.py +246 -0
  127. package/agent/redact.py +403 -0
  128. package/agent/retry_utils.py +57 -0
  129. package/agent/shell_hooks.py +837 -0
  130. package/agent/skill_commands.py +502 -0
  131. package/agent/skill_preprocessing.py +131 -0
  132. package/agent/skill_utils.py +512 -0
  133. package/agent/subdirectory_hints.py +224 -0
  134. package/agent/think_scrubber.py +386 -0
  135. package/agent/title_generator.py +171 -0
  136. package/agent/tool_guardrails.py +458 -0
  137. package/agent/tool_result_classification.py +26 -0
  138. package/agent/trajectory.py +56 -0
  139. package/agent/transports/__init__.py +68 -0
  140. package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
  141. package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
  142. package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
  143. package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
  144. package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
  145. package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
  146. package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
  147. package/agent/transports/anthropic.py +179 -0
  148. package/agent/transports/base.py +89 -0
  149. package/agent/transports/bedrock.py +154 -0
  150. package/agent/transports/chat_completions.py +614 -0
  151. package/agent/transports/codex.py +283 -0
  152. package/agent/transports/codex_app_server.py +368 -0
  153. package/agent/transports/codex_app_server_session.py +810 -0
  154. package/agent/transports/codex_event_projector.py +312 -0
  155. package/agent/transports/hermes_tools_mcp_server.py +233 -0
  156. package/agent/transports/types.py +162 -0
  157. package/agent/usage_pricing.py +877 -0
  158. package/agent/video_gen_provider.py +300 -0
  159. package/agent/video_gen_registry.py +117 -0
  160. package/agent/web_search_provider.py +221 -0
  161. package/agent/web_search_registry.py +262 -0
  162. package/assets/banner.png +0 -0
  163. package/batch_runner.py +1303 -0
  164. package/bin/calvyn.js +67 -0
  165. package/calvyn_bootstrap.py +130 -0
  166. package/calvyn_constants.py +346 -0
  167. package/calvyn_logging.py +390 -0
  168. package/calvyn_state.py +2967 -0
  169. package/calvyn_time.py +105 -0
  170. package/cli.py +14160 -0
  171. package/cron/__init__.py +42 -0
  172. package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
  173. package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
  174. package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
  175. package/cron/jobs.py +1160 -0
  176. package/cron/scheduler.py +1832 -0
  177. package/gateway/__init__.py +35 -0
  178. package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  179. package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
  180. package/gateway/__pycache__/config.cpython-312.pyc +0 -0
  181. package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
  182. package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
  183. package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
  184. package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
  185. package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
  186. package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
  187. package/gateway/__pycache__/run.cpython-312.pyc +0 -0
  188. package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
  189. package/gateway/__pycache__/session.cpython-312.pyc +0 -0
  190. package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
  191. package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
  192. package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
  193. package/gateway/__pycache__/status.cpython-312.pyc +0 -0
  194. package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
  195. package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
  196. package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
  197. package/gateway/builtin_hooks/__init__.py +1 -0
  198. package/gateway/channel_directory.py +357 -0
  199. package/gateway/config.py +1873 -0
  200. package/gateway/delivery.py +258 -0
  201. package/gateway/display_config.py +206 -0
  202. package/gateway/hooks.py +210 -0
  203. package/gateway/mirror.py +179 -0
  204. package/gateway/pairing.py +322 -0
  205. package/gateway/platform_registry.py +260 -0
  206. package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
  207. package/gateway/platforms/__init__.py +45 -0
  208. package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
  209. package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
  210. package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
  211. package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
  212. package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
  213. package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
  214. package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
  215. package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
  216. package/gateway/platforms/_http_client_limits.py +84 -0
  217. package/gateway/platforms/api_server.py +3488 -0
  218. package/gateway/platforms/base.py +3747 -0
  219. package/gateway/platforms/bluebubbles.py +937 -0
  220. package/gateway/platforms/dingtalk.py +1473 -0
  221. package/gateway/platforms/discord.py +5584 -0
  222. package/gateway/platforms/email.py +773 -0
  223. package/gateway/platforms/feishu.py +5059 -0
  224. package/gateway/platforms/feishu_comment.py +1382 -0
  225. package/gateway/platforms/feishu_comment_rules.py +430 -0
  226. package/gateway/platforms/helpers.py +279 -0
  227. package/gateway/platforms/homeassistant.py +449 -0
  228. package/gateway/platforms/matrix.py +2777 -0
  229. package/gateway/platforms/mattermost.py +852 -0
  230. package/gateway/platforms/msgraph_webhook.py +397 -0
  231. package/gateway/platforms/qqbot/__init__.py +91 -0
  232. package/gateway/platforms/qqbot/adapter.py +3072 -0
  233. package/gateway/platforms/qqbot/chunked_upload.py +602 -0
  234. package/gateway/platforms/qqbot/constants.py +74 -0
  235. package/gateway/platforms/qqbot/crypto.py +45 -0
  236. package/gateway/platforms/qqbot/keyboards.py +473 -0
  237. package/gateway/platforms/qqbot/onboard.py +220 -0
  238. package/gateway/platforms/qqbot/utils.py +71 -0
  239. package/gateway/platforms/signal.py +1518 -0
  240. package/gateway/platforms/signal_rate_limit.py +369 -0
  241. package/gateway/platforms/slack.py +3028 -0
  242. package/gateway/platforms/sms.py +377 -0
  243. package/gateway/platforms/telegram.py +4836 -0
  244. package/gateway/platforms/telegram_network.py +249 -0
  245. package/gateway/platforms/webhook.py +806 -0
  246. package/gateway/platforms/wecom.py +1610 -0
  247. package/gateway/platforms/wecom_callback.py +403 -0
  248. package/gateway/platforms/wecom_crypto.py +142 -0
  249. package/gateway/platforms/weixin.py +2170 -0
  250. package/gateway/platforms/whatsapp.py +1283 -0
  251. package/gateway/platforms/yuanbao.py +4873 -0
  252. package/gateway/platforms/yuanbao_media.py +645 -0
  253. package/gateway/platforms/yuanbao_proto.py +1209 -0
  254. package/gateway/platforms/yuanbao_sticker.py +558 -0
  255. package/gateway/restart.py +20 -0
  256. package/gateway/run.py +17074 -0
  257. package/gateway/runtime_footer.py +150 -0
  258. package/gateway/session.py +1399 -0
  259. package/gateway/session_context.py +156 -0
  260. package/gateway/shutdown_forensics.py +462 -0
  261. package/gateway/slash_access.py +229 -0
  262. package/gateway/status.py +972 -0
  263. package/gateway/sticker_cache.py +111 -0
  264. package/gateway/stream_consumer.py +1286 -0
  265. package/gateway/whatsapp_identity.py +156 -0
  266. package/hermes_cli/__init__.py +47 -0
  267. package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  268. package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
  269. package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
  270. package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
  271. package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
  272. package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
  273. package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
  274. package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
  275. package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
  276. package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
  277. package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
  278. package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
  279. package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
  280. package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
  281. package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
  282. package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
  283. package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
  284. package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
  285. package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
  286. package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
  287. package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
  288. package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
  289. package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
  290. package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
  291. package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
  292. package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
  293. package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
  294. package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
  295. package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
  296. package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
  297. package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
  298. package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
  299. package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
  300. package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
  301. package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
  302. package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
  303. package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
  304. package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
  305. package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
  306. package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
  307. package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
  308. package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
  309. package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
  310. package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
  311. package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
  312. package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
  313. package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
  314. package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
  315. package/hermes_cli/_parser.py +365 -0
  316. package/hermes_cli/_subprocess_compat.py +175 -0
  317. package/hermes_cli/auth.py +6299 -0
  318. package/hermes_cli/auth_commands.py +749 -0
  319. package/hermes_cli/azure_detect.py +300 -0
  320. package/hermes_cli/backup.py +938 -0
  321. package/hermes_cli/banner.py +703 -0
  322. package/hermes_cli/browser_connect.py +139 -0
  323. package/hermes_cli/callbacks.py +243 -0
  324. package/hermes_cli/checkpoints.py +244 -0
  325. package/hermes_cli/claw.py +810 -0
  326. package/hermes_cli/cli_output.py +78 -0
  327. package/hermes_cli/clipboard.py +495 -0
  328. package/hermes_cli/codex_models.py +198 -0
  329. package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
  330. package/hermes_cli/codex_runtime_switch.py +266 -0
  331. package/hermes_cli/colors.py +38 -0
  332. package/hermes_cli/commands.py +1728 -0
  333. package/hermes_cli/completion.py +315 -0
  334. package/hermes_cli/config.py +5382 -0
  335. package/hermes_cli/copilot_auth.py +392 -0
  336. package/hermes_cli/cron.py +313 -0
  337. package/hermes_cli/curator.py +598 -0
  338. package/hermes_cli/curses_ui.py +472 -0
  339. package/hermes_cli/debug.py +747 -0
  340. package/hermes_cli/default_soul.py +11 -0
  341. package/hermes_cli/dep_ensure.py +107 -0
  342. package/hermes_cli/dingtalk_auth.py +293 -0
  343. package/hermes_cli/doctor.py +1863 -0
  344. package/hermes_cli/dump.py +326 -0
  345. package/hermes_cli/env_loader.py +175 -0
  346. package/hermes_cli/fallback_cmd.py +361 -0
  347. package/hermes_cli/gateway.py +5422 -0
  348. package/hermes_cli/gateway_windows.py +692 -0
  349. package/hermes_cli/goals.py +757 -0
  350. package/hermes_cli/hooks.py +385 -0
  351. package/hermes_cli/inventory.py +240 -0
  352. package/hermes_cli/kanban.py +2252 -0
  353. package/hermes_cli/kanban_db.py +4840 -0
  354. package/hermes_cli/kanban_diagnostics.py +776 -0
  355. package/hermes_cli/kanban_specify.py +266 -0
  356. package/hermes_cli/logs.py +391 -0
  357. package/hermes_cli/main.py +12396 -0
  358. package/hermes_cli/mcp_config.py +781 -0
  359. package/hermes_cli/memory_setup.py +465 -0
  360. package/hermes_cli/model_catalog.py +330 -0
  361. package/hermes_cli/model_normalize.py +473 -0
  362. package/hermes_cli/model_switch.py +1777 -0
  363. package/hermes_cli/models.py +3789 -0
  364. package/hermes_cli/nous_subscription.py +799 -0
  365. package/hermes_cli/oneshot.py +351 -0
  366. package/hermes_cli/pairing.py +115 -0
  367. package/hermes_cli/platforms.py +83 -0
  368. package/hermes_cli/plugins.py +1562 -0
  369. package/hermes_cli/plugins_cmd.py +1587 -0
  370. package/hermes_cli/profile_distribution.py +703 -0
  371. package/hermes_cli/profiles.py +1319 -0
  372. package/hermes_cli/providers.py +720 -0
  373. package/hermes_cli/proxy/__init__.py +20 -0
  374. package/hermes_cli/proxy/adapters/__init__.py +35 -0
  375. package/hermes_cli/proxy/adapters/base.py +94 -0
  376. package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
  377. package/hermes_cli/proxy/cli.py +141 -0
  378. package/hermes_cli/proxy/server.py +265 -0
  379. package/hermes_cli/pt_input_extras.py +83 -0
  380. package/hermes_cli/pty_bridge.py +237 -0
  381. package/hermes_cli/relaunch.py +205 -0
  382. package/hermes_cli/runtime_provider.py +1428 -0
  383. package/hermes_cli/security_advisories.py +452 -0
  384. package/hermes_cli/setup.py +3559 -0
  385. package/hermes_cli/skills_config.py +177 -0
  386. package/hermes_cli/skills_hub.py +1595 -0
  387. package/hermes_cli/skin_engine.py +929 -0
  388. package/hermes_cli/slack_cli.py +160 -0
  389. package/hermes_cli/status.py +550 -0
  390. package/hermes_cli/stdio.py +252 -0
  391. package/hermes_cli/timeouts.py +82 -0
  392. package/hermes_cli/tips.py +487 -0
  393. package/hermes_cli/tools_config.py +3151 -0
  394. package/hermes_cli/uninstall.py +681 -0
  395. package/hermes_cli/vercel_auth.py +70 -0
  396. package/hermes_cli/voice.py +846 -0
  397. package/hermes_cli/web_server.py +4438 -0
  398. package/hermes_cli/webhook.py +275 -0
  399. package/locales/af.yaml +350 -0
  400. package/locales/de.yaml +350 -0
  401. package/locales/en.yaml +365 -0
  402. package/locales/es.yaml +350 -0
  403. package/locales/fr.yaml +350 -0
  404. package/locales/ga.yaml +354 -0
  405. package/locales/hu.yaml +350 -0
  406. package/locales/it.yaml +350 -0
  407. package/locales/ja.yaml +350 -0
  408. package/locales/ko.yaml +350 -0
  409. package/locales/pt.yaml +350 -0
  410. package/locales/ru.yaml +350 -0
  411. package/locales/tr.yaml +350 -0
  412. package/locales/uk.yaml +350 -0
  413. package/locales/zh-hant.yaml +350 -0
  414. package/locales/zh.yaml +350 -0
  415. package/mcp_serve.py +898 -0
  416. package/model_tools.py +899 -0
  417. package/optional-skills/DESCRIPTION.md +24 -0
  418. package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
  419. package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
  420. package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
  421. package/optional-skills/blockchain/evm/SKILL.md +211 -0
  422. package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
  423. package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
  424. package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
  425. package/optional-skills/blockchain/solana/SKILL.md +208 -0
  426. package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
  427. package/optional-skills/communication/DESCRIPTION.md +1 -0
  428. package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
  429. package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
  430. package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
  431. package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
  432. package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
  433. package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
  434. package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
  435. package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
  436. package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
  437. package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
  438. package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
  439. package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
  440. package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
  441. package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
  442. package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
  443. package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
  444. package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
  445. package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
  446. package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
  447. package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
  448. package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
  449. package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
  450. package/optional-skills/creative/hyperframes/SKILL.md +191 -0
  451. package/optional-skills/creative/hyperframes/references/cli.md +185 -0
  452. package/optional-skills/creative/hyperframes/references/composition.md +129 -0
  453. package/optional-skills/creative/hyperframes/references/features.md +289 -0
  454. package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
  455. package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
  456. package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
  457. package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
  458. package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
  459. package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
  460. package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
  461. package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
  462. package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
  463. package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
  464. package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
  465. package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
  466. package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
  467. package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
  468. package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
  469. package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
  470. package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
  471. package/optional-skills/creative/meme-generation/SKILL.md +130 -0
  472. package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
  473. package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
  474. package/optional-skills/devops/cli/SKILL.md +156 -0
  475. package/optional-skills/devops/cli/references/app-discovery.md +112 -0
  476. package/optional-skills/devops/cli/references/authentication.md +59 -0
  477. package/optional-skills/devops/cli/references/cli-reference.md +104 -0
  478. package/optional-skills/devops/cli/references/running-apps.md +171 -0
  479. package/optional-skills/devops/docker-management/SKILL.md +281 -0
  480. package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
  481. package/optional-skills/devops/watchers/SKILL.md +112 -0
  482. package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
  483. package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
  484. package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
  485. package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
  486. package/optional-skills/dogfood/DESCRIPTION.md +3 -0
  487. package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
  488. package/optional-skills/email/agentmail/SKILL.md +126 -0
  489. package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
  490. package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
  491. package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
  492. package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
  493. package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
  494. package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
  495. package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
  496. package/optional-skills/finance/dcf-model/requirements.txt +7 -0
  497. package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
  498. package/optional-skills/finance/excel-author/SKILL.md +244 -0
  499. package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
  500. package/optional-skills/finance/lbo-model/SKILL.md +291 -0
  501. package/optional-skills/finance/merger-model/SKILL.md +144 -0
  502. package/optional-skills/finance/pptx-author/SKILL.md +173 -0
  503. package/optional-skills/finance/stocks/SKILL.md +95 -0
  504. package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
  505. package/optional-skills/health/DESCRIPTION.md +1 -0
  506. package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
  507. package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
  508. package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
  509. package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
  510. package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
  511. package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
  512. package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
  513. package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
  514. package/optional-skills/mcp/DESCRIPTION.md +3 -0
  515. package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
  516. package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
  517. package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
  518. package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
  519. package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
  520. package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
  521. package/optional-skills/mcp/mcporter/SKILL.md +123 -0
  522. package/optional-skills/migration/DESCRIPTION.md +2 -0
  523. package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
  524. package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
  525. package/optional-skills/mlops/accelerate/SKILL.md +336 -0
  526. package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
  527. package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
  528. package/optional-skills/mlops/accelerate/references/performance.md +525 -0
  529. package/optional-skills/mlops/chroma/SKILL.md +410 -0
  530. package/optional-skills/mlops/chroma/references/integration.md +38 -0
  531. package/optional-skills/mlops/clip/SKILL.md +257 -0
  532. package/optional-skills/mlops/clip/references/applications.md +207 -0
  533. package/optional-skills/mlops/faiss/SKILL.md +225 -0
  534. package/optional-skills/mlops/faiss/references/index_types.md +280 -0
  535. package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
  536. package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
  537. package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
  538. package/optional-skills/mlops/guidance/SKILL.md +576 -0
  539. package/optional-skills/mlops/guidance/references/backends.md +554 -0
  540. package/optional-skills/mlops/guidance/references/constraints.md +674 -0
  541. package/optional-skills/mlops/guidance/references/examples.md +767 -0
  542. package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
  543. package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
  544. package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
  545. package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
  546. package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
  547. package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
  548. package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
  549. package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
  550. package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
  551. package/optional-skills/mlops/instructor/SKILL.md +744 -0
  552. package/optional-skills/mlops/instructor/references/examples.md +107 -0
  553. package/optional-skills/mlops/instructor/references/providers.md +70 -0
  554. package/optional-skills/mlops/instructor/references/validation.md +606 -0
  555. package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
  556. package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
  557. package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
  558. package/optional-skills/mlops/llava/SKILL.md +308 -0
  559. package/optional-skills/mlops/llava/references/training.md +197 -0
  560. package/optional-skills/mlops/modal/SKILL.md +345 -0
  561. package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
  562. package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
  563. package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
  564. package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
  565. package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
  566. package/optional-skills/mlops/peft/SKILL.md +435 -0
  567. package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
  568. package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
  569. package/optional-skills/mlops/pinecone/SKILL.md +362 -0
  570. package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
  571. package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
  572. package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
  573. package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
  574. package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
  575. package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
  576. package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
  577. package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
  578. package/optional-skills/mlops/qdrant/SKILL.md +497 -0
  579. package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
  580. package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
  581. package/optional-skills/mlops/saelens/SKILL.md +390 -0
  582. package/optional-skills/mlops/saelens/references/README.md +69 -0
  583. package/optional-skills/mlops/saelens/references/api.md +333 -0
  584. package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
  585. package/optional-skills/mlops/simpo/SKILL.md +223 -0
  586. package/optional-skills/mlops/simpo/references/datasets.md +478 -0
  587. package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
  588. package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
  589. package/optional-skills/mlops/slime/SKILL.md +468 -0
  590. package/optional-skills/mlops/slime/references/api-reference.md +392 -0
  591. package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
  592. package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
  593. package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
  594. package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
  595. package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
  596. package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
  597. package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
  598. package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
  599. package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
  600. package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
  601. package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
  602. package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
  603. package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
  604. package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
  605. package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
  606. package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
  607. package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
  608. package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
  609. package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
  610. package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
  611. package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
  612. package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
  613. package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
  614. package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
  615. package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
  616. package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
  617. package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
  618. package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
  619. package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
  620. package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
  621. package/optional-skills/mlops/whisper/SKILL.md +321 -0
  622. package/optional-skills/mlops/whisper/references/languages.md +189 -0
  623. package/optional-skills/productivity/canvas/SKILL.md +98 -0
  624. package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
  625. package/optional-skills/productivity/here-now/SKILL.md +217 -0
  626. package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
  627. package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
  628. package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
  629. package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
  630. package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
  631. package/optional-skills/productivity/shop-app/SKILL.md +340 -0
  632. package/optional-skills/productivity/shopify/SKILL.md +373 -0
  633. package/optional-skills/productivity/siyuan/SKILL.md +298 -0
  634. package/optional-skills/productivity/telephony/SKILL.md +418 -0
  635. package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
  636. package/optional-skills/research/bioinformatics/SKILL.md +235 -0
  637. package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
  638. package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
  639. package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
  640. package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
  641. package/optional-skills/research/domain-intel/SKILL.md +97 -0
  642. package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
  643. package/optional-skills/research/drug-discovery/SKILL.md +227 -0
  644. package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
  645. package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
  646. package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
  647. package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
  648. package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
  649. package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
  650. package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
  651. package/optional-skills/research/osint-investigation/SKILL.md +277 -0
  652. package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
  653. package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
  654. package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
  655. package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
  656. package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
  657. package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
  658. package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
  659. package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
  660. package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
  661. package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
  662. package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
  663. package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
  664. package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
  665. package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
  666. package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
  667. package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
  668. package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
  669. package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
  670. package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
  671. package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
  672. package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
  673. package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
  674. package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
  675. package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
  676. package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
  677. package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
  678. package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
  679. package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
  680. package/optional-skills/research/parallel-cli/SKILL.md +391 -0
  681. package/optional-skills/research/qmd/SKILL.md +441 -0
  682. package/optional-skills/research/scrapling/SKILL.md +336 -0
  683. package/optional-skills/research/searxng-search/SKILL.md +212 -0
  684. package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
  685. package/optional-skills/security/1password/SKILL.md +163 -0
  686. package/optional-skills/security/1password/references/cli-examples.md +31 -0
  687. package/optional-skills/security/1password/references/get-started.md +21 -0
  688. package/optional-skills/security/DESCRIPTION.md +3 -0
  689. package/optional-skills/security/oss-forensics/SKILL.md +423 -0
  690. package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
  691. package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
  692. package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
  693. package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
  694. package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
  695. package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
  696. package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
  697. package/optional-skills/security/sherlock/SKILL.md +193 -0
  698. package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
  699. package/optional-skills/web-development/DESCRIPTION.md +5 -0
  700. package/optional-skills/web-development/page-agent/SKILL.md +190 -0
  701. package/package.json +78 -0
  702. package/plugins/__init__.py +1 -0
  703. package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
  704. package/plugins/context_engine/__init__.py +219 -0
  705. package/plugins/disk-cleanup/README.md +51 -0
  706. package/plugins/disk-cleanup/__init__.py +316 -0
  707. package/plugins/disk-cleanup/disk_cleanup.py +497 -0
  708. package/plugins/disk-cleanup/plugin.yaml +7 -0
  709. package/plugins/example-dashboard/dashboard/manifest.json +14 -0
  710. package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
  711. package/plugins/google_meet/README.md +131 -0
  712. package/plugins/google_meet/SKILL.md +148 -0
  713. package/plugins/google_meet/__init__.py +103 -0
  714. package/plugins/google_meet/audio_bridge.py +244 -0
  715. package/plugins/google_meet/cli.py +479 -0
  716. package/plugins/google_meet/meet_bot.py +852 -0
  717. package/plugins/google_meet/node/__init__.py +54 -0
  718. package/plugins/google_meet/node/cli.py +125 -0
  719. package/plugins/google_meet/node/client.py +107 -0
  720. package/plugins/google_meet/node/protocol.py +124 -0
  721. package/plugins/google_meet/node/registry.py +113 -0
  722. package/plugins/google_meet/node/server.py +201 -0
  723. package/plugins/google_meet/plugin.yaml +16 -0
  724. package/plugins/google_meet/process_manager.py +324 -0
  725. package/plugins/google_meet/realtime/__init__.py +10 -0
  726. package/plugins/google_meet/realtime/openai_client.py +332 -0
  727. package/plugins/google_meet/tools.py +348 -0
  728. package/plugins/hermes-achievements/LICENSE +21 -0
  729. package/plugins/hermes-achievements/README.md +150 -0
  730. package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
  731. package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
  732. package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
  733. package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
  734. package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
  735. package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
  736. package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
  737. package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
  738. package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
  739. package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
  740. package/plugins/image_gen/openai/__init__.py +303 -0
  741. package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
  742. package/plugins/image_gen/openai/plugin.yaml +7 -0
  743. package/plugins/image_gen/openai-codex/__init__.py +378 -0
  744. package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  745. package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
  746. package/plugins/image_gen/xai/__init__.py +316 -0
  747. package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  748. package/plugins/image_gen/xai/plugin.yaml +7 -0
  749. package/plugins/kanban/dashboard/dist/index.js +3143 -0
  750. package/plugins/kanban/dashboard/dist/style.css +1500 -0
  751. package/plugins/kanban/dashboard/manifest.json +14 -0
  752. package/plugins/kanban/dashboard/plugin_api.py +1612 -0
  753. package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
  754. package/plugins/memory/__init__.py +408 -0
  755. package/plugins/memory/byterover/README.md +41 -0
  756. package/plugins/memory/byterover/__init__.py +384 -0
  757. package/plugins/memory/byterover/plugin.yaml +9 -0
  758. package/plugins/memory/hindsight/README.md +138 -0
  759. package/plugins/memory/hindsight/__init__.py +1758 -0
  760. package/plugins/memory/hindsight/plugin.yaml +8 -0
  761. package/plugins/memory/holographic/README.md +36 -0
  762. package/plugins/memory/holographic/__init__.py +409 -0
  763. package/plugins/memory/holographic/holographic.py +203 -0
  764. package/plugins/memory/holographic/plugin.yaml +5 -0
  765. package/plugins/memory/holographic/retrieval.py +593 -0
  766. package/plugins/memory/holographic/store.py +579 -0
  767. package/plugins/memory/honcho/README.md +328 -0
  768. package/plugins/memory/honcho/__init__.py +1329 -0
  769. package/plugins/memory/honcho/cli.py +1452 -0
  770. package/plugins/memory/honcho/client.py +784 -0
  771. package/plugins/memory/honcho/plugin.yaml +7 -0
  772. package/plugins/memory/honcho/session.py +1255 -0
  773. package/plugins/memory/mem0/README.md +38 -0
  774. package/plugins/memory/mem0/__init__.py +374 -0
  775. package/plugins/memory/mem0/plugin.yaml +5 -0
  776. package/plugins/memory/openviking/README.md +40 -0
  777. package/plugins/memory/openviking/__init__.py +945 -0
  778. package/plugins/memory/openviking/plugin.yaml +9 -0
  779. package/plugins/memory/retaindb/README.md +40 -0
  780. package/plugins/memory/retaindb/__init__.py +767 -0
  781. package/plugins/memory/retaindb/plugin.yaml +7 -0
  782. package/plugins/memory/supermemory/README.md +99 -0
  783. package/plugins/memory/supermemory/__init__.py +792 -0
  784. package/plugins/memory/supermemory/plugin.yaml +5 -0
  785. package/plugins/model-providers/README.md +70 -0
  786. package/plugins/model-providers/ai-gateway/__init__.py +43 -0
  787. package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  788. package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
  789. package/plugins/model-providers/alibaba/__init__.py +13 -0
  790. package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
  791. package/plugins/model-providers/alibaba/plugin.yaml +5 -0
  792. package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
  793. package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
  794. package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
  795. package/plugins/model-providers/anthropic/__init__.py +52 -0
  796. package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
  797. package/plugins/model-providers/anthropic/plugin.yaml +5 -0
  798. package/plugins/model-providers/arcee/__init__.py +13 -0
  799. package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
  800. package/plugins/model-providers/arcee/plugin.yaml +5 -0
  801. package/plugins/model-providers/azure-foundry/__init__.py +21 -0
  802. package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
  803. package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
  804. package/plugins/model-providers/bedrock/__init__.py +29 -0
  805. package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
  806. package/plugins/model-providers/bedrock/plugin.yaml +5 -0
  807. package/plugins/model-providers/copilot/__init__.py +58 -0
  808. package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
  809. package/plugins/model-providers/copilot/plugin.yaml +5 -0
  810. package/plugins/model-providers/copilot-acp/__init__.py +34 -0
  811. package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
  812. package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
  813. package/plugins/model-providers/custom/__init__.py +68 -0
  814. package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
  815. package/plugins/model-providers/custom/plugin.yaml +5 -0
  816. package/plugins/model-providers/deepseek/__init__.py +99 -0
  817. package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
  818. package/plugins/model-providers/deepseek/plugin.yaml +5 -0
  819. package/plugins/model-providers/gemini/__init__.py +72 -0
  820. package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
  821. package/plugins/model-providers/gemini/plugin.yaml +5 -0
  822. package/plugins/model-providers/gmi/__init__.py +31 -0
  823. package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
  824. package/plugins/model-providers/gmi/plugin.yaml +5 -0
  825. package/plugins/model-providers/huggingface/__init__.py +20 -0
  826. package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
  827. package/plugins/model-providers/huggingface/plugin.yaml +5 -0
  828. package/plugins/model-providers/kilocode/__init__.py +14 -0
  829. package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
  830. package/plugins/model-providers/kilocode/plugin.yaml +5 -0
  831. package/plugins/model-providers/kimi-coding/__init__.py +71 -0
  832. package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
  833. package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
  834. package/plugins/model-providers/minimax/__init__.py +45 -0
  835. package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
  836. package/plugins/model-providers/minimax/plugin.yaml +5 -0
  837. package/plugins/model-providers/nous/__init__.py +54 -0
  838. package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
  839. package/plugins/model-providers/nous/plugin.yaml +5 -0
  840. package/plugins/model-providers/novita/__init__.py +27 -0
  841. package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
  842. package/plugins/model-providers/novita/plugin.yaml +5 -0
  843. package/plugins/model-providers/nvidia/__init__.py +21 -0
  844. package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
  845. package/plugins/model-providers/nvidia/plugin.yaml +5 -0
  846. package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
  847. package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
  848. package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
  849. package/plugins/model-providers/openai-codex/__init__.py +15 -0
  850. package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  851. package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
  852. package/plugins/model-providers/opencode-zen/__init__.py +30 -0
  853. package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
  854. package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
  855. package/plugins/model-providers/openrouter/__init__.py +115 -0
  856. package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
  857. package/plugins/model-providers/openrouter/plugin.yaml +5 -0
  858. package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
  859. package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
  860. package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
  861. package/plugins/model-providers/stepfun/__init__.py +14 -0
  862. package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
  863. package/plugins/model-providers/stepfun/plugin.yaml +5 -0
  864. package/plugins/model-providers/xai/__init__.py +15 -0
  865. package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  866. package/plugins/model-providers/xai/plugin.yaml +5 -0
  867. package/plugins/model-providers/xiaomi/__init__.py +14 -0
  868. package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
  869. package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
  870. package/plugins/model-providers/zai/__init__.py +21 -0
  871. package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
  872. package/plugins/model-providers/zai/plugin.yaml +5 -0
  873. package/plugins/observability/langfuse/README.md +53 -0
  874. package/plugins/observability/langfuse/__init__.py +1004 -0
  875. package/plugins/observability/langfuse/plugin.yaml +14 -0
  876. package/plugins/platforms/google_chat/__init__.py +3 -0
  877. package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
  878. package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
  879. package/plugins/platforms/google_chat/adapter.py +3343 -0
  880. package/plugins/platforms/google_chat/oauth.py +639 -0
  881. package/plugins/platforms/google_chat/plugin.yaml +39 -0
  882. package/plugins/platforms/irc/__init__.py +3 -0
  883. package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
  884. package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
  885. package/plugins/platforms/irc/adapter.py +969 -0
  886. package/plugins/platforms/irc/plugin.yaml +54 -0
  887. package/plugins/platforms/line/__init__.py +3 -0
  888. package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
  889. package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
  890. package/plugins/platforms/line/adapter.py +1639 -0
  891. package/plugins/platforms/line/plugin.yaml +65 -0
  892. package/plugins/platforms/simplex/__init__.py +3 -0
  893. package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
  894. package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
  895. package/plugins/platforms/simplex/adapter.py +746 -0
  896. package/plugins/platforms/simplex/plugin.yaml +37 -0
  897. package/plugins/platforms/teams/__init__.py +3 -0
  898. package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
  899. package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
  900. package/plugins/platforms/teams/adapter.py +1188 -0
  901. package/plugins/platforms/teams/plugin.yaml +48 -0
  902. package/plugins/spotify/__init__.py +66 -0
  903. package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
  904. package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
  905. package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
  906. package/plugins/spotify/client.py +435 -0
  907. package/plugins/spotify/plugin.yaml +13 -0
  908. package/plugins/spotify/tools.py +454 -0
  909. package/plugins/teams_pipeline/__init__.py +23 -0
  910. package/plugins/teams_pipeline/cli.py +463 -0
  911. package/plugins/teams_pipeline/meetings.py +333 -0
  912. package/plugins/teams_pipeline/models.py +350 -0
  913. package/plugins/teams_pipeline/pipeline.py +692 -0
  914. package/plugins/teams_pipeline/plugin.yaml +9 -0
  915. package/plugins/teams_pipeline/runtime.py +135 -0
  916. package/plugins/teams_pipeline/store.py +194 -0
  917. package/plugins/teams_pipeline/subscriptions.py +249 -0
  918. package/plugins/video_gen/fal/__init__.py +523 -0
  919. package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
  920. package/plugins/video_gen/fal/plugin.yaml +7 -0
  921. package/plugins/video_gen/xai/__init__.py +441 -0
  922. package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  923. package/plugins/video_gen/xai/plugin.yaml +7 -0
  924. package/plugins/web/__init__.py +7 -0
  925. package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
  926. package/plugins/web/brave_free/__init__.py +14 -0
  927. package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
  928. package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
  929. package/plugins/web/brave_free/plugin.yaml +7 -0
  930. package/plugins/web/brave_free/provider.py +137 -0
  931. package/plugins/web/ddgs/__init__.py +15 -0
  932. package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
  933. package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
  934. package/plugins/web/ddgs/plugin.yaml +7 -0
  935. package/plugins/web/ddgs/provider.py +104 -0
  936. package/plugins/web/exa/__init__.py +15 -0
  937. package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
  938. package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
  939. package/plugins/web/exa/plugin.yaml +7 -0
  940. package/plugins/web/exa/provider.py +212 -0
  941. package/plugins/web/firecrawl/__init__.py +28 -0
  942. package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
  943. package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
  944. package/plugins/web/firecrawl/plugin.yaml +7 -0
  945. package/plugins/web/firecrawl/provider.py +773 -0
  946. package/plugins/web/parallel/__init__.py +16 -0
  947. package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
  948. package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
  949. package/plugins/web/parallel/plugin.yaml +7 -0
  950. package/plugins/web/parallel/provider.py +291 -0
  951. package/plugins/web/searxng/__init__.py +15 -0
  952. package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
  953. package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
  954. package/plugins/web/searxng/plugin.yaml +7 -0
  955. package/plugins/web/searxng/provider.py +140 -0
  956. package/plugins/web/tavily/__init__.py +15 -0
  957. package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
  958. package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
  959. package/plugins/web/tavily/plugin.yaml +7 -0
  960. package/plugins/web/tavily/provider.py +285 -0
  961. package/providers/README.md +78 -0
  962. package/providers/__init__.py +192 -0
  963. package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
  964. package/providers/__pycache__/base.cpython-312.pyc +0 -0
  965. package/providers/base.py +184 -0
  966. package/pyproject.toml +255 -0
  967. package/run_agent.py +16409 -0
  968. package/scripts/benchmark_browser_eval.py +138 -0
  969. package/scripts/build_model_catalog.py +95 -0
  970. package/scripts/build_skills_index.py +325 -0
  971. package/scripts/check-windows-footguns.py +624 -0
  972. package/scripts/contributor_audit.py +473 -0
  973. package/scripts/discord-voice-doctor.py +396 -0
  974. package/scripts/hermes-gateway +416 -0
  975. package/scripts/install.cmd +28 -0
  976. package/scripts/install.ps1 +1611 -0
  977. package/scripts/install.sh +2007 -0
  978. package/scripts/install_psutil_android.py +117 -0
  979. package/scripts/keystroke_diagnostic.py +81 -0
  980. package/scripts/kill_modal.sh +34 -0
  981. package/scripts/lib/node-bootstrap.sh +238 -0
  982. package/scripts/lint_diff.py +207 -0
  983. package/scripts/postinstall.js +150 -0
  984. package/scripts/profile-tui.py +626 -0
  985. package/scripts/release.py +1680 -0
  986. package/scripts/run_tests.sh +129 -0
  987. package/scripts/sample_and_compress.py +409 -0
  988. package/scripts/setup_open_webui.sh +349 -0
  989. package/scripts/whatsapp-bridge/allowlist.js +88 -0
  990. package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
  991. package/scripts/whatsapp-bridge/bridge.js +729 -0
  992. package/scripts/whatsapp-bridge/package-lock.json +2141 -0
  993. package/scripts/whatsapp-bridge/package.json +19 -0
  994. package/skills/apple/DESCRIPTION.md +2 -0
  995. package/skills/apple/apple-notes/SKILL.md +90 -0
  996. package/skills/apple/apple-reminders/SKILL.md +98 -0
  997. package/skills/apple/findmy/SKILL.md +131 -0
  998. package/skills/apple/imessage/SKILL.md +102 -0
  999. package/skills/apple/macos-computer-use/SKILL.md +201 -0
  1000. package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
  1001. package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
  1002. package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
  1003. package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
  1004. package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
  1005. package/skills/creative/DESCRIPTION.md +3 -0
  1006. package/skills/creative/architecture-diagram/SKILL.md +148 -0
  1007. package/skills/creative/architecture-diagram/templates/template.html +319 -0
  1008. package/skills/creative/ascii-art/SKILL.md +322 -0
  1009. package/skills/creative/ascii-video/README.md +290 -0
  1010. package/skills/creative/ascii-video/SKILL.md +241 -0
  1011. package/skills/creative/ascii-video/references/architecture.md +802 -0
  1012. package/skills/creative/ascii-video/references/composition.md +892 -0
  1013. package/skills/creative/ascii-video/references/effects.md +1865 -0
  1014. package/skills/creative/ascii-video/references/inputs.md +685 -0
  1015. package/skills/creative/ascii-video/references/optimization.md +688 -0
  1016. package/skills/creative/ascii-video/references/scenes.md +1011 -0
  1017. package/skills/creative/ascii-video/references/shaders.md +1385 -0
  1018. package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
  1019. package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
  1020. package/skills/creative/baoyu-comic/SKILL.md +247 -0
  1021. package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
  1022. package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
  1023. package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
  1024. package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
  1025. package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
  1026. package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
  1027. package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
  1028. package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
  1029. package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
  1030. package/skills/creative/baoyu-comic/references/character-template.md +180 -0
  1031. package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
  1032. package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
  1033. package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
  1034. package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
  1035. package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
  1036. package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
  1037. package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
  1038. package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
  1039. package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
  1040. package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
  1041. package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
  1042. package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
  1043. package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
  1044. package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
  1045. package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
  1046. package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
  1047. package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
  1048. package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
  1049. package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
  1050. package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
  1051. package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
  1052. package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
  1053. package/skills/creative/baoyu-comic/references/workflow.md +401 -0
  1054. package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
  1055. package/skills/creative/baoyu-infographic/SKILL.md +237 -0
  1056. package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
  1057. package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
  1058. package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
  1059. package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
  1060. package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
  1061. package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
  1062. package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
  1063. package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
  1064. package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
  1065. package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
  1066. package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
  1067. package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
  1068. package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
  1069. package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
  1070. package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
  1071. package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
  1072. package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
  1073. package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
  1074. package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
  1075. package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
  1076. package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
  1077. package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
  1078. package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
  1079. package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
  1080. package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
  1081. package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
  1082. package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
  1083. package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
  1084. package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
  1085. package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
  1086. package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
  1087. package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
  1088. package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
  1089. package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
  1090. package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
  1091. package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
  1092. package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
  1093. package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
  1094. package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
  1095. package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
  1096. package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
  1097. package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
  1098. package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
  1099. package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
  1100. package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
  1101. package/skills/creative/claude-design/SKILL.md +591 -0
  1102. package/skills/creative/comfyui/SKILL.md +612 -0
  1103. package/skills/creative/comfyui/references/official-cli.md +255 -0
  1104. package/skills/creative/comfyui/references/rest-api.md +312 -0
  1105. package/skills/creative/comfyui/references/template-integrity.md +243 -0
  1106. package/skills/creative/comfyui/references/workflow-format.md +226 -0
  1107. package/skills/creative/comfyui/scripts/_common.py +835 -0
  1108. package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
  1109. package/skills/creative/comfyui/scripts/check_deps.py +437 -0
  1110. package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
  1111. package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
  1112. package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
  1113. package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
  1114. package/skills/creative/comfyui/scripts/health_check.py +223 -0
  1115. package/skills/creative/comfyui/scripts/run_batch.py +243 -0
  1116. package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
  1117. package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
  1118. package/skills/creative/comfyui/tests/README.md +50 -0
  1119. package/skills/creative/comfyui/tests/conftest.py +64 -0
  1120. package/skills/creative/comfyui/tests/pytest.ini +5 -0
  1121. package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
  1122. package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
  1123. package/skills/creative/comfyui/tests/test_common.py +447 -0
  1124. package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
  1125. package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
  1126. package/skills/creative/comfyui/workflows/README.md +86 -0
  1127. package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
  1128. package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
  1129. package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
  1130. package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
  1131. package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
  1132. package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
  1133. package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
  1134. package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
  1135. package/skills/creative/creative-ideation/SKILL.md +152 -0
  1136. package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
  1137. package/skills/creative/design-md/SKILL.md +199 -0
  1138. package/skills/creative/design-md/templates/starter.md +99 -0
  1139. package/skills/creative/excalidraw/SKILL.md +199 -0
  1140. package/skills/creative/excalidraw/references/colors.md +44 -0
  1141. package/skills/creative/excalidraw/references/dark-mode.md +68 -0
  1142. package/skills/creative/excalidraw/references/examples.md +141 -0
  1143. package/skills/creative/excalidraw/scripts/upload.py +133 -0
  1144. package/skills/creative/humanizer/LICENSE +21 -0
  1145. package/skills/creative/humanizer/SKILL.md +578 -0
  1146. package/skills/creative/manim-video/README.md +23 -0
  1147. package/skills/creative/manim-video/SKILL.md +269 -0
  1148. package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
  1149. package/skills/creative/manim-video/references/animations.md +282 -0
  1150. package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
  1151. package/skills/creative/manim-video/references/decorations.md +202 -0
  1152. package/skills/creative/manim-video/references/equations.md +216 -0
  1153. package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
  1154. package/skills/creative/manim-video/references/mobjects.md +333 -0
  1155. package/skills/creative/manim-video/references/paper-explainer.md +255 -0
  1156. package/skills/creative/manim-video/references/production-quality.md +190 -0
  1157. package/skills/creative/manim-video/references/rendering.md +185 -0
  1158. package/skills/creative/manim-video/references/scene-planning.md +118 -0
  1159. package/skills/creative/manim-video/references/troubleshooting.md +135 -0
  1160. package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
  1161. package/skills/creative/manim-video/references/visual-design.md +124 -0
  1162. package/skills/creative/manim-video/scripts/setup.sh +14 -0
  1163. package/skills/creative/p5js/README.md +64 -0
  1164. package/skills/creative/p5js/SKILL.md +556 -0
  1165. package/skills/creative/p5js/references/animation.md +439 -0
  1166. package/skills/creative/p5js/references/color-systems.md +352 -0
  1167. package/skills/creative/p5js/references/core-api.md +410 -0
  1168. package/skills/creative/p5js/references/export-pipeline.md +566 -0
  1169. package/skills/creative/p5js/references/interaction.md +398 -0
  1170. package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
  1171. package/skills/creative/p5js/references/troubleshooting.md +532 -0
  1172. package/skills/creative/p5js/references/typography.md +302 -0
  1173. package/skills/creative/p5js/references/visual-effects.md +895 -0
  1174. package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
  1175. package/skills/creative/p5js/scripts/export-frames.js +179 -0
  1176. package/skills/creative/p5js/scripts/render.sh +108 -0
  1177. package/skills/creative/p5js/scripts/serve.sh +28 -0
  1178. package/skills/creative/p5js/scripts/setup.sh +87 -0
  1179. package/skills/creative/p5js/templates/viewer.html +395 -0
  1180. package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
  1181. package/skills/creative/pixel-art/SKILL.md +218 -0
  1182. package/skills/creative/pixel-art/references/palettes.md +49 -0
  1183. package/skills/creative/pixel-art/scripts/__init__.py +0 -0
  1184. package/skills/creative/pixel-art/scripts/palettes.py +167 -0
  1185. package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
  1186. package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
  1187. package/skills/creative/popular-web-designs/SKILL.md +214 -0
  1188. package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
  1189. package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
  1190. package/skills/creative/popular-web-designs/templates/apple.md +326 -0
  1191. package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
  1192. package/skills/creative/popular-web-designs/templates/cal.md +272 -0
  1193. package/skills/creative/popular-web-designs/templates/claude.md +325 -0
  1194. package/skills/creative/popular-web-designs/templates/clay.md +317 -0
  1195. package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
  1196. package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
  1197. package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
  1198. package/skills/creative/popular-web-designs/templates/composio.md +320 -0
  1199. package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
  1200. package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
  1201. package/skills/creative/popular-web-designs/templates/expo.md +294 -0
  1202. package/skills/creative/popular-web-designs/templates/figma.md +233 -0
  1203. package/skills/creative/popular-web-designs/templates/framer.md +259 -0
  1204. package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
  1205. package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
  1206. package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
  1207. package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
  1208. package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
  1209. package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
  1210. package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
  1211. package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
  1212. package/skills/creative/popular-web-designs/templates/miro.md +121 -0
  1213. package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
  1214. package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
  1215. package/skills/creative/popular-web-designs/templates/notion.md +322 -0
  1216. package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
  1217. package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
  1218. package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
  1219. package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
  1220. package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
  1221. package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
  1222. package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
  1223. package/skills/creative/popular-web-designs/templates/resend.md +316 -0
  1224. package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
  1225. package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
  1226. package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
  1227. package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
  1228. package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
  1229. package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
  1230. package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
  1231. package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
  1232. package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
  1233. package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
  1234. package/skills/creative/popular-web-designs/templates/uber.md +308 -0
  1235. package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
  1236. package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
  1237. package/skills/creative/popular-web-designs/templates/warp.md +266 -0
  1238. package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
  1239. package/skills/creative/popular-web-designs/templates/wise.md +186 -0
  1240. package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
  1241. package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
  1242. package/skills/creative/pretext/SKILL.md +220 -0
  1243. package/skills/creative/pretext/references/patterns.md +258 -0
  1244. package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
  1245. package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
  1246. package/skills/creative/sketch/SKILL.md +218 -0
  1247. package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
  1248. package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
  1249. package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
  1250. package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
  1251. package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
  1252. package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
  1253. package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
  1254. package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
  1255. package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
  1256. package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
  1257. package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
  1258. package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
  1259. package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
  1260. package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
  1261. package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
  1262. package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
  1263. package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
  1264. package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
  1265. package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
  1266. package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
  1267. package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
  1268. package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
  1269. package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
  1270. package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
  1271. package/skills/data-science/DESCRIPTION.md +3 -0
  1272. package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
  1273. package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
  1274. package/skills/devops/kanban-worker/SKILL.md +184 -0
  1275. package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
  1276. package/skills/diagramming/DESCRIPTION.md +3 -0
  1277. package/skills/dogfood/SKILL.md +162 -0
  1278. package/skills/dogfood/references/issue-taxonomy.md +109 -0
  1279. package/skills/dogfood/templates/dogfood-report-template.md +86 -0
  1280. package/skills/domain/DESCRIPTION.md +24 -0
  1281. package/skills/email/DESCRIPTION.md +3 -0
  1282. package/skills/email/himalaya/SKILL.md +299 -0
  1283. package/skills/email/himalaya/references/configuration.md +227 -0
  1284. package/skills/email/himalaya/references/message-composition.md +199 -0
  1285. package/skills/gaming/DESCRIPTION.md +3 -0
  1286. package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
  1287. package/skills/gaming/pokemon-player/SKILL.md +216 -0
  1288. package/skills/gifs/DESCRIPTION.md +3 -0
  1289. package/skills/github/DESCRIPTION.md +3 -0
  1290. package/skills/github/codebase-inspection/SKILL.md +116 -0
  1291. package/skills/github/github-auth/SKILL.md +247 -0
  1292. package/skills/github/github-auth/scripts/gh-env.sh +66 -0
  1293. package/skills/github/github-code-review/SKILL.md +481 -0
  1294. package/skills/github/github-code-review/references/review-output-template.md +74 -0
  1295. package/skills/github/github-issues/SKILL.md +370 -0
  1296. package/skills/github/github-issues/templates/bug-report.md +35 -0
  1297. package/skills/github/github-issues/templates/feature-request.md +31 -0
  1298. package/skills/github/github-pr-workflow/SKILL.md +367 -0
  1299. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
  1300. package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
  1301. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
  1302. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
  1303. package/skills/github/github-repo-management/SKILL.md +516 -0
  1304. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
  1305. package/skills/index-cache/anthropics_skills_skills_.json +1 -0
  1306. package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
  1307. package/skills/index-cache/lobehub_index.json +1 -0
  1308. package/skills/index-cache/openai_skills_skills_.json +1 -0
  1309. package/skills/inference-sh/DESCRIPTION.md +19 -0
  1310. package/skills/mcp/DESCRIPTION.md +3 -0
  1311. package/skills/mcp/native-mcp/SKILL.md +357 -0
  1312. package/skills/media/DESCRIPTION.md +3 -0
  1313. package/skills/media/gif-search/SKILL.md +91 -0
  1314. package/skills/media/heartmula/SKILL.md +171 -0
  1315. package/skills/media/songsee/SKILL.md +83 -0
  1316. package/skills/media/spotify/SKILL.md +135 -0
  1317. package/skills/media/youtube-content/SKILL.md +73 -0
  1318. package/skills/media/youtube-content/references/output-formats.md +56 -0
  1319. package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
  1320. package/skills/mlops/DESCRIPTION.md +3 -0
  1321. package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
  1322. package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
  1323. package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
  1324. package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
  1325. package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
  1326. package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
  1327. package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
  1328. package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
  1329. package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
  1330. package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
  1331. package/skills/mlops/huggingface-hub/SKILL.md +81 -0
  1332. package/skills/mlops/inference/DESCRIPTION.md +3 -0
  1333. package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
  1334. package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
  1335. package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
  1336. package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
  1337. package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
  1338. package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
  1339. package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
  1340. package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
  1341. package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
  1342. package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
  1343. package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
  1344. package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
  1345. package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
  1346. package/skills/mlops/inference/vllm/SKILL.md +372 -0
  1347. package/skills/mlops/inference/vllm/references/optimization.md +226 -0
  1348. package/skills/mlops/inference/vllm/references/quantization.md +284 -0
  1349. package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
  1350. package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
  1351. package/skills/mlops/models/DESCRIPTION.md +3 -0
  1352. package/skills/mlops/models/audiocraft/SKILL.md +568 -0
  1353. package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
  1354. package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
  1355. package/skills/mlops/models/segment-anything/SKILL.md +506 -0
  1356. package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
  1357. package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
  1358. package/skills/mlops/research/DESCRIPTION.md +3 -0
  1359. package/skills/mlops/research/dspy/SKILL.md +594 -0
  1360. package/skills/mlops/research/dspy/references/examples.md +663 -0
  1361. package/skills/mlops/research/dspy/references/modules.md +475 -0
  1362. package/skills/mlops/research/dspy/references/optimizers.md +566 -0
  1363. package/skills/mlops/training/DESCRIPTION.md +3 -0
  1364. package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
  1365. package/skills/note-taking/DESCRIPTION.md +3 -0
  1366. package/skills/note-taking/obsidian/SKILL.md +61 -0
  1367. package/skills/productivity/DESCRIPTION.md +3 -0
  1368. package/skills/productivity/airtable/SKILL.md +229 -0
  1369. package/skills/productivity/google-workspace/SKILL.md +335 -0
  1370. package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
  1371. package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
  1372. package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
  1373. package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
  1374. package/skills/productivity/google-workspace/scripts/setup.py +454 -0
  1375. package/skills/productivity/linear/SKILL.md +380 -0
  1376. package/skills/productivity/linear/scripts/linear_api.py +445 -0
  1377. package/skills/productivity/maps/SKILL.md +195 -0
  1378. package/skills/productivity/maps/scripts/maps_client.py +1298 -0
  1379. package/skills/productivity/nano-pdf/SKILL.md +52 -0
  1380. package/skills/productivity/notion/SKILL.md +448 -0
  1381. package/skills/productivity/notion/references/block-types.md +112 -0
  1382. package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
  1383. package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
  1384. package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
  1385. package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
  1386. package/skills/productivity/powerpoint/LICENSE.txt +30 -0
  1387. package/skills/productivity/powerpoint/SKILL.md +237 -0
  1388. package/skills/productivity/powerpoint/editing.md +205 -0
  1389. package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
  1390. package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
  1391. package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
  1392. package/skills/productivity/powerpoint/scripts/clean.py +286 -0
  1393. package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
  1394. package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
  1395. package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
  1396. package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
  1397. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  1398. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  1399. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  1400. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  1401. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  1402. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  1403. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  1404. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  1405. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  1406. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  1407. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  1408. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  1409. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  1410. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  1411. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  1412. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  1413. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  1414. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  1415. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  1416. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  1417. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  1418. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  1419. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  1420. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  1421. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  1422. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  1423. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  1424. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
  1425. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
  1426. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
  1427. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
  1428. package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
  1429. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  1430. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  1431. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  1432. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  1433. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  1434. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  1435. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  1436. package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
  1437. package/skills/red-teaming/godmode/SKILL.md +404 -0
  1438. package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
  1439. package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
  1440. package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
  1441. package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
  1442. package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
  1443. package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
  1444. package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
  1445. package/skills/red-teaming/godmode/templates/prefill.json +18 -0
  1446. package/skills/research/DESCRIPTION.md +3 -0
  1447. package/skills/research/arxiv/SKILL.md +282 -0
  1448. package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
  1449. package/skills/research/blogwatcher/SKILL.md +137 -0
  1450. package/skills/research/llm-wiki/SKILL.md +507 -0
  1451. package/skills/research/polymarket/SKILL.md +77 -0
  1452. package/skills/research/polymarket/references/api-endpoints.md +220 -0
  1453. package/skills/research/polymarket/scripts/polymarket.py +284 -0
  1454. package/skills/research/research-paper-writing/SKILL.md +2377 -0
  1455. package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
  1456. package/skills/research/research-paper-writing/references/checklists.md +434 -0
  1457. package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
  1458. package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
  1459. package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
  1460. package/skills/research/research-paper-writing/references/paper-types.md +481 -0
  1461. package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
  1462. package/skills/research/research-paper-writing/references/sources.md +191 -0
  1463. package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
  1464. package/skills/research/research-paper-writing/templates/README.md +251 -0
  1465. package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
  1466. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  1467. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  1468. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
  1469. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
  1470. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
  1471. package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
  1472. package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
  1473. package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
  1474. package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
  1475. package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
  1476. package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
  1477. package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
  1478. package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
  1479. package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
  1480. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
  1481. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
  1482. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
  1483. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
  1484. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
  1485. package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
  1486. package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
  1487. package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
  1488. package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
  1489. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
  1490. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
  1491. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
  1492. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
  1493. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
  1494. package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
  1495. package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
  1496. package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
  1497. package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
  1498. package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
  1499. package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
  1500. package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
  1501. package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
  1502. package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
  1503. package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
  1504. package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
  1505. package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
  1506. package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
  1507. package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
  1508. package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
  1509. package/skills/smart-home/DESCRIPTION.md +3 -0
  1510. package/skills/smart-home/openhue/SKILL.md +109 -0
  1511. package/skills/social-media/DESCRIPTION.md +3 -0
  1512. package/skills/social-media/xurl/SKILL.md +414 -0
  1513. package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
  1514. package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
  1515. package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
  1516. package/skills/software-development/plan/SKILL.md +58 -0
  1517. package/skills/software-development/python-debugpy/SKILL.md +375 -0
  1518. package/skills/software-development/requesting-code-review/SKILL.md +280 -0
  1519. package/skills/software-development/spike/SKILL.md +197 -0
  1520. package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
  1521. package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
  1522. package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
  1523. package/skills/software-development/systematic-debugging/SKILL.md +367 -0
  1524. package/skills/software-development/test-driven-development/SKILL.md +343 -0
  1525. package/skills/software-development/writing-plans/SKILL.md +297 -0
  1526. package/skills/yuanbao/SKILL.md +108 -0
  1527. package/tools/__init__.py +25 -0
  1528. package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  1529. package/tools/__pycache__/approval.cpython-312.pyc +0 -0
  1530. package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
  1531. package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
  1532. package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
  1533. package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
  1534. package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
  1535. package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
  1536. package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
  1537. package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
  1538. package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
  1539. package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
  1540. package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
  1541. package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
  1542. package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
  1543. package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
  1544. package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
  1545. package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
  1546. package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
  1547. package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
  1548. package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
  1549. package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
  1550. package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
  1551. package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  1552. package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
  1553. package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
  1554. package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
  1555. package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
  1556. package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
  1557. package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
  1558. package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
  1559. package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
  1560. package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
  1561. package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
  1562. package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
  1563. package/tools/__pycache__/registry.cpython-312.pyc +0 -0
  1564. package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
  1565. package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
  1566. package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
  1567. package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
  1568. package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
  1569. package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
  1570. package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
  1571. package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
  1572. package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
  1573. package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
  1574. package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
  1575. package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
  1576. package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
  1577. package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
  1578. package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
  1579. package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
  1580. package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
  1581. package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
  1582. package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
  1583. package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
  1584. package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  1585. package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
  1586. package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
  1587. package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
  1588. package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
  1589. package/tools/ansi_strip.py +44 -0
  1590. package/tools/approval.py +1392 -0
  1591. package/tools/binary_extensions.py +42 -0
  1592. package/tools/browser_camofox.py +700 -0
  1593. package/tools/browser_camofox_state.py +48 -0
  1594. package/tools/browser_cdp_tool.py +569 -0
  1595. package/tools/browser_dialog_tool.py +148 -0
  1596. package/tools/browser_providers/__init__.py +10 -0
  1597. package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
  1598. package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
  1599. package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
  1600. package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
  1601. package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
  1602. package/tools/browser_providers/base.py +59 -0
  1603. package/tools/browser_providers/browser_use.py +225 -0
  1604. package/tools/browser_providers/browserbase.py +222 -0
  1605. package/tools/browser_providers/firecrawl.py +112 -0
  1606. package/tools/browser_supervisor.py +1457 -0
  1607. package/tools/browser_tool.py +3676 -0
  1608. package/tools/budget_config.py +51 -0
  1609. package/tools/checkpoint_manager.py +1639 -0
  1610. package/tools/clarify_gateway.py +278 -0
  1611. package/tools/clarify_tool.py +141 -0
  1612. package/tools/code_execution_tool.py +1782 -0
  1613. package/tools/computer_use/__init__.py +43 -0
  1614. package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
  1615. package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
  1616. package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
  1617. package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
  1618. package/tools/computer_use/backend.py +150 -0
  1619. package/tools/computer_use/cua_backend.py +682 -0
  1620. package/tools/computer_use/schema.py +191 -0
  1621. package/tools/computer_use/tool.py +521 -0
  1622. package/tools/computer_use_tool.py +39 -0
  1623. package/tools/credential_files.py +437 -0
  1624. package/tools/cronjob_tools.py +719 -0
  1625. package/tools/debug_helpers.py +106 -0
  1626. package/tools/delegate_tool.py +2797 -0
  1627. package/tools/discord_tool.py +959 -0
  1628. package/tools/env_passthrough.py +145 -0
  1629. package/tools/environments/__init__.py +14 -0
  1630. package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
  1631. package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
  1632. package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
  1633. package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
  1634. package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
  1635. package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
  1636. package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
  1637. package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
  1638. package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
  1639. package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
  1640. package/tools/environments/base.py +844 -0
  1641. package/tools/environments/daytona.py +270 -0
  1642. package/tools/environments/docker.py +656 -0
  1643. package/tools/environments/file_sync.py +400 -0
  1644. package/tools/environments/local.py +658 -0
  1645. package/tools/environments/managed_modal.py +282 -0
  1646. package/tools/environments/modal.py +479 -0
  1647. package/tools/environments/modal_utils.py +199 -0
  1648. package/tools/environments/singularity.py +263 -0
  1649. package/tools/environments/ssh.py +295 -0
  1650. package/tools/environments/vercel_sandbox.py +655 -0
  1651. package/tools/feishu_doc_tool.py +138 -0
  1652. package/tools/feishu_drive_tool.py +431 -0
  1653. package/tools/file_operations.py +1825 -0
  1654. package/tools/file_state.py +332 -0
  1655. package/tools/file_tools.py +1172 -0
  1656. package/tools/fuzzy_match.py +703 -0
  1657. package/tools/homeassistant_tool.py +513 -0
  1658. package/tools/image_generation_tool.py +1098 -0
  1659. package/tools/interrupt.py +98 -0
  1660. package/tools/kanban_tools.py +1139 -0
  1661. package/tools/lazy_deps.py +608 -0
  1662. package/tools/managed_tool_gateway.py +168 -0
  1663. package/tools/mcp_oauth.py +633 -0
  1664. package/tools/mcp_oauth_manager.py +607 -0
  1665. package/tools/mcp_tool.py +3483 -0
  1666. package/tools/memory_tool.py +584 -0
  1667. package/tools/microsoft_graph_auth.py +245 -0
  1668. package/tools/microsoft_graph_client.py +408 -0
  1669. package/tools/mixture_of_agents_tool.py +542 -0
  1670. package/tools/neutts_samples/jo.txt +1 -0
  1671. package/tools/neutts_samples/jo.wav +0 -0
  1672. package/tools/neutts_synth.py +104 -0
  1673. package/tools/openrouter_client.py +33 -0
  1674. package/tools/osv_check.py +155 -0
  1675. package/tools/patch_parser.py +592 -0
  1676. package/tools/path_security.py +43 -0
  1677. package/tools/process_registry.py +1534 -0
  1678. package/tools/registry.py +589 -0
  1679. package/tools/schema_sanitizer.py +370 -0
  1680. package/tools/send_message_tool.py +1900 -0
  1681. package/tools/session_search_tool.py +613 -0
  1682. package/tools/skill_manager_tool.py +932 -0
  1683. package/tools/skill_provenance.py +78 -0
  1684. package/tools/skill_usage.py +610 -0
  1685. package/tools/skills_guard.py +932 -0
  1686. package/tools/skills_hub.py +3263 -0
  1687. package/tools/skills_sync.py +432 -0
  1688. package/tools/skills_tool.py +1569 -0
  1689. package/tools/slash_confirm.py +167 -0
  1690. package/tools/terminal_tool.py +2376 -0
  1691. package/tools/tirith_security.py +775 -0
  1692. package/tools/todo_tool.py +277 -0
  1693. package/tools/tool_backend_helpers.py +144 -0
  1694. package/tools/tool_output_limits.py +92 -0
  1695. package/tools/tool_result_storage.py +232 -0
  1696. package/tools/transcription_tools.py +936 -0
  1697. package/tools/tts_tool.py +2285 -0
  1698. package/tools/url_safety.py +330 -0
  1699. package/tools/video_generation_tool.py +561 -0
  1700. package/tools/vision_tools.py +1422 -0
  1701. package/tools/voice_mode.py +1019 -0
  1702. package/tools/web_tools.py +1551 -0
  1703. package/tools/website_policy.py +283 -0
  1704. package/tools/x_search_tool.py +424 -0
  1705. package/tools/xai_http.py +83 -0
  1706. package/tools/yuanbao_tools.py +736 -0
  1707. package/toolset_distributions.py +364 -0
  1708. package/toolsets.py +866 -0
  1709. package/trajectory_compressor.py +1509 -0
  1710. package/tui_gateway/__init__.py +0 -0
  1711. package/tui_gateway/entry.py +251 -0
  1712. package/tui_gateway/event_publisher.py +126 -0
  1713. package/tui_gateway/render.py +49 -0
  1714. package/tui_gateway/server.py +6623 -0
  1715. package/tui_gateway/slash_worker.py +76 -0
  1716. package/tui_gateway/transport.py +219 -0
  1717. package/tui_gateway/ws.py +178 -0
  1718. package/utils.py +361 -0
@@ -0,0 +1,4836 @@
1
+ """
2
+ Telegram platform adapter.
3
+
4
+ Uses python-telegram-bot library for:
5
+ - Receiving messages from users/groups
6
+ - Sending responses back
7
+ - Handling media and commands
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import tempfile
15
+ import html as _html
16
+ import re
17
+ from typing import Dict, List, Optional, Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ try:
22
+ from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
23
+ try:
24
+ from telegram import LinkPreviewOptions
25
+ except ImportError:
26
+ LinkPreviewOptions = None
27
+ from telegram.ext import (
28
+ Application,
29
+ CommandHandler,
30
+ CallbackQueryHandler,
31
+ MessageHandler as TelegramMessageHandler,
32
+ ContextTypes,
33
+ filters,
34
+ )
35
+ from telegram.constants import ParseMode, ChatType
36
+ from telegram.request import HTTPXRequest
37
+ TELEGRAM_AVAILABLE = True
38
+ except ImportError:
39
+ TELEGRAM_AVAILABLE = False
40
+ Update = Any
41
+ Bot = Any
42
+ Message = Any
43
+ InlineKeyboardButton = Any
44
+ InlineKeyboardMarkup = Any
45
+ LinkPreviewOptions = None
46
+ Application = Any
47
+ CommandHandler = Any
48
+ CallbackQueryHandler = Any
49
+ TelegramMessageHandler = Any
50
+ HTTPXRequest = Any
51
+ filters = None
52
+ ParseMode = None
53
+ ChatType = None
54
+
55
+ # Mock ContextTypes so type annotations using ContextTypes.DEFAULT_TYPE
56
+ # don't crash during class definition when the library isn't installed.
57
+ class _MockContextTypes:
58
+ DEFAULT_TYPE = Any
59
+ ContextTypes = _MockContextTypes
60
+
61
+ import sys
62
+ from pathlib import Path as _Path
63
+ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
64
+
65
+ from gateway.config import Platform, PlatformConfig
66
+ from gateway.platforms.base import (
67
+ BasePlatformAdapter,
68
+ MessageEvent,
69
+ MessageType,
70
+ ProcessingOutcome,
71
+ SendResult,
72
+ cache_image_from_bytes,
73
+ cache_audio_from_bytes,
74
+ cache_video_from_bytes,
75
+ cache_document_from_bytes,
76
+ resolve_proxy_url,
77
+ SUPPORTED_VIDEO_TYPES,
78
+ SUPPORTED_DOCUMENT_TYPES,
79
+ utf16_len,
80
+ )
81
+ from gateway.platforms.telegram_network import (
82
+ TelegramFallbackTransport,
83
+ discover_fallback_ips,
84
+ parse_fallback_ip_env,
85
+ )
86
+ from utils import atomic_replace
87
+
88
+ _TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
89
+ _TELEGRAM_IMAGE_MIME_TO_EXT = {
90
+ "image/png": ".png",
91
+ "image/jpeg": ".jpg",
92
+ "image/jpg": ".jpg",
93
+ "image/webp": ".webp",
94
+ "image/gif": ".gif",
95
+ }
96
+ _TELEGRAM_IMAGE_EXT_TO_MIME = {
97
+ ".png": "image/png",
98
+ ".jpg": "image/jpeg",
99
+ ".jpeg": "image/jpeg",
100
+ ".webp": "image/webp",
101
+ ".gif": "image/gif",
102
+ }
103
+
104
+
105
+ def check_telegram_requirements() -> bool:
106
+ """Check if Telegram dependencies are available.
107
+
108
+ If python-telegram-bot is missing, attempts to lazy-install it via
109
+ ``tools.lazy_deps.ensure("platform.telegram")``. After a successful
110
+ install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True
111
+ so the adapter's class-level type aliases get rebound.
112
+ """
113
+ global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
114
+ global InlineKeyboardMarkup, LinkPreviewOptions, Application
115
+ global CommandHandler, CallbackQueryHandler, TelegramMessageHandler
116
+ global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest
117
+ if TELEGRAM_AVAILABLE:
118
+ return True
119
+ try:
120
+ from tools.lazy_deps import ensure as _lazy_ensure
121
+ _lazy_ensure("platform.telegram", prompt=False)
122
+ except Exception:
123
+ return False
124
+ try:
125
+ from telegram import Update as _Update, Bot as _Bot, Message as _Message
126
+ from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM
127
+ try:
128
+ from telegram import LinkPreviewOptions as _LPO
129
+ except ImportError:
130
+ _LPO = None
131
+ from telegram.ext import (
132
+ Application as _App, CommandHandler as _CH,
133
+ CallbackQueryHandler as _CQH,
134
+ MessageHandler as _MH,
135
+ ContextTypes as _CT, filters as _filters,
136
+ )
137
+ from telegram.constants import ParseMode as _PM, ChatType as _CtT
138
+ from telegram.request import HTTPXRequest as _HR
139
+ except ImportError:
140
+ return False
141
+ Update = _Update
142
+ Bot = _Bot
143
+ Message = _Message
144
+ InlineKeyboardButton = _IKB
145
+ InlineKeyboardMarkup = _IKM
146
+ LinkPreviewOptions = _LPO
147
+ Application = _App
148
+ CommandHandler = _CH
149
+ CallbackQueryHandler = _CQH
150
+ TelegramMessageHandler = _MH
151
+ ContextTypes = _CT
152
+ filters = _filters
153
+ ParseMode = _PM
154
+ ChatType = _CtT
155
+ HTTPXRequest = _HR
156
+ TELEGRAM_AVAILABLE = True
157
+ return True
158
+
159
+
160
+ # Matches every character that MarkdownV2 requires to be backslash-escaped
161
+ # when it appears outside a code span or fenced code block.
162
+ _MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])')
163
+
164
+
165
+ def _escape_mdv2(text: str) -> str:
166
+ """Escape Telegram MarkdownV2 special characters with a preceding backslash."""
167
+ return _MDV2_ESCAPE_RE.sub(r'\\\1', text)
168
+
169
+
170
+ def _strip_mdv2(text: str) -> str:
171
+ """Strip MarkdownV2 escape backslashes to produce clean plain text.
172
+
173
+ Also removes MarkdownV2 formatting markers so the fallback
174
+ doesn't show stray syntax characters from format_message conversion.
175
+ """
176
+ # Remove escape backslashes before special characters
177
+ cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
178
+ # Remove MarkdownV2 bold markers that format_message converted from **bold**
179
+ cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
180
+ # Remove MarkdownV2 italic markers that format_message converted from *italic*
181
+ # Use word boundary (\b) to avoid breaking snake_case like my_variable_name
182
+ cleaned = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', cleaned)
183
+ # Remove MarkdownV2 strikethrough markers (~text~ → text)
184
+ cleaned = re.sub(r'~([^~]+)~', r'\1', cleaned)
185
+ # Remove MarkdownV2 spoiler markers (||text|| → text)
186
+ cleaned = re.sub(r'\|\|([^|]+)\|\|', r'\1', cleaned)
187
+ return cleaned
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Markdown table → Telegram-friendly row groups
192
+ # ---------------------------------------------------------------------------
193
+ # Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
194
+ # so pipe tables render as noisy backslash-pipe text with no alignment.
195
+ # Reformating each row into a bold heading plus bullet list keeps the content
196
+ # readable on mobile clients while preserving the source data.
197
+
198
+ # Matches a GFM table delimiter row: optional outer pipes, cells containing
199
+ # only dashes (with optional leading/trailing colons for alignment) separated
200
+ # by '|'. Requires at least one internal '|' so lone '---' horizontal rules
201
+ # are NOT matched.
202
+ _TABLE_SEPARATOR_RE = re.compile(
203
+ r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
204
+ )
205
+
206
+
207
+ def _is_table_row(line: str) -> bool:
208
+ """Return True if *line* could plausibly be a table data row."""
209
+ stripped = line.strip()
210
+ return bool(stripped) and '|' in stripped
211
+
212
+
213
+ def _split_markdown_table_row(line: str) -> list[str]:
214
+ """Split a simple GFM table row into stripped cell values."""
215
+ stripped = line.strip()
216
+ if stripped.startswith("|"):
217
+ stripped = stripped[1:]
218
+ if stripped.endswith("|"):
219
+ stripped = stripped[:-1]
220
+ return [cell.strip() for cell in stripped.split("|")]
221
+
222
+
223
+ def _render_table_block_for_telegram(table_block: list[str]) -> str:
224
+ """Render a detected GFM table as Telegram-friendly row groups."""
225
+ if len(table_block) < 3:
226
+ return "\n".join(table_block)
227
+
228
+ headers = _split_markdown_table_row(table_block[0])
229
+ if len(headers) < 2:
230
+ return "\n".join(table_block)
231
+
232
+ # Detect row-label column: present when data rows have one more cell
233
+ # than the header row (the row-label column carries no header).
234
+ first_data_row = _split_markdown_table_row(table_block[2]) if len(table_block) > 2 else []
235
+ has_row_label_col = len(first_data_row) == len(headers) + 1
236
+
237
+ rendered_rows: list[str] = []
238
+ for index, row in enumerate(table_block[2:], start=1):
239
+ cells = _split_markdown_table_row(row)
240
+ if has_row_label_col:
241
+ # First cell is the row-label (heading); remaining cells align with headers.
242
+ heading = cells[0] if cells and cells[0] else f"Row {index}"
243
+ data_cells = cells[1:]
244
+ else:
245
+ # No row-label column: use first non-empty cell as heading.
246
+ heading = next((cell for cell in cells if cell), f"Row {index}")
247
+ data_cells = cells
248
+
249
+ # Pad or trim data_cells to match headers length.
250
+ if len(data_cells) < len(headers):
251
+ data_cells.extend([""] * (len(headers) - len(data_cells)))
252
+ elif len(data_cells) > len(headers):
253
+ data_cells = data_cells[: len(headers)]
254
+
255
+ rendered_rows.append(f"**{heading}**")
256
+ rendered_rows.extend(
257
+ f"• {header}: {value}" for header, value in zip(headers, data_cells)
258
+ )
259
+
260
+ return "\n\n".join(rendered_rows)
261
+
262
+
263
+ def _wrap_markdown_tables(text: str) -> str:
264
+ """Rewrite GFM-style pipe tables into Telegram-friendly bullet groups.
265
+
266
+ Detected by a row containing '|' immediately followed by a delimiter
267
+ row matching :data:`_TABLE_SEPARATOR_RE`. Subsequent pipe-containing
268
+ non-blank lines are consumed as the table body and rewritten as
269
+ per-row bullet groups. Tables inside existing fenced code blocks are left
270
+ alone.
271
+ """
272
+ if '|' not in text or '-' not in text:
273
+ return text
274
+
275
+ lines = text.split('\n')
276
+ out: list[str] = []
277
+ in_fence = False
278
+ i = 0
279
+ while i < len(lines):
280
+ line = lines[i]
281
+ stripped = line.lstrip()
282
+
283
+ # Track existing fenced code blocks — never touch content inside.
284
+ if stripped.startswith('```'):
285
+ in_fence = not in_fence
286
+ out.append(line)
287
+ i += 1
288
+ continue
289
+ if in_fence:
290
+ out.append(line)
291
+ i += 1
292
+ continue
293
+
294
+ # Look for a header row (contains '|') immediately followed by a
295
+ # delimiter row.
296
+ if (
297
+ '|' in line
298
+ and i + 1 < len(lines)
299
+ and _TABLE_SEPARATOR_RE.match(lines[i + 1])
300
+ ):
301
+ table_block = [line, lines[i + 1]]
302
+ j = i + 2
303
+ while j < len(lines) and _is_table_row(lines[j]):
304
+ table_block.append(lines[j])
305
+ j += 1
306
+ out.append(_render_table_block_for_telegram(table_block))
307
+ i = j
308
+ continue
309
+
310
+ out.append(line)
311
+ i += 1
312
+
313
+ return '\n'.join(out)
314
+
315
+
316
+ class TelegramAdapter(BasePlatformAdapter):
317
+ """
318
+ Telegram bot adapter.
319
+
320
+ Handles:
321
+ - Receiving messages from users and groups
322
+ - Sending responses with Telegram markdown
323
+ - Forum topics (thread_id support)
324
+ - Media messages
325
+ """
326
+
327
+ # Telegram message limits
328
+ MAX_MESSAGE_LENGTH = 4096
329
+ # Threshold for detecting Telegram client-side message splits.
330
+ # When a chunk is near this limit, a continuation is almost certain.
331
+ _SPLIT_THRESHOLD = 4000
332
+ MEDIA_GROUP_WAIT_SECONDS = 0.8
333
+ _GENERAL_TOPIC_THREAD_ID = "1"
334
+
335
+ # Telegram's edit_message applies MarkdownV2 formatting only on the
336
+ # finalize=True path. Without this flag, stream_consumer._send_or_edit
337
+ # short-circuits when the raw text is unchanged between the last streamed
338
+ # edit and the final edit, skipping the plain-text → MarkdownV2 conversion.
339
+ # Fixes #25710.
340
+ REQUIRES_EDIT_FINALIZE: bool = True
341
+
342
+ # Adaptive text-batch ingress: short messages need a tighter delay so the
343
+ # first token reaches the agent fast. Numbers tuned for "feels instant":
344
+ # ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024
345
+ # (a normal paragraph) in ~240ms; longer waits the configured cap.
346
+ # Always clamped to ``_text_batch_delay_seconds`` so an operator can lower
347
+ # the cap further via env var.
348
+ _TEXT_BATCH_FAST_LEN = 320
349
+ _TEXT_BATCH_FAST_DELAY_S = 0.18
350
+ _TEXT_BATCH_SHORT_LEN = 1024
351
+ _TEXT_BATCH_SHORT_DELAY_S = 0.24
352
+
353
+ @staticmethod
354
+ def _env_float_clamped(
355
+ name: str,
356
+ default: float,
357
+ *,
358
+ min_value: Optional[float] = None,
359
+ max_value: Optional[float] = None,
360
+ ) -> float:
361
+ """Read a float env var, reject non-finite values, and clamp to bounds.
362
+
363
+ Guarantees the returned value is a finite number usable directly in
364
+ ``asyncio.sleep()`` and similar APIs that reject NaN / Inf.
365
+ """
366
+ import math
367
+
368
+ raw = os.getenv(name)
369
+ try:
370
+ value = float(raw) if raw is not None else float(default)
371
+ except (TypeError, ValueError):
372
+ value = float(default)
373
+ if not math.isfinite(value):
374
+ value = float(default)
375
+ if min_value is not None:
376
+ value = max(value, min_value)
377
+ if max_value is not None:
378
+ value = min(value, max_value)
379
+ return value
380
+
381
+ @property
382
+ def message_len_fn(self):
383
+ """Telegram measures message length in UTF-16 code units."""
384
+ return utf16_len
385
+
386
+ def __init__(self, config: PlatformConfig):
387
+ super().__init__(config, Platform.TELEGRAM)
388
+ self._app: Optional[Application] = None
389
+ self._bot: Optional[Bot] = None
390
+ self._webhook_mode: bool = False
391
+ self._mention_patterns = self._compile_mention_patterns()
392
+ self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
393
+ self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
394
+ # Buffer rapid/album photo updates so Telegram image bursts are handled
395
+ # as a single MessageEvent instead of self-interrupting multiple turns.
396
+ self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8"))
397
+ self._pending_photo_batches: Dict[str, MessageEvent] = {}
398
+ self._pending_photo_batch_tasks: Dict[str, asyncio.Task] = {}
399
+ self._media_group_events: Dict[str, MessageEvent] = {}
400
+ self._media_group_tasks: Dict[str, asyncio.Task] = {}
401
+ # Buffer rapid text messages so Telegram client-side splits of long
402
+ # messages are aggregated into a single MessageEvent. Lower defaults
403
+ # (0.3s / 1.0s instead of 0.6s / 2.0s) let short replies stream
404
+ # without a noticeable wait — combined with the adaptive fast-path
405
+ # in ``_calc_text_batch_delay`` below, ≤320-codepoint replies settle
406
+ # in ~180ms. All bounds are conservative for Telegram's
407
+ # ~1 edit/s flood envelope.
408
+ self._text_batch_delay_seconds = self._env_float_clamped(
409
+ "HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS",
410
+ 0.3,
411
+ min_value=0.08,
412
+ max_value=2.0,
413
+ )
414
+ self._text_batch_split_delay_seconds = self._env_float_clamped(
415
+ "HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS",
416
+ 1.0,
417
+ min_value=self._text_batch_delay_seconds,
418
+ max_value=4.0,
419
+ )
420
+ self._pending_text_batches: Dict[str, MessageEvent] = {}
421
+ self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
422
+ self._polling_error_task: Optional[asyncio.Task] = None
423
+ self._polling_conflict_count: int = 0
424
+ self._polling_network_error_count: int = 0
425
+ self._polling_error_callback_ref = None
426
+ # DM Topics: map of topic_name -> message_thread_id (populated at startup)
427
+ self._dm_topics: Dict[str, int] = {}
428
+ # DM Topics config from extra.dm_topics
429
+ self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
430
+ # Interactive model picker state per chat
431
+ self._model_picker_state: Dict[str, dict] = {}
432
+ # Approval button state: message_id → session_key
433
+ self._approval_state: Dict[int, str] = {}
434
+ # Slash-confirm button state: confirm_id → session_key (for /reload-mcp
435
+ # and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
436
+ self._slash_confirm_state: Dict[str, str] = {}
437
+ # Clarify button state: clarify_id → session_key (for the clarify tool's
438
+ # multiple-choice prompts; see GatewayRunner clarify_callback wiring).
439
+ self._clarify_state: Dict[str, str] = {}
440
+ # Notification mode for message sends.
441
+ # "important" — only final responses, approvals, and slash confirmations
442
+ # trigger notifications; tool progress, streaming, status
443
+ # messages are delivered silently via disable_notification.
444
+ # This is the default — Telegram users found per-tool-call
445
+ # push notifications too noisy.
446
+ # "all" — every message triggers a push notification (legacy
447
+ # behavior; opt-in via display.platforms.telegram.notifications).
448
+ self._notifications_mode: str = "important"
449
+
450
+ def _notification_kwargs(
451
+ self, metadata: Optional[Dict[str, Any]]
452
+ ) -> Dict[str, Any]:
453
+ """Return disable_notification kwargs when the adapter is in silent mode.
454
+
455
+ In "important" mode, all message sends are silently delivered
456
+ (disable_notification=True) unless the caller explicitly requests a
457
+ notification by setting ``metadata["notify"] = True``.
458
+ """
459
+ if getattr(self, "_notifications_mode", "important") != "important":
460
+ return {}
461
+ if (metadata or {}).get("notify"):
462
+ return {}
463
+ return {"disable_notification": True}
464
+
465
+ def _is_callback_user_authorized(
466
+ self,
467
+ user_id: str,
468
+ *,
469
+ chat_id: Optional[str] = None,
470
+ chat_type: Optional[str] = None,
471
+ thread_id: Optional[str] = None,
472
+ user_name: Optional[str] = None,
473
+ ) -> bool:
474
+ """Return whether a Telegram inline-button caller may perform gated actions."""
475
+ normalized_user_id = str(user_id or "").strip()
476
+ if not normalized_user_id:
477
+ return False
478
+
479
+ runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
480
+ auth_fn = getattr(runner, "_is_user_authorized", None)
481
+ if callable(auth_fn):
482
+ try:
483
+ from gateway.session import SessionSource
484
+
485
+ normalized_chat_type = str(chat_type or "dm").strip().lower() or "dm"
486
+ if normalized_chat_type == "private":
487
+ normalized_chat_type = "dm"
488
+ elif normalized_chat_type == "supergroup":
489
+ normalized_chat_type = "forum" if thread_id is not None else "group"
490
+
491
+ source = SessionSource(
492
+ platform=Platform.TELEGRAM,
493
+ chat_id=str(chat_id or normalized_user_id),
494
+ chat_type=normalized_chat_type,
495
+ user_id=normalized_user_id,
496
+ user_name=str(user_name).strip() if user_name else None,
497
+ thread_id=str(thread_id) if thread_id is not None else None,
498
+ )
499
+ return bool(auth_fn(source))
500
+ except Exception:
501
+ logger.debug(
502
+ "[Telegram] Falling back to env-only callback auth for user %s",
503
+ normalized_user_id,
504
+ exc_info=True,
505
+ )
506
+
507
+ allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
508
+ if not allowed_csv:
509
+ return True
510
+ allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
511
+ return "*" in allowed_ids or normalized_user_id in allowed_ids
512
+
513
+ @classmethod
514
+ def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
515
+ if not metadata:
516
+ return None
517
+ thread_id = metadata.get("thread_id") or metadata.get("message_thread_id")
518
+ return str(thread_id) if thread_id is not None else None
519
+
520
+ @classmethod
521
+ def _metadata_direct_messages_topic_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
522
+ if not metadata:
523
+ return None
524
+ topic_id = metadata.get("direct_messages_topic_id") or metadata.get("telegram_direct_messages_topic_id")
525
+ return str(topic_id) if topic_id is not None else None
526
+
527
+ @classmethod
528
+ def _metadata_reply_to_message_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[int]:
529
+ if not metadata:
530
+ return None
531
+ reply_to = metadata.get("telegram_reply_to_message_id")
532
+ return int(reply_to) if reply_to is not None else None
533
+
534
+ @classmethod
535
+ def _reply_to_message_id_for_send(
536
+ cls,
537
+ reply_to: Optional[str],
538
+ metadata: Optional[Dict[str, Any]] = None,
539
+ ) -> Optional[int]:
540
+ if reply_to:
541
+ return int(reply_to)
542
+ if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
543
+ return cls._metadata_reply_to_message_id(metadata)
544
+ return None
545
+
546
+ @classmethod
547
+ def _thread_kwargs_for_send(
548
+ cls,
549
+ chat_id: str,
550
+ thread_id: Optional[str],
551
+ metadata: Optional[Dict[str, Any]] = None,
552
+ reply_to_message_id: Optional[int] = None,
553
+ ) -> Dict[str, Any]:
554
+ """Return Telegram send kwargs for forum and direct-message topic routing.
555
+
556
+ Supergroup/forum topics use ``message_thread_id``. True Bot API Direct
557
+ Messages topics can opt in with explicit ``direct_messages_topic_id``
558
+ metadata. Hermes-created private-chat topic lanes are marked with
559
+ ``telegram_dm_topic_reply_fallback`` and must send the private topic
560
+ thread id together with a reply anchor. Live testing showed that either
561
+ parameter alone can render outside the visible lane.
562
+ """
563
+ if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
564
+ if reply_to_message_id is None:
565
+ reply_to_message_id = cls._metadata_reply_to_message_id(metadata)
566
+ if reply_to_message_id is None:
567
+ return {}
568
+ return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
569
+ direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
570
+ if direct_topic_id is not None:
571
+ return {
572
+ "message_thread_id": None,
573
+ "direct_messages_topic_id": int(direct_topic_id),
574
+ }
575
+ return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
576
+
577
+ @classmethod
578
+ def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]:
579
+ if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
580
+ return None
581
+ return int(thread_id)
582
+
583
+ @classmethod
584
+ def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
585
+ # Asymmetric with _message_thread_id_for_send on purpose. Telegram's
586
+ # sendMessage and sendChatAction treat thread id "1" (the forum General
587
+ # topic) differently: sends reject message_thread_id=1 and must omit it,
588
+ # but sendChatAction needs message_thread_id=1 to place the typing
589
+ # bubble in the General topic (omitting it hides the bubble entirely
590
+ # from the client's view of that topic). Preserve the real id here —
591
+ # sends still map "1" → None via _message_thread_id_for_send.
592
+ if not thread_id:
593
+ return None
594
+ return int(thread_id)
595
+
596
+ @staticmethod
597
+ def _is_thread_not_found_error(error: Exception) -> bool:
598
+ return "thread not found" in str(error).lower()
599
+
600
+ @staticmethod
601
+ def _is_bad_request_error(error: Exception) -> bool:
602
+ name = error.__class__.__name__.lower()
603
+ if name == "badrequest" or name.endswith("badrequest"):
604
+ return True
605
+ try:
606
+ from telegram.error import BadRequest
607
+ return isinstance(error, BadRequest)
608
+ except ImportError:
609
+ return False
610
+
611
+ @classmethod
612
+ def _should_retry_without_dm_topic_reply_anchor(
613
+ cls,
614
+ error: Exception,
615
+ metadata: Optional[Dict[str, Any]],
616
+ reply_to_message_id: Optional[int],
617
+ ) -> bool:
618
+ return (
619
+ bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
620
+ and reply_to_message_id is not None
621
+ and cls._is_bad_request_error(error)
622
+ and "message to be replied not found" in str(error).lower()
623
+ )
624
+
625
+ async def _send_with_dm_topic_reply_anchor_retry(
626
+ self,
627
+ send_fn: Any,
628
+ send_kwargs: Dict[str, Any],
629
+ metadata: Optional[Dict[str, Any]],
630
+ reply_to_message_id: Optional[int],
631
+ media_label: str,
632
+ reset_media: Optional[Any] = None,
633
+ ) -> Any:
634
+ """Retry stale private-topic media replies once without the topic anchor."""
635
+ try:
636
+ return await send_fn(**send_kwargs)
637
+ except Exception as send_err:
638
+ if not self._should_retry_without_dm_topic_reply_anchor(
639
+ send_err,
640
+ metadata,
641
+ reply_to_message_id,
642
+ ):
643
+ raise
644
+ logger.warning(
645
+ "[%s] Reply target deleted for Telegram %s, "
646
+ "retrying without reply/topic anchor: %s",
647
+ self.name,
648
+ media_label,
649
+ send_err,
650
+ )
651
+ if reset_media is not None:
652
+ reset_media()
653
+ retry_kwargs = dict(send_kwargs)
654
+ retry_kwargs["reply_to_message_id"] = None
655
+ retry_kwargs.pop("message_thread_id", None)
656
+ retry_kwargs.pop("direct_messages_topic_id", None)
657
+ return await send_fn(**retry_kwargs)
658
+
659
+ def _fallback_ips(self) -> list[str]:
660
+ """Return validated fallback IPs from config (populated by _apply_env_overrides)."""
661
+ configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else []
662
+ if isinstance(configured, str):
663
+ configured = configured.split(",")
664
+ return parse_fallback_ip_env(",".join(str(v) for v in configured) if configured else None)
665
+
666
+ @staticmethod
667
+ def _looks_like_polling_conflict(error: Exception) -> bool:
668
+ text = str(error).lower()
669
+ return (
670
+ error.__class__.__name__.lower() == "conflict"
671
+ or "terminated by other getupdates request" in text
672
+ or "another bot instance is running" in text
673
+ )
674
+
675
+ @staticmethod
676
+ def _looks_like_network_error(error: Exception) -> bool:
677
+ """Return True for transient network errors that warrant a reconnect attempt."""
678
+ name = error.__class__.__name__.lower()
679
+ if name in {"networkerror", "timedout", "connectionerror"}:
680
+ return True
681
+ try:
682
+ from telegram.error import NetworkError, TimedOut
683
+ if isinstance(error, (NetworkError, TimedOut)):
684
+ return True
685
+ except ImportError:
686
+ pass
687
+ return isinstance(error, OSError)
688
+
689
+ def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
690
+ value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
691
+ if value is None:
692
+ return default
693
+ if isinstance(value, str):
694
+ lowered = value.strip().lower()
695
+ if lowered in {"true", "1", "yes", "on"}:
696
+ return True
697
+ if lowered in {"false", "0", "no", "off"}:
698
+ return False
699
+ return default
700
+ return bool(value)
701
+
702
+ def _link_preview_kwargs(self) -> Dict[str, Any]:
703
+ if not getattr(self, "_disable_link_previews", False):
704
+ return {}
705
+ if LinkPreviewOptions is not None:
706
+ return {"link_preview_options": LinkPreviewOptions(is_disabled=True)}
707
+ return {"disable_web_page_preview": True}
708
+
709
+ async def _drain_polling_connections(self) -> None:
710
+ """Reset the httpx connection pool used for getUpdates polling.
711
+
712
+ Network errors (especially through proxies like sing-box) can leave
713
+ httpx connections in a half-closed state that still occupy pool slots.
714
+ After enough reconnect cycles the pool fills up entirely, causing
715
+ ``Pool timeout: All connections in the connection pool are occupied.``
716
+
717
+ We reset ONLY ``_request[0]`` (the getUpdates request) — the general
718
+ request (``_request[1]``) is left untouched so concurrent
719
+ ``send_message`` / ``edit_message`` calls are never interrupted.
720
+
721
+ Implementation note: accesses ``Bot._request[0]`` which is the
722
+ get-updates ``BaseRequest`` in the PTB 22.x internal tuple
723
+ ``(get_updates_request, general_request)``. There is no public
724
+ accessor for the polling request; review if upgrading to PTB 23+.
725
+ """
726
+ if not (self._app and self._app.bot):
727
+ return
728
+ try:
729
+ # PTB 22.x: _request is a (get_updates, general) tuple;
730
+ # no public accessor exists for the polling request.
731
+ polling_req = self._app.bot._request[0] # noqa: SLF001
732
+ except Exception:
733
+ return
734
+ try:
735
+ await polling_req.shutdown()
736
+ except Exception:
737
+ logger.debug(
738
+ "[%s] Polling request shutdown failed (non-fatal)",
739
+ self.name, exc_info=True,
740
+ )
741
+ try:
742
+ await polling_req.initialize()
743
+ logger.debug(
744
+ "[%s] Polling request pool drained before reconnect", self.name
745
+ )
746
+ except Exception:
747
+ logger.debug(
748
+ "[%s] Polling request re-initialize failed (non-fatal)",
749
+ self.name, exc_info=True,
750
+ )
751
+
752
+ async def _handle_polling_network_error(self, error: Exception) -> None:
753
+ """Reconnect polling after a transient network interruption.
754
+
755
+ Triggered by NetworkError/TimedOut in the polling error callback, which
756
+ happen when the host loses connectivity (Mac sleep, WiFi switch, VPN
757
+ reconnect, etc.). The gateway process stays alive but the long-poll
758
+ connection silently dies; without this handler the bot never recovers.
759
+
760
+ Strategy: exponential back-off (5s, 10s, 20s, 40s, 60s cap) up to
761
+ MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so
762
+ the supervisor restarts the gateway process.
763
+ """
764
+ if self.has_fatal_error:
765
+ return
766
+
767
+ MAX_NETWORK_RETRIES = 10
768
+ BASE_DELAY = 5
769
+ MAX_DELAY = 60
770
+
771
+ self._polling_network_error_count += 1
772
+ attempt = self._polling_network_error_count
773
+
774
+ if attempt > MAX_NETWORK_RETRIES:
775
+ message = (
776
+ "Telegram polling could not reconnect after %d network error retries. "
777
+ "Restarting gateway." % MAX_NETWORK_RETRIES
778
+ )
779
+ logger.error("[%s] %s Last error: %s", self.name, message, error)
780
+ self._set_fatal_error("telegram_network_error", message, retryable=True)
781
+ await self._notify_fatal_error()
782
+ return
783
+
784
+ delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)
785
+ logger.warning(
786
+ "[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s",
787
+ self.name, attempt, MAX_NETWORK_RETRIES, delay, error,
788
+ )
789
+ await asyncio.sleep(delay)
790
+
791
+ try:
792
+ if self._app and self._app.updater and self._app.updater.running:
793
+ await self._app.updater.stop()
794
+ except Exception:
795
+ pass
796
+
797
+ await self._drain_polling_connections()
798
+
799
+ try:
800
+ await self._app.updater.start_polling(
801
+ allowed_updates=Update.ALL_TYPES,
802
+ drop_pending_updates=False,
803
+ error_callback=self._polling_error_callback_ref,
804
+ )
805
+ logger.info(
806
+ "[%s] Telegram polling resumed after network error (attempt %d)",
807
+ self.name, attempt,
808
+ )
809
+ self._polling_network_error_count = 0
810
+ # start_polling() returning is necessary but not sufficient:
811
+ # PTB's Updater can be left in a state where `running` is True
812
+ # but the underlying long-poll task is wedged on a stale httpx
813
+ # connection and never makes progress. No error_callback fires
814
+ # in that state, so the reconnect ladder won't advance on its
815
+ # own. Schedule a deferred probe to detect the wedge and
816
+ # re-enter the ladder if needed.
817
+ if not self.has_fatal_error:
818
+ probe = asyncio.ensure_future(self._verify_polling_after_reconnect())
819
+ self._background_tasks.add(probe)
820
+ probe.add_done_callback(self._background_tasks.discard)
821
+ except Exception as retry_err:
822
+ logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, retry_err)
823
+ # start_polling failed — polling is dead and no further error
824
+ # callbacks will fire, so schedule the next retry ourselves.
825
+ if not self.has_fatal_error:
826
+ task = asyncio.ensure_future(
827
+ self._handle_polling_network_error(retry_err)
828
+ )
829
+ self._background_tasks.add(task)
830
+ task.add_done_callback(self._background_tasks.discard)
831
+
832
+ async def _verify_polling_after_reconnect(self) -> None:
833
+ """Heartbeat probe scheduled after a successful reconnect.
834
+
835
+ PTB's Updater can survive a botched stop()+start_polling() cycle
836
+ with `running=True` but a wedged consumer task. No error callback
837
+ fires, so the reconnect ladder doesn't advance on its own. This
838
+ probe detects the wedge by:
839
+
840
+ 1. Sleeping HEARTBEAT_PROBE_DELAY so a healthy long-poll has time
841
+ to complete at least one cycle.
842
+ 2. Verifying `Updater.running` is still True.
843
+ 3. Probing the bot endpoint with a tight asyncio timeout. A
844
+ wedged httpx pool fails this probe; a healthy one returns
845
+ well under the timeout.
846
+
847
+ On any failure, re-enter the reconnect ladder so the existing
848
+ MAX_NETWORK_RETRIES path can ultimately escalate to fatal-error.
849
+ """
850
+ HEARTBEAT_PROBE_DELAY = 60
851
+ PROBE_TIMEOUT = 10
852
+
853
+ await asyncio.sleep(HEARTBEAT_PROBE_DELAY)
854
+
855
+ if self.has_fatal_error:
856
+ return
857
+ if not (self._app and self._app.updater and self._app.updater.running):
858
+ logger.warning(
859
+ "[%s] Updater not running %ds after reconnect — treating as wedged",
860
+ self.name, HEARTBEAT_PROBE_DELAY,
861
+ )
862
+ await self._handle_polling_network_error(
863
+ RuntimeError("Updater not running after reconnect heartbeat")
864
+ )
865
+ return
866
+
867
+ try:
868
+ await asyncio.wait_for(self._app.bot.get_me(), PROBE_TIMEOUT)
869
+ except Exception as probe_err:
870
+ logger.warning(
871
+ "[%s] Polling heartbeat probe failed %ds after reconnect: %s",
872
+ self.name, HEARTBEAT_PROBE_DELAY, probe_err,
873
+ )
874
+ await self._handle_polling_network_error(probe_err)
875
+
876
+ async def _handle_polling_conflict(self, error: Exception) -> None:
877
+ if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict":
878
+ return
879
+ # Track consecutive conflicts — transient 409s can occur when a
880
+ # previous gateway instance hasn't fully released its long-poll
881
+ # session on Telegram's server (e.g. during --replace handoffs or
882
+ # systemd Restart=on-failure respawns). Retry a few times before
883
+ # giving up, so the old session has time to expire.
884
+ self._polling_conflict_count += 1
885
+
886
+ MAX_CONFLICT_RETRIES = 3
887
+ RETRY_DELAY = 10 # seconds
888
+
889
+ if self._polling_conflict_count <= MAX_CONFLICT_RETRIES:
890
+ logger.warning(
891
+ "[%s] Telegram polling conflict (%d/%d), will retry in %ds. Error: %s",
892
+ self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
893
+ RETRY_DELAY, error,
894
+ )
895
+ try:
896
+ if self._app and self._app.updater and self._app.updater.running:
897
+ await self._app.updater.stop()
898
+ except Exception:
899
+ pass
900
+ await asyncio.sleep(RETRY_DELAY)
901
+ await self._drain_polling_connections()
902
+ try:
903
+ await self._app.updater.start_polling(
904
+ allowed_updates=Update.ALL_TYPES,
905
+ drop_pending_updates=False,
906
+ error_callback=self._polling_error_callback_ref,
907
+ )
908
+ logger.info("[%s] Telegram polling resumed after conflict retry %d", self.name, self._polling_conflict_count)
909
+ self._polling_conflict_count = 0 # reset on success
910
+ return
911
+ except Exception as retry_err:
912
+ logger.warning("[%s] Telegram polling retry failed: %s", self.name, retry_err)
913
+ # Don't fall through to fatal yet — wait for the next conflict
914
+ # to trigger another retry attempt (up to MAX_CONFLICT_RETRIES).
915
+ return
916
+
917
+ # Exhausted retries — fatal
918
+ message = (
919
+ "Another process is already polling this Telegram bot token "
920
+ "(possibly OpenClaw or another Hermes instance). "
921
+ "Hermes stopped Telegram polling after %d retries. "
922
+ "Only one poller can run per token — stop the other process "
923
+ "and restart with 'hermes start'."
924
+ % MAX_CONFLICT_RETRIES
925
+ )
926
+ logger.error("[%s] %s Original error: %s", self.name, message, error)
927
+ self._set_fatal_error("telegram_polling_conflict", message, retryable=False)
928
+ try:
929
+ if self._app and self._app.updater:
930
+ await self._app.updater.stop()
931
+ except Exception as stop_error:
932
+ logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True)
933
+ await self._notify_fatal_error()
934
+
935
+ async def _create_dm_topic(
936
+ self,
937
+ chat_id: int,
938
+ name: str,
939
+ icon_color: Optional[int] = None,
940
+ icon_custom_emoji_id: Optional[str] = None,
941
+ ) -> Optional[int]:
942
+ """Create a forum topic in a private (DM) chat.
943
+
944
+ Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats.
945
+ Returns the message_thread_id on success, None on failure.
946
+ """
947
+ if not self._bot:
948
+ return None
949
+ try:
950
+ kwargs: Dict[str, Any] = {"chat_id": chat_id, "name": name}
951
+ if icon_color is not None:
952
+ kwargs["icon_color"] = icon_color
953
+ if icon_custom_emoji_id:
954
+ kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
955
+
956
+ topic = await self._bot.create_forum_topic(**kwargs)
957
+ thread_id = topic.message_thread_id
958
+ logger.info(
959
+ "[%s] Created DM topic '%s' in chat %s -> thread_id=%s",
960
+ self.name, name, chat_id, thread_id,
961
+ )
962
+ return thread_id
963
+ except Exception as e:
964
+ error_text = str(e).lower()
965
+ # If topic already exists, try to find it via getForumTopicIconStickers
966
+ # or we just log and skip — Telegram doesn't provide a "list topics" API
967
+ if "topic_name_duplicate" in error_text or "already" in error_text:
968
+ logger.info(
969
+ "[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)",
970
+ self.name, name, chat_id,
971
+ )
972
+ elif "not a forum" in error_text or "forums_disabled" in error_text:
973
+ logger.warning(
974
+ "[%s] Cannot create DM topic '%s' in chat %s: Topics mode is not enabled. "
975
+ "The user must open the DM with this bot in Telegram, tap the bot name "
976
+ "at the top, and enable 'Topics' in chat settings before topics can be created.",
977
+ self.name, name, chat_id,
978
+ )
979
+ else:
980
+ logger.warning(
981
+ "[%s] Failed to create DM topic '%s' in chat %s: %s",
982
+ self.name, name, chat_id, e,
983
+ )
984
+ return None
985
+
986
+ async def create_handoff_thread(
987
+ self,
988
+ parent_chat_id: str,
989
+ name: str,
990
+ ) -> Optional[str]:
991
+ """Create a forum topic for a session handoff.
992
+
993
+ Works for DM topics (Bot API 9.4+, requires user to enable Topics
994
+ in their chat with the bot) and forum supergroups. Returns the
995
+ ``message_thread_id`` as a string, or ``None`` on failure.
996
+ """
997
+ try:
998
+ chat_id_int = int(parent_chat_id)
999
+ except (TypeError, ValueError):
1000
+ return None
1001
+ thread_id = await self._create_dm_topic(chat_id_int, name=name)
1002
+ return str(thread_id) if thread_id else None
1003
+
1004
+ async def rename_dm_topic(
1005
+ self,
1006
+ chat_id: int,
1007
+ thread_id: int,
1008
+ name: str,
1009
+ ) -> None:
1010
+ """Rename a forum topic in a private (DM) chat."""
1011
+ if not self._bot:
1012
+ return
1013
+ try:
1014
+ chat_id_arg = int(chat_id)
1015
+ except (TypeError, ValueError):
1016
+ chat_id_arg = chat_id
1017
+ await self._bot.edit_forum_topic(
1018
+ chat_id=chat_id_arg,
1019
+ message_thread_id=int(thread_id),
1020
+ name=name,
1021
+ )
1022
+ logger.info(
1023
+ "[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'",
1024
+ self.name, chat_id, thread_id, name,
1025
+ )
1026
+
1027
+ def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
1028
+ """Save a newly created thread_id back into config.yaml so it persists across restarts."""
1029
+ try:
1030
+ from calvyn_constants import get_hermes_home
1031
+ config_path = get_hermes_home() / "config.yaml"
1032
+ if not config_path.exists():
1033
+ logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
1034
+ return
1035
+
1036
+ import yaml as _yaml
1037
+ with open(config_path, "r", encoding="utf-8") as f:
1038
+ config = _yaml.safe_load(f) or {}
1039
+
1040
+ # Navigate to platforms.telegram.extra.dm_topics
1041
+ dm_topics = (
1042
+ config.get("platforms", {})
1043
+ .get("telegram", {})
1044
+ .get("extra", {})
1045
+ .get("dm_topics", [])
1046
+ )
1047
+ if not dm_topics:
1048
+ return
1049
+
1050
+ changed = False
1051
+ for chat_entry in dm_topics:
1052
+ if int(chat_entry.get("chat_id", 0)) != int(chat_id):
1053
+ continue
1054
+ for t in chat_entry.get("topics", []):
1055
+ if t.get("name") == topic_name and not t.get("thread_id"):
1056
+ t["thread_id"] = thread_id
1057
+ changed = True
1058
+ break
1059
+
1060
+ if changed:
1061
+ fd, tmp_path = tempfile.mkstemp(
1062
+ dir=str(config_path.parent),
1063
+ suffix=".tmp",
1064
+ prefix=".config_",
1065
+ )
1066
+ try:
1067
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1068
+ _yaml.dump(config, f, default_flow_style=False, sort_keys=False)
1069
+ f.flush()
1070
+ os.fsync(f.fileno())
1071
+ atomic_replace(tmp_path, config_path)
1072
+ except BaseException:
1073
+ try:
1074
+ os.unlink(tmp_path)
1075
+ except OSError:
1076
+ pass
1077
+ raise
1078
+ logger.info(
1079
+ "[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
1080
+ self.name, thread_id, topic_name,
1081
+ )
1082
+ except Exception as e:
1083
+ logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True)
1084
+
1085
+ async def _setup_dm_topics(self) -> None:
1086
+ """Load or create configured DM topics for specified chats.
1087
+
1088
+ Reads config.extra['dm_topics'] — a list of dicts:
1089
+ [
1090
+ {
1091
+ "chat_id": 123456789,
1092
+ "topics": [
1093
+ {"name": "General", "icon_color": 7322096, "thread_id": 100},
1094
+ {"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"}
1095
+ ]
1096
+ }
1097
+ ]
1098
+
1099
+ If a topic already has a thread_id in the config (persisted from a previous
1100
+ creation), it is loaded into the cache without calling createForumTopic.
1101
+ Only topics without a thread_id are created via the API, and their thread_id
1102
+ is then saved back to config.yaml for future restarts.
1103
+ """
1104
+ if not self._dm_topics_config:
1105
+ return
1106
+
1107
+ for chat_entry in self._dm_topics_config:
1108
+ chat_id = chat_entry.get("chat_id")
1109
+ topics = chat_entry.get("topics", [])
1110
+ if not chat_id or not topics:
1111
+ continue
1112
+
1113
+ logger.info(
1114
+ "[%s] Setting up %d DM topic(s) for chat %s",
1115
+ self.name, len(topics), chat_id,
1116
+ )
1117
+
1118
+ for topic_conf in topics:
1119
+ topic_name = topic_conf.get("name")
1120
+ if not topic_name:
1121
+ continue
1122
+
1123
+ cache_key = f"{chat_id}:{topic_name}"
1124
+
1125
+ # If thread_id is already persisted in config, just load into cache
1126
+ existing_thread_id = topic_conf.get("thread_id")
1127
+ if existing_thread_id:
1128
+ self._dm_topics[cache_key] = int(existing_thread_id)
1129
+ logger.info(
1130
+ "[%s] DM topic loaded from config: %s -> thread_id=%s",
1131
+ self.name, cache_key, existing_thread_id,
1132
+ )
1133
+ continue
1134
+
1135
+ # No persisted thread_id — create the topic via API
1136
+ icon_color = topic_conf.get("icon_color")
1137
+ icon_emoji = topic_conf.get("icon_custom_emoji_id")
1138
+
1139
+ thread_id = await self._create_dm_topic(
1140
+ chat_id=int(chat_id),
1141
+ name=topic_name,
1142
+ icon_color=icon_color,
1143
+ icon_custom_emoji_id=icon_emoji,
1144
+ )
1145
+
1146
+ if thread_id:
1147
+ self._dm_topics[cache_key] = thread_id
1148
+ logger.info(
1149
+ "[%s] DM topic cached: %s -> thread_id=%s",
1150
+ self.name, cache_key, thread_id,
1151
+ )
1152
+ # Persist thread_id to config so we don't recreate on next restart
1153
+ self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
1154
+
1155
+ # Send a seed message so the topic is visible in Telegram's client.
1156
+ # Empty topics are hidden by the client UI until they contain a message.
1157
+ try:
1158
+ await self._bot.send_message(
1159
+ chat_id=int(chat_id),
1160
+ message_thread_id=thread_id,
1161
+ text=f"\U0001f4cc {topic_name}",
1162
+ )
1163
+ except Exception as seed_err:
1164
+ logger.debug(
1165
+ "[%s] Could not send seed message to topic '%s': %s",
1166
+ self.name, topic_name, seed_err,
1167
+ )
1168
+
1169
+ async def connect(self) -> bool:
1170
+ """Connect to Telegram via polling or webhook.
1171
+
1172
+ By default, uses long polling (outbound connection to Telegram).
1173
+ If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server
1174
+ instead. Webhook mode is useful for cloud deployments (Fly.io,
1175
+ Railway) where inbound HTTP can wake a suspended machine.
1176
+
1177
+ Env vars for webhook mode::
1178
+
1179
+ TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
1180
+ TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
1181
+ TELEGRAM_WEBHOOK_SECRET Secret token for update verification
1182
+ """
1183
+ if not TELEGRAM_AVAILABLE:
1184
+ logger.error(
1185
+ "[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
1186
+ self.name,
1187
+ )
1188
+ return False
1189
+
1190
+ if not self.config.token:
1191
+ logger.error("[%s] No bot token configured", self.name)
1192
+ return False
1193
+
1194
+ try:
1195
+ if not self._acquire_platform_lock('telegram-bot-token', self.config.token, 'Telegram bot token'):
1196
+ return False
1197
+
1198
+ # Build the application
1199
+ builder = Application.builder().token(self.config.token)
1200
+ custom_base_url = self.config.extra.get("base_url")
1201
+ if custom_base_url:
1202
+ builder = builder.base_url(custom_base_url)
1203
+ builder = builder.base_file_url(
1204
+ self.config.extra.get("base_file_url", custom_base_url)
1205
+ )
1206
+ logger.info(
1207
+ "[%s] Using custom Telegram base_url: %s",
1208
+ self.name, custom_base_url,
1209
+ )
1210
+
1211
+ # PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and
1212
+ # can trigger "Pool timeout: All connections in the connection pool are occupied"
1213
+ # during reconnect/bootstrap. Use safer defaults and allow env overrides.
1214
+ def _env_int(name: str, default: int) -> int:
1215
+ try:
1216
+ return int(os.getenv(name, str(default)))
1217
+ except (TypeError, ValueError):
1218
+ return default
1219
+
1220
+ def _env_float(name: str, default: float) -> float:
1221
+ try:
1222
+ return float(os.getenv(name, str(default)))
1223
+ except (TypeError, ValueError):
1224
+ return default
1225
+
1226
+ request_kwargs = {
1227
+ "connection_pool_size": _env_int("HERMES_TELEGRAM_HTTP_POOL_SIZE", 512),
1228
+ "pool_timeout": _env_float("HERMES_TELEGRAM_HTTP_POOL_TIMEOUT", 8.0),
1229
+ "connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0),
1230
+ "read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0),
1231
+ "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
1232
+ }
1233
+
1234
+ disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"})
1235
+ fallback_ips = self._fallback_ips()
1236
+ if not fallback_ips:
1237
+ fallback_ips = await discover_fallback_ips()
1238
+ logger.info(
1239
+ "[%s] Auto-discovered Telegram fallback IPs: %s",
1240
+ self.name,
1241
+ ", ".join(fallback_ips),
1242
+ )
1243
+
1244
+ proxy_targets = ["api.telegram.org", *fallback_ips]
1245
+ proxy_url = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=proxy_targets)
1246
+ if fallback_ips and not proxy_url and not disable_fallback:
1247
+ logger.info(
1248
+ "[%s] Telegram fallback IPs active: %s",
1249
+ self.name,
1250
+ ", ".join(fallback_ips),
1251
+ )
1252
+ # Keep request/update pools separate to reduce contention during
1253
+ # polling reconnect + bot API bootstrap/delete_webhook calls.
1254
+ request = HTTPXRequest(
1255
+ **request_kwargs,
1256
+ httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
1257
+ )
1258
+ get_updates_request = HTTPXRequest(
1259
+ **request_kwargs,
1260
+ httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
1261
+ )
1262
+ elif proxy_url:
1263
+ logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url)
1264
+ request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
1265
+ get_updates_request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
1266
+ else:
1267
+ if disable_fallback:
1268
+ logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
1269
+ request = HTTPXRequest(**request_kwargs)
1270
+ get_updates_request = HTTPXRequest(**request_kwargs)
1271
+
1272
+ builder = builder.request(request).get_updates_request(get_updates_request)
1273
+ self._app = builder.build()
1274
+ self._bot = self._app.bot
1275
+
1276
+ # Register handlers
1277
+ self._app.add_handler(TelegramMessageHandler(
1278
+ filters.TEXT & ~filters.COMMAND,
1279
+ self._handle_text_message
1280
+ ))
1281
+ self._app.add_handler(TelegramMessageHandler(
1282
+ filters.COMMAND,
1283
+ self._handle_command
1284
+ ))
1285
+ self._app.add_handler(TelegramMessageHandler(
1286
+ filters.LOCATION | getattr(filters, "VENUE", filters.LOCATION),
1287
+ self._handle_location_message
1288
+ ))
1289
+ self._app.add_handler(TelegramMessageHandler(
1290
+ filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL,
1291
+ self._handle_media_message
1292
+ ))
1293
+ # Handle inline keyboard button callbacks (update prompts)
1294
+ self._app.add_handler(CallbackQueryHandler(self._handle_callback_query))
1295
+
1296
+ # Start polling — retry initialize() for transient TLS resets
1297
+ try:
1298
+ from telegram.error import NetworkError, TimedOut
1299
+ except ImportError:
1300
+ NetworkError = TimedOut = OSError # type: ignore[misc,assignment]
1301
+ _max_connect = 8
1302
+ for _attempt in range(_max_connect):
1303
+ try:
1304
+ await self._app.initialize()
1305
+ break
1306
+ except (NetworkError, TimedOut, OSError) as init_err:
1307
+ if _attempt < _max_connect - 1:
1308
+ wait = min(2 ** _attempt, 15)
1309
+ logger.warning(
1310
+ "[%s] Connect attempt %d/%d failed: %s — retrying in %ds",
1311
+ self.name, _attempt + 1, _max_connect, init_err, wait,
1312
+ )
1313
+ await asyncio.sleep(wait)
1314
+ else:
1315
+ raise
1316
+ await self._app.start()
1317
+
1318
+ # Decide between webhook and polling mode
1319
+ webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
1320
+
1321
+ if webhook_url:
1322
+ # ── Webhook mode ─────────────────────────────────────
1323
+ # Telegram pushes updates to our HTTP endpoint. This
1324
+ # enables cloud platforms (Fly.io, Railway) to auto-wake
1325
+ # suspended machines on inbound HTTP traffic.
1326
+ #
1327
+ # SECURITY: TELEGRAM_WEBHOOK_SECRET is REQUIRED. Without it,
1328
+ # python-telegram-bot passes secret_token=None and the
1329
+ # webhook endpoint accepts any HTTP POST — attackers can
1330
+ # inject forged updates as if from Telegram. Refuse to
1331
+ # start rather than silently run in fail-open mode.
1332
+ # See GHSA-3vpc-7q5r-276h.
1333
+ webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443"))
1334
+ webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip()
1335
+ if not webhook_secret:
1336
+ raise RuntimeError(
1337
+ "TELEGRAM_WEBHOOK_SECRET is required when "
1338
+ "TELEGRAM_WEBHOOK_URL is set. Without it, the "
1339
+ "webhook endpoint accepts forged updates from "
1340
+ "anyone who can reach it — see "
1341
+ "https://github.com/NousResearch/hermes-agent/"
1342
+ "security/advisories/GHSA-3vpc-7q5r-276h.\n\n"
1343
+ "Generate a secret and set it in your .env:\n"
1344
+ " export TELEGRAM_WEBHOOK_SECRET=\"$(openssl rand -hex 32)\"\n\n"
1345
+ "Then register it with Telegram when setting the "
1346
+ "webhook via setWebhook's secret_token parameter."
1347
+ )
1348
+ from urllib.parse import urlparse
1349
+ webhook_path = urlparse(webhook_url).path or "/telegram"
1350
+
1351
+ await self._app.updater.start_webhook(
1352
+ listen="0.0.0.0",
1353
+ port=webhook_port,
1354
+ url_path=webhook_path,
1355
+ webhook_url=webhook_url,
1356
+ secret_token=webhook_secret,
1357
+ allowed_updates=Update.ALL_TYPES,
1358
+ drop_pending_updates=True,
1359
+ )
1360
+ self._webhook_mode = True
1361
+ logger.info(
1362
+ "[%s] Webhook server listening on 0.0.0.0:%d%s",
1363
+ self.name, webhook_port, webhook_path,
1364
+ )
1365
+ else:
1366
+ # ── Polling mode (default) ───────────────────────────
1367
+ # Clear any stale webhook first so polling doesn't inherit a
1368
+ # previous webhook registration and silently stop receiving updates.
1369
+ delete_webhook = getattr(self._bot, "delete_webhook", None)
1370
+ if callable(delete_webhook):
1371
+ await delete_webhook(drop_pending_updates=False)
1372
+
1373
+ loop = asyncio.get_running_loop()
1374
+
1375
+ def _polling_error_callback(error: Exception) -> None:
1376
+ if self._polling_error_task and not self._polling_error_task.done():
1377
+ return
1378
+ if self._looks_like_polling_conflict(error):
1379
+ self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
1380
+ elif self._looks_like_network_error(error):
1381
+ logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
1382
+ self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
1383
+ else:
1384
+ logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
1385
+
1386
+ # Store reference for retry use in _handle_polling_conflict
1387
+ self._polling_error_callback_ref = _polling_error_callback
1388
+
1389
+ await self._app.updater.start_polling(
1390
+ allowed_updates=Update.ALL_TYPES,
1391
+ drop_pending_updates=True,
1392
+ error_callback=_polling_error_callback,
1393
+ )
1394
+
1395
+ # Register bot commands so Telegram shows a hint menu when users type /
1396
+ # List is derived from the central COMMAND_REGISTRY — adding a new
1397
+ # gateway command there automatically adds it to the Telegram menu.
1398
+ try:
1399
+ from telegram import BotCommand
1400
+ from hermes_cli.commands import telegram_menu_commands
1401
+ # Telegram allows up to 100 commands but has an undocumented
1402
+ # payload size limit. Skill descriptions are truncated to 40
1403
+ # chars in telegram_menu_commands() to fit 100 commands safely.
1404
+ menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
1405
+ await self._bot.set_my_commands([
1406
+ BotCommand(name, desc) for name, desc in menu_commands
1407
+ ])
1408
+ if hidden_count:
1409
+ logger.info(
1410
+ "[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
1411
+ self.name, len(menu_commands), hidden_count,
1412
+ )
1413
+ except Exception as e:
1414
+ logger.warning(
1415
+ "[%s] Could not register Telegram command menu: %s",
1416
+ self.name,
1417
+ e,
1418
+ exc_info=True,
1419
+ )
1420
+
1421
+ self._mark_connected()
1422
+ mode = "webhook" if self._webhook_mode else "polling"
1423
+ logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
1424
+
1425
+ # Set up DM topics (Bot API 9.4 — Private Chat Topics)
1426
+ # Runs after connection is established so the bot can call createForumTopic.
1427
+ # Failures here are non-fatal — the bot works fine without topics.
1428
+ try:
1429
+ await self._setup_dm_topics()
1430
+ except Exception as topics_err:
1431
+ logger.warning(
1432
+ "[%s] DM topics setup failed (non-fatal): %s",
1433
+ self.name, topics_err, exc_info=True,
1434
+ )
1435
+
1436
+ return True
1437
+
1438
+ except Exception as e:
1439
+ self._release_platform_lock()
1440
+ message = f"Telegram startup failed: {e}"
1441
+ self._set_fatal_error("telegram_connect_error", message, retryable=True)
1442
+ logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
1443
+ return False
1444
+
1445
+ async def disconnect(self) -> None:
1446
+ """Stop polling/webhook, cancel pending album flushes, and disconnect."""
1447
+ pending_media_group_tasks = list(self._media_group_tasks.values())
1448
+ for task in pending_media_group_tasks:
1449
+ task.cancel()
1450
+ if pending_media_group_tasks:
1451
+ await asyncio.gather(*pending_media_group_tasks, return_exceptions=True)
1452
+ self._media_group_tasks.clear()
1453
+ self._media_group_events.clear()
1454
+
1455
+ if self._app:
1456
+ try:
1457
+ # Only stop the updater if it's running
1458
+ if self._app.updater and self._app.updater.running:
1459
+ await self._app.updater.stop()
1460
+ if self._app.running:
1461
+ await self._app.stop()
1462
+ await self._app.shutdown()
1463
+ except Exception as e:
1464
+ logger.warning("[%s] Error during Telegram disconnect: %s", self.name, e, exc_info=True)
1465
+ self._release_platform_lock()
1466
+
1467
+ for task in self._pending_photo_batch_tasks.values():
1468
+ if task and not task.done():
1469
+ task.cancel()
1470
+ self._pending_photo_batch_tasks.clear()
1471
+ self._pending_photo_batches.clear()
1472
+
1473
+ self._mark_disconnected()
1474
+ self._app = None
1475
+ self._bot = None
1476
+ logger.info("[%s] Disconnected from Telegram", self.name)
1477
+
1478
+ def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool:
1479
+ """Determine if this message chunk should thread to the original message.
1480
+
1481
+ Args:
1482
+ reply_to: The original message ID to reply to
1483
+ chunk_index: Index of this chunk (0 = first chunk)
1484
+
1485
+ Returns:
1486
+ True if this chunk should be threaded to the original message
1487
+ """
1488
+ if not reply_to:
1489
+ return False
1490
+ mode = self._reply_to_mode
1491
+ if mode == "off":
1492
+ return False
1493
+ elif mode == "all":
1494
+ return True
1495
+ else: # "first" (default)
1496
+ return chunk_index == 0
1497
+
1498
+ async def send(
1499
+ self,
1500
+ chat_id: str,
1501
+ content: str,
1502
+ reply_to: Optional[str] = None,
1503
+ metadata: Optional[Dict[str, Any]] = None
1504
+ ) -> SendResult:
1505
+ """Send a message to a Telegram chat."""
1506
+ if not self._bot:
1507
+ return SendResult(success=False, error="Not connected")
1508
+
1509
+ # Skip whitespace-only text to prevent Telegram 400 empty-text errors.
1510
+ if not content or not content.strip():
1511
+ return SendResult(success=True, message_id=None)
1512
+
1513
+ try:
1514
+ # Format and split message if needed
1515
+ formatted = self.format_message(content)
1516
+ chunks = self.truncate_message(
1517
+ formatted, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
1518
+ )
1519
+ if len(chunks) > 1:
1520
+ # truncate_message appends a raw " (1/2)" suffix. Escape the
1521
+ # MarkdownV2-special parentheses so Telegram doesn't reject the
1522
+ # chunk and fall back to plain text.
1523
+ chunks = [
1524
+ re.sub(r" \((\d+)/(\d+)\)$", r" \\(\1/\2\\)", chunk)
1525
+ for chunk in chunks
1526
+ ]
1527
+
1528
+ message_ids = []
1529
+ thread_id = self._metadata_thread_id(metadata)
1530
+
1531
+ try:
1532
+ from telegram.error import NetworkError as _NetErr
1533
+ except ImportError:
1534
+ _NetErr = OSError # type: ignore[misc,assignment]
1535
+
1536
+ try:
1537
+ from telegram.error import BadRequest as _BadReq
1538
+ except ImportError:
1539
+ _BadReq = None # type: ignore[assignment,misc]
1540
+
1541
+ try:
1542
+ from telegram.error import TimedOut as _TimedOut
1543
+ except (ImportError, AttributeError):
1544
+ _TimedOut = None # type: ignore[assignment,misc]
1545
+
1546
+ for i, chunk in enumerate(chunks):
1547
+ metadata_reply_to = self._metadata_reply_to_message_id(metadata)
1548
+ reply_to_source = reply_to or (
1549
+ str(metadata_reply_to)
1550
+ if metadata and metadata.get("telegram_dm_topic_reply_fallback") and metadata_reply_to is not None else None
1551
+ )
1552
+ if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
1553
+ should_thread = reply_to_source is not None
1554
+ else:
1555
+ should_thread = self._should_thread_reply(reply_to_source, i)
1556
+ reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None
1557
+ thread_kwargs = self._thread_kwargs_for_send(
1558
+ chat_id,
1559
+ thread_id,
1560
+ metadata,
1561
+ reply_to_message_id=reply_to_id,
1562
+ )
1563
+ effective_thread_id = thread_kwargs.get("message_thread_id")
1564
+
1565
+ msg = None
1566
+ for _send_attempt in range(3):
1567
+ try:
1568
+ # Try Markdown first, fall back to plain text if it fails
1569
+ try:
1570
+ msg = await self._bot.send_message(
1571
+ chat_id=int(chat_id),
1572
+ text=chunk,
1573
+ parse_mode=ParseMode.MARKDOWN_V2,
1574
+ reply_to_message_id=reply_to_id,
1575
+ **thread_kwargs,
1576
+ **self._link_preview_kwargs(),
1577
+ **self._notification_kwargs(metadata),
1578
+ )
1579
+ except Exception as md_error:
1580
+ # Markdown parsing failed, try plain text
1581
+ if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
1582
+ logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error)
1583
+ plain_chunk = _strip_mdv2(chunk)
1584
+ msg = await self._bot.send_message(
1585
+ chat_id=int(chat_id),
1586
+ text=plain_chunk,
1587
+ parse_mode=None,
1588
+ reply_to_message_id=reply_to_id,
1589
+ **thread_kwargs,
1590
+ **self._link_preview_kwargs(),
1591
+ **self._notification_kwargs(metadata),
1592
+ )
1593
+ else:
1594
+ raise
1595
+ break # success
1596
+ except _NetErr as send_err:
1597
+ # BadRequest is a subclass of NetworkError in
1598
+ # python-telegram-bot but represents permanent errors
1599
+ # (not transient network issues). Detect and handle
1600
+ # specific cases instead of blindly retrying.
1601
+ if _BadReq and isinstance(send_err, _BadReq):
1602
+ if self._is_thread_not_found_error(send_err) and effective_thread_id is not None:
1603
+ # Thread doesn't exist — retry without
1604
+ # message_thread_id so the message still
1605
+ # reaches the chat.
1606
+ logger.warning(
1607
+ "[%s] Thread %s not found, retrying without message_thread_id",
1608
+ self.name, effective_thread_id,
1609
+ )
1610
+ effective_thread_id = None
1611
+ thread_kwargs = {"message_thread_id": None}
1612
+ continue
1613
+ err_lower = str(send_err).lower()
1614
+ if "message to be replied not found" in err_lower and reply_to_id is not None:
1615
+ # Original message was deleted before we
1616
+ # could reply. For private-topic fallback
1617
+ # sends, message_thread_id is only valid with
1618
+ # the reply anchor, so drop both together.
1619
+ logger.warning(
1620
+ "[%s] Reply target deleted, retrying without reply_to: %s",
1621
+ self.name, send_err,
1622
+ )
1623
+ reply_to_id = None
1624
+ if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
1625
+ thread_kwargs = {}
1626
+ effective_thread_id = None
1627
+ else:
1628
+ thread_kwargs = self._thread_kwargs_for_send(
1629
+ chat_id,
1630
+ thread_id,
1631
+ metadata,
1632
+ reply_to_message_id=reply_to_id,
1633
+ )
1634
+ effective_thread_id = thread_kwargs.get("message_thread_id")
1635
+ continue
1636
+ # Other BadRequest errors are permanent — don't retry
1637
+ raise
1638
+ # TimedOut is also a subclass of NetworkError but
1639
+ # indicates the request may have reached the server —
1640
+ # retrying risks duplicate message delivery.
1641
+ if _TimedOut and isinstance(send_err, _TimedOut):
1642
+ raise
1643
+ if _send_attempt < 2:
1644
+ wait = 2 ** _send_attempt
1645
+ logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
1646
+ self.name, _send_attempt + 1, wait, send_err)
1647
+ await asyncio.sleep(wait)
1648
+ else:
1649
+ raise
1650
+ except Exception as send_err:
1651
+ retry_after = getattr(send_err, "retry_after", None)
1652
+ if retry_after is not None or "retry after" in str(send_err).lower():
1653
+ if _send_attempt < 2:
1654
+ wait = float(retry_after) if retry_after is not None else 1.0
1655
+ logger.warning(
1656
+ "[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s",
1657
+ self.name,
1658
+ _send_attempt + 1,
1659
+ wait,
1660
+ send_err,
1661
+ )
1662
+ await asyncio.sleep(wait)
1663
+ continue
1664
+ raise
1665
+ message_ids.append(str(msg.message_id))
1666
+
1667
+ return SendResult(
1668
+ success=True,
1669
+ message_id=message_ids[0] if message_ids else None,
1670
+ raw_response={"message_ids": message_ids}
1671
+ )
1672
+
1673
+ except Exception as e:
1674
+ logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True)
1675
+ err_str = str(e).lower()
1676
+ # Message too long — content exceeded 4096 chars. Return failure so
1677
+ # stream consumer enters fallback mode and sends the remainder.
1678
+ if "message_too_long" in err_str or "too long" in err_str:
1679
+ logger.debug(
1680
+ "[%s] send() content too long, falling back to new-message continuation",
1681
+ self.name,
1682
+ )
1683
+ return SendResult(success=False, error="message_too_long")
1684
+ # TimedOut means the request may have reached Telegram —
1685
+ # mark as non-retryable so _send_with_retry() doesn't re-send.
1686
+ _to = locals().get("_TimedOut")
1687
+ is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
1688
+ return SendResult(success=False, error=str(e), retryable=not is_timeout)
1689
+
1690
+ async def edit_message(
1691
+ self,
1692
+ chat_id: str,
1693
+ message_id: str,
1694
+ content: str,
1695
+ *,
1696
+ finalize: bool = False,
1697
+ ) -> SendResult:
1698
+ """Edit a previously sent Telegram message.
1699
+
1700
+ Telegram caps single-message text at 4096 UTF-16 codeunits. Streaming
1701
+ replies that grow past this limit must NOT be silently truncated and
1702
+ must NOT return failure (the consumer would re-send and create a
1703
+ duplicate). Instead this method split-and-delivers: edit the
1704
+ existing message with the first chunk and send the rest as
1705
+ continuation messages, returning the final chunk's id so subsequent
1706
+ edits target the most recent visible message.
1707
+ """
1708
+ if not self._bot:
1709
+ return SendResult(success=False, error="Not connected")
1710
+
1711
+ # Pre-flight: if content already exceeds the limit, split-and-deliver
1712
+ # without round-tripping a doomed edit.
1713
+ if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
1714
+ return await self._edit_overflow_split(
1715
+ chat_id, message_id, content, finalize=finalize,
1716
+ )
1717
+
1718
+ try:
1719
+ if not finalize:
1720
+ await self._bot.edit_message_text(
1721
+ chat_id=int(chat_id),
1722
+ message_id=int(message_id),
1723
+ text=content,
1724
+ )
1725
+ return SendResult(success=True, message_id=message_id)
1726
+
1727
+ formatted = self.format_message(content)
1728
+ try:
1729
+ await self._bot.edit_message_text(
1730
+ chat_id=int(chat_id),
1731
+ message_id=int(message_id),
1732
+ text=formatted,
1733
+ parse_mode=ParseMode.MARKDOWN_V2,
1734
+ )
1735
+ except Exception as fmt_err:
1736
+ # "Message is not modified" is a no-op, not an error
1737
+ if "not modified" in str(fmt_err).lower():
1738
+ return SendResult(success=True, message_id=message_id)
1739
+ # Fallback: retry without markdown formatting
1740
+ await self._bot.edit_message_text(
1741
+ chat_id=int(chat_id),
1742
+ message_id=int(message_id),
1743
+ text=content,
1744
+ )
1745
+ return SendResult(success=True, message_id=message_id)
1746
+ except Exception as e:
1747
+ err_str = str(e).lower()
1748
+ # "Message is not modified" — content identical, treat as success
1749
+ if "not modified" in err_str:
1750
+ return SendResult(success=True, message_id=message_id)
1751
+ # Reactive split-and-deliver: parse_mode formatting can inflate
1752
+ # the payload past the limit even when the raw text was under
1753
+ # (e.g. MarkdownV2 escapes). Same fix as the pre-flight path.
1754
+ if "message_too_long" in err_str or "too long" in err_str:
1755
+ logger.debug(
1756
+ "[%s] edit_message overflow (%d UTF-16 > %d), splitting",
1757
+ self.name, utf16_len(content), self.MAX_MESSAGE_LENGTH,
1758
+ )
1759
+ return await self._edit_overflow_split(
1760
+ chat_id, message_id, content, finalize=finalize,
1761
+ )
1762
+ # Flood control / RetryAfter — short waits are retried inline,
1763
+ # long waits return a failure immediately so streaming can fall back
1764
+ # to a normal final send instead of leaving a truncated partial.
1765
+ retry_after = getattr(e, "retry_after", None)
1766
+ if retry_after is not None or "retry after" in err_str:
1767
+ wait = retry_after if retry_after else 1.0
1768
+ logger.warning(
1769
+ "[%s] Telegram flood control, waiting %.1fs",
1770
+ self.name, wait,
1771
+ )
1772
+ if wait > 5.0:
1773
+ return SendResult(success=False, error=f"flood_control:{wait}")
1774
+ await asyncio.sleep(wait)
1775
+ try:
1776
+ await self._bot.edit_message_text(
1777
+ chat_id=int(chat_id),
1778
+ message_id=int(message_id),
1779
+ text=content,
1780
+ )
1781
+ return SendResult(success=True, message_id=message_id)
1782
+ except Exception as retry_err:
1783
+ logger.error(
1784
+ "[%s] Edit retry failed after flood wait: %s",
1785
+ self.name, retry_err,
1786
+ )
1787
+ return SendResult(success=False, error=str(retry_err))
1788
+ logger.error(
1789
+ "[%s] Failed to edit Telegram message %s: %s",
1790
+ self.name,
1791
+ message_id,
1792
+ e,
1793
+ exc_info=True,
1794
+ )
1795
+ return SendResult(success=False, error=str(e))
1796
+
1797
+ async def _edit_overflow_split(
1798
+ self,
1799
+ chat_id: str,
1800
+ message_id: str,
1801
+ content: str,
1802
+ *,
1803
+ finalize: bool,
1804
+ ) -> SendResult:
1805
+ """Split an oversized edit across the existing message + continuations.
1806
+
1807
+ Edit the original ``message_id`` with chunk 1 (with the platform's
1808
+ usual ``(1/N)`` suffix preserved), then send the remaining chunks as
1809
+ new messages threaded as replies to the previous chunk so the user
1810
+ sees them grouped. Returns ``SendResult(success=True,
1811
+ message_id=<last-chunk-id>, continuation_message_ids=(...))`` so the
1812
+ stream consumer can keep editing the most recent visible message
1813
+ and the gateway has full visibility into every message id we put on
1814
+ screen.
1815
+
1816
+ Falls back to ``SendResult(success=False)`` only if even the first-
1817
+ chunk edit fails — that's a real adapter problem, not an overflow.
1818
+ """
1819
+ chunks = self.truncate_message(
1820
+ content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
1821
+ )
1822
+ if len(chunks) <= 1:
1823
+ # Defensive: shouldn't happen given the caller's pre-flight, but
1824
+ # if truncate_message returned a single chunk just edit normally.
1825
+ chunks = [content]
1826
+
1827
+ # Step 1 — edit the existing message with the first chunk.
1828
+ first_chunk = chunks[0]
1829
+ try:
1830
+ if finalize:
1831
+ # Use format_message + parse_mode for the final chunk;
1832
+ # mirror edit_message's main happy-path.
1833
+ formatted = self.format_message(first_chunk)
1834
+ try:
1835
+ await self._bot.edit_message_text(
1836
+ chat_id=int(chat_id),
1837
+ message_id=int(message_id),
1838
+ text=formatted,
1839
+ parse_mode=ParseMode.MARKDOWN_V2,
1840
+ )
1841
+ except Exception as fmt_err:
1842
+ if "not modified" not in str(fmt_err).lower():
1843
+ await self._bot.edit_message_text(
1844
+ chat_id=int(chat_id),
1845
+ message_id=int(message_id),
1846
+ text=first_chunk,
1847
+ )
1848
+ else:
1849
+ await self._bot.edit_message_text(
1850
+ chat_id=int(chat_id),
1851
+ message_id=int(message_id),
1852
+ text=first_chunk,
1853
+ )
1854
+ except Exception as e:
1855
+ err_str = str(e).lower()
1856
+ if "not modified" in err_str:
1857
+ # First chunk identical to current text — fall through to
1858
+ # send continuations.
1859
+ pass
1860
+ else:
1861
+ logger.error(
1862
+ "[%s] Overflow split: first-chunk edit failed: %s",
1863
+ self.name, e, exc_info=True,
1864
+ )
1865
+ return SendResult(success=False, error=str(e))
1866
+
1867
+ # Step 2 — send each remaining chunk as a continuation message,
1868
+ # threaded as a reply to the previous so the user sees them as a
1869
+ # contiguous block. We call self._bot.send_message directly so the
1870
+ # continuation skips ``self.send``'s own pre-chunking pass (chunks
1871
+ # are already correctly sized). Best-effort MarkdownV2 with plain
1872
+ # fallback, mirroring send().
1873
+ continuation_ids: list[str] = []
1874
+ prev_id = message_id
1875
+ for chunk in chunks[1:]:
1876
+ sent_msg = None
1877
+ for use_markdown in (True, False) if finalize else (False,):
1878
+ try:
1879
+ text = self.format_message(chunk) if use_markdown else chunk
1880
+ sent_msg = await self._bot.send_message(
1881
+ chat_id=int(chat_id),
1882
+ text=text,
1883
+ parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None,
1884
+ reply_to_message_id=int(prev_id) if prev_id else None,
1885
+ )
1886
+ break
1887
+ except Exception as send_err:
1888
+ if "reply message not found" in str(send_err).lower():
1889
+ # Drop the reply anchor and try again.
1890
+ try:
1891
+ sent_msg = await self._bot.send_message(
1892
+ chat_id=int(chat_id),
1893
+ text=chunk,
1894
+ )
1895
+ break
1896
+ except Exception as _retry_err:
1897
+ logger.warning(
1898
+ "[%s] Overflow continuation no-reply retry failed: %s",
1899
+ self.name, _retry_err,
1900
+ )
1901
+ sent_msg = None
1902
+ break
1903
+ if use_markdown:
1904
+ # try plain text on next loop iteration
1905
+ continue
1906
+ logger.warning(
1907
+ "[%s] Overflow continuation send failed: %s",
1908
+ self.name, send_err,
1909
+ )
1910
+ sent_msg = None
1911
+ break
1912
+ if sent_msg is None:
1913
+ # Continuation failed — the user has chunk 1 + however many
1914
+ # continuations succeeded. Report success with what we got
1915
+ # so the stream consumer knows the edit landed; the
1916
+ # remaining tail is lost on this attempt and the next
1917
+ # streaming tick may retry.
1918
+ logger.warning(
1919
+ "[%s] Overflow split: stopped at %d/%d chunks delivered",
1920
+ self.name, 1 + len(continuation_ids), len(chunks),
1921
+ )
1922
+ break
1923
+ new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
1924
+ continuation_ids.append(new_id)
1925
+ prev_id = new_id
1926
+
1927
+ last_id = continuation_ids[-1] if continuation_ids else message_id
1928
+ logger.debug(
1929
+ "[%s] Overflow split delivered %d chunks; last_id=%s",
1930
+ self.name, 1 + len(continuation_ids), last_id,
1931
+ )
1932
+ return SendResult(
1933
+ success=True,
1934
+ message_id=last_id,
1935
+ continuation_message_ids=tuple(continuation_ids),
1936
+ )
1937
+
1938
+ async def delete_message(self, chat_id: str, message_id: str) -> bool:
1939
+ """Delete a previously sent Telegram message.
1940
+
1941
+ Used by the stream consumer's fresh-final cleanup path (ported
1942
+ from openclaw/openclaw#72038) to remove long-lived preview
1943
+ messages after sending the completed reply as a fresh message.
1944
+ Telegram's Bot API ``deleteMessage`` works for bot-posted
1945
+ messages in the last 48 hours. Failures are non-fatal — the
1946
+ caller leaves the preview in place and logs at debug level.
1947
+ """
1948
+ if not self._bot:
1949
+ return False
1950
+ try:
1951
+ await self._bot.delete_message(
1952
+ chat_id=int(chat_id),
1953
+ message_id=int(message_id),
1954
+ )
1955
+ return True
1956
+ except Exception as e:
1957
+ logger.debug(
1958
+ "[%s] Failed to delete Telegram message %s: %s",
1959
+ self.name, message_id, e,
1960
+ )
1961
+ return False
1962
+
1963
+ def supports_draft_streaming(
1964
+ self,
1965
+ chat_type: Optional[str] = None,
1966
+ metadata: Optional[Dict[str, Any]] = None,
1967
+ ) -> bool:
1968
+ """Telegram supports sendMessageDraft for private chats only.
1969
+
1970
+ Bot API 9.5 (March 2026) opened ``sendMessageDraft`` to all bots
1971
+ unconditionally for private (DM) chats. Groups, supergroups, and
1972
+ channels still rely on the edit-based path.
1973
+
1974
+ We additionally require ``self._bot`` to expose ``send_message_draft``
1975
+ (added to python-telegram-bot in 22.6); older PTB installs gracefully
1976
+ fall back to the edit path even on DMs.
1977
+ """
1978
+ if not self._bot or not hasattr(self._bot, "send_message_draft"):
1979
+ return False
1980
+ return (chat_type or "").lower() in {"dm", "private"}
1981
+
1982
+ async def send_draft(
1983
+ self,
1984
+ chat_id: str,
1985
+ draft_id: int,
1986
+ content: str,
1987
+ metadata: Optional[Dict[str, Any]] = None,
1988
+ ) -> SendResult:
1989
+ """Stream a partial message via Telegram's native sendMessageDraft.
1990
+
1991
+ The Bot API animates the preview when the same ``draft_id`` is reused
1992
+ across consecutive calls in the same chat. When the response
1993
+ finishes, the caller sends the final text via the normal ``send``
1994
+ path; the draft preview clears naturally on the client (Telegram has
1995
+ no Bot API to "promote" a draft to a real message — the final
1996
+ ``sendMessage`` is what the user receives in their history).
1997
+ """
1998
+ if not self._bot:
1999
+ return SendResult(success=False, error="not_connected")
2000
+ if not hasattr(self._bot, "send_message_draft"):
2001
+ return SendResult(success=False, error="api_unavailable")
2002
+
2003
+ # Trim to the same UTF-16 budget the platform enforces on regular
2004
+ # sends. Drafts have the same length contract as messages.
2005
+ text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
2006
+ self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
2007
+
2008
+ kwargs: Dict[str, Any] = {
2009
+ "chat_id": int(chat_id),
2010
+ "draft_id": int(draft_id),
2011
+ "text": text,
2012
+ }
2013
+ thread_id = self._metadata_thread_id(metadata)
2014
+ if thread_id is not None:
2015
+ kwargs["message_thread_id"] = thread_id
2016
+
2017
+ try:
2018
+ ok = await self._bot.send_message_draft(**kwargs)
2019
+ if ok:
2020
+ # Drafts have no message_id; we report success without one
2021
+ # so the caller knows the animation frame landed.
2022
+ return SendResult(success=True, message_id=None)
2023
+ return SendResult(success=False, error="draft_rejected")
2024
+ except Exception as e:
2025
+ # Most likely: BadRequest because this bot/chat doesn't allow
2026
+ # drafts, or a transient server hiccup. The caller treats any
2027
+ # failure as "fall back to edit-based for this response".
2028
+ logger.debug(
2029
+ "[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
2030
+ self.name, chat_id, draft_id, e,
2031
+ )
2032
+ return SendResult(success=False, error=str(e))
2033
+
2034
+ async def _send_message_with_thread_fallback(self, **kwargs):
2035
+ """Send a Telegram message, retrying once without message_thread_id
2036
+ if Telegram returns 'Message thread not found'.
2037
+
2038
+ Used for control-style sends (approval prompts, model picker,
2039
+ update prompts) that can carry a stale thread_id from a DM
2040
+ reply chain. The streaming send loop has its own equivalent
2041
+ (PR #3390) at the body of ``send``; this helper applies the
2042
+ same retry pattern to the non-streaming control paths.
2043
+ """
2044
+ if not self._bot:
2045
+ raise RuntimeError("Not connected")
2046
+
2047
+ message_thread_id = kwargs.get("message_thread_id")
2048
+ try:
2049
+ return await self._bot.send_message(**kwargs)
2050
+ except Exception as send_err:
2051
+ if (
2052
+ message_thread_id is not None
2053
+ and self._is_bad_request_error(send_err)
2054
+ and self._is_thread_not_found_error(send_err)
2055
+ ):
2056
+ logger.warning(
2057
+ "[%s] Thread %s not found for control message, retrying without message_thread_id",
2058
+ self.name,
2059
+ message_thread_id,
2060
+ )
2061
+ retry_kwargs = dict(kwargs)
2062
+ retry_kwargs.pop("message_thread_id", None)
2063
+ return await self._bot.send_message(**retry_kwargs)
2064
+ raise
2065
+
2066
+ async def send_update_prompt(
2067
+ self, chat_id: str, prompt: str, default: str = "",
2068
+ session_key: str = "",
2069
+ metadata: Optional[Dict[str, Any]] = None,
2070
+ ) -> SendResult:
2071
+ """Send an inline-keyboard update prompt (Yes / No buttons).
2072
+
2073
+ Used by the gateway ``/update`` watcher when ``hermes update --gateway``
2074
+ needs user input (stash restore, config migration).
2075
+ """
2076
+ if not self._bot:
2077
+ return SendResult(success=False, error="Not connected")
2078
+ try:
2079
+ default_hint = f" (default: {default})" if default else ""
2080
+ text = self.format_message(f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}")
2081
+ keyboard = InlineKeyboardMarkup([
2082
+ [
2083
+ InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
2084
+ InlineKeyboardButton("✗ No", callback_data="update_prompt:n"),
2085
+ ]
2086
+ ])
2087
+ thread_id = self._metadata_thread_id(metadata)
2088
+ reply_to_id = self._reply_to_message_id_for_send(None, metadata)
2089
+ msg = await self._send_message_with_thread_fallback(
2090
+ chat_id=int(chat_id),
2091
+ text=text,
2092
+ parse_mode=ParseMode.MARKDOWN_V2,
2093
+ reply_markup=keyboard,
2094
+ reply_to_message_id=reply_to_id,
2095
+ **self._thread_kwargs_for_send(
2096
+ chat_id,
2097
+ thread_id,
2098
+ metadata,
2099
+ reply_to_message_id=reply_to_id,
2100
+ ),
2101
+ **self._link_preview_kwargs(),
2102
+ )
2103
+ return SendResult(success=True, message_id=str(msg.message_id))
2104
+ except Exception as e:
2105
+ logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
2106
+ return SendResult(success=False, error=str(e))
2107
+
2108
+ async def send_exec_approval(
2109
+ self, chat_id: str, command: str, session_key: str,
2110
+ description: str = "dangerous command",
2111
+ metadata: Optional[Dict[str, Any]] = None,
2112
+ ) -> SendResult:
2113
+ """Send an inline-keyboard approval prompt with interactive buttons.
2114
+
2115
+ The buttons call ``resolve_gateway_approval()`` to unblock the waiting
2116
+ agent thread — same mechanism as the text ``/approve`` flow.
2117
+ """
2118
+ if not self._bot:
2119
+ return SendResult(success=False, error="Not connected")
2120
+
2121
+ try:
2122
+ cmd_preview = command[:3800] + "..." if len(command) > 3800 else command
2123
+ text = (
2124
+ f"⚠️ <b>Command Approval Required</b>\n\n"
2125
+ f"<pre>{_html.escape(cmd_preview)}</pre>\n\n"
2126
+ f"Reason: {_html.escape(description)}"
2127
+ )
2128
+
2129
+ # Resolve thread context for thread replies
2130
+ thread_id = self._metadata_thread_id(metadata)
2131
+
2132
+ # We'll use the message_id as part of callback_data to look up session_key
2133
+ # Send a placeholder first, then update — or use a counter.
2134
+ # Simpler: use a monotonic counter to generate short IDs.
2135
+ import itertools
2136
+ if not hasattr(self, "_approval_counter"):
2137
+ self._approval_counter = itertools.count(1)
2138
+ approval_id = next(self._approval_counter)
2139
+
2140
+ keyboard = InlineKeyboardMarkup([
2141
+ [
2142
+ InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"),
2143
+ InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"),
2144
+ ],
2145
+ [
2146
+ InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"),
2147
+ InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"),
2148
+ ],
2149
+ ])
2150
+
2151
+ kwargs: Dict[str, Any] = {
2152
+ "chat_id": int(chat_id),
2153
+ "text": text,
2154
+ "parse_mode": ParseMode.HTML,
2155
+ "reply_markup": keyboard,
2156
+ **self._link_preview_kwargs(),
2157
+ }
2158
+ reply_to_id = self._reply_to_message_id_for_send(None, metadata)
2159
+ kwargs["reply_to_message_id"] = reply_to_id
2160
+ kwargs.update(
2161
+ self._thread_kwargs_for_send(
2162
+ chat_id,
2163
+ thread_id,
2164
+ metadata,
2165
+ reply_to_message_id=reply_to_id,
2166
+ )
2167
+ )
2168
+
2169
+ msg = await self._send_message_with_thread_fallback(**kwargs)
2170
+
2171
+ # Store session_key keyed by approval_id for the callback handler
2172
+ self._approval_state[approval_id] = session_key
2173
+
2174
+ return SendResult(success=True, message_id=str(msg.message_id))
2175
+ except Exception as e:
2176
+ logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
2177
+ return SendResult(success=False, error=str(e))
2178
+
2179
+ async def send_slash_confirm(
2180
+ self, chat_id: str, title: str, message: str, session_key: str,
2181
+ confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
2182
+ ) -> SendResult:
2183
+ """Render a three-button slash-command confirmation prompt."""
2184
+ if not self._bot:
2185
+ return SendResult(success=False, error="Not connected")
2186
+
2187
+ try:
2188
+ # Message body: render as plain text (message already contains
2189
+ # markdown formatting from the gateway primitive).
2190
+ preview = message if len(message) <= 3800 else message[:3800] + "..."
2191
+
2192
+ keyboard = InlineKeyboardMarkup([
2193
+ [
2194
+ InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
2195
+ InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}"),
2196
+ ],
2197
+ [
2198
+ InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}"),
2199
+ ],
2200
+ ])
2201
+
2202
+ thread_id = self._metadata_thread_id(metadata)
2203
+ kwargs: Dict[str, Any] = {
2204
+ "chat_id": int(chat_id),
2205
+ "text": preview,
2206
+ "parse_mode": ParseMode.MARKDOWN,
2207
+ "reply_markup": keyboard,
2208
+ **self._link_preview_kwargs(),
2209
+ }
2210
+ reply_to_id = self._reply_to_message_id_for_send(None, metadata)
2211
+ kwargs["reply_to_message_id"] = reply_to_id
2212
+ kwargs.update(
2213
+ self._thread_kwargs_for_send(
2214
+ chat_id,
2215
+ thread_id,
2216
+ metadata,
2217
+ reply_to_message_id=reply_to_id,
2218
+ )
2219
+ )
2220
+
2221
+ msg = await self._send_message_with_thread_fallback(**kwargs)
2222
+ self._slash_confirm_state[confirm_id] = session_key
2223
+ return SendResult(success=True, message_id=str(msg.message_id))
2224
+ except Exception as e:
2225
+ logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
2226
+ return SendResult(success=False, error=str(e))
2227
+
2228
+ async def send_clarify(
2229
+ self,
2230
+ chat_id: str,
2231
+ question: str,
2232
+ choices: Optional[list],
2233
+ clarify_id: str,
2234
+ session_key: str,
2235
+ metadata: Optional[Dict[str, Any]] = None,
2236
+ ) -> SendResult:
2237
+ """Render a clarify prompt with one inline button per choice.
2238
+
2239
+ Multi-choice mode (``choices`` non-empty): renders one button per
2240
+ option plus a final "✏️ Other (type answer)" button. Picking the
2241
+ "Other" button flips the entry into text-capture mode so the next
2242
+ message becomes the response.
2243
+
2244
+ Open-ended mode (``choices`` empty): renders the question as plain
2245
+ text — no buttons. The next message in the session is captured by
2246
+ the gateway's text-intercept and resolves the clarify.
2247
+ """
2248
+ if not self._bot:
2249
+ return SendResult(success=False, error="Not connected")
2250
+
2251
+ try:
2252
+ text = f"❓ {_html.escape(question)}"
2253
+ thread_id = self._metadata_thread_id(metadata)
2254
+
2255
+ kwargs: Dict[str, Any] = {
2256
+ "chat_id": int(chat_id),
2257
+ "text": text,
2258
+ "parse_mode": ParseMode.HTML,
2259
+ **self._link_preview_kwargs(),
2260
+ }
2261
+
2262
+ if choices:
2263
+ # Telegram caps callback_data at 64 bytes; keep "cl:<id>:<idx>"
2264
+ # short. Button label is also capped (~64 chars in practice).
2265
+ rows = []
2266
+ for idx, choice in enumerate(choices):
2267
+ label = str(choice)
2268
+ if len(label) > 60:
2269
+ label = label[:57] + "..."
2270
+ rows.append([
2271
+ InlineKeyboardButton(
2272
+ f"{idx + 1}. {label}",
2273
+ callback_data=f"cl:{clarify_id}:{idx}",
2274
+ )
2275
+ ])
2276
+ rows.append([
2277
+ InlineKeyboardButton(
2278
+ "✏️ Other (type answer)",
2279
+ callback_data=f"cl:{clarify_id}:other",
2280
+ )
2281
+ ])
2282
+ kwargs["reply_markup"] = InlineKeyboardMarkup(rows)
2283
+
2284
+ reply_to_id = self._reply_to_message_id_for_send(None, metadata)
2285
+ kwargs["reply_to_message_id"] = reply_to_id
2286
+ kwargs.update(
2287
+ self._thread_kwargs_for_send(
2288
+ chat_id,
2289
+ thread_id,
2290
+ metadata,
2291
+ reply_to_message_id=reply_to_id,
2292
+ )
2293
+ )
2294
+
2295
+ msg = await self._send_message_with_thread_fallback(**kwargs)
2296
+ self._clarify_state[clarify_id] = session_key
2297
+ return SendResult(success=True, message_id=str(msg.message_id))
2298
+ except Exception as e:
2299
+ logger.warning("[%s] send_clarify failed: %s", self.name, e)
2300
+ return SendResult(success=False, error=str(e))
2301
+
2302
+ async def send_model_picker(
2303
+ self,
2304
+ chat_id: str,
2305
+ providers: list,
2306
+ current_model: str,
2307
+ current_provider: str,
2308
+ session_key: str,
2309
+ on_model_selected,
2310
+ metadata: Optional[Dict[str, Any]] = None,
2311
+ ) -> SendResult:
2312
+ """Send an interactive inline-keyboard model picker.
2313
+
2314
+ Two-step drill-down: provider selection → model selection.
2315
+ Edits the same message in-place as the user navigates.
2316
+ """
2317
+ if not self._bot:
2318
+ return SendResult(success=False, error="Not connected")
2319
+
2320
+ try:
2321
+ from hermes_cli.providers import get_label
2322
+ except ImportError:
2323
+ def get_label(slug):
2324
+ return slug
2325
+
2326
+ try:
2327
+ # Build provider buttons — 2 per row
2328
+ buttons: list = []
2329
+ for p in providers:
2330
+ count = p.get("total_models", len(p.get("models", [])))
2331
+ label = f"{p['name']} ({count})"
2332
+ if p.get("is_current"):
2333
+ label = f"✓ {label}"
2334
+ # Compact callback data: mp:<slug> (max 64 bytes)
2335
+ buttons.append(
2336
+ InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
2337
+ )
2338
+
2339
+ rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
2340
+ rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
2341
+ keyboard = InlineKeyboardMarkup(rows)
2342
+
2343
+ provider_label = get_label(current_provider)
2344
+ text = self.format_message(
2345
+ (
2346
+ f"⚙ *Model Configuration*\n\n"
2347
+ f"Current model: `{current_model or 'unknown'}`\n"
2348
+ f"Provider: {provider_label}\n\n"
2349
+ f"Select a provider:"
2350
+ )
2351
+ )
2352
+
2353
+ thread_id = metadata.get("thread_id") if metadata else None
2354
+ reply_to_id = self._reply_to_message_id_for_send(None, metadata)
2355
+ msg = await self._send_message_with_thread_fallback(
2356
+ chat_id=int(chat_id),
2357
+ text=text,
2358
+ parse_mode=ParseMode.MARKDOWN_V2,
2359
+ reply_markup=keyboard,
2360
+ reply_to_message_id=reply_to_id,
2361
+ **self._thread_kwargs_for_send(
2362
+ chat_id,
2363
+ thread_id,
2364
+ metadata,
2365
+ reply_to_message_id=reply_to_id,
2366
+ ),
2367
+ **self._link_preview_kwargs(),
2368
+ )
2369
+
2370
+ # Store picker state keyed by chat_id
2371
+ self._model_picker_state[str(chat_id)] = {
2372
+ "msg_id": msg.message_id,
2373
+ "providers": providers,
2374
+ "session_key": session_key,
2375
+ "on_model_selected": on_model_selected,
2376
+ "current_model": current_model,
2377
+ "current_provider": current_provider,
2378
+ }
2379
+
2380
+ return SendResult(success=True, message_id=str(msg.message_id))
2381
+ except Exception as e:
2382
+ logger.warning("[%s] send_model_picker failed: %s", self.name, e)
2383
+ return SendResult(success=False, error=str(e))
2384
+
2385
+ _MODEL_PAGE_SIZE = 8
2386
+
2387
+ def _build_model_keyboard(self, models: list, page: int) -> tuple:
2388
+ """Build paginated model buttons. Returns (keyboard, page_info_text)."""
2389
+ page_size = self._MODEL_PAGE_SIZE
2390
+ total = len(models)
2391
+ total_pages = max(1, (total + page_size - 1) // page_size)
2392
+ page = max(0, min(page, total_pages - 1))
2393
+
2394
+ start = page * page_size
2395
+ end = min(start + page_size, total)
2396
+ page_models = models[start:end]
2397
+
2398
+ buttons: list = []
2399
+ for i, model_id in enumerate(page_models):
2400
+ abs_idx = start + i
2401
+ short = model_id.split("/")[-1] if "/" in model_id else model_id
2402
+ if len(short) > 38:
2403
+ short = short[:35] + "..."
2404
+ buttons.append(
2405
+ InlineKeyboardButton(short, callback_data=f"mm:{abs_idx}")
2406
+ )
2407
+
2408
+ rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
2409
+
2410
+ # Pagination row (if needed)
2411
+ if total_pages > 1:
2412
+ nav: list = []
2413
+ if page > 0:
2414
+ nav.append(InlineKeyboardButton("◀ Prev", callback_data=f"mg:{page - 1}"))
2415
+ nav.append(InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="mx:noop"))
2416
+ if page < total_pages - 1:
2417
+ nav.append(InlineKeyboardButton("Next ▶", callback_data=f"mg:{page + 1}"))
2418
+ rows.append(nav)
2419
+
2420
+ rows.append([
2421
+ InlineKeyboardButton("◀ Back", callback_data="mb"),
2422
+ InlineKeyboardButton("✗ Cancel", callback_data="mx"),
2423
+ ])
2424
+
2425
+ page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else ""
2426
+ return InlineKeyboardMarkup(rows), page_info
2427
+
2428
+ async def _handle_model_picker_callback(
2429
+ self, query, data: str, chat_id: str
2430
+ ) -> None:
2431
+ """Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
2432
+ state = self._model_picker_state.get(chat_id)
2433
+ if not state:
2434
+ await query.answer(text="Picker expired — use /model again.")
2435
+ return
2436
+
2437
+ try:
2438
+ from hermes_cli.providers import get_label
2439
+ except ImportError:
2440
+ def get_label(slug):
2441
+ return slug
2442
+
2443
+ if data.startswith("mp:"):
2444
+ # --- Provider selected: show model buttons (page 0) ---
2445
+ provider_slug = data[3:]
2446
+ provider = next(
2447
+ (p for p in state["providers"] if p["slug"] == provider_slug),
2448
+ None,
2449
+ )
2450
+ if not provider:
2451
+ await query.answer(text="Provider not found.")
2452
+ return
2453
+
2454
+ models = provider.get("models", [])
2455
+ state["selected_provider"] = provider_slug
2456
+ state["selected_provider_name"] = provider.get("name", provider_slug)
2457
+ state["model_list"] = models
2458
+ state["model_page"] = 0
2459
+
2460
+ keyboard, page_info = self._build_model_keyboard(models, 0)
2461
+
2462
+ pname = provider.get("name", provider_slug)
2463
+ total = provider.get("total_models", len(models))
2464
+ shown = len(models)
2465
+ extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
2466
+
2467
+ await query.edit_message_text(
2468
+ text=self.format_message(
2469
+ (
2470
+ f"⚙ *Model Configuration*\n\n"
2471
+ f"Provider: *{pname}*{page_info}\n"
2472
+ f"Select a model:{extra}"
2473
+ )
2474
+ ),
2475
+ parse_mode=ParseMode.MARKDOWN_V2,
2476
+ reply_markup=keyboard,
2477
+ )
2478
+ await query.answer()
2479
+
2480
+ elif data.startswith("mg:"):
2481
+ # --- Page navigation ---
2482
+ try:
2483
+ page = int(data[3:])
2484
+ except ValueError:
2485
+ await query.answer(text="Invalid page.")
2486
+ return
2487
+
2488
+ models = state.get("model_list", [])
2489
+ state["model_page"] = page
2490
+
2491
+ keyboard, page_info = self._build_model_keyboard(models, page)
2492
+
2493
+ pname = state.get("selected_provider_name", "")
2494
+ provider_slug = state.get("selected_provider", "")
2495
+ provider = next(
2496
+ (p for p in state["providers"] if p["slug"] == provider_slug),
2497
+ None,
2498
+ )
2499
+ total = provider.get("total_models", len(models)) if provider else len(models)
2500
+ shown = len(models)
2501
+ extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
2502
+
2503
+ await query.edit_message_text(
2504
+ text=self.format_message(
2505
+ (
2506
+ f"⚙ *Model Configuration*\n\n"
2507
+ f"Provider: *{pname}*{page_info}\n"
2508
+ f"Select a model:{extra}"
2509
+ )
2510
+ ),
2511
+ parse_mode=ParseMode.MARKDOWN_V2,
2512
+ reply_markup=keyboard,
2513
+ )
2514
+ await query.answer()
2515
+
2516
+ elif data.startswith("mm:"):
2517
+ # --- Model selected: perform the switch ---
2518
+ try:
2519
+ idx = int(data[3:])
2520
+ except ValueError:
2521
+ await query.answer(text="Invalid selection.")
2522
+ return
2523
+
2524
+ model_list = state.get("model_list", [])
2525
+ if idx < 0 or idx >= len(model_list):
2526
+ await query.answer(text="Invalid model index.")
2527
+ return
2528
+
2529
+ model_id = model_list[idx]
2530
+ provider_slug = state.get("selected_provider", "")
2531
+ callback = state.get("on_model_selected")
2532
+
2533
+ if not callback:
2534
+ await query.answer(text="Picker expired.")
2535
+ return
2536
+
2537
+ try:
2538
+ result_text = await callback(chat_id, model_id, provider_slug)
2539
+ except Exception as exc:
2540
+ logger.error("Model picker switch failed: %s", exc)
2541
+ result_text = f"Error switching model: {exc}"
2542
+
2543
+ # Edit message to show confirmation, remove buttons
2544
+ try:
2545
+ await query.edit_message_text(
2546
+ text=self.format_message(result_text),
2547
+ parse_mode=ParseMode.MARKDOWN_V2,
2548
+ reply_markup=None,
2549
+ )
2550
+ except Exception:
2551
+ # Markdown parse failure — retry as plain text
2552
+ try:
2553
+ await query.edit_message_text(
2554
+ text=result_text,
2555
+ parse_mode=None,
2556
+ reply_markup=None,
2557
+ )
2558
+ except Exception:
2559
+ pass
2560
+ await query.answer(text="Model switched!")
2561
+
2562
+ # Clean up state
2563
+ self._model_picker_state.pop(chat_id, None)
2564
+
2565
+ elif data == "mb":
2566
+ # --- Back to provider list ---
2567
+ buttons = []
2568
+ for p in state["providers"]:
2569
+ count = p.get("total_models", len(p.get("models", [])))
2570
+ label = f"{p['name']} ({count})"
2571
+ if p.get("is_current"):
2572
+ label = f"✓ {label}"
2573
+ buttons.append(
2574
+ InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
2575
+ )
2576
+
2577
+ rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
2578
+ rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
2579
+ keyboard = InlineKeyboardMarkup(rows)
2580
+
2581
+ try:
2582
+ provider_label = get_label(state["current_provider"])
2583
+ except Exception:
2584
+ provider_label = state["current_provider"]
2585
+
2586
+ await query.edit_message_text(
2587
+ text=self.format_message(
2588
+ (
2589
+ f"⚙ *Model Configuration*\n\n"
2590
+ f"Current model: `{state['current_model'] or 'unknown'}`\n"
2591
+ f"Provider: {provider_label}\n\n"
2592
+ f"Select a provider:"
2593
+ )
2594
+ ),
2595
+ parse_mode=ParseMode.MARKDOWN_V2,
2596
+ reply_markup=keyboard,
2597
+ )
2598
+ await query.answer()
2599
+
2600
+ elif data == "mx":
2601
+ # --- Cancel ---
2602
+ self._model_picker_state.pop(chat_id, None)
2603
+ await query.edit_message_text(
2604
+ text="Model selection cancelled.",
2605
+ reply_markup=None,
2606
+ )
2607
+ await query.answer()
2608
+
2609
+ else:
2610
+ # Catch-all (e.g. page counter button "mx:noop")
2611
+ await query.answer()
2612
+
2613
+ async def _handle_callback_query(
2614
+ self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
2615
+ ) -> None:
2616
+ """Handle inline keyboard button clicks."""
2617
+ query = update.callback_query
2618
+ if not query or not query.data:
2619
+ return
2620
+ data = query.data
2621
+ query_message = getattr(query, "message", None)
2622
+ query_chat_id = getattr(query_message, "chat_id", None)
2623
+ query_chat = getattr(query_message, "chat", None)
2624
+ query_chat_type = getattr(query_chat, "type", None)
2625
+ query_thread_id = getattr(query_message, "message_thread_id", None)
2626
+ query_user_name = getattr(query.from_user, "first_name", None)
2627
+
2628
+ # --- Model picker callbacks ---
2629
+ if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
2630
+ chat_id = str(query.message.chat_id) if query.message else None
2631
+ if chat_id:
2632
+ await self._handle_model_picker_callback(query, data, chat_id)
2633
+ return
2634
+
2635
+ # --- Exec approval callbacks (ea:choice:id) ---
2636
+ if data.startswith("ea:"):
2637
+ parts = data.split(":", 2)
2638
+ if len(parts) == 3:
2639
+ choice = parts[1] # once, session, always, deny
2640
+ try:
2641
+ approval_id = int(parts[2])
2642
+ except (ValueError, IndexError):
2643
+ await query.answer(text="Invalid approval data.")
2644
+ return
2645
+
2646
+ # Only authorized users may click approval buttons.
2647
+ caller_id = str(getattr(query.from_user, "id", ""))
2648
+ if not self._is_callback_user_authorized(
2649
+ caller_id,
2650
+ chat_id=query_chat_id,
2651
+ chat_type=str(query_chat_type) if query_chat_type is not None else None,
2652
+ thread_id=str(query_thread_id) if query_thread_id is not None else None,
2653
+ user_name=query_user_name,
2654
+ ):
2655
+ await query.answer(text="⛔ You are not authorized to approve commands.")
2656
+ return
2657
+
2658
+ session_key = self._approval_state.pop(approval_id, None)
2659
+ if not session_key:
2660
+ await query.answer(text="This approval has already been resolved.")
2661
+ return
2662
+
2663
+ # Map choice to human-readable label
2664
+ label_map = {
2665
+ "once": "✅ Approved once",
2666
+ "session": "✅ Approved for session",
2667
+ "always": "✅ Approved permanently",
2668
+ "deny": "❌ Denied",
2669
+ }
2670
+ user_display = getattr(query.from_user, "first_name", "User")
2671
+ label = label_map.get(choice, "Resolved")
2672
+
2673
+ await query.answer(text=label)
2674
+
2675
+ # Edit message to show decision, remove buttons
2676
+ try:
2677
+ await query.edit_message_text(
2678
+ text=self.format_message(f"{label} by {user_display}"),
2679
+ parse_mode=ParseMode.MARKDOWN_V2,
2680
+ reply_markup=None,
2681
+ )
2682
+ except Exception:
2683
+ pass # non-fatal if edit fails
2684
+
2685
+ # Resolve the approval — unblocks the agent thread
2686
+ try:
2687
+ from tools.approval import resolve_gateway_approval
2688
+ count = resolve_gateway_approval(session_key, choice)
2689
+ logger.info(
2690
+ "Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)",
2691
+ count, session_key, choice, user_display,
2692
+ )
2693
+ except Exception as exc:
2694
+ logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
2695
+ return
2696
+
2697
+ # --- Slash-confirm callbacks (sc:choice:confirm_id) ---
2698
+ if data.startswith("sc:"):
2699
+ parts = data.split(":", 2)
2700
+ if len(parts) == 3:
2701
+ choice = parts[1] # once, always, cancel
2702
+ confirm_id = parts[2]
2703
+
2704
+ caller_id = str(getattr(query.from_user, "id", ""))
2705
+ if not self._is_callback_user_authorized(
2706
+ caller_id,
2707
+ chat_id=query_chat_id,
2708
+ chat_type=str(query_chat_type) if query_chat_type is not None else None,
2709
+ thread_id=str(query_thread_id) if query_thread_id is not None else None,
2710
+ user_name=query_user_name,
2711
+ ):
2712
+ await query.answer(text="⛔ You are not authorized to answer this prompt.")
2713
+ return
2714
+
2715
+ session_key = self._slash_confirm_state.pop(confirm_id, None)
2716
+ if not session_key:
2717
+ await query.answer(text="This prompt has already been resolved.")
2718
+ return
2719
+
2720
+ label_map = {
2721
+ "once": "✅ Approved once",
2722
+ "always": "🔒 Always approve",
2723
+ "cancel": "❌ Cancelled",
2724
+ }
2725
+ user_display = getattr(query.from_user, "first_name", "User")
2726
+ label = label_map.get(choice, "Resolved")
2727
+
2728
+ await query.answer(text=label)
2729
+
2730
+ try:
2731
+ await query.edit_message_text(
2732
+ text=self.format_message(f"{label} by {user_display}"),
2733
+ parse_mode=ParseMode.MARKDOWN_V2,
2734
+ reply_markup=None,
2735
+ )
2736
+ except Exception:
2737
+ pass
2738
+
2739
+ # Resolve via the module-level primitive. The runner stored
2740
+ # a handler keyed by session_key; we run it on the event
2741
+ # loop and (if it returns a string) send it as a follow-up
2742
+ # message in the same chat.
2743
+ try:
2744
+ from tools import slash_confirm as _slash_confirm_mod
2745
+ result_text = await _slash_confirm_mod.resolve(
2746
+ session_key, confirm_id, choice,
2747
+ )
2748
+ if result_text and query.message:
2749
+ # Inherit the prompt message's topic. Supergroup forums
2750
+ # use message_thread_id; Telegram private DM-topic lanes
2751
+ # need both the private topic id and the prompt reply anchor.
2752
+ thread_id = getattr(query.message, "message_thread_id", None)
2753
+ chat = getattr(query.message, "chat", None)
2754
+ chat_type = getattr(chat, "type", None)
2755
+ prompt_message_id = getattr(query.message, "message_id", None)
2756
+ send_kwargs: Dict[str, Any] = {
2757
+ "chat_id": int(query.message.chat_id),
2758
+ "text": self.format_message(result_text),
2759
+ "parse_mode": ParseMode.MARKDOWN_V2,
2760
+ **self._link_preview_kwargs(),
2761
+ }
2762
+ chat_type_value = getattr(chat_type, "value", chat_type)
2763
+ is_private_chat = str(chat_type_value).lower() in {
2764
+ "private",
2765
+ str(ChatType.PRIVATE).lower(),
2766
+ str(getattr(ChatType.PRIVATE, "value", ChatType.PRIVATE)).lower(),
2767
+ }
2768
+ if thread_id is not None and is_private_chat and prompt_message_id is not None:
2769
+ reply_to_id = int(prompt_message_id)
2770
+ send_kwargs["reply_to_message_id"] = reply_to_id
2771
+ send_kwargs.update(
2772
+ self._thread_kwargs_for_send(
2773
+ str(query.message.chat_id),
2774
+ str(thread_id),
2775
+ {
2776
+ "thread_id": str(thread_id),
2777
+ "telegram_dm_topic_reply_fallback": True,
2778
+ },
2779
+ reply_to_message_id=reply_to_id,
2780
+ )
2781
+ )
2782
+ elif thread_id is not None:
2783
+ send_kwargs.update(
2784
+ self._thread_kwargs_for_send(
2785
+ str(query.message.chat_id),
2786
+ str(thread_id),
2787
+ {"thread_id": str(thread_id)},
2788
+ )
2789
+ )
2790
+ await self._send_message_with_thread_fallback(**send_kwargs)
2791
+ except Exception as exc:
2792
+ logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
2793
+ return
2794
+
2795
+ # --- Clarify callbacks (cl:clarify_id:idx | cl:clarify_id:other) ---
2796
+ if data.startswith("cl:"):
2797
+ parts = data.split(":", 2)
2798
+ if len(parts) == 3:
2799
+ clarify_id = parts[1]
2800
+ choice_token = parts[2]
2801
+
2802
+ caller_id = str(getattr(query.from_user, "id", ""))
2803
+ if not self._is_callback_user_authorized(
2804
+ caller_id,
2805
+ chat_id=query_chat_id,
2806
+ chat_type=str(query_chat_type) if query_chat_type is not None else None,
2807
+ thread_id=str(query_thread_id) if query_thread_id is not None else None,
2808
+ user_name=query_user_name,
2809
+ ):
2810
+ await query.answer(text="⛔ You are not authorized to answer this prompt.")
2811
+ return
2812
+
2813
+ session_key = self._clarify_state.get(clarify_id)
2814
+ if not session_key:
2815
+ await query.answer(text="This prompt has already been resolved.")
2816
+ return
2817
+
2818
+ user_display = getattr(query.from_user, "first_name", "User")
2819
+
2820
+ if choice_token == "other":
2821
+ # Flip into text-capture mode and tell the user to type
2822
+ # their answer. The gateway's text-intercept will pick
2823
+ # up the next message in this session and resolve the
2824
+ # clarify. Do NOT pop _clarify_state yet — we still
2825
+ # need it if the user is slow to respond and the entry
2826
+ # is cleared by something else.
2827
+ try:
2828
+ from tools.clarify_gateway import mark_awaiting_text
2829
+ mark_awaiting_text(clarify_id)
2830
+ except Exception as exc:
2831
+ logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
2832
+
2833
+ await query.answer(text="✏️ Type your answer in the chat.")
2834
+ try:
2835
+ await query.edit_message_text(
2836
+ text=f"❓ {query.message.text or ''}\n\n<i>Awaiting typed response from {_html.escape(user_display)}…</i>",
2837
+ parse_mode=ParseMode.HTML,
2838
+ reply_markup=None,
2839
+ )
2840
+ except Exception:
2841
+ pass
2842
+ return
2843
+
2844
+ # Numeric choice → resolve immediately with the chosen text
2845
+ try:
2846
+ idx = int(choice_token)
2847
+ except (ValueError, TypeError):
2848
+ await query.answer(text="Invalid choice.")
2849
+ return
2850
+
2851
+ # Look up the choice text from the entry registered in the
2852
+ # clarify primitive. Fall back to the index if the entry
2853
+ # has been cleaned up (race with timeout / session reset).
2854
+ resolved_text: Optional[str] = None
2855
+ try:
2856
+ from tools.clarify_gateway import _entries as _clarify_entries # type: ignore
2857
+ entry = _clarify_entries.get(clarify_id)
2858
+ if entry and entry.choices and 0 <= idx < len(entry.choices):
2859
+ resolved_text = entry.choices[idx]
2860
+ except Exception:
2861
+ resolved_text = None
2862
+
2863
+ if resolved_text is None:
2864
+ # Race: entry vanished. Echo the index as a number so
2865
+ # the agent at least sees an intentional response
2866
+ # rather than nothing.
2867
+ resolved_text = f"choice {idx + 1}"
2868
+
2869
+ # Pop state and resolve
2870
+ self._clarify_state.pop(clarify_id, None)
2871
+ try:
2872
+ from tools.clarify_gateway import resolve_gateway_clarify
2873
+ resolved = resolve_gateway_clarify(clarify_id, resolved_text)
2874
+ except Exception as exc:
2875
+ logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
2876
+ resolved = False
2877
+
2878
+ await query.answer(text=f"✓ {resolved_text[:60]}")
2879
+ try:
2880
+ await query.edit_message_text(
2881
+ text=f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}",
2882
+ parse_mode=ParseMode.HTML,
2883
+ reply_markup=None,
2884
+ )
2885
+ except Exception:
2886
+ pass
2887
+
2888
+ if resolved:
2889
+ logger.info(
2890
+ "Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
2891
+ clarify_id, resolved_text, user_display,
2892
+ )
2893
+ else:
2894
+ logger.warning(
2895
+ "Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
2896
+ clarify_id,
2897
+ )
2898
+ return
2899
+
2900
+ # --- Update prompt callbacks ---
2901
+ if not data.startswith("update_prompt:"):
2902
+ return
2903
+ answer = data.split(":", 1)[1] # "y" or "n"
2904
+ caller_id = str(getattr(query.from_user, "id", ""))
2905
+ if not self._is_callback_user_authorized(
2906
+ caller_id,
2907
+ chat_id=query_chat_id,
2908
+ chat_type=str(query_chat_type) if query_chat_type is not None else None,
2909
+ thread_id=str(query_thread_id) if query_thread_id is not None else None,
2910
+ user_name=query_user_name,
2911
+ ):
2912
+ await query.answer(text="⛔ You are not authorized to answer update prompts.")
2913
+ return
2914
+ await query.answer(text=f"Sent '{answer}' to the update process.")
2915
+ # Edit the message to show the choice and remove buttons
2916
+ label = "Yes" if answer == "y" else "No"
2917
+ try:
2918
+ await query.edit_message_text(
2919
+ text=self.format_message(f"⚕ Update prompt answered: *{label}*"),
2920
+ parse_mode=ParseMode.MARKDOWN_V2,
2921
+ reply_markup=None,
2922
+ )
2923
+ except Exception:
2924
+ pass # non-fatal if edit fails
2925
+ # Write the response file
2926
+ try:
2927
+ from calvyn_constants import get_hermes_home
2928
+ home = get_hermes_home()
2929
+ response_path = home / ".update_response"
2930
+ tmp = response_path.with_suffix(".tmp")
2931
+ tmp.write_text(answer)
2932
+ tmp.replace(response_path)
2933
+ logger.info("Telegram update prompt answered '%s' by user %s",
2934
+ answer, getattr(query.from_user, "id", "unknown"))
2935
+ except Exception as exc:
2936
+ logger.error("Failed to write update response from callback: %s", exc)
2937
+
2938
+ def _missing_media_path_error(self, label: str, path: str) -> str:
2939
+ """Build an actionable file-not-found error for gateway MEDIA delivery.
2940
+
2941
+ Paths like /workspace/... or /output/... often only exist inside the
2942
+ Docker sandbox, while the gateway process runs on the host.
2943
+ """
2944
+ error = f"{label} file not found: {path}"
2945
+ if path.startswith(("/workspace/", "/output/", "/outputs/")):
2946
+ error += (
2947
+ " (path may only exist inside the Docker sandbox. "
2948
+ "Bind-mount a host directory and emit the host-visible "
2949
+ "path in MEDIA: for gateway file delivery.)"
2950
+ )
2951
+ return error
2952
+
2953
+ async def send_voice(
2954
+ self,
2955
+ chat_id: str,
2956
+ audio_path: str,
2957
+ caption: Optional[str] = None,
2958
+ reply_to: Optional[str] = None,
2959
+ metadata: Optional[Dict[str, Any]] = None,
2960
+ **kwargs,
2961
+ ) -> SendResult:
2962
+ """Send audio as a native Telegram voice message or audio file."""
2963
+ if not self._bot:
2964
+ return SendResult(success=False, error="Not connected")
2965
+
2966
+ try:
2967
+ if not os.path.exists(audio_path):
2968
+ return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
2969
+
2970
+ with open(audio_path, "rb") as audio_file:
2971
+ ext = os.path.splitext(audio_path)[1].lower()
2972
+ # .ogg / .opus files -> send as voice (round playable bubble)
2973
+ if ext in {".ogg", ".opus"}:
2974
+ _voice_thread = self._metadata_thread_id(metadata)
2975
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
2976
+ voice_thread_kwargs = self._thread_kwargs_for_send(
2977
+ chat_id,
2978
+ _voice_thread,
2979
+ metadata,
2980
+ reply_to_message_id=reply_to_id,
2981
+ )
2982
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
2983
+ self._bot.send_voice,
2984
+ {
2985
+ "chat_id": int(chat_id),
2986
+ "voice": audio_file,
2987
+ "caption": caption[:1024] if caption else None,
2988
+ "reply_to_message_id": reply_to_id,
2989
+ **voice_thread_kwargs,
2990
+ **self._notification_kwargs(metadata),
2991
+ },
2992
+ metadata,
2993
+ reply_to_id,
2994
+ "voice",
2995
+ reset_media=lambda: audio_file.seek(0),
2996
+ )
2997
+ elif ext in {".mp3", ".m4a"}:
2998
+ # Telegram's Bot API sendAudio only accepts MP3 / M4A.
2999
+ _audio_thread = self._metadata_thread_id(metadata)
3000
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
3001
+ audio_thread_kwargs = self._thread_kwargs_for_send(
3002
+ chat_id,
3003
+ _audio_thread,
3004
+ metadata,
3005
+ reply_to_message_id=reply_to_id,
3006
+ )
3007
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3008
+ self._bot.send_audio,
3009
+ {
3010
+ "chat_id": int(chat_id),
3011
+ "audio": audio_file,
3012
+ "caption": caption[:1024] if caption else None,
3013
+ "reply_to_message_id": reply_to_id,
3014
+ **audio_thread_kwargs,
3015
+ **self._notification_kwargs(metadata),
3016
+ },
3017
+ metadata,
3018
+ reply_to_id,
3019
+ "audio",
3020
+ reset_media=lambda: audio_file.seek(0),
3021
+ )
3022
+ else:
3023
+ # Formats Telegram can't play natively (.wav, .flac, ...)
3024
+ # — fall back to document delivery instead of raising.
3025
+ return await self.send_document(
3026
+ chat_id=chat_id,
3027
+ file_path=audio_path,
3028
+ caption=caption,
3029
+ reply_to=reply_to,
3030
+ metadata=metadata,
3031
+ )
3032
+ return SendResult(success=True, message_id=str(msg.message_id))
3033
+ except Exception as e:
3034
+ logger.error(
3035
+ "[%s] Failed to send Telegram voice/audio, falling back to base adapter: %s",
3036
+ self.name,
3037
+ e,
3038
+ exc_info=True,
3039
+ )
3040
+ return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
3041
+
3042
+ async def send_multiple_images(
3043
+ self,
3044
+ chat_id: str,
3045
+ images: List[tuple],
3046
+ metadata: Optional[Dict[str, Any]] = None,
3047
+ human_delay: float = 0.0,
3048
+ ) -> None:
3049
+ """Send a batch of images natively via Telegram's media group API.
3050
+
3051
+ Telegram's ``send_media_group`` bundles up to 10 photos/videos into
3052
+ a single album. Larger batches are chunked. Animated GIFs cannot
3053
+ go into a media group (they require ``send_animation``), so they
3054
+ are peeled off and sent individually via the base default path.
3055
+
3056
+ URL-based photos go into the group directly; local files are
3057
+ opened as byte streams. On failure the whole batch falls back to
3058
+ the base adapter's per-image loop.
3059
+ """
3060
+ if not self._bot:
3061
+ return
3062
+ if not images:
3063
+ return
3064
+
3065
+ try:
3066
+ from telegram import InputMediaPhoto
3067
+ except Exception as exc: # pragma: no cover - missing SDK
3068
+ logger.warning(
3069
+ "[%s] InputMediaPhoto unavailable, falling back to per-image send: %s",
3070
+ self.name, exc,
3071
+ )
3072
+ await super().send_multiple_images(chat_id, images, metadata, human_delay)
3073
+ return
3074
+
3075
+ # Peel off animations — they need send_animation, not send_media_group
3076
+ animations: List[tuple] = []
3077
+ photos: List[tuple] = []
3078
+ for image_url, alt_text in images:
3079
+ if not image_url.startswith("file://") and self._is_animation_url(image_url):
3080
+ animations.append((image_url, alt_text))
3081
+ else:
3082
+ photos.append((image_url, alt_text))
3083
+
3084
+ # Animations: route through the base default (per-image send_animation)
3085
+ if animations:
3086
+ await super().send_multiple_images(
3087
+ chat_id, animations, metadata, human_delay=human_delay,
3088
+ )
3089
+
3090
+ if not photos:
3091
+ return
3092
+
3093
+ from urllib.parse import unquote as _unquote
3094
+ _thread = self._metadata_thread_id(metadata)
3095
+
3096
+ # Chunk into groups of 10 (Telegram's album limit)
3097
+ CHUNK = 10
3098
+ chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
3099
+
3100
+ for chunk_idx, chunk in enumerate(chunks):
3101
+ if human_delay > 0 and chunk_idx > 0:
3102
+ await asyncio.sleep(human_delay)
3103
+
3104
+ media: List[Any] = []
3105
+ opened_files: List[Any] = []
3106
+ try:
3107
+ for image_url, alt_text in chunk:
3108
+ caption = alt_text[:1024] if alt_text else None
3109
+ if image_url.startswith("file://"):
3110
+ local_path = _unquote(image_url[7:])
3111
+ if not os.path.exists(local_path):
3112
+ logger.warning(
3113
+ "[%s] Skipping missing image in media group: %s",
3114
+ self.name, local_path,
3115
+ )
3116
+ continue
3117
+ fh = open(local_path, "rb")
3118
+ opened_files.append(fh)
3119
+ media.append(InputMediaPhoto(media=fh, caption=caption))
3120
+ else:
3121
+ media.append(InputMediaPhoto(media=image_url, caption=caption))
3122
+
3123
+ if not media:
3124
+ continue
3125
+
3126
+ logger.info(
3127
+ "[%s] Sending media group of %d photo(s) (chunk %d/%d)",
3128
+ self.name, len(media), chunk_idx + 1, len(chunks),
3129
+ )
3130
+ reply_to_id = self._reply_to_message_id_for_send(None, metadata)
3131
+ thread_kwargs = self._thread_kwargs_for_send(
3132
+ chat_id,
3133
+ _thread,
3134
+ metadata,
3135
+ reply_to_message_id=reply_to_id,
3136
+ )
3137
+
3138
+ def _reset_opened_files() -> None:
3139
+ for fh in opened_files:
3140
+ try:
3141
+ fh.seek(0)
3142
+ except Exception:
3143
+ pass
3144
+
3145
+ await self._send_with_dm_topic_reply_anchor_retry(
3146
+ self._bot.send_media_group,
3147
+ {
3148
+ "chat_id": int(chat_id),
3149
+ "media": media,
3150
+ "reply_to_message_id": reply_to_id,
3151
+ **thread_kwargs,
3152
+ **self._notification_kwargs(metadata),
3153
+ },
3154
+ metadata,
3155
+ reply_to_id,
3156
+ "media group",
3157
+ reset_media=_reset_opened_files,
3158
+ )
3159
+ except Exception as e:
3160
+ logger.warning(
3161
+ "[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s",
3162
+ self.name, chunk_idx + 1, len(chunks), e,
3163
+ exc_info=True,
3164
+ )
3165
+ # Fallback: send each photo in this chunk individually
3166
+ await super().send_multiple_images(
3167
+ chat_id, chunk, metadata, human_delay=human_delay,
3168
+ )
3169
+ finally:
3170
+ for fh in opened_files:
3171
+ try:
3172
+ fh.close()
3173
+ except Exception:
3174
+ pass
3175
+
3176
+ async def send_image_file(
3177
+ self,
3178
+ chat_id: str,
3179
+ image_path: str,
3180
+ caption: Optional[str] = None,
3181
+ reply_to: Optional[str] = None,
3182
+ metadata: Optional[Dict[str, Any]] = None,
3183
+ **kwargs,
3184
+ ) -> SendResult:
3185
+ """Send a local image file natively as a Telegram photo."""
3186
+ if not self._bot:
3187
+ return SendResult(success=False, error="Not connected")
3188
+
3189
+ try:
3190
+ if not os.path.exists(image_path):
3191
+ return SendResult(success=False, error=self._missing_media_path_error("Image", image_path))
3192
+
3193
+ _thread = self._metadata_thread_id(metadata)
3194
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
3195
+ thread_kwargs = self._thread_kwargs_for_send(
3196
+ chat_id,
3197
+ _thread,
3198
+ metadata,
3199
+ reply_to_message_id=reply_to_id,
3200
+ )
3201
+ with open(image_path, "rb") as image_file:
3202
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3203
+ self._bot.send_photo,
3204
+ {
3205
+ "chat_id": int(chat_id),
3206
+ "photo": image_file,
3207
+ "caption": caption[:1024] if caption else None,
3208
+ "reply_to_message_id": reply_to_id,
3209
+ **thread_kwargs,
3210
+ **self._notification_kwargs(metadata),
3211
+ },
3212
+ metadata,
3213
+ reply_to_id,
3214
+ "photo",
3215
+ reset_media=lambda: image_file.seek(0),
3216
+ )
3217
+ return SendResult(success=True, message_id=str(msg.message_id))
3218
+ except Exception as e:
3219
+ error_str = str(e)
3220
+ # Dimension-related errors are the expected case for valid image
3221
+ # files that Telegram just refuses as photos (screenshots, extreme
3222
+ # aspect ratios). Log at INFO because the document fallback is
3223
+ # the correct path. Any other send_photo failure also falls back
3224
+ # to document (rate limits, corrupt file markers, format edge
3225
+ # cases), but at WARNING because it's unexpected and worth
3226
+ # surfacing in logs.
3227
+ is_dim_error = (
3228
+ "Photo_invalid_dimensions" in error_str
3229
+ or "PHOTO_INVALID_DIMENSIONS" in error_str
3230
+ )
3231
+ if is_dim_error:
3232
+ logger.info(
3233
+ "[%s] Image dimensions exceed Telegram photo limits, "
3234
+ "sending as document: %s",
3235
+ self.name,
3236
+ image_path,
3237
+ )
3238
+ else:
3239
+ logger.warning(
3240
+ "[%s] Failed to send Telegram local image as photo, "
3241
+ "trying document fallback: %s",
3242
+ self.name,
3243
+ e,
3244
+ exc_info=True,
3245
+ )
3246
+ # Fallback to sending as document (file) — no dimension limit,
3247
+ # only 50MB size limit. If even that fails, fall back to the
3248
+ # base adapter's text-only "Image: /path" rendering.
3249
+ try:
3250
+ return await self.send_document(
3251
+ chat_id=chat_id,
3252
+ file_path=image_path,
3253
+ caption=caption,
3254
+ file_name=os.path.basename(image_path),
3255
+ reply_to=reply_to,
3256
+ metadata=metadata,
3257
+ )
3258
+ except Exception as doc_err:
3259
+ logger.error(
3260
+ "[%s] Failed to send Telegram local image as document, "
3261
+ "falling back to base adapter: %s",
3262
+ self.name,
3263
+ doc_err,
3264
+ exc_info=True,
3265
+ )
3266
+ return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
3267
+
3268
+ async def send_document(
3269
+ self,
3270
+ chat_id: str,
3271
+ file_path: str,
3272
+ caption: Optional[str] = None,
3273
+ file_name: Optional[str] = None,
3274
+ reply_to: Optional[str] = None,
3275
+ metadata: Optional[Dict[str, Any]] = None,
3276
+ **kwargs,
3277
+ ) -> SendResult:
3278
+ """Send a document/file natively as a Telegram file attachment."""
3279
+ if not self._bot:
3280
+ return SendResult(success=False, error="Not connected")
3281
+
3282
+ try:
3283
+ if not os.path.exists(file_path):
3284
+ return SendResult(success=False, error=self._missing_media_path_error("File", file_path))
3285
+
3286
+ display_name = file_name or os.path.basename(file_path)
3287
+ _thread = self._metadata_thread_id(metadata)
3288
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
3289
+ thread_kwargs = self._thread_kwargs_for_send(
3290
+ chat_id,
3291
+ _thread,
3292
+ metadata,
3293
+ reply_to_message_id=reply_to_id,
3294
+ )
3295
+
3296
+ with open(file_path, "rb") as f:
3297
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3298
+ self._bot.send_document,
3299
+ {
3300
+ "chat_id": int(chat_id),
3301
+ "document": f,
3302
+ "filename": display_name,
3303
+ "caption": caption[:1024] if caption else None,
3304
+ "reply_to_message_id": reply_to_id,
3305
+ **thread_kwargs,
3306
+ **self._notification_kwargs(metadata),
3307
+ },
3308
+ metadata,
3309
+ reply_to_id,
3310
+ "document",
3311
+ reset_media=lambda: f.seek(0),
3312
+ )
3313
+ return SendResult(success=True, message_id=str(msg.message_id))
3314
+ except Exception as e:
3315
+ print(f"[{self.name}] Failed to send document: {e}")
3316
+ return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
3317
+
3318
+ async def send_video(
3319
+ self,
3320
+ chat_id: str,
3321
+ video_path: str,
3322
+ caption: Optional[str] = None,
3323
+ reply_to: Optional[str] = None,
3324
+ metadata: Optional[Dict[str, Any]] = None,
3325
+ **kwargs,
3326
+ ) -> SendResult:
3327
+ """Send a video natively as a Telegram video message."""
3328
+ if not self._bot:
3329
+ return SendResult(success=False, error="Not connected")
3330
+
3331
+ try:
3332
+ if not os.path.exists(video_path):
3333
+ return SendResult(success=False, error=self._missing_media_path_error("Video", video_path))
3334
+
3335
+ _thread = self._metadata_thread_id(metadata)
3336
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
3337
+ thread_kwargs = self._thread_kwargs_for_send(
3338
+ chat_id,
3339
+ _thread,
3340
+ metadata,
3341
+ reply_to_message_id=reply_to_id,
3342
+ )
3343
+ with open(video_path, "rb") as f:
3344
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3345
+ self._bot.send_video,
3346
+ {
3347
+ "chat_id": int(chat_id),
3348
+ "video": f,
3349
+ "caption": caption[:1024] if caption else None,
3350
+ "reply_to_message_id": reply_to_id,
3351
+ **thread_kwargs,
3352
+ **self._notification_kwargs(metadata),
3353
+ },
3354
+ metadata,
3355
+ reply_to_id,
3356
+ "video",
3357
+ reset_media=lambda: f.seek(0),
3358
+ )
3359
+ return SendResult(success=True, message_id=str(msg.message_id))
3360
+ except Exception as e:
3361
+ print(f"[{self.name}] Failed to send video: {e}")
3362
+ return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
3363
+
3364
+ async def send_image(
3365
+ self,
3366
+ chat_id: str,
3367
+ image_url: str,
3368
+ caption: Optional[str] = None,
3369
+ reply_to: Optional[str] = None,
3370
+ metadata: Optional[Dict[str, Any]] = None,
3371
+ ) -> SendResult:
3372
+ """Send an image natively as a Telegram photo.
3373
+
3374
+ Tries URL-based send first (fast, works for <5MB images).
3375
+ Falls back to downloading and uploading as file (supports up to 10MB).
3376
+ """
3377
+ if not self._bot:
3378
+ return SendResult(success=False, error="Not connected")
3379
+
3380
+ from tools.url_safety import is_safe_url
3381
+ if not is_safe_url(image_url):
3382
+ logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name)
3383
+ return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
3384
+
3385
+ try:
3386
+ # Telegram can send photos directly from URLs (up to ~5MB)
3387
+ _photo_thread = self._metadata_thread_id(metadata)
3388
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
3389
+ photo_thread_kwargs = self._thread_kwargs_for_send(
3390
+ chat_id,
3391
+ _photo_thread,
3392
+ metadata,
3393
+ reply_to_message_id=reply_to_id,
3394
+ )
3395
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3396
+ self._bot.send_photo,
3397
+ {
3398
+ "chat_id": int(chat_id),
3399
+ "photo": image_url,
3400
+ "caption": caption[:1024] if caption else None,
3401
+ "reply_to_message_id": reply_to_id,
3402
+ **photo_thread_kwargs,
3403
+ **self._notification_kwargs(metadata),
3404
+ },
3405
+ metadata,
3406
+ reply_to_id,
3407
+ "URL photo",
3408
+ )
3409
+ return SendResult(success=True, message_id=str(msg.message_id))
3410
+ except Exception as e:
3411
+ logger.warning(
3412
+ "[%s] URL-based send_photo failed, trying file upload: %s",
3413
+ self.name,
3414
+ e,
3415
+ exc_info=True,
3416
+ )
3417
+ # Fallback: download and upload as file (supports up to 10MB)
3418
+ try:
3419
+ import httpx
3420
+ async with httpx.AsyncClient(timeout=30.0) as client:
3421
+ resp = await client.get(image_url)
3422
+ resp.raise_for_status()
3423
+ image_data = resp.content
3424
+
3425
+ upload_thread_kwargs = self._thread_kwargs_for_send(
3426
+ chat_id,
3427
+ _photo_thread,
3428
+ metadata,
3429
+ reply_to_message_id=reply_to_id,
3430
+ )
3431
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3432
+ self._bot.send_photo,
3433
+ {
3434
+ "chat_id": int(chat_id),
3435
+ "photo": image_data,
3436
+ "caption": caption[:1024] if caption else None,
3437
+ "reply_to_message_id": reply_to_id,
3438
+ **upload_thread_kwargs,
3439
+ **self._notification_kwargs(metadata),
3440
+ },
3441
+ metadata,
3442
+ reply_to_id,
3443
+ "uploaded photo",
3444
+ )
3445
+ return SendResult(success=True, message_id=str(msg.message_id))
3446
+ except Exception as e2:
3447
+ logger.error(
3448
+ "[%s] File upload send_photo also failed: %s",
3449
+ self.name,
3450
+ e2,
3451
+ exc_info=True,
3452
+ )
3453
+ # Final fallback: send URL as text
3454
+ return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
3455
+
3456
+ async def send_animation(
3457
+ self,
3458
+ chat_id: str,
3459
+ animation_url: str,
3460
+ caption: Optional[str] = None,
3461
+ reply_to: Optional[str] = None,
3462
+ metadata: Optional[Dict[str, Any]] = None,
3463
+ ) -> SendResult:
3464
+ """Send an animated GIF natively as a Telegram animation (auto-plays inline)."""
3465
+ if not self._bot:
3466
+ return SendResult(success=False, error="Not connected")
3467
+
3468
+ try:
3469
+ _anim_thread = self._metadata_thread_id(metadata)
3470
+ reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata)
3471
+ animation_thread_kwargs = self._thread_kwargs_for_send(
3472
+ chat_id,
3473
+ _anim_thread,
3474
+ metadata,
3475
+ reply_to_message_id=reply_to_id,
3476
+ )
3477
+ msg = await self._send_with_dm_topic_reply_anchor_retry(
3478
+ self._bot.send_animation,
3479
+ {
3480
+ "chat_id": int(chat_id),
3481
+ "animation": animation_url,
3482
+ "caption": caption[:1024] if caption else None,
3483
+ "reply_to_message_id": reply_to_id,
3484
+ **animation_thread_kwargs,
3485
+ **self._notification_kwargs(metadata),
3486
+ },
3487
+ metadata,
3488
+ reply_to_id,
3489
+ "animation",
3490
+ )
3491
+ return SendResult(success=True, message_id=str(msg.message_id))
3492
+ except Exception as e:
3493
+ logger.error(
3494
+ "[%s] Failed to send Telegram animation, falling back to photo: %s",
3495
+ self.name,
3496
+ e,
3497
+ exc_info=True,
3498
+ )
3499
+ # Fallback: try as a regular photo
3500
+ return await self.send_image(chat_id, animation_url, caption, reply_to, metadata=metadata)
3501
+
3502
+ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
3503
+ """Send typing indicator."""
3504
+ if self._bot:
3505
+ try:
3506
+ _typing_thread = self._metadata_thread_id(metadata)
3507
+ # Skip the Bot API call entirely for Hermes-created DM topic
3508
+ # lanes: send_chat_action only accepts message_thread_id, which
3509
+ # Telegram's Bot API 10.0 rejects for these lanes. The send
3510
+ # path uses the reply-anchor fallback instead, but typing has
3511
+ # no equivalent — skipping avoids noisy "thread not found"
3512
+ # debug logs on every typing tick.
3513
+ if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
3514
+ return
3515
+ message_thread_id = self._message_thread_id_for_typing(_typing_thread)
3516
+ # No retry-without-thread fallback here: _message_thread_id_for_typing
3517
+ # already maps the forum General topic to None, so any non-None value
3518
+ # reaching this call is a user-created topic. If Telegram rejects it
3519
+ # (e.g. topic deleted mid-session), we swallow the failure rather than
3520
+ # showing a typing indicator in the wrong chat/All Messages.
3521
+ await self._bot.send_chat_action(
3522
+ chat_id=int(chat_id),
3523
+ action="typing",
3524
+ message_thread_id=message_thread_id,
3525
+ )
3526
+ except Exception as e:
3527
+ # Typing failures are non-fatal; log at debug level only.
3528
+ logger.debug(
3529
+ "[%s] Failed to send Telegram typing indicator: %s",
3530
+ self.name,
3531
+ e,
3532
+ exc_info=True,
3533
+ )
3534
+
3535
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
3536
+ """Get information about a Telegram chat."""
3537
+ if not self._bot:
3538
+ return {"name": "Unknown", "type": "dm"}
3539
+
3540
+ try:
3541
+ chat = await self._bot.get_chat(int(chat_id))
3542
+
3543
+ chat_type = "dm"
3544
+ if chat.type == ChatType.GROUP:
3545
+ chat_type = "group"
3546
+ elif chat.type == ChatType.SUPERGROUP:
3547
+ chat_type = "group"
3548
+ if chat.is_forum:
3549
+ chat_type = "forum"
3550
+ elif chat.type == ChatType.CHANNEL:
3551
+ chat_type = "channel"
3552
+
3553
+ return {
3554
+ "name": chat.title or chat.full_name or str(chat_id),
3555
+ "type": chat_type,
3556
+ "username": chat.username,
3557
+ "is_forum": getattr(chat, "is_forum", False),
3558
+ }
3559
+ except Exception as e:
3560
+ logger.error(
3561
+ "[%s] Failed to get Telegram chat info for %s: %s",
3562
+ self.name,
3563
+ chat_id,
3564
+ e,
3565
+ exc_info=True,
3566
+ )
3567
+ return {"name": str(chat_id), "type": "dm", "error": str(e)}
3568
+
3569
+ def format_message(self, content: str) -> str:
3570
+ """
3571
+ Convert standard markdown to Telegram MarkdownV2 format.
3572
+
3573
+ Protected regions (code blocks, inline code) are extracted first so
3574
+ their contents are never modified. Standard markdown constructs
3575
+ (headers, bold, italic, links) are translated to MarkdownV2 syntax,
3576
+ and all remaining special characters are escaped.
3577
+ """
3578
+ if not content:
3579
+ return content
3580
+
3581
+ placeholders: dict = {}
3582
+ counter = [0]
3583
+
3584
+ def _ph(value: str) -> str:
3585
+ """Stash *value* behind a placeholder token that survives escaping."""
3586
+ key = f"\x00PH{counter[0]}\x00"
3587
+ counter[0] += 1
3588
+ placeholders[key] = value
3589
+ return key
3590
+
3591
+ text = content
3592
+
3593
+ # 0) Rewrite GFM-style pipe tables into Telegram-friendly row groups
3594
+ # before the normal MarkdownV2 conversions run.
3595
+ text = _wrap_markdown_tables(text)
3596
+
3597
+ # 1) Protect fenced code blocks (``` ... ```)
3598
+ # Per MarkdownV2 spec, \ and ` inside pre/code must be escaped.
3599
+ def _protect_fenced(m):
3600
+ raw = m.group(0)
3601
+ # Split off opening ``` (with optional language) and closing ```
3602
+ open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3
3603
+ opening = raw[:open_end]
3604
+ body_and_close = raw[open_end:]
3605
+ body = body_and_close[:-3]
3606
+ body = body.replace('\\', '\\\\').replace('`', '\\`')
3607
+ return _ph(opening + body + '```')
3608
+
3609
+ text = re.sub(
3610
+ r'(```(?:[^\n]*\n)?[\s\S]*?```)',
3611
+ _protect_fenced,
3612
+ text,
3613
+ )
3614
+
3615
+ # 2) Protect inline code (`...`)
3616
+ # Escape \ inside inline code per MarkdownV2 spec.
3617
+ text = re.sub(
3618
+ r'(`[^`]+`)',
3619
+ lambda m: _ph(m.group(0).replace('\\', '\\\\')),
3620
+ text,
3621
+ )
3622
+
3623
+ # 3) Convert markdown links – escape the display text; inside the URL
3624
+ # only ')' and '\' need escaping per the MarkdownV2 spec.
3625
+ def _convert_link(m):
3626
+ display = _escape_mdv2(m.group(1))
3627
+ url = m.group(2).replace('\\', '\\\\').replace(')', '\\)')
3628
+ return _ph(f'[{display}]({url})')
3629
+
3630
+ text = re.sub(r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', _convert_link, text)
3631
+
3632
+ # 4) Convert markdown headers (## Title) → bold *Title*
3633
+ def _convert_header(m):
3634
+ inner = m.group(1).strip()
3635
+ # Strip redundant bold markers that may appear inside a header
3636
+ inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner)
3637
+ return _ph(f'*{_escape_mdv2(inner)}*')
3638
+
3639
+ text = re.sub(
3640
+ r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
3641
+ )
3642
+
3643
+ # 5) Convert bold: **text** → *text* (MarkdownV2 bold)
3644
+ text = re.sub(
3645
+ r'\*\*(.+?)\*\*',
3646
+ lambda m: _ph(f'*{_escape_mdv2(m.group(1))}*'),
3647
+ text,
3648
+ )
3649
+
3650
+ # 6) Convert italic: *text* (single asterisk) → _text_ (MarkdownV2 italic)
3651
+ # [^*\n]+ prevents matching across newlines (which would corrupt
3652
+ # bullet lists using * markers and multi-line content).
3653
+ text = re.sub(
3654
+ r'\*([^*\n]+)\*',
3655
+ lambda m: _ph(f'_{_escape_mdv2(m.group(1))}_'),
3656
+ text,
3657
+ )
3658
+
3659
+ # 7) Convert strikethrough: ~~text~~ → ~text~ (MarkdownV2)
3660
+ text = re.sub(
3661
+ r'~~(.+?)~~',
3662
+ lambda m: _ph(f'~{_escape_mdv2(m.group(1))}~'),
3663
+ text,
3664
+ )
3665
+
3666
+ # 8) Convert spoiler: ||text|| → ||text|| (protect from | escaping)
3667
+ text = re.sub(
3668
+ r'\|\|(.+?)\|\|',
3669
+ lambda m: _ph(f'||{_escape_mdv2(m.group(1))}||'),
3670
+ text,
3671
+ )
3672
+
3673
+ # 9) Convert blockquotes: > at line start → protect > from escaping
3674
+ # Handle both regular blockquotes (> text) and expandable blockquotes
3675
+ # (Telegram MarkdownV2: **> for expandable start, || to end the quote)
3676
+ def _convert_blockquote(m):
3677
+ prefix = m.group(1) # >, >>, >>>, **>, or **>> etc.
3678
+ content = m.group(2)
3679
+ # Check if content ends with || (expandable blockquote end marker)
3680
+ # In this case, preserve the trailing || unescaped for Telegram
3681
+ if prefix.startswith('**') and content.endswith('||'):
3682
+ return _ph(f'{prefix} {_escape_mdv2(content[:-2])}||')
3683
+ return _ph(f'{prefix} {_escape_mdv2(content)}')
3684
+
3685
+ text = re.sub(
3686
+ r'^((?:\*\*)?>{1,3}) (.+)$',
3687
+ _convert_blockquote,
3688
+ text,
3689
+ flags=re.MULTILINE,
3690
+ )
3691
+
3692
+ # 10) Escape remaining special characters in plain text
3693
+ text = _escape_mdv2(text)
3694
+
3695
+ # 11) Restore placeholders in reverse insertion order so that
3696
+ # nested references (a placeholder inside another) resolve correctly.
3697
+ for key in reversed(list(placeholders.keys())):
3698
+ text = text.replace(key, placeholders[key])
3699
+
3700
+ # 12) Safety net: escape unescaped ( ) { } that slipped through
3701
+ # placeholder processing. Split the text into code/non-code
3702
+ # segments so we never touch content inside ``` or ` spans.
3703
+ _code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text)
3704
+ _safe_parts = []
3705
+ for _idx, _seg in enumerate(_code_split):
3706
+ if _idx % 2 == 1:
3707
+ # Inside code span/block — leave untouched
3708
+ _safe_parts.append(_seg)
3709
+ else:
3710
+ # Outside code — escape bare ( ) { }
3711
+ def _esc_bare(m, _seg=_seg):
3712
+ s = m.start()
3713
+ ch = m.group(0)
3714
+ # Already escaped
3715
+ if s > 0 and _seg[s - 1] == '\\':
3716
+ return ch
3717
+ # ( that opens a MarkdownV2 link [text](url)
3718
+ if ch == '(' and s > 0 and _seg[s - 1] == ']':
3719
+ return ch
3720
+ # ) that closes a link URL
3721
+ if ch == ')':
3722
+ before = _seg[:s]
3723
+ if '](http' in before or '](' in before:
3724
+ # Check depth
3725
+ depth = 0
3726
+ for j in range(s - 1, max(s - 2000, -1), -1):
3727
+ if _seg[j] == '(':
3728
+ depth -= 1
3729
+ if depth < 0:
3730
+ if j > 0 and _seg[j - 1] == ']':
3731
+ return ch
3732
+ break
3733
+ elif _seg[j] == ')':
3734
+ depth += 1
3735
+ return '\\' + ch
3736
+ _safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg))
3737
+ text = ''.join(_safe_parts)
3738
+
3739
+ return text
3740
+
3741
+ # ── Group mention gating ──────────────────────────────────────────────
3742
+
3743
+ def _telegram_require_mention(self) -> bool:
3744
+ """Return whether group chats should require an explicit bot trigger."""
3745
+ configured = self.config.extra.get("require_mention")
3746
+ if configured is not None:
3747
+ if isinstance(configured, str):
3748
+ return configured.lower() in {"true", "1", "yes", "on"}
3749
+ return bool(configured)
3750
+ return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
3751
+
3752
+ def _telegram_guest_mode(self) -> bool:
3753
+ """Return whether non-allowlisted groups may trigger via direct @mention."""
3754
+ configured = self.config.extra.get("guest_mode")
3755
+ if configured is not None:
3756
+ if isinstance(configured, str):
3757
+ return configured.lower() in {"true", "1", "yes", "on"}
3758
+ return bool(configured)
3759
+ return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in {"true", "1", "yes", "on"}
3760
+
3761
+ def _telegram_free_response_chats(self) -> set[str]:
3762
+ raw = self.config.extra.get("free_response_chats")
3763
+ if raw is None:
3764
+ raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
3765
+ if isinstance(raw, list):
3766
+ return {str(part).strip() for part in raw if str(part).strip()}
3767
+ return {part.strip() for part in str(raw).split(",") if part.strip()}
3768
+
3769
+ def _telegram_allowed_chats(self) -> set[str]:
3770
+ """Return the whitelist of group/supergroup chat IDs the bot will respond in.
3771
+
3772
+ When non-empty, group messages from chats NOT in this set are
3773
+ silently ignored unless ``guest_mode`` is enabled and the bot is
3774
+ explicitly @mentioned. DMs are never filtered.
3775
+ Empty set means no restriction (fully backward compatible).
3776
+ """
3777
+ raw = self.config.extra.get("allowed_chats")
3778
+ if raw is None:
3779
+ raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
3780
+ if isinstance(raw, list):
3781
+ return {str(part).strip() for part in raw if str(part).strip()}
3782
+ return {part.strip() for part in str(raw).split(",") if part.strip()}
3783
+
3784
+ def _telegram_ignored_threads(self) -> set[int]:
3785
+ raw = self.config.extra.get("ignored_threads")
3786
+ if raw is None:
3787
+ raw = os.getenv("TELEGRAM_IGNORED_THREADS", "")
3788
+
3789
+ if isinstance(raw, list):
3790
+ values = raw
3791
+ else:
3792
+ values = str(raw).split(",")
3793
+
3794
+ ignored: set[int] = set()
3795
+ for value in values:
3796
+ text = str(value).strip()
3797
+ if not text:
3798
+ continue
3799
+ try:
3800
+ ignored.add(int(text))
3801
+ except (TypeError, ValueError):
3802
+ logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value)
3803
+ return ignored
3804
+
3805
+ def _compile_mention_patterns(self) -> List[re.Pattern]:
3806
+ """Compile optional regex wake-word patterns for group triggers."""
3807
+ patterns = self.config.extra.get("mention_patterns")
3808
+ if patterns is None:
3809
+ raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
3810
+ if raw:
3811
+ try:
3812
+ loaded = json.loads(raw)
3813
+ except Exception:
3814
+ loaded = [part.strip() for part in raw.splitlines() if part.strip()]
3815
+ if not loaded:
3816
+ loaded = [part.strip() for part in raw.split(",") if part.strip()]
3817
+ patterns = loaded
3818
+
3819
+ if patterns is None:
3820
+ return []
3821
+ if isinstance(patterns, str):
3822
+ patterns = [patterns]
3823
+ if not isinstance(patterns, list):
3824
+ logger.warning(
3825
+ "[%s] telegram mention_patterns must be a list or string; got %s",
3826
+ self.name,
3827
+ type(patterns).__name__,
3828
+ )
3829
+ return []
3830
+
3831
+ compiled: List[re.Pattern] = []
3832
+ for pattern in patterns:
3833
+ if not isinstance(pattern, str) or not pattern.strip():
3834
+ continue
3835
+ try:
3836
+ compiled.append(re.compile(pattern, re.IGNORECASE))
3837
+ except re.error as exc:
3838
+ logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
3839
+ if compiled:
3840
+ logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
3841
+ return compiled
3842
+
3843
+ def _is_group_chat(self, message: Message) -> bool:
3844
+ chat = getattr(message, "chat", None)
3845
+ if not chat:
3846
+ return False
3847
+ chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
3848
+ return chat_type in {"group", "supergroup"}
3849
+
3850
+ def _is_reply_to_bot(self, message: Message) -> bool:
3851
+ if not self._bot or not getattr(message, "reply_to_message", None):
3852
+ return False
3853
+ reply_user = getattr(message.reply_to_message, "from_user", None)
3854
+ return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
3855
+
3856
+ def _message_mentions_bot(self, message: Message) -> bool:
3857
+ if not self._bot:
3858
+ return False
3859
+
3860
+ bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
3861
+ bot_id = getattr(self._bot, "id", None)
3862
+ expected = f"@{bot_username}" if bot_username else None
3863
+
3864
+ def _iter_sources():
3865
+ yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
3866
+ yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
3867
+
3868
+ # Telegram parses mentions server-side and emits MessageEntity objects
3869
+ # (type=mention for @username, type=text_mention for @FirstName targeting
3870
+ # a user without a public username). Only those entities are authoritative —
3871
+ # raw substring matches like "foo@hermes_bot.example" are not mentions
3872
+ # (bug #12545). Entities also correctly handle @handles inside URLs, code
3873
+ # blocks, and quoted text, where a regex scan would over-match.
3874
+ for source_text, entities in _iter_sources():
3875
+ for entity in entities:
3876
+ entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
3877
+ if entity_type == "mention" and expected:
3878
+ offset = int(getattr(entity, "offset", -1))
3879
+ length = int(getattr(entity, "length", 0))
3880
+ if offset < 0 or length <= 0:
3881
+ continue
3882
+ if source_text[offset:offset + length].strip().lower() == expected:
3883
+ return True
3884
+ elif entity_type == "text_mention":
3885
+ user = getattr(entity, "user", None)
3886
+ if user and getattr(user, "id", None) == bot_id:
3887
+ return True
3888
+ elif entity_type == "bot_command" and expected:
3889
+ # Telegram's official group-disambiguation form for slash
3890
+ # commands (``/cmd@botname``) is emitted as a single
3891
+ # ``bot_command`` entity covering the whole span — there
3892
+ # is no accompanying ``mention`` entity. Treat it as a
3893
+ # direct address to this bot when the ``@botname`` suffix
3894
+ # matches. This is the form Telegram's own command menu
3895
+ # autocomplete produces in groups, so dropping it at the
3896
+ # mention gate would break /new, /reset, /help, ... for
3897
+ # every group that has ``require_mention`` enabled (#15415).
3898
+ offset = int(getattr(entity, "offset", -1))
3899
+ length = int(getattr(entity, "length", 0))
3900
+ if offset < 0 or length <= 0:
3901
+ continue
3902
+ command_text = source_text[offset:offset + length]
3903
+ at_index = command_text.find("@")
3904
+ if at_index < 0:
3905
+ continue
3906
+ if command_text[at_index:].strip().lower() == expected:
3907
+ return True
3908
+ return False
3909
+
3910
+ def _message_matches_mention_patterns(self, message: Message) -> bool:
3911
+ if not self._mention_patterns:
3912
+ return False
3913
+ for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
3914
+ if not candidate:
3915
+ continue
3916
+ for pattern in self._mention_patterns:
3917
+ if pattern.search(candidate):
3918
+ return True
3919
+ return False
3920
+
3921
+ def _is_guest_mention(self, message: Message) -> bool:
3922
+ """Return True for the narrow guest-mode bypass: explicit bot mention.
3923
+
3924
+ The caller (:meth:`_should_process_message`) has already verified
3925
+ the message is a group chat, so that check is not repeated here.
3926
+ """
3927
+ return self._telegram_guest_mode() and self._message_mentions_bot(message)
3928
+
3929
+ def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
3930
+ if not text or not self._bot or not getattr(self._bot, "username", None):
3931
+ return text
3932
+ username = re.escape(self._bot.username)
3933
+ cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
3934
+ return cleaned or text
3935
+
3936
+ def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
3937
+ """Apply Telegram group trigger rules.
3938
+
3939
+ DMs remain unrestricted. Group/supergroup messages are accepted when:
3940
+ - the chat passes the ``allowed_chats`` whitelist (when set), or
3941
+ ``guest_mode`` is enabled and the bot is explicitly mentioned
3942
+ - the chat is explicitly allowlisted in ``free_response_chats``
3943
+ - ``require_mention`` is disabled
3944
+ - the message replies to the bot
3945
+ - the bot is @mentioned
3946
+ - the text/caption matches a configured regex wake-word pattern
3947
+
3948
+ When ``allowed_chats`` is non-empty, it remains a hard gate except for
3949
+ the narrow ``guest_mode`` bypass: group/supergroup messages that
3950
+ explicitly @mention this bot. Replies and regex wake words do not bypass
3951
+ ``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given
3952
+ special treatment — they must pass the same mention/reply checks
3953
+ as any other group message. Users can still trigger commands via
3954
+ the Telegram bot menu (``/command@botname``) or by explicitly
3955
+ mentioning the bot (``@botname /command``), both of which are
3956
+ recognised as mentions by :meth:`_message_mentions_bot`.
3957
+ """
3958
+ if not self._is_group_chat(message):
3959
+ return True
3960
+
3961
+ thread_id = getattr(message, "message_thread_id", None)
3962
+ if thread_id is not None:
3963
+ try:
3964
+ if int(thread_id) in self._telegram_ignored_threads():
3965
+ return False
3966
+ except (TypeError, ValueError):
3967
+ logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
3968
+
3969
+ chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
3970
+
3971
+ # Resolve guest-mode mention bypass once so _message_mentions_bot
3972
+ # is not called redundantly in the normal flow below.
3973
+ guest_mention = self._is_guest_mention(message)
3974
+
3975
+ # allowed_chats check (whitelist). When set, group messages from chats
3976
+ # outside the whitelist are ignored unless guest_mode permits this
3977
+ # exact message as an explicit direct mention. DMs are excluded above.
3978
+ allowed = self._telegram_allowed_chats()
3979
+ if allowed and chat_id_str not in allowed:
3980
+ return guest_mention
3981
+
3982
+ if guest_mention:
3983
+ return True
3984
+ if chat_id_str in self._telegram_free_response_chats():
3985
+ return True
3986
+ if not self._telegram_require_mention():
3987
+ return True
3988
+ if self._is_reply_to_bot(message):
3989
+ return True
3990
+ # When guest_mode is True, _is_guest_mention already called
3991
+ # _message_mentions_bot above — skip the redundant second call.
3992
+ if not self._telegram_guest_mode() and self._message_mentions_bot(message):
3993
+ return True
3994
+ return self._message_matches_mention_patterns(message)
3995
+
3996
+ async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
3997
+ """Handle incoming text messages.
3998
+
3999
+ Telegram clients split long messages into multiple updates. Buffer
4000
+ rapid successive text messages from the same user/chat and aggregate
4001
+ them into a single MessageEvent before dispatching.
4002
+ """
4003
+ if not update.message or not update.message.text:
4004
+ return
4005
+ if not self._should_process_message(update.message):
4006
+ return
4007
+
4008
+ event = self._build_message_event(update.message, MessageType.TEXT, update_id=update.update_id)
4009
+ event.text = self._clean_bot_trigger_text(event.text)
4010
+ self._enqueue_text_event(event)
4011
+
4012
+ async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
4013
+ """Handle incoming command messages."""
4014
+ if not update.message or not update.message.text:
4015
+ return
4016
+ if not self._should_process_message(update.message, is_command=True):
4017
+ return
4018
+
4019
+ event = self._build_message_event(update.message, MessageType.COMMAND, update_id=update.update_id)
4020
+ await self.handle_message(event)
4021
+
4022
+ async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
4023
+ """Handle incoming location/venue pin messages."""
4024
+ if not update.message:
4025
+ return
4026
+ if not self._should_process_message(update.message):
4027
+ return
4028
+
4029
+ msg = update.message
4030
+ venue = getattr(msg, "venue", None)
4031
+ location = getattr(venue, "location", None) if venue else getattr(msg, "location", None)
4032
+
4033
+ if not location:
4034
+ return
4035
+
4036
+ lat = getattr(location, "latitude", None)
4037
+ lon = getattr(location, "longitude", None)
4038
+ if lat is None or lon is None:
4039
+ return
4040
+
4041
+ # Build a text message with coordinates and context
4042
+ parts = ["[The user shared a location pin.]"]
4043
+ if venue:
4044
+ title = getattr(venue, "title", None)
4045
+ address = getattr(venue, "address", None)
4046
+ if title:
4047
+ parts.append(f"Venue: {title}")
4048
+ if address:
4049
+ parts.append(f"Address: {address}")
4050
+ parts.append(f"latitude: {lat}")
4051
+ parts.append(f"longitude: {lon}")
4052
+ parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}")
4053
+ parts.append("Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences.")
4054
+
4055
+ event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
4056
+ event.text = "\n".join(parts)
4057
+ await self.handle_message(event)
4058
+
4059
+ # ------------------------------------------------------------------
4060
+ # Text message aggregation (handles Telegram client-side splits)
4061
+ # ------------------------------------------------------------------
4062
+
4063
+ def _text_batch_key(self, event: MessageEvent) -> str:
4064
+ """Session-scoped key for text message batching."""
4065
+ from gateway.session import build_session_key
4066
+ return build_session_key(
4067
+ event.source,
4068
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
4069
+ thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
4070
+ )
4071
+
4072
+ def _enqueue_text_event(self, event: MessageEvent) -> None:
4073
+ """Buffer a text event and reset the flush timer.
4074
+
4075
+ When Telegram splits a long user message into multiple updates,
4076
+ they arrive within a few hundred milliseconds. This method
4077
+ concatenates them and waits for a short quiet period before
4078
+ dispatching the combined message.
4079
+ """
4080
+ key = self._text_batch_key(event)
4081
+ existing = self._pending_text_batches.get(key)
4082
+ chunk_len = len(event.text or "")
4083
+ if existing is None:
4084
+ event._last_chunk_len = chunk_len # type: ignore[attr-defined]
4085
+ self._pending_text_batches[key] = event
4086
+ else:
4087
+ # Append text from the follow-up chunk
4088
+ if event.text:
4089
+ existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
4090
+ existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
4091
+ # Merge any media that might be attached
4092
+ if event.media_urls:
4093
+ existing.media_urls.extend(event.media_urls)
4094
+ existing.media_types.extend(event.media_types)
4095
+
4096
+ # Cancel any pending flush and restart the timer
4097
+ prior_task = self._pending_text_batch_tasks.get(key)
4098
+ if prior_task and not prior_task.done():
4099
+ prior_task.cancel()
4100
+ self._pending_text_batch_tasks[key] = asyncio.create_task(
4101
+ self._flush_text_batch(key)
4102
+ )
4103
+
4104
+ async def _flush_text_batch(self, key: str) -> None:
4105
+ """Wait for the quiet period then dispatch the aggregated text.
4106
+
4107
+ Uses a longer delay when the latest chunk is near Telegram's 4096-char
4108
+ split point, since a continuation chunk is almost certain.
4109
+ """
4110
+ current_task = asyncio.current_task()
4111
+ try:
4112
+ # Adaptive delay tiers:
4113
+ # - last chunk ≥ _SPLIT_THRESHOLD: a continuation is almost
4114
+ # certain → wait the longer split delay.
4115
+ # - total accumulated text ≤ _TEXT_BATCH_FAST_LEN (~320 cp):
4116
+ # short message → cap delay at _TEXT_BATCH_FAST_DELAY_S
4117
+ # so the agent sees the text near-instantly.
4118
+ # - total ≤ _TEXT_BATCH_SHORT_LEN (~1024 cp):
4119
+ # medium → cap at _TEXT_BATCH_SHORT_DELAY_S.
4120
+ # - otherwise: use the configured cap.
4121
+ # Tiers compose with operator overrides via the env-var-driven
4122
+ # ``_text_batch_delay_seconds`` (e.g. an operator who sets the
4123
+ # cap below 0.18s gets that lower number on every tier).
4124
+ pending = self._pending_text_batches.get(key)
4125
+ last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
4126
+ total_len = len(getattr(pending, "text", "") or "") if pending else 0
4127
+ if last_len >= self._SPLIT_THRESHOLD:
4128
+ delay = self._text_batch_split_delay_seconds
4129
+ elif total_len <= self._TEXT_BATCH_FAST_LEN:
4130
+ delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_FAST_DELAY_S)
4131
+ elif total_len <= self._TEXT_BATCH_SHORT_LEN:
4132
+ delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_SHORT_DELAY_S)
4133
+ else:
4134
+ delay = self._text_batch_delay_seconds
4135
+ await asyncio.sleep(delay)
4136
+ event = self._pending_text_batches.pop(key, None)
4137
+ if not event:
4138
+ return
4139
+ logger.info(
4140
+ "[Telegram] Flushing text batch %s (%d chars)",
4141
+ key, len(event.text or ""),
4142
+ )
4143
+ await self.handle_message(event)
4144
+ finally:
4145
+ if self._pending_text_batch_tasks.get(key) is current_task:
4146
+ self._pending_text_batch_tasks.pop(key, None)
4147
+
4148
+ # ------------------------------------------------------------------
4149
+ # Photo batching
4150
+ # ------------------------------------------------------------------
4151
+
4152
+ def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
4153
+ """Return a batching key for Telegram photos/albums."""
4154
+ from gateway.session import build_session_key
4155
+ session_key = build_session_key(
4156
+ event.source,
4157
+ group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
4158
+ thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
4159
+ )
4160
+ media_group_id = getattr(msg, "media_group_id", None)
4161
+ if media_group_id:
4162
+ return f"{session_key}:album:{media_group_id}"
4163
+ return f"{session_key}:photo-burst"
4164
+
4165
+ async def _flush_photo_batch(self, batch_key: str) -> None:
4166
+ """Send a buffered photo burst/album as a single MessageEvent."""
4167
+ current_task = asyncio.current_task()
4168
+ try:
4169
+ await asyncio.sleep(self._media_batch_delay_seconds)
4170
+ event = self._pending_photo_batches.pop(batch_key, None)
4171
+ if not event:
4172
+ return
4173
+ logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls))
4174
+ await self.handle_message(event)
4175
+ finally:
4176
+ if self._pending_photo_batch_tasks.get(batch_key) is current_task:
4177
+ self._pending_photo_batch_tasks.pop(batch_key, None)
4178
+
4179
+ def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:
4180
+ """Merge photo events into a pending batch and schedule flush."""
4181
+ existing = self._pending_photo_batches.get(batch_key)
4182
+ if existing is None:
4183
+ self._pending_photo_batches[batch_key] = event
4184
+ else:
4185
+ existing.media_urls.extend(event.media_urls)
4186
+ existing.media_types.extend(event.media_types)
4187
+ if event.text:
4188
+ existing.text = self._merge_caption(existing.text, event.text)
4189
+
4190
+ prior_task = self._pending_photo_batch_tasks.get(batch_key)
4191
+ if prior_task and not prior_task.done():
4192
+ prior_task.cancel()
4193
+
4194
+ self._pending_photo_batch_tasks[batch_key] = asyncio.create_task(self._flush_photo_batch(batch_key))
4195
+
4196
+ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
4197
+ """Handle incoming media messages, downloading images to local cache."""
4198
+ if not update.message:
4199
+ return
4200
+ if not self._should_process_message(update.message):
4201
+ return
4202
+
4203
+ msg = update.message
4204
+
4205
+ # Determine media type
4206
+ if msg.sticker:
4207
+ msg_type = MessageType.STICKER
4208
+ elif msg.photo:
4209
+ msg_type = MessageType.PHOTO
4210
+ elif msg.video:
4211
+ msg_type = MessageType.VIDEO
4212
+ elif msg.audio:
4213
+ msg_type = MessageType.AUDIO
4214
+ elif msg.voice:
4215
+ msg_type = MessageType.VOICE
4216
+ elif msg.document:
4217
+ msg_type = MessageType.DOCUMENT
4218
+ else:
4219
+ msg_type = MessageType.DOCUMENT
4220
+
4221
+ event = self._build_message_event(msg, msg_type, update_id=update.update_id)
4222
+
4223
+ # Add caption as text
4224
+ if msg.caption:
4225
+ event.text = self._clean_bot_trigger_text(msg.caption)
4226
+
4227
+ # Handle stickers: describe via vision tool with caching
4228
+ if msg.sticker:
4229
+ await self._handle_sticker(msg, event)
4230
+ await self.handle_message(event)
4231
+ return
4232
+
4233
+ # Download photo to local image cache so the vision tool can access it
4234
+ # even after Telegram's ephemeral file URLs expire (~1 hour).
4235
+ if msg.photo:
4236
+ try:
4237
+ # msg.photo is a list of PhotoSize sorted by size; take the largest
4238
+ photo = msg.photo[-1]
4239
+ file_obj = await photo.get_file()
4240
+ # Download the image bytes directly into memory
4241
+ image_bytes = await file_obj.download_as_bytearray()
4242
+ # Determine extension from the file path if available
4243
+ ext = ".jpg"
4244
+ if file_obj.file_path:
4245
+ for candidate in [".png", ".webp", ".gif", ".jpeg", ".jpg"]:
4246
+ if file_obj.file_path.lower().endswith(candidate):
4247
+ ext = candidate
4248
+ break
4249
+ # Save to local cache (for vision tool access)
4250
+ cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext)
4251
+ event.media_urls = [cached_path]
4252
+ event.media_types = [f"image/{ext.lstrip('.')}" ]
4253
+ logger.info("[Telegram] Cached user photo at %s", cached_path)
4254
+ media_group_id = getattr(msg, "media_group_id", None)
4255
+ if media_group_id:
4256
+ await self._queue_media_group_event(str(media_group_id), event)
4257
+ else:
4258
+ batch_key = self._photo_batch_key(event, msg)
4259
+ self._enqueue_photo_event(batch_key, event)
4260
+ return
4261
+
4262
+ except Exception as e:
4263
+ logger.warning("[Telegram] Failed to cache photo: %s", e, exc_info=True)
4264
+
4265
+ # Download voice/audio messages to cache for STT transcription
4266
+ if msg.voice:
4267
+ try:
4268
+ file_obj = await msg.voice.get_file()
4269
+ audio_bytes = await file_obj.download_as_bytearray()
4270
+ cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
4271
+ event.media_urls = [cached_path]
4272
+ event.media_types = ["audio/ogg"]
4273
+ logger.info("[Telegram] Cached user voice at %s", cached_path)
4274
+ except Exception as e:
4275
+ logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
4276
+ elif msg.audio:
4277
+ try:
4278
+ file_obj = await msg.audio.get_file()
4279
+ audio_bytes = await file_obj.download_as_bytearray()
4280
+ cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
4281
+ event.media_urls = [cached_path]
4282
+ event.media_types = ["audio/mp3"]
4283
+ logger.info("[Telegram] Cached user audio at %s", cached_path)
4284
+ except Exception as e:
4285
+ logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True)
4286
+
4287
+ elif msg.video:
4288
+ try:
4289
+ file_obj = await msg.video.get_file()
4290
+ video_bytes = await file_obj.download_as_bytearray()
4291
+ ext = ".mp4"
4292
+ if getattr(file_obj, "file_path", None):
4293
+ for candidate in SUPPORTED_VIDEO_TYPES:
4294
+ if file_obj.file_path.lower().endswith(candidate):
4295
+ ext = candidate
4296
+ break
4297
+ cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
4298
+ event.media_urls = [cached_path]
4299
+ event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")]
4300
+ logger.info("[Telegram] Cached user video at %s", cached_path)
4301
+ except Exception as e:
4302
+ logger.warning("[Telegram] Failed to cache video: %s", e, exc_info=True)
4303
+
4304
+ # Download document files to cache for agent processing
4305
+ elif msg.document:
4306
+ doc = msg.document
4307
+ try:
4308
+ # Determine file extension
4309
+ ext = ""
4310
+ original_filename = doc.file_name or ""
4311
+ if original_filename:
4312
+ _, ext = os.path.splitext(original_filename)
4313
+ ext = ext.lower()
4314
+
4315
+ # Normalize mime_type for robust comparisons (some clients send
4316
+ # uppercase like "IMAGE/PNG").
4317
+ doc_mime = (doc.mime_type or "").lower()
4318
+
4319
+ # If no extension from filename, reverse-lookup from MIME type
4320
+ if not ext and doc_mime:
4321
+ ext = _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, "")
4322
+ if not ext:
4323
+ mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
4324
+ ext = mime_to_ext.get(doc_mime, "")
4325
+
4326
+ # Check file size early so image documents cannot bypass the
4327
+ # document size limit by taking the image path.
4328
+ MAX_DOC_BYTES = 20 * 1024 * 1024
4329
+ if not doc.file_size or doc.file_size > MAX_DOC_BYTES:
4330
+ event.text = (
4331
+ "The document is too large or its size could not be verified. "
4332
+ "Maximum: 20 MB."
4333
+ )
4334
+ logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
4335
+ await self.handle_message(event)
4336
+ return
4337
+
4338
+ # Telegram may deliver screenshots/photos as documents. If the
4339
+ # payload is actually an image, route it through the image cache
4340
+ # and batching path instead of rejecting it as a document.
4341
+ if ext in _TELEGRAM_IMAGE_EXTENSIONS or doc_mime.startswith("image/"):
4342
+ file_obj = await doc.get_file()
4343
+ image_bytes = await file_obj.download_as_bytearray()
4344
+ image_ext = ext if ext in _TELEGRAM_IMAGE_EXTENSIONS else _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, ".jpg")
4345
+ try:
4346
+ cached_path = cache_image_from_bytes(bytes(image_bytes), ext=image_ext)
4347
+ except ValueError as e:
4348
+ logger.warning("[Telegram] Failed to cache image document: %s", e, exc_info=True)
4349
+ event.text = (
4350
+ f"Image document '{original_filename or doc_mime or ext or 'unknown'}' "
4351
+ "could not be read as an image."
4352
+ )
4353
+ await self.handle_message(event)
4354
+ return
4355
+
4356
+ event.message_type = MessageType.PHOTO
4357
+ event.media_urls = [cached_path]
4358
+ event.media_types = [doc_mime if doc_mime.startswith("image/") else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg")]
4359
+ logger.info("[Telegram] Cached user image-document at %s", cached_path)
4360
+
4361
+ media_group_id = getattr(msg, "media_group_id", None)
4362
+ if media_group_id:
4363
+ await self._queue_media_group_event(str(media_group_id), event)
4364
+ else:
4365
+ batch_key = self._photo_batch_key(event, msg)
4366
+ self._enqueue_photo_event(batch_key, event)
4367
+ return
4368
+
4369
+ if not ext and doc.mime_type:
4370
+ video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
4371
+ ext = video_mime_to_ext.get(doc.mime_type, "")
4372
+
4373
+ if ext in SUPPORTED_VIDEO_TYPES:
4374
+ file_obj = await doc.get_file()
4375
+ video_bytes = await file_obj.download_as_bytearray()
4376
+ cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
4377
+ event.media_urls = [cached_path]
4378
+ event.media_types = [SUPPORTED_VIDEO_TYPES[ext]]
4379
+ event.message_type = MessageType.VIDEO
4380
+ logger.info("[Telegram] Cached user video document at %s", cached_path)
4381
+ await self.handle_message(event)
4382
+ return
4383
+
4384
+ # Check if supported
4385
+ if ext not in SUPPORTED_DOCUMENT_TYPES:
4386
+ supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))
4387
+ event.text = (
4388
+ f"Unsupported document type '{ext or 'unknown'}'. "
4389
+ f"Supported types: {supported_list}"
4390
+ )
4391
+ logger.info("[Telegram] Unsupported document type: %s", ext or "unknown")
4392
+ await self.handle_message(event)
4393
+ return
4394
+
4395
+ # Download and cache
4396
+ file_obj = await doc.get_file()
4397
+ doc_bytes = await file_obj.download_as_bytearray()
4398
+ raw_bytes = bytes(doc_bytes)
4399
+ cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext}")
4400
+ mime_type = SUPPORTED_DOCUMENT_TYPES[ext]
4401
+ event.media_urls = [cached_path]
4402
+ event.media_types = [mime_type]
4403
+ logger.info("[Telegram] Cached user document at %s", cached_path)
4404
+
4405
+ # For text files, inject content into event.text (capped at 100 KB)
4406
+ MAX_TEXT_INJECT_BYTES = 100 * 1024
4407
+ if ext in {".md", ".txt"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
4408
+ try:
4409
+ text_content = raw_bytes.decode("utf-8")
4410
+ display_name = original_filename or f"document{ext}"
4411
+ display_name = re.sub(r'[^\w.\- ]', '_', display_name)
4412
+ injection = f"[Content of {display_name}]:\n{text_content}"
4413
+ if event.text:
4414
+ event.text = f"{injection}\n\n{event.text}"
4415
+ else:
4416
+ event.text = injection
4417
+ except UnicodeDecodeError:
4418
+ logger.warning(
4419
+ "[Telegram] Could not decode text file as UTF-8, skipping content injection",
4420
+ exc_info=True,
4421
+ )
4422
+
4423
+ except Exception as e:
4424
+ logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True)
4425
+
4426
+ media_group_id = getattr(msg, "media_group_id", None)
4427
+ if media_group_id:
4428
+ await self._queue_media_group_event(str(media_group_id), event)
4429
+ return
4430
+
4431
+ await self.handle_message(event)
4432
+
4433
+ async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
4434
+ """Buffer Telegram media-group items so albums arrive as one logical event.
4435
+
4436
+ Telegram delivers albums as multiple updates with a shared media_group_id.
4437
+ If we forward each item immediately, the gateway thinks the second image is a
4438
+ new user message and interrupts the first. We debounce briefly and merge the
4439
+ attachments into a single MessageEvent.
4440
+ """
4441
+ existing = self._media_group_events.get(media_group_id)
4442
+ if existing is None:
4443
+ self._media_group_events[media_group_id] = event
4444
+ else:
4445
+ existing.media_urls.extend(event.media_urls)
4446
+ existing.media_types.extend(event.media_types)
4447
+ if event.text:
4448
+ existing.text = self._merge_caption(existing.text, event.text)
4449
+
4450
+ prior_task = self._media_group_tasks.get(media_group_id)
4451
+ if prior_task:
4452
+ prior_task.cancel()
4453
+
4454
+ self._media_group_tasks[media_group_id] = asyncio.create_task(
4455
+ self._flush_media_group_event(media_group_id)
4456
+ )
4457
+
4458
+ async def _flush_media_group_event(self, media_group_id: str) -> None:
4459
+ try:
4460
+ await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS)
4461
+ event = self._media_group_events.pop(media_group_id, None)
4462
+ if event is not None:
4463
+ await self.handle_message(event)
4464
+ except asyncio.CancelledError:
4465
+ return
4466
+ finally:
4467
+ self._media_group_tasks.pop(media_group_id, None)
4468
+
4469
+ async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
4470
+ """
4471
+ Describe a Telegram sticker via vision analysis, with caching.
4472
+
4473
+ For static stickers (WEBP), we download, analyze with vision, and cache
4474
+ the description by file_unique_id. For animated/video stickers, we inject
4475
+ a placeholder noting the emoji.
4476
+ """
4477
+ from gateway.sticker_cache import (
4478
+ get_cached_description,
4479
+ cache_sticker_description,
4480
+ build_sticker_injection,
4481
+ build_animated_sticker_injection,
4482
+ STICKER_VISION_PROMPT,
4483
+ )
4484
+
4485
+ sticker = msg.sticker
4486
+ emoji = sticker.emoji or ""
4487
+ set_name = sticker.set_name or ""
4488
+
4489
+ # Animated and video stickers can't be analyzed as static images
4490
+ if sticker.is_animated or sticker.is_video:
4491
+ event.text = build_animated_sticker_injection(emoji)
4492
+ return
4493
+
4494
+ # Check the cache first
4495
+ cached = get_cached_description(sticker.file_unique_id)
4496
+ if cached:
4497
+ event.text = build_sticker_injection(
4498
+ cached["description"], cached.get("emoji", emoji), cached.get("set_name", set_name)
4499
+ )
4500
+ logger.info("[Telegram] Sticker cache hit: %s", sticker.file_unique_id)
4501
+ return
4502
+
4503
+ # Cache miss -- download and analyze
4504
+ try:
4505
+ file_obj = await sticker.get_file()
4506
+ image_bytes = await file_obj.download_as_bytearray()
4507
+ cached_path = cache_image_from_bytes(bytes(image_bytes), ext=".webp")
4508
+ logger.info("[Telegram] Analyzing sticker at %s", cached_path)
4509
+
4510
+ from tools.vision_tools import vision_analyze_tool
4511
+ result_json = await vision_analyze_tool(
4512
+ image_url=cached_path,
4513
+ user_prompt=STICKER_VISION_PROMPT,
4514
+ )
4515
+ result = json.loads(result_json)
4516
+
4517
+ if result.get("success"):
4518
+ description = result.get("analysis", "a sticker")
4519
+ cache_sticker_description(sticker.file_unique_id, description, emoji, set_name)
4520
+ event.text = build_sticker_injection(description, emoji, set_name)
4521
+ else:
4522
+ # Vision failed -- use emoji as fallback
4523
+ event.text = build_sticker_injection(
4524
+ f"a sticker with emoji {emoji}" if emoji else "a sticker",
4525
+ emoji, set_name,
4526
+ )
4527
+ except Exception as e:
4528
+ logger.warning("[Telegram] Sticker analysis error: %s", e, exc_info=True)
4529
+ event.text = build_sticker_injection(
4530
+ f"a sticker with emoji {emoji}" if emoji else "a sticker",
4531
+ emoji, set_name,
4532
+ )
4533
+
4534
+ def _reload_dm_topics_from_config(self) -> None:
4535
+ """Re-read dm_topics from config.yaml and load any new thread_ids into cache.
4536
+
4537
+ This allows topics created externally (e.g. by the agent via API) to be
4538
+ recognized without a gateway restart.
4539
+ """
4540
+ try:
4541
+ from calvyn_constants import get_hermes_home
4542
+ config_path = get_hermes_home() / "config.yaml"
4543
+ if not config_path.exists():
4544
+ return
4545
+
4546
+ import yaml as _yaml
4547
+ with open(config_path, "r", encoding="utf-8") as f:
4548
+ config = _yaml.safe_load(f) or {}
4549
+
4550
+ dm_topics = (
4551
+ config.get("platforms", {})
4552
+ .get("telegram", {})
4553
+ .get("extra", {})
4554
+ .get("dm_topics", [])
4555
+ )
4556
+ if not dm_topics:
4557
+ return
4558
+
4559
+ # Update in-memory config and cache any new thread_ids
4560
+ self._dm_topics_config = dm_topics
4561
+ for chat_entry in dm_topics:
4562
+ cid = chat_entry.get("chat_id")
4563
+ if not cid:
4564
+ continue
4565
+ for t in chat_entry.get("topics", []):
4566
+ tid = t.get("thread_id")
4567
+ name = t.get("name")
4568
+ if tid and name:
4569
+ cache_key = f"{cid}:{name}"
4570
+ if cache_key not in self._dm_topics:
4571
+ self._dm_topics[cache_key] = int(tid)
4572
+ logger.info(
4573
+ "[%s] Hot-loaded DM topic from config: %s -> thread_id=%s",
4574
+ self.name, cache_key, tid,
4575
+ )
4576
+ except Exception as e:
4577
+ logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e)
4578
+
4579
+ def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
4580
+ """Look up DM topic config by chat_id and thread_id.
4581
+
4582
+ Returns the topic config dict (name, skill, etc.) if this thread_id
4583
+ matches a known DM topic, or None.
4584
+ """
4585
+ if not thread_id:
4586
+ return None
4587
+
4588
+ thread_id_int = int(thread_id)
4589
+
4590
+ # Check cached topics first (created by us or loaded at startup)
4591
+ for key, cached_tid in self._dm_topics.items():
4592
+ if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
4593
+ topic_name = key.split(":", 1)[1]
4594
+ # Find the full config for this topic
4595
+ for chat_entry in self._dm_topics_config:
4596
+ if str(chat_entry.get("chat_id")) == chat_id:
4597
+ for t in chat_entry.get("topics", []):
4598
+ if t.get("name") == topic_name:
4599
+ return t
4600
+ return {"name": topic_name}
4601
+
4602
+ # Not in cache — hot-reload config in case topics were added externally
4603
+ self._reload_dm_topics_from_config()
4604
+
4605
+ # Check cache again after reload
4606
+ for key, cached_tid in self._dm_topics.items():
4607
+ if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
4608
+ topic_name = key.split(":", 1)[1]
4609
+ for chat_entry in self._dm_topics_config:
4610
+ if str(chat_entry.get("chat_id")) == chat_id:
4611
+ for t in chat_entry.get("topics", []):
4612
+ if t.get("name") == topic_name:
4613
+ return t
4614
+ return {"name": topic_name}
4615
+
4616
+ return None
4617
+
4618
+ def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
4619
+ """Cache a thread_id -> topic_name mapping discovered from an incoming message."""
4620
+ cache_key = f"{chat_id}:{topic_name}"
4621
+ if cache_key not in self._dm_topics:
4622
+ self._dm_topics[cache_key] = int(thread_id)
4623
+ logger.info(
4624
+ "[%s] Cached DM topic from message: %s -> thread_id=%s",
4625
+ self.name, cache_key, thread_id,
4626
+ )
4627
+
4628
+ def _build_message_event(
4629
+ self,
4630
+ message: Message,
4631
+ msg_type: MessageType,
4632
+ update_id: Optional[int] = None,
4633
+ ) -> MessageEvent:
4634
+ """Build a MessageEvent from a Telegram message.
4635
+
4636
+ ``update_id`` is the ``Update.update_id`` from PTB; passing it through
4637
+ lets ``/restart`` record the triggering offset so the new gateway
4638
+ process can advance past it (prevents ``/restart`` being re-delivered
4639
+ when PTB's graceful-shutdown ACK fails).
4640
+ """
4641
+ chat = message.chat
4642
+ user = message.from_user
4643
+
4644
+ # Determine chat type
4645
+ chat_type = "dm"
4646
+ if chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}:
4647
+ chat_type = "group"
4648
+ elif chat.type == ChatType.CHANNEL:
4649
+ chat_type = "channel"
4650
+
4651
+ # Resolve DM topic name and skill binding.
4652
+ # In private chats, only preserve thread ids for real topic messages
4653
+ # (is_topic_message=True). Telegram puts message_thread_id on every
4654
+ # DM that is a reply, even when the user is just replying to a
4655
+ # previous message in the same DM — that bogus id then routes to a
4656
+ # nonexistent thread and Telegram returns 'Message thread not found'
4657
+ # on send (#3206).
4658
+ thread_id_raw = message.message_thread_id
4659
+ is_topic_message = bool(getattr(message, "is_topic_message", False))
4660
+ thread_id_str = None
4661
+ if thread_id_raw is not None:
4662
+ if chat_type == "group":
4663
+ thread_id_str = str(thread_id_raw)
4664
+ elif chat_type == "dm" and is_topic_message:
4665
+ thread_id_str = str(thread_id_raw)
4666
+ # For forum groups without an explicit topic, default to the
4667
+ # General-topic id so the gateway routes back to the General topic
4668
+ # rather than dropping into the bot's main channel (#22423).
4669
+ if chat_type == "group" and thread_id_str is None and getattr(chat, "is_forum", False):
4670
+ thread_id_str = self._GENERAL_TOPIC_THREAD_ID
4671
+ chat_topic = None
4672
+ topic_skill = None
4673
+
4674
+ if chat_type == "dm" and thread_id_str:
4675
+ topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
4676
+ if topic_info:
4677
+ chat_topic = topic_info.get("name")
4678
+ topic_skill = topic_info.get("skill")
4679
+
4680
+ # Also check forum_topic_created service message for topic discovery
4681
+ if hasattr(message, "forum_topic_created") and message.forum_topic_created:
4682
+ created_name = message.forum_topic_created.name
4683
+ if created_name:
4684
+ self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
4685
+ if not chat_topic:
4686
+ chat_topic = created_name
4687
+
4688
+ elif chat_type == "group" and thread_id_str:
4689
+ # Group/supergroup forum topic skill binding via config.extra['group_topics']
4690
+ group_topics_config: list = self.config.extra.get("group_topics", [])
4691
+ for chat_entry in group_topics_config:
4692
+ if str(chat_entry.get("chat_id", "")) == str(chat.id):
4693
+ for topic in chat_entry.get("topics", []):
4694
+ tid = topic.get("thread_id")
4695
+ if tid is not None and str(tid) == thread_id_str:
4696
+ chat_topic = topic.get("name")
4697
+ topic_skill = topic.get("skill")
4698
+ break
4699
+ break
4700
+
4701
+ # Build source
4702
+ source = self.build_source(
4703
+ chat_id=str(chat.id),
4704
+ chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None),
4705
+ chat_type=chat_type,
4706
+ user_id=str(user.id) if user else (str(chat.id) if chat_type == "dm" else None),
4707
+ user_name=user.full_name if user else (chat.full_name if hasattr(chat, "full_name") and chat_type == "dm" else None),
4708
+ thread_id=thread_id_str,
4709
+ chat_topic=chat_topic,
4710
+ )
4711
+
4712
+ # Extract reply context if this message is a reply.
4713
+ # Prefer Telegram's native partial quote (message.quote, TextQuote)
4714
+ # so a user replying to a single selected substring of a prior
4715
+ # multi-section message doesn't get the whole replied-to message
4716
+ # injected into the agent's context — which can cause the agent
4717
+ # to act on unrelated actionable-looking text the user didn't
4718
+ # quote (#22619). Fall back to the full replied-to message text
4719
+ # / caption when no native quote is present.
4720
+ reply_to_id = None
4721
+ reply_to_text = None
4722
+ if message.reply_to_message:
4723
+ reply_to_id = str(message.reply_to_message.message_id)
4724
+ quote = getattr(message, "quote", None)
4725
+ quote_text = getattr(quote, "text", None) if quote is not None else None
4726
+ if quote_text:
4727
+ reply_to_text = quote_text
4728
+ else:
4729
+ reply_to_text = (
4730
+ message.reply_to_message.text
4731
+ or message.reply_to_message.caption
4732
+ or None
4733
+ )
4734
+
4735
+ # Per-channel/topic ephemeral prompt
4736
+ from gateway.platforms.base import resolve_channel_prompt
4737
+ _chat_id_str = str(chat.id)
4738
+ _channel_prompt = resolve_channel_prompt(
4739
+ self.config.extra,
4740
+ thread_id_str or _chat_id_str,
4741
+ _chat_id_str if thread_id_str else None,
4742
+ )
4743
+
4744
+ return MessageEvent(
4745
+ text=message.text or "",
4746
+ message_type=msg_type,
4747
+ source=source,
4748
+ raw_message=message,
4749
+ message_id=str(message.message_id),
4750
+ platform_update_id=update_id,
4751
+ reply_to_message_id=reply_to_id,
4752
+ reply_to_text=reply_to_text,
4753
+ auto_skill=topic_skill,
4754
+ channel_prompt=_channel_prompt,
4755
+ timestamp=message.date,
4756
+ )
4757
+
4758
+ # ── Message reactions (processing lifecycle) ──────────────────────────
4759
+
4760
+ def _reactions_enabled(self) -> bool:
4761
+ """Check if message reactions are enabled via config/env."""
4762
+ return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"}
4763
+
4764
+ async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
4765
+ """Set a single emoji reaction on a Telegram message."""
4766
+ if not self._bot:
4767
+ return False
4768
+ try:
4769
+ await self._bot.set_message_reaction(
4770
+ chat_id=int(chat_id),
4771
+ message_id=int(message_id),
4772
+ reaction=emoji,
4773
+ )
4774
+ return True
4775
+ except Exception as e:
4776
+ logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
4777
+ return False
4778
+
4779
+ async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
4780
+ """Clear all reactions from a Telegram message.
4781
+
4782
+ Calling ``set_message_reaction`` with ``reaction=None`` (or an empty
4783
+ sequence) is the documented Bot API way to remove all bot-set
4784
+ reactions on a message — equivalent to Bot API 10.0's
4785
+ ``deleteMessageReaction`` but supported in PTB 22.6 already.
4786
+ """
4787
+ if not self._bot:
4788
+ return False
4789
+ try:
4790
+ await self._bot.set_message_reaction(
4791
+ chat_id=int(chat_id),
4792
+ message_id=int(message_id),
4793
+ reaction=None,
4794
+ )
4795
+ return True
4796
+ except Exception as e:
4797
+ logger.debug("[%s] clear reactions failed: %s", self.name, e)
4798
+ return False
4799
+
4800
+ async def on_processing_start(self, event: MessageEvent) -> None:
4801
+ """Add an in-progress reaction when message processing begins."""
4802
+ if not self._reactions_enabled():
4803
+ return
4804
+ chat_id = getattr(event.source, "chat_id", None)
4805
+ message_id = getattr(event, "message_id", None)
4806
+ if chat_id and message_id:
4807
+ await self._set_reaction(chat_id, message_id, "\U0001f440")
4808
+
4809
+ async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
4810
+ """Swap the in-progress reaction for a final success/failure reaction.
4811
+
4812
+ Unlike Discord (additive reactions), Telegram's set_message_reaction
4813
+ replaces all existing reactions in one call — no remove step needed.
4814
+
4815
+ On CANCELLED outcomes (e.g. the user runs ``/stop``, or a session is
4816
+ interrupted mid-flight), we explicitly clear the 👀 in-progress
4817
+ reaction so it doesn't linger on the user's message indefinitely.
4818
+ Without this clear, the only way to remove the 👀 was to wait for
4819
+ another agent run to swap it to 👍/👎 — which never happens if the
4820
+ cancellation was the last activity in the chat.
4821
+ """
4822
+ if not self._reactions_enabled():
4823
+ return
4824
+ chat_id = getattr(event.source, "chat_id", None)
4825
+ message_id = getattr(event, "message_id", None)
4826
+ if not (chat_id and message_id):
4827
+ return
4828
+ if outcome == ProcessingOutcome.CANCELLED:
4829
+ await self._clear_reactions(chat_id, message_id)
4830
+ else:
4831
+ await self._set_reaction(
4832
+ chat_id,
4833
+ message_id,
4834
+ "\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
4835
+ )
4836
+