enya-agent 0.1.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 (389) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/ci.yml +70 -0
  3. package/.github/workflows/publish.yml +250 -0
  4. package/.gitmodules +3 -0
  5. package/Cargo.lock +3584 -0
  6. package/Cargo.toml +97 -0
  7. package/crates/enact/Cargo.toml +27 -0
  8. package/crates/enact/src/lib.rs +60 -0
  9. package/crates/enact-a2a/Cargo.toml +25 -0
  10. package/crates/enact-a2a/src/lib.rs +411 -0
  11. package/crates/enact-channels/Cargo.toml +64 -0
  12. package/crates/enact-channels/examples/README.md +80 -0
  13. package/crates/enact-channels/examples/channel_bot.rs +169 -0
  14. package/crates/enact-channels/examples/telegram-echo.rs +34 -0
  15. package/crates/enact-channels/examples/whatsapp-echo.rs +142 -0
  16. package/crates/enact-channels/src/config.rs +213 -0
  17. package/crates/enact-channels/src/lib.rs +25 -0
  18. package/crates/enact-channels/src/runtime.rs +237 -0
  19. package/crates/enact-channels/src/security/mod.rs +5 -0
  20. package/crates/enact-channels/src/security/pairing.rs +205 -0
  21. package/crates/enact-channels/src/teams.rs +601 -0
  22. package/crates/enact-channels/src/telegram.rs +2833 -0
  23. package/crates/enact-channels/src/traits.rs +200 -0
  24. package/crates/enact-channels/src/webhook.rs +262 -0
  25. package/crates/enact-channels/src/whatsapp.rs +310 -0
  26. package/crates/enact-cli/Cargo.toml +40 -0
  27. package/crates/enact-cli/src/commands/doctor.rs +62 -0
  28. package/crates/enact-cli/src/commands/mod.rs +3 -0
  29. package/crates/enact-cli/src/commands/run.rs +69 -0
  30. package/crates/enact-cli/src/commands/serve.rs +81 -0
  31. package/crates/enact-cli/src/config.rs +2 -0
  32. package/crates/enact-cli/src/main.rs +79 -0
  33. package/crates/enact-config/Cargo.toml +36 -0
  34. package/crates/enact-config/ENV_VAR_MAPPING.md +135 -0
  35. package/crates/enact-config/QUICK_REFERENCE.md +92 -0
  36. package/crates/enact-config/README.md +107 -0
  37. package/crates/enact-config/TESTING.md +161 -0
  38. package/crates/enact-config/examples/test-env-vars.rs +100 -0
  39. package/crates/enact-config/src/config.rs +399 -0
  40. package/crates/enact-config/src/encrypted_store.rs +211 -0
  41. package/crates/enact-config/src/lib.rs +298 -0
  42. package/crates/enact-config/src/secrets.rs +149 -0
  43. package/crates/enact-config/src/sync.rs +260 -0
  44. package/crates/enact-config/test-env-vars.sh +34 -0
  45. package/crates/enact-config/tests/README.md +99 -0
  46. package/crates/enact-config/tests/config_integration_test.rs +202 -0
  47. package/crates/enact-config/tests/security_test.rs +140 -0
  48. package/crates/enact-context/Cargo.toml +41 -0
  49. package/crates/enact-context/src/budget.rs +314 -0
  50. package/crates/enact-context/src/calibrator.rs +535 -0
  51. package/crates/enact-context/src/compactor.rs +392 -0
  52. package/crates/enact-context/src/condenser.rs +826 -0
  53. package/crates/enact-context/src/lib.rs +94 -0
  54. package/crates/enact-context/src/segment.rs +238 -0
  55. package/crates/enact-context/src/step_context.rs +645 -0
  56. package/crates/enact-context/src/token_counter.rs +148 -0
  57. package/crates/enact-context/src/window.rs +372 -0
  58. package/crates/enact-core/Cargo.toml +42 -0
  59. package/crates/enact-core/README.md +98 -0
  60. package/crates/enact-core/src/background/executor.rs +524 -0
  61. package/crates/enact-core/src/background/mod.rs +48 -0
  62. package/crates/enact-core/src/background/target_binding.rs +390 -0
  63. package/crates/enact-core/src/background/trigger.rs +511 -0
  64. package/crates/enact-core/src/callable/callable.rs +152 -0
  65. package/crates/enact-core/src/callable/composite.rs +817 -0
  66. package/crates/enact-core/src/callable/graph.rs +104 -0
  67. package/crates/enact-core/src/callable/llm.rs +211 -0
  68. package/crates/enact-core/src/callable/mod.rs +64 -0
  69. package/crates/enact-core/src/callable/registry.rs +206 -0
  70. package/crates/enact-core/src/context/execution_context.rs +757 -0
  71. package/crates/enact-core/src/context/invocation.rs +99 -0
  72. package/crates/enact-core/src/context/mod.rs +50 -0
  73. package/crates/enact-core/src/context/tenant.rs +175 -0
  74. package/crates/enact-core/src/context/trace.rs +127 -0
  75. package/crates/enact-core/src/flow/conditional.rs +293 -0
  76. package/crates/enact-core/src/flow/mod.rs +43 -0
  77. package/crates/enact-core/src/flow/parallel.rs +437 -0
  78. package/crates/enact-core/src/flow/repeat.rs +534 -0
  79. package/crates/enact-core/src/flow/sequential.rs +248 -0
  80. package/crates/enact-core/src/graph/checkpoint.rs +79 -0
  81. package/crates/enact-core/src/graph/checkpoint_store.rs +76 -0
  82. package/crates/enact-core/src/graph/compiled.rs +189 -0
  83. package/crates/enact-core/src/graph/edge.rs +59 -0
  84. package/crates/enact-core/src/graph/graph_schema.rs +218 -0
  85. package/crates/enact-core/src/graph/loader.rs +155 -0
  86. package/crates/enact-core/src/graph/mod.rs +18 -0
  87. package/crates/enact-core/src/graph/node/function.rs +49 -0
  88. package/crates/enact-core/src/graph/node/mod.rs +48 -0
  89. package/crates/enact-core/src/graph/schema.rs +62 -0
  90. package/crates/enact-core/src/inbox/message.rs +405 -0
  91. package/crates/enact-core/src/inbox/mod.rs +31 -0
  92. package/crates/enact-core/src/inbox/store.rs +355 -0
  93. package/crates/enact-core/src/kernel/artifact/filesystem.rs +546 -0
  94. package/crates/enact-core/src/kernel/artifact/metadata.rs +283 -0
  95. package/crates/enact-core/src/kernel/artifact/mod.rs +27 -0
  96. package/crates/enact-core/src/kernel/artifact/store.rs +427 -0
  97. package/crates/enact-core/src/kernel/enforcement.rs +1315 -0
  98. package/crates/enact-core/src/kernel/error.rs +1200 -0
  99. package/crates/enact-core/src/kernel/event.rs +1394 -0
  100. package/crates/enact-core/src/kernel/execution_model.rs +831 -0
  101. package/crates/enact-core/src/kernel/execution_state.rs +189 -0
  102. package/crates/enact-core/src/kernel/execution_strategy.rs +117 -0
  103. package/crates/enact-core/src/kernel/ids.rs +2086 -0
  104. package/crates/enact-core/src/kernel/interrupt.rs +125 -0
  105. package/crates/enact-core/src/kernel/kernel.rs +1283 -0
  106. package/crates/enact-core/src/kernel/mod.rs +205 -0
  107. package/crates/enact-core/src/kernel/persistence/event_store.rs +270 -0
  108. package/crates/enact-core/src/kernel/persistence/message_store.rs +908 -0
  109. package/crates/enact-core/src/kernel/persistence/mod.rs +102 -0
  110. package/crates/enact-core/src/kernel/persistence/state_store.rs +228 -0
  111. package/crates/enact-core/src/kernel/persistence/vector_store.rs +299 -0
  112. package/crates/enact-core/src/kernel/reducer.rs +808 -0
  113. package/crates/enact-core/src/kernel/replay.rs +153 -0
  114. package/crates/enact-core/src/lib.rs +413 -0
  115. package/crates/enact-core/src/memory/episodic.rs +0 -0
  116. package/crates/enact-core/src/memory/mod.rs +6 -0
  117. package/crates/enact-core/src/memory/semantic.rs +0 -0
  118. package/crates/enact-core/src/memory/trait.rs +0 -0
  119. package/crates/enact-core/src/memory/vector_db.rs +0 -0
  120. package/crates/enact-core/src/memory/working.rs +0 -0
  121. package/crates/enact-core/src/policy/execution_policy.rs +292 -0
  122. package/crates/enact-core/src/policy/filters.rs +458 -0
  123. package/crates/enact-core/src/policy/input_processor.rs +407 -0
  124. package/crates/enact-core/src/policy/long_running.rs +134 -0
  125. package/crates/enact-core/src/policy/mod.rs +193 -0
  126. package/crates/enact-core/src/policy/pii_input.rs +274 -0
  127. package/crates/enact-core/src/policy/tenant_policy.rs +453 -0
  128. package/crates/enact-core/src/policy/tool_policy.rs +407 -0
  129. package/crates/enact-core/src/providers/mod.rs +63 -0
  130. package/crates/enact-core/src/providers/trait.rs +292 -0
  131. package/crates/enact-core/src/runner/callbacks.rs +6 -0
  132. package/crates/enact-core/src/runner/execution_runner.rs +476 -0
  133. package/crates/enact-core/src/runner/loop.rs +117 -0
  134. package/crates/enact-core/src/runner/mod.rs +58 -0
  135. package/crates/enact-core/src/runner/protected_runner.rs +280 -0
  136. package/crates/enact-core/src/signal/inmemory.rs +231 -0
  137. package/crates/enact-core/src/signal/mod.rs +108 -0
  138. package/crates/enact-core/src/streaming/event_logger.rs +195 -0
  139. package/crates/enact-core/src/streaming/event_stream.rs +1423 -0
  140. package/crates/enact-core/src/streaming/mod.rs +108 -0
  141. package/crates/enact-core/src/streaming/pause_cancel.rs +0 -0
  142. package/crates/enact-core/src/streaming/protected_emitter.rs +173 -0
  143. package/crates/enact-core/src/streaming/protection/context.rs +136 -0
  144. package/crates/enact-core/src/streaming/protection/encryption.rs +289 -0
  145. package/crates/enact-core/src/streaming/protection/mod.rs +43 -0
  146. package/crates/enact-core/src/streaming/protection/pii_protection.rs +243 -0
  147. package/crates/enact-core/src/streaming/protection/processor.rs +166 -0
  148. package/crates/enact-core/src/streaming/sse.rs +0 -0
  149. package/crates/enact-core/src/telemetry/exporter.rs +0 -0
  150. package/crates/enact-core/src/telemetry/init.rs +0 -0
  151. package/crates/enact-core/src/telemetry/mod.rs +49 -0
  152. package/crates/enact-core/src/telemetry/spans.rs +245 -0
  153. package/crates/enact-core/src/tool/agent_tool.rs +177 -0
  154. package/crates/enact-core/src/tool/browser/mod.rs +0 -0
  155. package/crates/enact-core/src/tool/browser/webdriver.rs +0 -0
  156. package/crates/enact-core/src/tool/cost.rs +247 -0
  157. package/crates/enact-core/src/tool/discovery.rs +0 -0
  158. package/crates/enact-core/src/tool/dispatcher.rs +347 -0
  159. package/crates/enact-core/src/tool/filesystem.rs +231 -0
  160. package/crates/enact-core/src/tool/function.rs +99 -0
  161. package/crates/enact-core/src/tool/git.rs +162 -0
  162. package/crates/enact-core/src/tool/http.rs +214 -0
  163. package/crates/enact-core/src/tool/mcp/client.rs +0 -0
  164. package/crates/enact-core/src/tool/mcp/mod.rs +0 -0
  165. package/crates/enact-core/src/tool/mod.rs +51 -0
  166. package/crates/enact-core/src/tool/reasoning/debugging.rs +0 -0
  167. package/crates/enact-core/src/tool/reasoning/mcts.rs +0 -0
  168. package/crates/enact-core/src/tool/reasoning/mod.rs +0 -0
  169. package/crates/enact-core/src/tool/reasoning/sequential.rs +0 -0
  170. package/crates/enact-core/src/tool/sandbox/dagger.rs +0 -0
  171. package/crates/enact-core/src/tool/sandbox/mod.rs +0 -0
  172. package/crates/enact-core/src/tool/shell.rs +147 -0
  173. package/crates/enact-core/src/tool/trait.rs +33 -0
  174. package/crates/enact-core/src/tool/web_search.rs +277 -0
  175. package/crates/enact-core/src/util/config.rs +0 -0
  176. package/crates/enact-core/src/util/errors.rs +0 -0
  177. package/crates/enact-core/src/util/mod.rs +6 -0
  178. package/crates/enact-core/tests/airgapped_e2e_test.rs +291 -0
  179. package/crates/enact-core/tests/e2e_agentic_loop.rs +119 -0
  180. package/crates/enact-core/tests/e2e_test.rs +259 -0
  181. package/crates/enact-core/tests/graph_test.rs +130 -0
  182. package/crates/enact-core/tests/stream_event_id_validation.rs +435 -0
  183. package/crates/enact-cron/Cargo.toml +28 -0
  184. package/crates/enact-cron/src/lib.rs +44 -0
  185. package/crates/enact-cron/src/schedule.rs +156 -0
  186. package/crates/enact-cron/src/store.rs +589 -0
  187. package/crates/enact-cron/src/types.rs +148 -0
  188. package/crates/enact-gateway/Cargo.toml +31 -0
  189. package/crates/enact-gateway/README.md +30 -0
  190. package/crates/enact-gateway/examples/whatsapp-gateway-runner-mock.rs +59 -0
  191. package/crates/enact-gateway/examples/whatsapp-gateway.rs +42 -0
  192. package/crates/enact-gateway/src/lib.rs +582 -0
  193. package/crates/enact-mcp/Cargo.toml +24 -0
  194. package/crates/enact-mcp/src/lib.rs +178 -0
  195. package/crates/enact-memory/Cargo.toml +25 -0
  196. package/crates/enact-memory/src/backend.rs +20 -0
  197. package/crates/enact-memory/src/chunker.rs +230 -0
  198. package/crates/enact-memory/src/embeddings.rs +221 -0
  199. package/crates/enact-memory/src/lib.rs +67 -0
  200. package/crates/enact-memory/src/markdown.rs +127 -0
  201. package/crates/enact-memory/src/none.rs +61 -0
  202. package/crates/enact-memory/src/sqlite.rs +276 -0
  203. package/crates/enact-memory/src/traits.rs +65 -0
  204. package/crates/enact-memory/src/vector.rs +198 -0
  205. package/crates/enact-oauth/Cargo.toml +27 -0
  206. package/crates/enact-oauth/src/lib.rs +584 -0
  207. package/crates/enact-observability/Cargo.toml +22 -0
  208. package/crates/enact-observability/src/lib.rs +197 -0
  209. package/crates/enact-providers/Cargo.toml +33 -0
  210. package/crates/enact-providers/examples/hello-agent.rs +33 -0
  211. package/crates/enact-providers/src/anthropic.rs +182 -0
  212. package/crates/enact-providers/src/azure.rs +96 -0
  213. package/crates/enact-providers/src/bridge.rs +221 -0
  214. package/crates/enact-providers/src/gemini.rs +227 -0
  215. package/crates/enact-providers/src/http.rs +78 -0
  216. package/crates/enact-providers/src/lib.rs +53 -0
  217. package/crates/enact-providers/src/openai_compatible.rs +167 -0
  218. package/crates/enact-providers/src/openrouter.rs +33 -0
  219. package/crates/enact-runner/Cargo.toml +24 -0
  220. package/crates/enact-runner/README.md +76 -0
  221. package/crates/enact-runner/src/compaction.rs +225 -0
  222. package/crates/enact-runner/src/config.rs +118 -0
  223. package/crates/enact-runner/src/lib.rs +63 -0
  224. package/crates/enact-runner/src/loop_driver.rs +414 -0
  225. package/crates/enact-runner/src/parser.rs +421 -0
  226. package/crates/enact-runner/src/retry.rs +262 -0
  227. package/crates/enact-runner/tests/integration.rs +278 -0
  228. package/crates/enact-security/Cargo.toml +22 -0
  229. package/crates/enact-security/src/audit.rs +375 -0
  230. package/crates/enact-security/src/lib.rs +37 -0
  231. package/crates/enact-security/src/policy.rs +406 -0
  232. package/crates/enact-skills/Cargo.toml +25 -0
  233. package/crates/enact-skills/src/lib.rs +506 -0
  234. package/crates/enact-tools/Cargo.toml +22 -0
  235. package/crates/enact-tools/src/file_read.rs +166 -0
  236. package/crates/enact-tools/src/file_write.rs +216 -0
  237. package/crates/enact-tools/src/git_operations.rs +513 -0
  238. package/crates/enact-tools/src/http_request.rs +417 -0
  239. package/crates/enact-tools/src/lib.rs +104 -0
  240. package/crates/enact-tools/src/security.rs +227 -0
  241. package/crates/enact-tools/src/shell.rs +191 -0
  242. package/crates/enact-tools/src/traits.rs +159 -0
  243. package/docs/Makefile +74 -0
  244. package/docs/config.toml +62 -0
  245. package/docs/content/_index.md +174 -0
  246. package/docs/content/a2a/_index.md +431 -0
  247. package/docs/content/api/_index.md +323 -0
  248. package/docs/content/channels/_index.md +160 -0
  249. package/docs/content/channels/teams.md +205 -0
  250. package/docs/content/channels/telegram.md +182 -0
  251. package/docs/content/channels/webhook.md +423 -0
  252. package/docs/content/channels/whatsapp.md +240 -0
  253. package/docs/content/cli/_index.md +261 -0
  254. package/docs/content/concepts/_index.md +273 -0
  255. package/docs/content/configuration/_index.md +241 -0
  256. package/docs/content/cron/_index.md +248 -0
  257. package/docs/content/developers/_index.md +278 -0
  258. package/docs/content/getting-started/_index.md +180 -0
  259. package/docs/content/installation/_index.md +186 -0
  260. package/docs/content/installation/uninstall.md +101 -0
  261. package/docs/content/installation/updating.md +120 -0
  262. package/docs/content/mcp/_index.md +215 -0
  263. package/docs/content/memory/_index.md +163 -0
  264. package/docs/content/oauth/_index.md +515 -0
  265. package/docs/content/providers/_index.md +206 -0
  266. package/docs/content/roadmap/_index.md +199 -0
  267. package/docs/content/security/_index.md +219 -0
  268. package/docs/content/skills/_index.md +228 -0
  269. package/docs/content/tools/_index.md +485 -0
  270. package/docs/content/troubleshooting/_index.md +259 -0
  271. package/docs/content/yaml-schema/_index.md +294 -0
  272. package/docs/static/giallo-dark.css +91 -0
  273. package/docs/static/giallo-light.css +91 -0
  274. package/docs/themes/tanuki/.github/workflows/deploy.yml +44 -0
  275. package/docs/themes/tanuki/LICENSE +21 -0
  276. package/docs/themes/tanuki/README.md +166 -0
  277. package/docs/themes/tanuki/examples/blog/config.toml +58 -0
  278. package/docs/themes/tanuki/examples/blog/content/_index.md +4 -0
  279. package/docs/themes/tanuki/examples/blog/content/about.md +33 -0
  280. package/docs/themes/tanuki/examples/blog/content/blog/_index.md +7 -0
  281. package/docs/themes/tanuki/examples/blog/content/blog/api-design-best-practices.md +245 -0
  282. package/docs/themes/tanuki/examples/blog/content/blog/building-accessible-websites.md +147 -0
  283. package/docs/themes/tanuki/examples/blog/content/blog/css-grid-vs-flexbox.md +165 -0
  284. package/docs/themes/tanuki/examples/blog/content/blog/customizing-catppuccin-colors.md +137 -0
  285. package/docs/themes/tanuki/examples/blog/content/blog/dark-mode-best-practices.md +82 -0
  286. package/docs/themes/tanuki/examples/blog/content/blog/docker-essentials.md +301 -0
  287. package/docs/themes/tanuki/examples/blog/content/blog/getting-started-with-zola.md +129 -0
  288. package/docs/themes/tanuki/examples/blog/content/blog/git-workflow-for-content.md +112 -0
  289. package/docs/themes/tanuki/examples/blog/content/blog/introduction-to-webassembly.md +183 -0
  290. package/docs/themes/tanuki/examples/blog/content/blog/modern-javascript-features.md +234 -0
  291. package/docs/themes/tanuki/examples/blog/content/blog/testing-strategies.md +311 -0
  292. package/docs/themes/tanuki/examples/blog/content/blog/typography-for-developers.md +104 -0
  293. package/docs/themes/tanuki/examples/blog/content/blog/welcome-to-tanuki.md +67 -0
  294. package/docs/themes/tanuki/examples/blog/content/blog/why-static-sites.md +85 -0
  295. package/docs/themes/tanuki/examples/blog/content/projects.md +64 -0
  296. package/docs/themes/tanuki/examples/book/config.toml +17 -0
  297. package/docs/themes/tanuki/examples/book/content/_index.md +12 -0
  298. package/docs/themes/tanuki/examples/book/content/chapter-1.md +90 -0
  299. package/docs/themes/tanuki/examples/book/content/chapter-2.md +143 -0
  300. package/docs/themes/tanuki/examples/book/content/chapter-3.md +217 -0
  301. package/docs/themes/tanuki/examples/book/content/chapter-4.md +224 -0
  302. package/docs/themes/tanuki/examples/book/content/chapter-5.md +297 -0
  303. package/docs/themes/tanuki/examples/book/content/print.md +6 -0
  304. package/docs/themes/tanuki/examples/docs/config.toml +28 -0
  305. package/docs/themes/tanuki/examples/docs/content/_index.md +20 -0
  306. package/docs/themes/tanuki/examples/docs/content/components.md +156 -0
  307. package/docs/themes/tanuki/examples/docs/content/configuration.md +94 -0
  308. package/docs/themes/tanuki/examples/docs/content/customization.md +202 -0
  309. package/docs/themes/tanuki/examples/docs/content/deployment.md +204 -0
  310. package/docs/themes/tanuki/examples/docs/content/installation.md +59 -0
  311. package/docs/themes/tanuki/examples/docs/content/print.md +6 -0
  312. package/docs/themes/tanuki/examples/docs/static/img/tanuki-icon.avif +0 -0
  313. package/docs/themes/tanuki/examples/index.html +2104 -0
  314. package/docs/themes/tanuki/mise.toml +108 -0
  315. package/docs/themes/tanuki/sass/base/_catppuccin.scss +164 -0
  316. package/docs/themes/tanuki/sass/base/_fonts.scss +64 -0
  317. package/docs/themes/tanuki/sass/base/_reset.scss +152 -0
  318. package/docs/themes/tanuki/sass/base/_typography.scss +523 -0
  319. package/docs/themes/tanuki/sass/components/_buttons.scss +209 -0
  320. package/docs/themes/tanuki/sass/components/_code.scss +457 -0
  321. package/docs/themes/tanuki/sass/components/_landing.scss +633 -0
  322. package/docs/themes/tanuki/sass/components/_layout.scss +294 -0
  323. package/docs/themes/tanuki/sass/components/_navigation.scss +1200 -0
  324. package/docs/themes/tanuki/sass/components/_print.scss +237 -0
  325. package/docs/themes/tanuki/sass/components/_search.scss +224 -0
  326. package/docs/themes/tanuki/sass/components/_sidebar.scss +473 -0
  327. package/docs/themes/tanuki/sass/components/_theme-toggle.scss +186 -0
  328. package/docs/themes/tanuki/sass/modes/_blog.scss +366 -0
  329. package/docs/themes/tanuki/sass/modes/_product.scss +875 -0
  330. package/docs/themes/tanuki/sass/modes/_raskell.scss +1696 -0
  331. package/docs/themes/tanuki/sass/patterns/_buttons.scss +183 -0
  332. package/docs/themes/tanuki/sass/patterns/_cards.scss +144 -0
  333. package/docs/themes/tanuki/sass/patterns/_index.scss +9 -0
  334. package/docs/themes/tanuki/sass/patterns/_lists.scss +259 -0
  335. package/docs/themes/tanuki/sass/patterns/_sections.scss +243 -0
  336. package/docs/themes/tanuki/sass/style.scss +47 -0
  337. package/docs/themes/tanuki/sass/tokens/_colors.scss +139 -0
  338. package/docs/themes/tanuki/sass/tokens/_spacing.scss +100 -0
  339. package/docs/themes/tanuki/sass/tokens/_typography.scss +186 -0
  340. package/docs/themes/tanuki/screenshot.png +0 -0
  341. package/docs/themes/tanuki/sentinel.kdl +59 -0
  342. package/docs/themes/tanuki/static/elasticlunr.min.js +10 -0
  343. package/docs/themes/tanuki/static/fonts/GEIST-LICENSE.txt +92 -0
  344. package/docs/themes/tanuki/static/fonts/Geist-Variable.woff2 +0 -0
  345. package/docs/themes/tanuki/static/fonts/GeistMono-Variable.woff2 +0 -0
  346. package/docs/themes/tanuki/static/img/tanuki-icon.avif +0 -0
  347. package/docs/themes/tanuki/static/img/tanuki-icon.png +0 -0
  348. package/docs/themes/tanuki/static/js/anchors.js +18 -0
  349. package/docs/themes/tanuki/static/js/app.js +274 -0
  350. package/docs/themes/tanuki/static/js/code.js +394 -0
  351. package/docs/themes/tanuki/static/js/navigation.js +778 -0
  352. package/docs/themes/tanuki/static/js/scroll-to-top.js +33 -0
  353. package/docs/themes/tanuki/static/js/search-raskell.js +240 -0
  354. package/docs/themes/tanuki/static/js/search.js +215 -0
  355. package/docs/themes/tanuki/static/js/theme.js +169 -0
  356. package/docs/themes/tanuki/static/syntax-dark.css +151 -0
  357. package/docs/themes/tanuki/static/syntax-light.css +151 -0
  358. package/docs/themes/tanuki/static/wasm/sentinel_playground_wasm.js +486 -0
  359. package/docs/themes/tanuki/static/wasm/sentinel_playground_wasm_bg.wasm +0 -0
  360. package/docs/themes/tanuki/templates/404.html +52 -0
  361. package/docs/themes/tanuki/templates/base.html +428 -0
  362. package/docs/themes/tanuki/templates/blog.html +66 -0
  363. package/docs/themes/tanuki/templates/home.html +108 -0
  364. package/docs/themes/tanuki/templates/index.html +178 -0
  365. package/docs/themes/tanuki/templates/landing.html +168 -0
  366. package/docs/themes/tanuki/templates/macros/nav.html +128 -0
  367. package/docs/themes/tanuki/templates/macros/posts.html +101 -0
  368. package/docs/themes/tanuki/templates/macros/ui.html +159 -0
  369. package/docs/themes/tanuki/templates/page.html +135 -0
  370. package/docs/themes/tanuki/templates/partials/footer.html +38 -0
  371. package/docs/themes/tanuki/templates/partials/header.html +366 -0
  372. package/docs/themes/tanuki/templates/partials/nav-buttons.html +55 -0
  373. package/docs/themes/tanuki/templates/partials/nav-overlay.html +81 -0
  374. package/docs/themes/tanuki/templates/partials/page-toc-panel.html +43 -0
  375. package/docs/themes/tanuki/templates/partials/search.html +52 -0
  376. package/docs/themes/tanuki/templates/partials/sidebar.html +107 -0
  377. package/docs/themes/tanuki/templates/partials/theme-toggle.html +35 -0
  378. package/docs/themes/tanuki/templates/partials/toc-overlay.html +146 -0
  379. package/docs/themes/tanuki/templates/partials/version-picker.html +38 -0
  380. package/docs/themes/tanuki/templates/print.html +244 -0
  381. package/docs/themes/tanuki/templates/section.html +186 -0
  382. package/docs/themes/tanuki/templates/taxonomy_list.html +18 -0
  383. package/docs/themes/tanuki/templates/taxonomy_single.html +31 -0
  384. package/docs/themes/tanuki/theme.toml +58 -0
  385. package/examples/hello-agent.rs +55 -0
  386. package/package.json +36 -0
  387. package/proto/config.proto +60 -0
  388. package/proto/events.proto +0 -0
  389. package/proto/runtime.proto +215 -0
@@ -0,0 +1,2833 @@
1
+ use super::traits::{Channel, ChannelMessage, SendMessage};
2
+ use crate::config::{Config, StreamMode};
3
+ use crate::security::pairing::PairingGuard;
4
+ use anyhow::Context;
5
+ use async_trait::async_trait;
6
+ use directories::UserDirs;
7
+ use parking_lot::Mutex;
8
+ use reqwest::multipart::{Form, Part};
9
+ use std::fs;
10
+ use std::path::Path;
11
+ use std::sync::{Arc, RwLock};
12
+ use std::time::Duration;
13
+
14
+ /// Telegram's maximum message length for text messages
15
+ const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
16
+ const TELEGRAM_BIND_COMMAND: &str = "/bind";
17
+
18
+ /// Split a message into chunks that respect Telegram's 4096 character limit.
19
+ /// Tries to split at word boundaries when possible, and handles continuation.
20
+ fn split_message_for_telegram(message: &str) -> Vec<String> {
21
+ if message.len() <= TELEGRAM_MAX_MESSAGE_LENGTH {
22
+ return vec![message.to_string()];
23
+ }
24
+
25
+ let mut chunks = Vec::new();
26
+ let mut remaining = message;
27
+
28
+ while !remaining.is_empty() {
29
+ let chunk_end = if remaining.len() <= TELEGRAM_MAX_MESSAGE_LENGTH {
30
+ remaining.len()
31
+ } else {
32
+ // Try to find a good break point (newline, then space)
33
+ let search_area = &remaining[..TELEGRAM_MAX_MESSAGE_LENGTH];
34
+
35
+ // Prefer splitting at newline
36
+ if let Some(pos) = search_area.rfind('\n') {
37
+ // Don't split if the newline is too close to the start
38
+ if pos >= TELEGRAM_MAX_MESSAGE_LENGTH / 2 {
39
+ pos + 1
40
+ } else {
41
+ // Try space as fallback
42
+ search_area
43
+ .rfind(' ')
44
+ .unwrap_or(TELEGRAM_MAX_MESSAGE_LENGTH)
45
+ + 1
46
+ }
47
+ } else if let Some(pos) = search_area.rfind(' ') {
48
+ pos + 1
49
+ } else {
50
+ // Hard split at the limit
51
+ TELEGRAM_MAX_MESSAGE_LENGTH
52
+ }
53
+ };
54
+
55
+ chunks.push(remaining[..chunk_end].to_string());
56
+ remaining = &remaining[chunk_end..];
57
+ }
58
+
59
+ chunks
60
+ }
61
+
62
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
63
+ enum TelegramAttachmentKind {
64
+ Image,
65
+ Document,
66
+ Video,
67
+ Audio,
68
+ Voice,
69
+ }
70
+
71
+ #[derive(Debug, Clone, PartialEq, Eq)]
72
+ struct TelegramAttachment {
73
+ kind: TelegramAttachmentKind,
74
+ target: String,
75
+ }
76
+
77
+ impl TelegramAttachmentKind {
78
+ fn from_marker(marker: &str) -> Option<Self> {
79
+ match marker.trim().to_ascii_uppercase().as_str() {
80
+ "IMAGE" | "PHOTO" => Some(Self::Image),
81
+ "DOCUMENT" | "FILE" => Some(Self::Document),
82
+ "VIDEO" => Some(Self::Video),
83
+ "AUDIO" => Some(Self::Audio),
84
+ "VOICE" => Some(Self::Voice),
85
+ _ => None,
86
+ }
87
+ }
88
+ }
89
+
90
+ fn is_http_url(target: &str) -> bool {
91
+ target.starts_with("http://") || target.starts_with("https://")
92
+ }
93
+
94
+ fn infer_attachment_kind_from_target(target: &str) -> Option<TelegramAttachmentKind> {
95
+ let normalized = target
96
+ .split('?')
97
+ .next()
98
+ .unwrap_or(target)
99
+ .split('#')
100
+ .next()
101
+ .unwrap_or(target);
102
+
103
+ let extension = Path::new(normalized)
104
+ .extension()
105
+ .and_then(|ext| ext.to_str())?
106
+ .to_ascii_lowercase();
107
+
108
+ match extension.as_str() {
109
+ "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Some(TelegramAttachmentKind::Image),
110
+ "mp4" | "mov" | "mkv" | "avi" | "webm" => Some(TelegramAttachmentKind::Video),
111
+ "mp3" | "m4a" | "wav" | "flac" => Some(TelegramAttachmentKind::Audio),
112
+ "ogg" | "oga" | "opus" => Some(TelegramAttachmentKind::Voice),
113
+ "pdf" | "txt" | "md" | "csv" | "json" | "zip" | "tar" | "gz" | "doc" | "docx" | "xls"
114
+ | "xlsx" | "ppt" | "pptx" => Some(TelegramAttachmentKind::Document),
115
+ _ => None,
116
+ }
117
+ }
118
+
119
+ fn parse_path_only_attachment(message: &str) -> Option<TelegramAttachment> {
120
+ let trimmed = message.trim();
121
+ if trimmed.is_empty() || trimmed.contains('\n') {
122
+ return None;
123
+ }
124
+
125
+ let candidate = trimmed.trim_matches(|c| matches!(c, '`' | '"' | '\''));
126
+ if candidate.chars().any(char::is_whitespace) {
127
+ return None;
128
+ }
129
+
130
+ let candidate = candidate.strip_prefix("file://").unwrap_or(candidate);
131
+ let kind = infer_attachment_kind_from_target(candidate)?;
132
+
133
+ if !is_http_url(candidate) && !Path::new(candidate).exists() {
134
+ return None;
135
+ }
136
+
137
+ Some(TelegramAttachment {
138
+ kind,
139
+ target: candidate.to_string(),
140
+ })
141
+ }
142
+
143
+ /// Strip tool_call XML-style tags from message text.
144
+ /// These tags are used internally but must not be sent to Telegram as raw markup,
145
+ /// since Telegram's Markdown parser will reject them (causing status 400 errors).
146
+ fn strip_tool_call_tags(message: &str) -> String {
147
+ const TOOL_CALL_OPEN_TAGS: [&str; 5] = [
148
+ "<tool_call>",
149
+ "<toolcall>",
150
+ "<tool-call>",
151
+ "<tool>",
152
+ "<invoke>",
153
+ ];
154
+
155
+ fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
156
+ tags.iter()
157
+ .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
158
+ .min_by_key(|(idx, _)| *idx)
159
+ }
160
+
161
+ fn matching_close_tag(open_tag: &str) -> Option<&'static str> {
162
+ match open_tag {
163
+ "<tool_call>" => Some("</tool_call>"),
164
+ "<toolcall>" => Some("</toolcall>"),
165
+ "<tool-call>" => Some("</tool-call>"),
166
+ "<tool>" => Some("</tool>"),
167
+ "<invoke>" => Some("</invoke>"),
168
+ _ => None,
169
+ }
170
+ }
171
+
172
+ fn extract_first_json_end(input: &str) -> Option<usize> {
173
+ let trimmed = input.trim_start();
174
+ let trim_offset = input.len().saturating_sub(trimmed.len());
175
+
176
+ for (byte_idx, ch) in trimmed.char_indices() {
177
+ if ch != '{' && ch != '[' {
178
+ continue;
179
+ }
180
+
181
+ let slice = &trimmed[byte_idx..];
182
+ let mut stream =
183
+ serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
184
+ if let Some(Ok(_value)) = stream.next() {
185
+ let consumed = stream.byte_offset();
186
+ if consumed > 0 {
187
+ return Some(trim_offset + byte_idx + consumed);
188
+ }
189
+ }
190
+ }
191
+
192
+ None
193
+ }
194
+
195
+ fn strip_leading_close_tags(mut input: &str) -> &str {
196
+ loop {
197
+ let trimmed = input.trim_start();
198
+ if !trimmed.starts_with("</") {
199
+ return trimmed;
200
+ }
201
+
202
+ let Some(close_end) = trimmed.find('>') else {
203
+ return "";
204
+ };
205
+ input = &trimmed[close_end + 1..];
206
+ }
207
+ }
208
+
209
+ let mut kept_segments = Vec::new();
210
+ let mut remaining = message;
211
+
212
+ while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
213
+ let before = &remaining[..start];
214
+ if !before.is_empty() {
215
+ kept_segments.push(before.to_string());
216
+ }
217
+
218
+ let Some(close_tag) = matching_close_tag(open_tag) else {
219
+ break;
220
+ };
221
+ let after_open = &remaining[start + open_tag.len()..];
222
+
223
+ if let Some(close_idx) = after_open.find(close_tag) {
224
+ remaining = &after_open[close_idx + close_tag.len()..];
225
+ continue;
226
+ }
227
+
228
+ if let Some(consumed_end) = extract_first_json_end(after_open) {
229
+ remaining = strip_leading_close_tags(&after_open[consumed_end..]);
230
+ continue;
231
+ }
232
+
233
+ kept_segments.push(remaining[start..].to_string());
234
+ remaining = "";
235
+ break;
236
+ }
237
+
238
+ if !remaining.is_empty() {
239
+ kept_segments.push(remaining.to_string());
240
+ }
241
+
242
+ let mut result = kept_segments.concat();
243
+
244
+ // Clean up any resulting blank lines (but preserve paragraphs)
245
+ while result.contains("\n\n\n") {
246
+ result = result.replace("\n\n\n", "\n\n");
247
+ }
248
+
249
+ result.trim().to_string()
250
+ }
251
+
252
+ fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>) {
253
+ let mut cleaned = String::with_capacity(message.len());
254
+ let mut attachments = Vec::new();
255
+ let mut cursor = 0;
256
+
257
+ while cursor < message.len() {
258
+ let Some(open_rel) = message[cursor..].find('[') else {
259
+ cleaned.push_str(&message[cursor..]);
260
+ break;
261
+ };
262
+
263
+ let open = cursor + open_rel;
264
+ cleaned.push_str(&message[cursor..open]);
265
+
266
+ let Some(close_rel) = message[open..].find(']') else {
267
+ cleaned.push_str(&message[open..]);
268
+ break;
269
+ };
270
+
271
+ let close = open + close_rel;
272
+ let marker = &message[open + 1..close];
273
+
274
+ let parsed = marker.split_once(':').and_then(|(kind, target)| {
275
+ let kind = TelegramAttachmentKind::from_marker(kind)?;
276
+ let target = target.trim();
277
+ if target.is_empty() {
278
+ return None;
279
+ }
280
+ Some(TelegramAttachment {
281
+ kind,
282
+ target: target.to_string(),
283
+ })
284
+ });
285
+
286
+ if let Some(attachment) = parsed {
287
+ attachments.push(attachment);
288
+ } else {
289
+ cleaned.push_str(&message[open..=close]);
290
+ }
291
+
292
+ cursor = close + 1;
293
+ }
294
+
295
+ (cleaned.trim().to_string(), attachments)
296
+ }
297
+
298
+ /// Telegram channel — long-polls the Bot API for updates
299
+ pub struct TelegramChannel {
300
+ bot_token: String,
301
+ allowed_users: Arc<RwLock<Vec<String>>>,
302
+ pairing: Option<PairingGuard>,
303
+ client: reqwest::Client,
304
+ typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
305
+ stream_mode: StreamMode,
306
+ draft_update_interval_ms: u64,
307
+ last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
308
+ mention_only: bool,
309
+ bot_username: Mutex<Option<String>>,
310
+ }
311
+
312
+ impl TelegramChannel {
313
+ pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
314
+ let normalized_allowed = Self::normalize_allowed_users(allowed_users);
315
+ let pairing = if normalized_allowed.is_empty() {
316
+ let guard = PairingGuard::new(true, &[]);
317
+ if let Some(code) = guard.pairing_code() {
318
+ println!(" 🔐 Telegram pairing required. One-time bind code: {code}");
319
+ println!(" Send `{TELEGRAM_BIND_COMMAND} <code>` from your Telegram account.");
320
+ }
321
+ Some(guard)
322
+ } else {
323
+ None
324
+ };
325
+
326
+ Self {
327
+ bot_token,
328
+ allowed_users: Arc::new(RwLock::new(normalized_allowed)),
329
+ pairing,
330
+ client: reqwest::Client::new(),
331
+ stream_mode: StreamMode::Off,
332
+ draft_update_interval_ms: 1000,
333
+ last_draft_edit: Mutex::new(std::collections::HashMap::new()),
334
+ typing_handle: Mutex::new(None),
335
+ mention_only,
336
+ bot_username: Mutex::new(None),
337
+ }
338
+ }
339
+
340
+ /// Configure streaming mode for progressive draft updates.
341
+ pub fn with_streaming(
342
+ mut self,
343
+ stream_mode: StreamMode,
344
+ draft_update_interval_ms: u64,
345
+ ) -> Self {
346
+ self.stream_mode = stream_mode;
347
+ self.draft_update_interval_ms = draft_update_interval_ms;
348
+ self
349
+ }
350
+
351
+ /// Parse reply_target into (chat_id, optional thread_id).
352
+ fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
353
+ if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
354
+ (chat_id.to_string(), Some(thread_id.to_string()))
355
+ } else {
356
+ (reply_target.to_string(), None)
357
+ }
358
+ }
359
+
360
+ fn http_client(&self) -> reqwest::Client {
361
+ crate::config::build_runtime_proxy_client("channel.telegram")
362
+ }
363
+
364
+ fn normalize_identity(value: &str) -> String {
365
+ value.trim().trim_start_matches('@').to_string()
366
+ }
367
+
368
+ fn normalize_allowed_users(allowed_users: Vec<String>) -> Vec<String> {
369
+ allowed_users
370
+ .into_iter()
371
+ .map(|entry| Self::normalize_identity(&entry))
372
+ .filter(|entry| !entry.is_empty())
373
+ .collect()
374
+ }
375
+
376
+ fn load_config_without_env() -> anyhow::Result<Config> {
377
+ let home = UserDirs::new()
378
+ .map(|u| u.home_dir().to_path_buf())
379
+ .context("Could not find home directory")?;
380
+ let enact_dir = home.join(".enact");
381
+ let config_path = enact_dir.join("config.toml");
382
+
383
+ let contents = fs::read_to_string(&config_path)
384
+ .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
385
+ let mut config: Config = toml::from_str(&contents)
386
+ .context("Failed to parse config file for Telegram binding")?;
387
+ config.config_path = config_path;
388
+ config.workspace_dir = enact_dir.join("workspace");
389
+ Ok(config)
390
+ }
391
+
392
+ fn persist_allowed_identity_blocking(identity: &str) -> anyhow::Result<()> {
393
+ let mut config = Self::load_config_without_env()?;
394
+ let Some(telegram) = config.channels.telegram.as_mut() else {
395
+ anyhow::bail!("Telegram channel config is missing in config.toml");
396
+ };
397
+
398
+ let normalized = Self::normalize_identity(identity);
399
+ if normalized.is_empty() {
400
+ anyhow::bail!("Cannot persist empty Telegram identity");
401
+ }
402
+
403
+ if !telegram.allowed_users.iter().any(|u| u == &normalized) {
404
+ telegram.allowed_users.push(normalized);
405
+ config
406
+ .save()
407
+ .context("Failed to persist Telegram allowlist to config.toml")?;
408
+ }
409
+
410
+ Ok(())
411
+ }
412
+
413
+ async fn persist_allowed_identity(&self, identity: &str) -> anyhow::Result<()> {
414
+ let identity = identity.to_string();
415
+ tokio::task::spawn_blocking(move || Self::persist_allowed_identity_blocking(&identity))
416
+ .await
417
+ .map_err(|e| anyhow::anyhow!("Failed to join Telegram bind save task: {e}"))??;
418
+ Ok(())
419
+ }
420
+
421
+ fn add_allowed_identity_runtime(&self, identity: &str) {
422
+ let normalized = Self::normalize_identity(identity);
423
+ if normalized.is_empty() {
424
+ return;
425
+ }
426
+ if let Ok(mut users) = self.allowed_users.write() {
427
+ if !users.iter().any(|u| u == &normalized) {
428
+ users.push(normalized);
429
+ }
430
+ }
431
+ }
432
+
433
+ fn extract_bind_code(text: &str) -> Option<&str> {
434
+ let mut parts = text.split_whitespace();
435
+ let command = parts.next()?;
436
+ let base_command = command.split('@').next().unwrap_or(command);
437
+ if base_command != TELEGRAM_BIND_COMMAND {
438
+ return None;
439
+ }
440
+ parts.next().map(str::trim).filter(|code| !code.is_empty())
441
+ }
442
+
443
+ fn pairing_code_active(&self) -> bool {
444
+ self.pairing
445
+ .as_ref()
446
+ .and_then(PairingGuard::pairing_code)
447
+ .is_some()
448
+ }
449
+
450
+ fn api_url(&self, method: &str) -> String {
451
+ format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
452
+ }
453
+
454
+ async fn fetch_bot_username(&self) -> anyhow::Result<String> {
455
+ let resp = self.http_client().get(self.api_url("getMe")).send().await?;
456
+
457
+ if !resp.status().is_success() {
458
+ anyhow::bail!("Failed to fetch bot info: {}", resp.status());
459
+ }
460
+
461
+ let data: serde_json::Value = resp.json().await?;
462
+ let username = data
463
+ .get("result")
464
+ .and_then(|r| r.get("username"))
465
+ .and_then(|u| u.as_str())
466
+ .context("Bot username not found in response")?;
467
+
468
+ Ok(username.to_string())
469
+ }
470
+
471
+ async fn get_bot_username(&self) -> Option<String> {
472
+ {
473
+ let cache = self.bot_username.lock();
474
+ if let Some(ref username) = *cache {
475
+ return Some(username.clone());
476
+ }
477
+ }
478
+
479
+ match self.fetch_bot_username().await {
480
+ Ok(username) => {
481
+ let mut cache = self.bot_username.lock();
482
+ *cache = Some(username.clone());
483
+ Some(username)
484
+ }
485
+ Err(e) => {
486
+ tracing::warn!("Failed to fetch bot username: {e}");
487
+ None
488
+ }
489
+ }
490
+ }
491
+
492
+ fn is_telegram_username_char(ch: char) -> bool {
493
+ ch.is_ascii_alphanumeric() || ch == '_'
494
+ }
495
+
496
+ fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
497
+ let bot_username = bot_username.trim_start_matches('@');
498
+ if bot_username.is_empty() {
499
+ return Vec::new();
500
+ }
501
+
502
+ let mut spans = Vec::new();
503
+
504
+ for (at_idx, ch) in text.char_indices() {
505
+ if ch != '@' {
506
+ continue;
507
+ }
508
+
509
+ if at_idx > 0 {
510
+ let prev = text[..at_idx].chars().next_back().unwrap_or(' ');
511
+ if Self::is_telegram_username_char(prev) {
512
+ continue;
513
+ }
514
+ }
515
+
516
+ let username_start = at_idx + 1;
517
+ let mut username_end = username_start;
518
+
519
+ for (rel_idx, candidate_ch) in text[username_start..].char_indices() {
520
+ if Self::is_telegram_username_char(candidate_ch) {
521
+ username_end = username_start + rel_idx + candidate_ch.len_utf8();
522
+ } else {
523
+ break;
524
+ }
525
+ }
526
+
527
+ if username_end == username_start {
528
+ continue;
529
+ }
530
+
531
+ let mention_username = &text[username_start..username_end];
532
+ if mention_username.eq_ignore_ascii_case(bot_username) {
533
+ spans.push((at_idx, username_end));
534
+ }
535
+ }
536
+
537
+ spans
538
+ }
539
+
540
+ fn contains_bot_mention(text: &str, bot_username: &str) -> bool {
541
+ !Self::find_bot_mention_spans(text, bot_username).is_empty()
542
+ }
543
+
544
+ fn normalize_incoming_content(text: &str, bot_username: &str) -> Option<String> {
545
+ let spans = Self::find_bot_mention_spans(text, bot_username);
546
+ if spans.is_empty() {
547
+ let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
548
+ return (!normalized.is_empty()).then_some(normalized);
549
+ }
550
+
551
+ let mut normalized = String::with_capacity(text.len());
552
+ let mut cursor = 0;
553
+ for (start, end) in spans {
554
+ normalized.push_str(&text[cursor..start]);
555
+ cursor = end;
556
+ }
557
+ normalized.push_str(&text[cursor..]);
558
+
559
+ let normalized = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
560
+ (!normalized.is_empty()).then_some(normalized)
561
+ }
562
+
563
+ fn is_group_message(message: &serde_json::Value) -> bool {
564
+ message
565
+ .get("chat")
566
+ .and_then(|c| c.get("type"))
567
+ .and_then(|t| t.as_str())
568
+ .map(|t| t == "group" || t == "supergroup")
569
+ .unwrap_or(false)
570
+ }
571
+
572
+ fn is_user_allowed(&self, username: &str) -> bool {
573
+ let identity = Self::normalize_identity(username);
574
+ self.allowed_users
575
+ .read()
576
+ .map(|users| users.iter().any(|u| u == "*" || u == &identity))
577
+ .unwrap_or(false)
578
+ }
579
+
580
+ fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool
581
+ where
582
+ I: IntoIterator<Item = &'a str>,
583
+ {
584
+ identities.into_iter().any(|id| self.is_user_allowed(id))
585
+ }
586
+
587
+ async fn handle_unauthorized_message(&self, update: &serde_json::Value) {
588
+ let Some(message) = update.get("message") else {
589
+ return;
590
+ };
591
+
592
+ let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
593
+ return;
594
+ };
595
+
596
+ let username_opt = message
597
+ .get("from")
598
+ .and_then(|from| from.get("username"))
599
+ .and_then(serde_json::Value::as_str);
600
+ let username = username_opt.unwrap_or("unknown");
601
+ let normalized_username = Self::normalize_identity(username);
602
+
603
+ let user_id = message
604
+ .get("from")
605
+ .and_then(|from| from.get("id"))
606
+ .and_then(serde_json::Value::as_i64);
607
+ let user_id_str = user_id.map(|id| id.to_string());
608
+ let normalized_user_id = user_id_str.as_deref().map(Self::normalize_identity);
609
+
610
+ let chat_id = message
611
+ .get("chat")
612
+ .and_then(|chat| chat.get("id"))
613
+ .and_then(serde_json::Value::as_i64)
614
+ .map(|id| id.to_string());
615
+
616
+ let Some(chat_id) = chat_id else {
617
+ tracing::warn!("Telegram: missing chat_id in message, skipping");
618
+ return;
619
+ };
620
+
621
+ let mut identities = vec![normalized_username.as_str()];
622
+ if let Some(ref id) = normalized_user_id {
623
+ identities.push(id.as_str());
624
+ }
625
+
626
+ if self.is_any_user_allowed(identities.iter().copied()) {
627
+ return;
628
+ }
629
+
630
+ if let Some(code) = Self::extract_bind_code(text) {
631
+ if let Some(pairing) = self.pairing.as_ref() {
632
+ match pairing.try_pair(code) {
633
+ Ok(Some(_token)) => {
634
+ let bind_identity = normalized_user_id.clone().or_else(|| {
635
+ if normalized_username.is_empty() || normalized_username == "unknown" {
636
+ None
637
+ } else {
638
+ Some(normalized_username.clone())
639
+ }
640
+ });
641
+
642
+ if let Some(identity) = bind_identity {
643
+ self.add_allowed_identity_runtime(&identity);
644
+ match self.persist_allowed_identity(&identity).await {
645
+ Ok(()) => {
646
+ let _ = self
647
+ .send(&SendMessage::new(
648
+ "✅ Telegram account bound successfully. You can talk to ZeroClaw now.",
649
+ &chat_id,
650
+ ))
651
+ .await;
652
+ tracing::info!(
653
+ "Telegram: paired and allowlisted identity={identity}"
654
+ );
655
+ }
656
+ Err(e) => {
657
+ tracing::error!(
658
+ "Telegram: failed to persist allowlist after bind: {e}"
659
+ );
660
+ let _ = self
661
+ .send(&SendMessage::new(
662
+ "⚠️ Bound for this runtime, but failed to persist config. Access may be lost after restart; check config file permissions.",
663
+ &chat_id,
664
+ ))
665
+ .await;
666
+ }
667
+ }
668
+ } else {
669
+ let _ = self
670
+ .send(&SendMessage::new(
671
+ "❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.",
672
+ &chat_id,
673
+ ))
674
+ .await;
675
+ }
676
+ }
677
+ Ok(None) => {
678
+ let _ = self
679
+ .send(&SendMessage::new(
680
+ "❌ Invalid binding code. Ask operator for the latest code and retry.",
681
+ &chat_id,
682
+ ))
683
+ .await;
684
+ }
685
+ Err(lockout_secs) => {
686
+ let _ = self
687
+ .send(&SendMessage::new(
688
+ format!("⏳ Too many invalid attempts. Retry in {lockout_secs}s."),
689
+ &chat_id,
690
+ ))
691
+ .await;
692
+ }
693
+ }
694
+ } else {
695
+ let _ = self
696
+ .send(&SendMessage::new(
697
+ "ℹ️ Telegram pairing is not active. Ask operator to update allowlist in config.toml.",
698
+ &chat_id,
699
+ ))
700
+ .await;
701
+ }
702
+ return;
703
+ }
704
+
705
+ tracing::warn!(
706
+ "Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
707
+ Allowlist Telegram username (without '@') or numeric user ID.",
708
+ user_id_str.as_deref().unwrap_or("unknown")
709
+ );
710
+
711
+ let suggested_identity = normalized_user_id
712
+ .clone()
713
+ .or_else(|| {
714
+ if normalized_username.is_empty() || normalized_username == "unknown" {
715
+ None
716
+ } else {
717
+ Some(normalized_username.clone())
718
+ }
719
+ })
720
+ .unwrap_or_else(|| "YOUR_TELEGRAM_ID".to_string());
721
+
722
+ let _ = self
723
+ .send(&SendMessage::new(
724
+ format!(
725
+ "🔐 This bot requires operator approval.\n\nCopy this command to operator terminal:\n`enact channel bind-telegram {suggested_identity}`\n\nAfter operator runs it, send your message again."
726
+ ),
727
+ &chat_id,
728
+ ))
729
+ .await;
730
+
731
+ if self.pairing_code_active() {
732
+ let _ = self
733
+ .send(&SendMessage::new(
734
+ "ℹ️ If operator provides a one-time pairing code, you can also run `/bind <code>`.",
735
+ &chat_id,
736
+ ))
737
+ .await;
738
+ }
739
+ }
740
+
741
+ fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
742
+ let message = update.get("message")?;
743
+
744
+ let text = message.get("text").and_then(serde_json::Value::as_str)?;
745
+
746
+ let username = message
747
+ .get("from")
748
+ .and_then(|from| from.get("username"))
749
+ .and_then(serde_json::Value::as_str)
750
+ .unwrap_or("unknown")
751
+ .to_string();
752
+
753
+ let user_id = message
754
+ .get("from")
755
+ .and_then(|from| from.get("id"))
756
+ .and_then(serde_json::Value::as_i64)
757
+ .map(|id| id.to_string());
758
+
759
+ let sender_identity = if username == "unknown" {
760
+ user_id.clone().unwrap_or_else(|| "unknown".to_string())
761
+ } else {
762
+ username.clone()
763
+ };
764
+
765
+ let mut identities = vec![username.as_str()];
766
+ if let Some(id) = user_id.as_deref() {
767
+ identities.push(id);
768
+ }
769
+
770
+ if !self.is_any_user_allowed(identities.iter().copied()) {
771
+ return None;
772
+ }
773
+
774
+ let is_group = Self::is_group_message(message);
775
+ if self.mention_only && is_group {
776
+ let bot_username = self.bot_username.lock();
777
+ if let Some(ref bot_username) = *bot_username {
778
+ if !Self::contains_bot_mention(text, bot_username) {
779
+ return None;
780
+ }
781
+ } else {
782
+ return None;
783
+ }
784
+ }
785
+
786
+ let chat_id = message
787
+ .get("chat")
788
+ .and_then(|chat| chat.get("id"))
789
+ .and_then(serde_json::Value::as_i64)
790
+ .map(|id| id.to_string())?;
791
+
792
+ let message_id = message
793
+ .get("message_id")
794
+ .and_then(serde_json::Value::as_i64)
795
+ .unwrap_or(0);
796
+
797
+ // Extract thread/topic ID for forum support
798
+ let thread_id = message
799
+ .get("message_thread_id")
800
+ .and_then(serde_json::Value::as_i64)
801
+ .map(|id| id.to_string());
802
+
803
+ // reply_target: chat_id or chat_id:thread_id format
804
+ let reply_target = if let Some(tid) = thread_id {
805
+ format!("{}:{}", chat_id, tid)
806
+ } else {
807
+ chat_id.clone()
808
+ };
809
+
810
+ let content = if self.mention_only && is_group {
811
+ let bot_username = self.bot_username.lock();
812
+ let bot_username = bot_username.as_ref()?;
813
+ Self::normalize_incoming_content(text, bot_username)?
814
+ } else {
815
+ text.to_string()
816
+ };
817
+
818
+ Some(ChannelMessage {
819
+ id: format!("telegram_{chat_id}_{message_id}"),
820
+ sender: sender_identity,
821
+ reply_target,
822
+ content,
823
+ channel: "telegram".to_string(),
824
+ timestamp: std::time::SystemTime::now()
825
+ .duration_since(std::time::UNIX_EPOCH)
826
+ .unwrap_or_default()
827
+ .as_secs(),
828
+ })
829
+ }
830
+
831
+ async fn send_text_chunks(
832
+ &self,
833
+ message: &str,
834
+ chat_id: &str,
835
+ thread_id: Option<&str>,
836
+ ) -> anyhow::Result<()> {
837
+ let chunks = split_message_for_telegram(message);
838
+
839
+ for (index, chunk) in chunks.iter().enumerate() {
840
+ let text = if chunks.len() > 1 {
841
+ if index == 0 {
842
+ format!("{chunk}\n\n(continues...)")
843
+ } else if index == chunks.len() - 1 {
844
+ format!("(continued)\n\n{chunk}")
845
+ } else {
846
+ format!("(continued)\n\n{chunk}\n\n(continues...)")
847
+ }
848
+ } else {
849
+ chunk.to_string()
850
+ };
851
+
852
+ let mut markdown_body = serde_json::json!({
853
+ "chat_id": chat_id,
854
+ "text": text,
855
+ "parse_mode": "Markdown"
856
+ });
857
+
858
+ // Add message_thread_id for forum topic support
859
+ if let Some(tid) = thread_id {
860
+ markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
861
+ }
862
+
863
+ let markdown_resp = self
864
+ .http_client()
865
+ .post(self.api_url("sendMessage"))
866
+ .json(&markdown_body)
867
+ .send()
868
+ .await?;
869
+
870
+ if markdown_resp.status().is_success() {
871
+ if index < chunks.len() - 1 {
872
+ tokio::time::sleep(Duration::from_millis(100)).await;
873
+ }
874
+ continue;
875
+ }
876
+
877
+ let markdown_status = markdown_resp.status();
878
+ let markdown_err = markdown_resp.text().await.unwrap_or_default();
879
+ tracing::warn!(
880
+ status = ?markdown_status,
881
+ "Telegram sendMessage with Markdown failed; retrying without parse_mode"
882
+ );
883
+
884
+ let mut plain_body = serde_json::json!({
885
+ "chat_id": chat_id,
886
+ "text": text,
887
+ });
888
+
889
+ // Add message_thread_id for forum topic support
890
+ if let Some(tid) = thread_id {
891
+ plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
892
+ }
893
+ let plain_resp = self
894
+ .http_client()
895
+ .post(self.api_url("sendMessage"))
896
+ .json(&plain_body)
897
+ .send()
898
+ .await?;
899
+
900
+ if !plain_resp.status().is_success() {
901
+ let plain_status = plain_resp.status();
902
+ let plain_err = plain_resp.text().await.unwrap_or_default();
903
+ anyhow::bail!(
904
+ "Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
905
+ markdown_status,
906
+ markdown_err,
907
+ plain_status,
908
+ plain_err
909
+ );
910
+ }
911
+
912
+ if index < chunks.len() - 1 {
913
+ tokio::time::sleep(Duration::from_millis(100)).await;
914
+ }
915
+ }
916
+
917
+ Ok(())
918
+ }
919
+
920
+ async fn send_media_by_url(
921
+ &self,
922
+ method: &str,
923
+ media_field: &str,
924
+ chat_id: &str,
925
+ thread_id: Option<&str>,
926
+ url: &str,
927
+ caption: Option<&str>,
928
+ ) -> anyhow::Result<()> {
929
+ let mut body = serde_json::json!({
930
+ "chat_id": chat_id,
931
+ });
932
+ body[media_field] = serde_json::Value::String(url.to_string());
933
+
934
+ if let Some(tid) = thread_id {
935
+ body["message_thread_id"] = serde_json::Value::String(tid.to_string());
936
+ }
937
+
938
+ if let Some(cap) = caption {
939
+ body["caption"] = serde_json::Value::String(cap.to_string());
940
+ }
941
+
942
+ let resp = self
943
+ .http_client()
944
+ .post(self.api_url(method))
945
+ .json(&body)
946
+ .send()
947
+ .await?;
948
+
949
+ if !resp.status().is_success() {
950
+ let err = resp.text().await?;
951
+ anyhow::bail!("Telegram {method} by URL failed: {err}");
952
+ }
953
+
954
+ tracing::info!("Telegram {method} sent to {chat_id}: {url}");
955
+ Ok(())
956
+ }
957
+
958
+ async fn send_attachment(
959
+ &self,
960
+ chat_id: &str,
961
+ thread_id: Option<&str>,
962
+ attachment: &TelegramAttachment,
963
+ ) -> anyhow::Result<()> {
964
+ let target = attachment.target.trim();
965
+
966
+ if is_http_url(target) {
967
+ return match attachment.kind {
968
+ TelegramAttachmentKind::Image => {
969
+ self.send_photo_by_url(chat_id, thread_id, target, None)
970
+ .await
971
+ }
972
+ TelegramAttachmentKind::Document => {
973
+ self.send_document_by_url(chat_id, thread_id, target, None)
974
+ .await
975
+ }
976
+ TelegramAttachmentKind::Video => {
977
+ self.send_video_by_url(chat_id, thread_id, target, None)
978
+ .await
979
+ }
980
+ TelegramAttachmentKind::Audio => {
981
+ self.send_audio_by_url(chat_id, thread_id, target, None)
982
+ .await
983
+ }
984
+ TelegramAttachmentKind::Voice => {
985
+ self.send_voice_by_url(chat_id, thread_id, target, None)
986
+ .await
987
+ }
988
+ };
989
+ }
990
+
991
+ let path = Path::new(target);
992
+ if !path.exists() {
993
+ anyhow::bail!("Telegram attachment path not found: {target}");
994
+ }
995
+
996
+ match attachment.kind {
997
+ TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await,
998
+ TelegramAttachmentKind::Document => {
999
+ self.send_document(chat_id, thread_id, path, None).await
1000
+ }
1001
+ TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await,
1002
+ TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await,
1003
+ TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await,
1004
+ }
1005
+ }
1006
+
1007
+ /// Send a document/file to a Telegram chat
1008
+ pub async fn send_document(
1009
+ &self,
1010
+ chat_id: &str,
1011
+ thread_id: Option<&str>,
1012
+ file_path: &Path,
1013
+ caption: Option<&str>,
1014
+ ) -> anyhow::Result<()> {
1015
+ let file_name = file_path
1016
+ .file_name()
1017
+ .and_then(|n| n.to_str())
1018
+ .unwrap_or("file");
1019
+
1020
+ let file_bytes = tokio::fs::read(file_path).await?;
1021
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1022
+
1023
+ let mut form = Form::new()
1024
+ .text("chat_id", chat_id.to_string())
1025
+ .part("document", part);
1026
+
1027
+ if let Some(tid) = thread_id {
1028
+ form = form.text("message_thread_id", tid.to_string());
1029
+ }
1030
+
1031
+ if let Some(cap) = caption {
1032
+ form = form.text("caption", cap.to_string());
1033
+ }
1034
+
1035
+ let resp = self
1036
+ .http_client()
1037
+ .post(self.api_url("sendDocument"))
1038
+ .multipart(form)
1039
+ .send()
1040
+ .await?;
1041
+
1042
+ if !resp.status().is_success() {
1043
+ let err = resp.text().await?;
1044
+ anyhow::bail!("Telegram sendDocument failed: {err}");
1045
+ }
1046
+
1047
+ tracing::info!("Telegram document sent to {chat_id}: {file_name}");
1048
+ Ok(())
1049
+ }
1050
+
1051
+ /// Send a document from bytes (in-memory) to a Telegram chat
1052
+ pub async fn send_document_bytes(
1053
+ &self,
1054
+ chat_id: &str,
1055
+ thread_id: Option<&str>,
1056
+ file_bytes: Vec<u8>,
1057
+ file_name: &str,
1058
+ caption: Option<&str>,
1059
+ ) -> anyhow::Result<()> {
1060
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1061
+
1062
+ let mut form = Form::new()
1063
+ .text("chat_id", chat_id.to_string())
1064
+ .part("document", part);
1065
+
1066
+ if let Some(tid) = thread_id {
1067
+ form = form.text("message_thread_id", tid.to_string());
1068
+ }
1069
+
1070
+ if let Some(cap) = caption {
1071
+ form = form.text("caption", cap.to_string());
1072
+ }
1073
+
1074
+ let resp = self
1075
+ .http_client()
1076
+ .post(self.api_url("sendDocument"))
1077
+ .multipart(form)
1078
+ .send()
1079
+ .await?;
1080
+
1081
+ if !resp.status().is_success() {
1082
+ let err = resp.text().await?;
1083
+ anyhow::bail!("Telegram sendDocument failed: {err}");
1084
+ }
1085
+
1086
+ tracing::info!("Telegram document sent to {chat_id}: {file_name}");
1087
+ Ok(())
1088
+ }
1089
+
1090
+ /// Send a photo to a Telegram chat
1091
+ pub async fn send_photo(
1092
+ &self,
1093
+ chat_id: &str,
1094
+ thread_id: Option<&str>,
1095
+ file_path: &Path,
1096
+ caption: Option<&str>,
1097
+ ) -> anyhow::Result<()> {
1098
+ let file_name = file_path
1099
+ .file_name()
1100
+ .and_then(|n| n.to_str())
1101
+ .unwrap_or("photo.jpg");
1102
+
1103
+ let file_bytes = tokio::fs::read(file_path).await?;
1104
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1105
+
1106
+ let mut form = Form::new()
1107
+ .text("chat_id", chat_id.to_string())
1108
+ .part("photo", part);
1109
+
1110
+ if let Some(tid) = thread_id {
1111
+ form = form.text("message_thread_id", tid.to_string());
1112
+ }
1113
+
1114
+ if let Some(cap) = caption {
1115
+ form = form.text("caption", cap.to_string());
1116
+ }
1117
+
1118
+ let resp = self
1119
+ .http_client()
1120
+ .post(self.api_url("sendPhoto"))
1121
+ .multipart(form)
1122
+ .send()
1123
+ .await?;
1124
+
1125
+ if !resp.status().is_success() {
1126
+ let err = resp.text().await?;
1127
+ anyhow::bail!("Telegram sendPhoto failed: {err}");
1128
+ }
1129
+
1130
+ tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
1131
+ Ok(())
1132
+ }
1133
+
1134
+ /// Send a photo from bytes (in-memory) to a Telegram chat
1135
+ pub async fn send_photo_bytes(
1136
+ &self,
1137
+ chat_id: &str,
1138
+ thread_id: Option<&str>,
1139
+ file_bytes: Vec<u8>,
1140
+ file_name: &str,
1141
+ caption: Option<&str>,
1142
+ ) -> anyhow::Result<()> {
1143
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1144
+
1145
+ let mut form = Form::new()
1146
+ .text("chat_id", chat_id.to_string())
1147
+ .part("photo", part);
1148
+
1149
+ if let Some(tid) = thread_id {
1150
+ form = form.text("message_thread_id", tid.to_string());
1151
+ }
1152
+
1153
+ if let Some(cap) = caption {
1154
+ form = form.text("caption", cap.to_string());
1155
+ }
1156
+
1157
+ let resp = self
1158
+ .http_client()
1159
+ .post(self.api_url("sendPhoto"))
1160
+ .multipart(form)
1161
+ .send()
1162
+ .await?;
1163
+
1164
+ if !resp.status().is_success() {
1165
+ let err = resp.text().await?;
1166
+ anyhow::bail!("Telegram sendPhoto failed: {err}");
1167
+ }
1168
+
1169
+ tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
1170
+ Ok(())
1171
+ }
1172
+
1173
+ /// Send a video to a Telegram chat
1174
+ pub async fn send_video(
1175
+ &self,
1176
+ chat_id: &str,
1177
+ thread_id: Option<&str>,
1178
+ file_path: &Path,
1179
+ caption: Option<&str>,
1180
+ ) -> anyhow::Result<()> {
1181
+ let file_name = file_path
1182
+ .file_name()
1183
+ .and_then(|n| n.to_str())
1184
+ .unwrap_or("video.mp4");
1185
+
1186
+ let file_bytes = tokio::fs::read(file_path).await?;
1187
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1188
+
1189
+ let mut form = Form::new()
1190
+ .text("chat_id", chat_id.to_string())
1191
+ .part("video", part);
1192
+
1193
+ if let Some(tid) = thread_id {
1194
+ form = form.text("message_thread_id", tid.to_string());
1195
+ }
1196
+
1197
+ if let Some(cap) = caption {
1198
+ form = form.text("caption", cap.to_string());
1199
+ }
1200
+
1201
+ let resp = self
1202
+ .http_client()
1203
+ .post(self.api_url("sendVideo"))
1204
+ .multipart(form)
1205
+ .send()
1206
+ .await?;
1207
+
1208
+ if !resp.status().is_success() {
1209
+ let err = resp.text().await?;
1210
+ anyhow::bail!("Telegram sendVideo failed: {err}");
1211
+ }
1212
+
1213
+ tracing::info!("Telegram video sent to {chat_id}: {file_name}");
1214
+ Ok(())
1215
+ }
1216
+
1217
+ /// Send an audio file to a Telegram chat
1218
+ pub async fn send_audio(
1219
+ &self,
1220
+ chat_id: &str,
1221
+ thread_id: Option<&str>,
1222
+ file_path: &Path,
1223
+ caption: Option<&str>,
1224
+ ) -> anyhow::Result<()> {
1225
+ let file_name = file_path
1226
+ .file_name()
1227
+ .and_then(|n| n.to_str())
1228
+ .unwrap_or("audio.mp3");
1229
+
1230
+ let file_bytes = tokio::fs::read(file_path).await?;
1231
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1232
+
1233
+ let mut form = Form::new()
1234
+ .text("chat_id", chat_id.to_string())
1235
+ .part("audio", part);
1236
+
1237
+ if let Some(tid) = thread_id {
1238
+ form = form.text("message_thread_id", tid.to_string());
1239
+ }
1240
+
1241
+ if let Some(cap) = caption {
1242
+ form = form.text("caption", cap.to_string());
1243
+ }
1244
+
1245
+ let resp = self
1246
+ .http_client()
1247
+ .post(self.api_url("sendAudio"))
1248
+ .multipart(form)
1249
+ .send()
1250
+ .await?;
1251
+
1252
+ if !resp.status().is_success() {
1253
+ let err = resp.text().await?;
1254
+ anyhow::bail!("Telegram sendAudio failed: {err}");
1255
+ }
1256
+
1257
+ tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
1258
+ Ok(())
1259
+ }
1260
+
1261
+ /// Send a voice message to a Telegram chat
1262
+ pub async fn send_voice(
1263
+ &self,
1264
+ chat_id: &str,
1265
+ thread_id: Option<&str>,
1266
+ file_path: &Path,
1267
+ caption: Option<&str>,
1268
+ ) -> anyhow::Result<()> {
1269
+ let file_name = file_path
1270
+ .file_name()
1271
+ .and_then(|n| n.to_str())
1272
+ .unwrap_or("voice.ogg");
1273
+
1274
+ let file_bytes = tokio::fs::read(file_path).await?;
1275
+ let part = Part::bytes(file_bytes).file_name(file_name.to_string());
1276
+
1277
+ let mut form = Form::new()
1278
+ .text("chat_id", chat_id.to_string())
1279
+ .part("voice", part);
1280
+
1281
+ if let Some(tid) = thread_id {
1282
+ form = form.text("message_thread_id", tid.to_string());
1283
+ }
1284
+
1285
+ if let Some(cap) = caption {
1286
+ form = form.text("caption", cap.to_string());
1287
+ }
1288
+
1289
+ let resp = self
1290
+ .http_client()
1291
+ .post(self.api_url("sendVoice"))
1292
+ .multipart(form)
1293
+ .send()
1294
+ .await?;
1295
+
1296
+ if !resp.status().is_success() {
1297
+ let err = resp.text().await?;
1298
+ anyhow::bail!("Telegram sendVoice failed: {err}");
1299
+ }
1300
+
1301
+ tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
1302
+ Ok(())
1303
+ }
1304
+
1305
+ /// Send a file by URL (Telegram will download it)
1306
+ pub async fn send_document_by_url(
1307
+ &self,
1308
+ chat_id: &str,
1309
+ thread_id: Option<&str>,
1310
+ url: &str,
1311
+ caption: Option<&str>,
1312
+ ) -> anyhow::Result<()> {
1313
+ let mut body = serde_json::json!({
1314
+ "chat_id": chat_id,
1315
+ "document": url
1316
+ });
1317
+
1318
+ if let Some(tid) = thread_id {
1319
+ body["message_thread_id"] = serde_json::Value::String(tid.to_string());
1320
+ }
1321
+
1322
+ if let Some(cap) = caption {
1323
+ body["caption"] = serde_json::Value::String(cap.to_string());
1324
+ }
1325
+
1326
+ let resp = self
1327
+ .http_client()
1328
+ .post(self.api_url("sendDocument"))
1329
+ .json(&body)
1330
+ .send()
1331
+ .await?;
1332
+
1333
+ if !resp.status().is_success() {
1334
+ let err = resp.text().await?;
1335
+ anyhow::bail!("Telegram sendDocument by URL failed: {err}");
1336
+ }
1337
+
1338
+ tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
1339
+ Ok(())
1340
+ }
1341
+
1342
+ /// Send a photo by URL (Telegram will download it)
1343
+ pub async fn send_photo_by_url(
1344
+ &self,
1345
+ chat_id: &str,
1346
+ thread_id: Option<&str>,
1347
+ url: &str,
1348
+ caption: Option<&str>,
1349
+ ) -> anyhow::Result<()> {
1350
+ let mut body = serde_json::json!({
1351
+ "chat_id": chat_id,
1352
+ "photo": url
1353
+ });
1354
+
1355
+ if let Some(tid) = thread_id {
1356
+ body["message_thread_id"] = serde_json::Value::String(tid.to_string());
1357
+ }
1358
+
1359
+ if let Some(cap) = caption {
1360
+ body["caption"] = serde_json::Value::String(cap.to_string());
1361
+ }
1362
+
1363
+ let resp = self
1364
+ .http_client()
1365
+ .post(self.api_url("sendPhoto"))
1366
+ .json(&body)
1367
+ .send()
1368
+ .await?;
1369
+
1370
+ if !resp.status().is_success() {
1371
+ let err = resp.text().await?;
1372
+ anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
1373
+ }
1374
+
1375
+ tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
1376
+ Ok(())
1377
+ }
1378
+
1379
+ /// Send a video by URL (Telegram will download it)
1380
+ pub async fn send_video_by_url(
1381
+ &self,
1382
+ chat_id: &str,
1383
+ thread_id: Option<&str>,
1384
+ url: &str,
1385
+ caption: Option<&str>,
1386
+ ) -> anyhow::Result<()> {
1387
+ self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption)
1388
+ .await
1389
+ }
1390
+
1391
+ /// Send an audio file by URL (Telegram will download it)
1392
+ pub async fn send_audio_by_url(
1393
+ &self,
1394
+ chat_id: &str,
1395
+ thread_id: Option<&str>,
1396
+ url: &str,
1397
+ caption: Option<&str>,
1398
+ ) -> anyhow::Result<()> {
1399
+ self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption)
1400
+ .await
1401
+ }
1402
+
1403
+ /// Send a voice message by URL (Telegram will download it)
1404
+ pub async fn send_voice_by_url(
1405
+ &self,
1406
+ chat_id: &str,
1407
+ thread_id: Option<&str>,
1408
+ url: &str,
1409
+ caption: Option<&str>,
1410
+ ) -> anyhow::Result<()> {
1411
+ self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption)
1412
+ .await
1413
+ }
1414
+ }
1415
+
1416
+ #[async_trait]
1417
+ impl Channel for TelegramChannel {
1418
+ fn name(&self) -> &str {
1419
+ "telegram"
1420
+ }
1421
+
1422
+ fn supports_draft_updates(&self) -> bool {
1423
+ self.stream_mode != StreamMode::Off
1424
+ }
1425
+
1426
+ async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
1427
+ if self.stream_mode == StreamMode::Off {
1428
+ return Ok(None);
1429
+ }
1430
+
1431
+ let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient);
1432
+ let initial_text = if message.content.is_empty() {
1433
+ "...".to_string()
1434
+ } else {
1435
+ message.content.clone()
1436
+ };
1437
+
1438
+ let mut body = serde_json::json!({
1439
+ "chat_id": chat_id,
1440
+ "text": initial_text,
1441
+ });
1442
+ if let Some(tid) = thread_id {
1443
+ body["message_thread_id"] = serde_json::Value::String(tid.to_string());
1444
+ }
1445
+
1446
+ let resp = self
1447
+ .client
1448
+ .post(self.api_url("sendMessage"))
1449
+ .json(&body)
1450
+ .send()
1451
+ .await?;
1452
+
1453
+ if !resp.status().is_success() {
1454
+ let err = resp.text().await.unwrap_or_default();
1455
+ anyhow::bail!("Telegram sendMessage (draft) failed: {err}");
1456
+ }
1457
+
1458
+ let resp_json: serde_json::Value = resp.json().await?;
1459
+ let message_id = resp_json
1460
+ .get("result")
1461
+ .and_then(|r| r.get("message_id"))
1462
+ .and_then(|id| id.as_i64())
1463
+ .map(|id| id.to_string());
1464
+
1465
+ self.last_draft_edit
1466
+ .lock()
1467
+ .insert(chat_id.to_string(), std::time::Instant::now());
1468
+
1469
+ Ok(message_id)
1470
+ }
1471
+
1472
+ async fn update_draft(
1473
+ &self,
1474
+ recipient: &str,
1475
+ message_id: &str,
1476
+ text: &str,
1477
+ ) -> anyhow::Result<()> {
1478
+ let (chat_id, _) = Self::parse_reply_target(recipient);
1479
+
1480
+ // Rate-limit edits per chat
1481
+ {
1482
+ let last_edits = self.last_draft_edit.lock();
1483
+ if let Some(last_time) = last_edits.get(&chat_id) {
1484
+ let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
1485
+ if elapsed < self.draft_update_interval_ms {
1486
+ return Ok(());
1487
+ }
1488
+ }
1489
+ }
1490
+
1491
+ // Truncate to Telegram limit for mid-stream edits (UTF-8 safe)
1492
+ let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
1493
+ let mut end = 0;
1494
+ for (idx, ch) in text.char_indices() {
1495
+ let next = idx + ch.len_utf8();
1496
+ if next > TELEGRAM_MAX_MESSAGE_LENGTH {
1497
+ break;
1498
+ }
1499
+ end = next;
1500
+ }
1501
+ &text[..end]
1502
+ } else {
1503
+ text
1504
+ };
1505
+
1506
+ let message_id_parsed = match message_id.parse::<i64>() {
1507
+ Ok(id) => id,
1508
+ Err(e) => {
1509
+ tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
1510
+ return Ok(());
1511
+ }
1512
+ };
1513
+
1514
+ let body = serde_json::json!({
1515
+ "chat_id": chat_id,
1516
+ "message_id": message_id_parsed,
1517
+ "text": display_text,
1518
+ });
1519
+
1520
+ let resp = self
1521
+ .client
1522
+ .post(self.api_url("editMessageText"))
1523
+ .json(&body)
1524
+ .send()
1525
+ .await?;
1526
+
1527
+ if resp.status().is_success() {
1528
+ self.last_draft_edit
1529
+ .lock()
1530
+ .insert(chat_id.clone(), std::time::Instant::now());
1531
+ } else {
1532
+ let status = resp.status();
1533
+ let err = resp.text().await.unwrap_or_default();
1534
+ tracing::debug!("Telegram editMessageText failed ({status}): {err}");
1535
+ }
1536
+
1537
+ Ok(())
1538
+ }
1539
+
1540
+ async fn finalize_draft(
1541
+ &self,
1542
+ recipient: &str,
1543
+ message_id: &str,
1544
+ text: &str,
1545
+ ) -> anyhow::Result<()> {
1546
+ let text = &strip_tool_call_tags(text);
1547
+ let (chat_id, thread_id) = Self::parse_reply_target(recipient);
1548
+
1549
+ // Clean up rate-limit tracking for this chat
1550
+ self.last_draft_edit.lock().remove(&chat_id);
1551
+
1552
+ // If text exceeds limit, delete draft and send as chunked messages
1553
+ if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
1554
+ let msg_id = match message_id.parse::<i64>() {
1555
+ Ok(id) => id,
1556
+ Err(e) => {
1557
+ tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
1558
+ return self
1559
+ .send_text_chunks(text, &chat_id, thread_id.as_deref())
1560
+ .await;
1561
+ }
1562
+ };
1563
+
1564
+ // Delete the draft
1565
+ let _ = self
1566
+ .client
1567
+ .post(self.api_url("deleteMessage"))
1568
+ .json(&serde_json::json!({
1569
+ "chat_id": chat_id,
1570
+ "message_id": msg_id,
1571
+ }))
1572
+ .send()
1573
+ .await;
1574
+
1575
+ // Fall back to chunked send
1576
+ return self
1577
+ .send_text_chunks(text, &chat_id, thread_id.as_deref())
1578
+ .await;
1579
+ }
1580
+
1581
+ let msg_id = match message_id.parse::<i64>() {
1582
+ Ok(id) => id,
1583
+ Err(e) => {
1584
+ tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
1585
+ return self
1586
+ .send_text_chunks(text, &chat_id, thread_id.as_deref())
1587
+ .await;
1588
+ }
1589
+ };
1590
+
1591
+ // Try editing with Markdown formatting
1592
+ let body = serde_json::json!({
1593
+ "chat_id": chat_id,
1594
+ "message_id": msg_id,
1595
+ "text": text,
1596
+ "parse_mode": "Markdown",
1597
+ });
1598
+
1599
+ let resp = self
1600
+ .client
1601
+ .post(self.api_url("editMessageText"))
1602
+ .json(&body)
1603
+ .send()
1604
+ .await?;
1605
+
1606
+ if resp.status().is_success() {
1607
+ return Ok(());
1608
+ }
1609
+
1610
+ // Markdown failed — retry without parse_mode
1611
+ let plain_body = serde_json::json!({
1612
+ "chat_id": chat_id,
1613
+ "message_id": msg_id,
1614
+ "text": text,
1615
+ });
1616
+
1617
+ let resp = self
1618
+ .client
1619
+ .post(self.api_url("editMessageText"))
1620
+ .json(&plain_body)
1621
+ .send()
1622
+ .await?;
1623
+
1624
+ if resp.status().is_success() {
1625
+ return Ok(());
1626
+ }
1627
+
1628
+ // Edit failed entirely — fall back to new message
1629
+ tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage");
1630
+ self.send_text_chunks(text, &chat_id, thread_id.as_deref())
1631
+ .await
1632
+ }
1633
+
1634
+ async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
1635
+ // Strip tool_call tags before processing to prevent Markdown parsing failures
1636
+ let content = strip_tool_call_tags(&message.content);
1637
+
1638
+ // Parse recipient: "chat_id" or "chat_id:thread_id" format
1639
+ let (chat_id, thread_id) = match message.recipient.split_once(':') {
1640
+ Some((chat, thread)) => (chat, Some(thread)),
1641
+ None => (message.recipient.as_str(), None),
1642
+ };
1643
+
1644
+ let (text_without_markers, attachments) = parse_attachment_markers(&content);
1645
+
1646
+ if !attachments.is_empty() {
1647
+ if !text_without_markers.is_empty() {
1648
+ self.send_text_chunks(&text_without_markers, chat_id, thread_id)
1649
+ .await?;
1650
+ }
1651
+
1652
+ for attachment in &attachments {
1653
+ self.send_attachment(chat_id, thread_id, attachment).await?;
1654
+ }
1655
+
1656
+ return Ok(());
1657
+ }
1658
+
1659
+ if let Some(attachment) = parse_path_only_attachment(&content) {
1660
+ self.send_attachment(chat_id, thread_id, &attachment)
1661
+ .await?;
1662
+ return Ok(());
1663
+ }
1664
+
1665
+ self.send_text_chunks(&content, chat_id, thread_id).await
1666
+ }
1667
+
1668
+ async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
1669
+ let mut offset: i64 = 0;
1670
+
1671
+ if self.mention_only {
1672
+ let _ = self.get_bot_username().await;
1673
+ }
1674
+
1675
+ tracing::info!("Telegram channel listening for messages...");
1676
+
1677
+ loop {
1678
+ if self.mention_only {
1679
+ let missing_username = self.bot_username.lock().is_none();
1680
+ if missing_username {
1681
+ let _ = self.get_bot_username().await;
1682
+ }
1683
+ }
1684
+
1685
+ let url = self.api_url("getUpdates");
1686
+ let body = serde_json::json!({
1687
+ "offset": offset,
1688
+ "timeout": 30,
1689
+ "allowed_updates": ["message"]
1690
+ });
1691
+
1692
+ let resp = match self.http_client().post(&url).json(&body).send().await {
1693
+ Ok(r) => r,
1694
+ Err(e) => {
1695
+ tracing::warn!("Telegram poll error: {e}");
1696
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1697
+ continue;
1698
+ }
1699
+ };
1700
+
1701
+ let data: serde_json::Value = match resp.json().await {
1702
+ Ok(d) => d,
1703
+ Err(e) => {
1704
+ tracing::warn!("Telegram parse error: {e}");
1705
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1706
+ continue;
1707
+ }
1708
+ };
1709
+
1710
+ let ok = data
1711
+ .get("ok")
1712
+ .and_then(serde_json::Value::as_bool)
1713
+ .unwrap_or(true);
1714
+ if !ok {
1715
+ let error_code = data
1716
+ .get("error_code")
1717
+ .and_then(serde_json::Value::as_i64)
1718
+ .unwrap_or_default();
1719
+ let description = data
1720
+ .get("description")
1721
+ .and_then(serde_json::Value::as_str)
1722
+ .unwrap_or("unknown Telegram API error");
1723
+
1724
+ if error_code == 409 {
1725
+ tracing::warn!(
1726
+ "Telegram polling conflict (409): {description}. \
1727
+ Ensure only one `enact` process is using this bot token."
1728
+ );
1729
+ tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1730
+ } else {
1731
+ tracing::warn!(
1732
+ "Telegram getUpdates API error (code={}): {description}",
1733
+ error_code
1734
+ );
1735
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1736
+ }
1737
+ continue;
1738
+ }
1739
+
1740
+ if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) {
1741
+ for update in results {
1742
+ // Advance offset past this update
1743
+ if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) {
1744
+ offset = uid + 1;
1745
+ }
1746
+
1747
+ let Some(msg) = self.parse_update_message(update) else {
1748
+ self.handle_unauthorized_message(update).await;
1749
+ continue;
1750
+ };
1751
+ // Send "typing" indicator immediately when we receive a message
1752
+ let typing_body = serde_json::json!({
1753
+ "chat_id": &msg.reply_target,
1754
+ "action": "typing"
1755
+ });
1756
+ let _ = self
1757
+ .http_client()
1758
+ .post(self.api_url("sendChatAction"))
1759
+ .json(&typing_body)
1760
+ .send()
1761
+ .await; // Ignore errors for typing indicator
1762
+
1763
+ if tx.send(msg).await.is_err() {
1764
+ return Ok(());
1765
+ }
1766
+ }
1767
+ }
1768
+ }
1769
+ }
1770
+
1771
+ async fn health_check(&self) -> bool {
1772
+ let timeout_duration = Duration::from_secs(5);
1773
+
1774
+ match tokio::time::timeout(
1775
+ timeout_duration,
1776
+ self.http_client().get(self.api_url("getMe")).send(),
1777
+ )
1778
+ .await
1779
+ {
1780
+ Ok(Ok(resp)) => resp.status().is_success(),
1781
+ Ok(Err(e)) => {
1782
+ tracing::debug!("Telegram health check failed: {e}");
1783
+ false
1784
+ }
1785
+ Err(_) => {
1786
+ tracing::debug!("Telegram health check timed out after 5s");
1787
+ false
1788
+ }
1789
+ }
1790
+ }
1791
+
1792
+ async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
1793
+ self.stop_typing(recipient).await?;
1794
+
1795
+ let client = self.http_client();
1796
+ let url = self.api_url("sendChatAction");
1797
+ let chat_id = recipient.to_string();
1798
+
1799
+ let handle = tokio::spawn(async move {
1800
+ loop {
1801
+ let body = serde_json::json!({
1802
+ "chat_id": &chat_id,
1803
+ "action": "typing"
1804
+ });
1805
+ let _ = client.post(&url).json(&body).send().await;
1806
+ // Telegram typing indicator expires after 5s; refresh at 4s
1807
+ tokio::time::sleep(Duration::from_secs(4)).await;
1808
+ }
1809
+ });
1810
+
1811
+ let mut guard = self.typing_handle.lock();
1812
+ *guard = Some(handle);
1813
+
1814
+ Ok(())
1815
+ }
1816
+
1817
+ async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
1818
+ let mut guard = self.typing_handle.lock();
1819
+ if let Some(handle) = guard.take() {
1820
+ handle.abort();
1821
+ }
1822
+ Ok(())
1823
+ }
1824
+ }
1825
+
1826
+ #[cfg(test)]
1827
+ mod tests {
1828
+ use super::*;
1829
+
1830
+ #[test]
1831
+ fn telegram_channel_name() {
1832
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
1833
+ assert_eq!(ch.name(), "telegram");
1834
+ }
1835
+
1836
+ #[test]
1837
+ fn typing_handle_starts_as_none() {
1838
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
1839
+ let guard = ch.typing_handle.lock();
1840
+ assert!(guard.is_none());
1841
+ }
1842
+
1843
+ #[tokio::test]
1844
+ async fn stop_typing_clears_handle() {
1845
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
1846
+
1847
+ // Manually insert a dummy handle
1848
+ {
1849
+ let mut guard = ch.typing_handle.lock();
1850
+ *guard = Some(tokio::spawn(async {
1851
+ tokio::time::sleep(Duration::from_secs(60)).await;
1852
+ }));
1853
+ }
1854
+
1855
+ // stop_typing should abort and clear
1856
+ ch.stop_typing("123").await.unwrap();
1857
+
1858
+ let guard = ch.typing_handle.lock();
1859
+ assert!(guard.is_none());
1860
+ }
1861
+
1862
+ #[tokio::test]
1863
+ async fn start_typing_replaces_previous_handle() {
1864
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
1865
+
1866
+ // Insert a dummy handle first
1867
+ {
1868
+ let mut guard = ch.typing_handle.lock();
1869
+ *guard = Some(tokio::spawn(async {
1870
+ tokio::time::sleep(Duration::from_secs(60)).await;
1871
+ }));
1872
+ }
1873
+
1874
+ // start_typing should abort the old handle and set a new one
1875
+ let _ = ch.start_typing("123").await;
1876
+
1877
+ let guard = ch.typing_handle.lock();
1878
+ assert!(guard.is_some());
1879
+ }
1880
+
1881
+ #[test]
1882
+ fn supports_draft_updates_respects_stream_mode() {
1883
+ let off = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
1884
+ assert!(!off.supports_draft_updates());
1885
+
1886
+ let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
1887
+ .with_streaming(StreamMode::Partial, 750);
1888
+ assert!(partial.supports_draft_updates());
1889
+ assert_eq!(partial.draft_update_interval_ms, 750);
1890
+ }
1891
+
1892
+ #[tokio::test]
1893
+ async fn send_draft_returns_none_when_stream_mode_off() {
1894
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
1895
+ let id = ch
1896
+ .send_draft(&SendMessage::new("draft", "123"))
1897
+ .await
1898
+ .unwrap();
1899
+ assert!(id.is_none());
1900
+ }
1901
+
1902
+ #[tokio::test]
1903
+ async fn update_draft_rate_limit_short_circuits_network() {
1904
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
1905
+ .with_streaming(StreamMode::Partial, 60_000);
1906
+ ch.last_draft_edit
1907
+ .lock()
1908
+ .insert("123".to_string(), std::time::Instant::now());
1909
+
1910
+ let result = ch.update_draft("123", "42", "delta text").await;
1911
+ assert!(result.is_ok());
1912
+ }
1913
+
1914
+ #[tokio::test]
1915
+ async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() {
1916
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
1917
+ .with_streaming(StreamMode::Partial, 0);
1918
+ let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20);
1919
+
1920
+ // Invalid message_id returns early after building display_text.
1921
+ // This asserts truncation never panics on UTF-8 boundaries.
1922
+ let result = ch
1923
+ .update_draft("123", "not-a-number", &long_emoji_text)
1924
+ .await;
1925
+ assert!(result.is_ok());
1926
+ }
1927
+
1928
+ #[tokio::test]
1929
+ async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() {
1930
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
1931
+ .with_streaming(StreamMode::Partial, 0);
1932
+ let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64);
1933
+
1934
+ // For oversized text + invalid draft message_id, finalize_draft should
1935
+ // fall back to chunked send instead of returning early.
1936
+ let result = ch.finalize_draft("123", "not-a-number", &long_text).await;
1937
+ assert!(result.is_err());
1938
+ }
1939
+
1940
+ #[test]
1941
+ fn telegram_api_url() {
1942
+ let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
1943
+ assert_eq!(
1944
+ ch.api_url("getMe"),
1945
+ "https://api.telegram.org/bot123:ABC/getMe"
1946
+ );
1947
+ }
1948
+
1949
+ #[test]
1950
+ fn telegram_user_allowed_wildcard() {
1951
+ let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
1952
+ assert!(ch.is_user_allowed("anyone"));
1953
+ }
1954
+
1955
+ #[test]
1956
+ fn telegram_user_allowed_specific() {
1957
+ let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], false);
1958
+ assert!(ch.is_user_allowed("alice"));
1959
+ assert!(!ch.is_user_allowed("eve"));
1960
+ }
1961
+
1962
+ #[test]
1963
+ fn telegram_user_allowed_with_at_prefix_in_config() {
1964
+ let ch = TelegramChannel::new("t".into(), vec!["@alice".into()], false);
1965
+ assert!(ch.is_user_allowed("alice"));
1966
+ }
1967
+
1968
+ #[test]
1969
+ fn telegram_user_denied_empty() {
1970
+ let ch = TelegramChannel::new("t".into(), vec![], false);
1971
+ assert!(!ch.is_user_allowed("anyone"));
1972
+ }
1973
+
1974
+ #[test]
1975
+ fn telegram_user_exact_match_not_substring() {
1976
+ let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false);
1977
+ assert!(!ch.is_user_allowed("alice_bot"));
1978
+ assert!(!ch.is_user_allowed("alic"));
1979
+ assert!(!ch.is_user_allowed("malice"));
1980
+ }
1981
+
1982
+ #[test]
1983
+ fn telegram_user_empty_string_denied() {
1984
+ let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false);
1985
+ assert!(!ch.is_user_allowed(""));
1986
+ }
1987
+
1988
+ #[test]
1989
+ fn telegram_user_case_sensitive() {
1990
+ let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], false);
1991
+ assert!(ch.is_user_allowed("Alice"));
1992
+ assert!(!ch.is_user_allowed("alice"));
1993
+ assert!(!ch.is_user_allowed("ALICE"));
1994
+ }
1995
+
1996
+ #[test]
1997
+ fn telegram_wildcard_with_specific_users() {
1998
+ let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], false);
1999
+ assert!(ch.is_user_allowed("alice"));
2000
+ assert!(ch.is_user_allowed("bob"));
2001
+ assert!(ch.is_user_allowed("anyone"));
2002
+ }
2003
+
2004
+ #[test]
2005
+ fn telegram_user_allowed_by_numeric_id_identity() {
2006
+ let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], false);
2007
+ assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
2008
+ }
2009
+
2010
+ #[test]
2011
+ fn telegram_user_denied_when_none_of_identities_match() {
2012
+ let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], false);
2013
+ assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
2014
+ }
2015
+
2016
+ #[test]
2017
+ fn telegram_pairing_enabled_with_empty_allowlist() {
2018
+ let ch = TelegramChannel::new("t".into(), vec![], false);
2019
+ assert!(ch.pairing_code_active());
2020
+ }
2021
+
2022
+ #[test]
2023
+ fn telegram_pairing_disabled_with_nonempty_allowlist() {
2024
+ let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false);
2025
+ assert!(!ch.pairing_code_active());
2026
+ }
2027
+
2028
+ #[test]
2029
+ fn telegram_extract_bind_code_plain_command() {
2030
+ assert_eq!(
2031
+ TelegramChannel::extract_bind_code("/bind 123456"),
2032
+ Some("123456")
2033
+ );
2034
+ }
2035
+
2036
+ #[test]
2037
+ fn telegram_extract_bind_code_supports_bot_mention() {
2038
+ assert_eq!(
2039
+ TelegramChannel::extract_bind_code("/bind@zeroclaw_bot 654321"),
2040
+ Some("654321")
2041
+ );
2042
+ }
2043
+
2044
+ #[test]
2045
+ fn telegram_extract_bind_code_rejects_invalid_forms() {
2046
+ assert_eq!(TelegramChannel::extract_bind_code("/bind"), None);
2047
+ assert_eq!(TelegramChannel::extract_bind_code("/start"), None);
2048
+ }
2049
+
2050
+ #[test]
2051
+ fn parse_attachment_markers_extracts_multiple_types() {
2052
+ let message = "Here are files [IMAGE:/tmp/a.png] and [DOCUMENT:https://example.com/a.pdf]";
2053
+ let (cleaned, attachments) = parse_attachment_markers(message);
2054
+
2055
+ assert_eq!(cleaned, "Here are files and");
2056
+ assert_eq!(attachments.len(), 2);
2057
+ assert_eq!(attachments[0].kind, TelegramAttachmentKind::Image);
2058
+ assert_eq!(attachments[0].target, "/tmp/a.png");
2059
+ assert_eq!(attachments[1].kind, TelegramAttachmentKind::Document);
2060
+ assert_eq!(attachments[1].target, "https://example.com/a.pdf");
2061
+ }
2062
+
2063
+ #[test]
2064
+ fn parse_attachment_markers_keeps_invalid_markers_in_text() {
2065
+ let message = "Report [UNKNOWN:/tmp/a.bin]";
2066
+ let (cleaned, attachments) = parse_attachment_markers(message);
2067
+
2068
+ assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
2069
+ assert!(attachments.is_empty());
2070
+ }
2071
+
2072
+ #[test]
2073
+ fn parse_path_only_attachment_detects_existing_file() {
2074
+ let dir = tempfile::tempdir().unwrap();
2075
+ let image_path = dir.path().join("snap.png");
2076
+ std::fs::write(&image_path, b"fake-png").unwrap();
2077
+
2078
+ let parsed = parse_path_only_attachment(image_path.to_string_lossy().as_ref())
2079
+ .expect("expected attachment");
2080
+
2081
+ assert_eq!(parsed.kind, TelegramAttachmentKind::Image);
2082
+ assert_eq!(parsed.target, image_path.to_string_lossy());
2083
+ }
2084
+
2085
+ #[test]
2086
+ fn parse_path_only_attachment_rejects_sentence_text() {
2087
+ assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none());
2088
+ }
2089
+
2090
+ #[test]
2091
+ fn infer_attachment_kind_from_target_detects_document_extension() {
2092
+ assert_eq!(
2093
+ infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"),
2094
+ Some(TelegramAttachmentKind::Document)
2095
+ );
2096
+ }
2097
+
2098
+ #[test]
2099
+ fn parse_update_message_uses_chat_id_as_reply_target() {
2100
+ let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
2101
+ let update = serde_json::json!({
2102
+ "update_id": 1,
2103
+ "message": {
2104
+ "message_id": 33,
2105
+ "text": "hello",
2106
+ "from": {
2107
+ "id": 555,
2108
+ "username": "alice"
2109
+ },
2110
+ "chat": {
2111
+ "id": -100_200_300
2112
+ }
2113
+ }
2114
+ });
2115
+
2116
+ let msg = ch
2117
+ .parse_update_message(&update)
2118
+ .expect("message should parse");
2119
+
2120
+ assert_eq!(msg.sender, "alice");
2121
+ assert_eq!(msg.reply_target, "-100200300");
2122
+ assert_eq!(msg.content, "hello");
2123
+ assert_eq!(msg.id, "telegram_-100200300_33");
2124
+ }
2125
+
2126
+ #[test]
2127
+ fn parse_update_message_allows_numeric_id_without_username() {
2128
+ let ch = TelegramChannel::new("token".into(), vec!["555".into()], false);
2129
+ let update = serde_json::json!({
2130
+ "update_id": 2,
2131
+ "message": {
2132
+ "message_id": 9,
2133
+ "text": "ping",
2134
+ "from": {
2135
+ "id": 555
2136
+ },
2137
+ "chat": {
2138
+ "id": 12345
2139
+ }
2140
+ }
2141
+ });
2142
+
2143
+ let msg = ch
2144
+ .parse_update_message(&update)
2145
+ .expect("numeric allowlist should pass");
2146
+
2147
+ assert_eq!(msg.sender, "555");
2148
+ assert_eq!(msg.reply_target, "12345");
2149
+ }
2150
+
2151
+ #[test]
2152
+ fn parse_update_message_extracts_thread_id_for_forum_topic() {
2153
+ let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
2154
+ let update = serde_json::json!({
2155
+ "update_id": 3,
2156
+ "message": {
2157
+ "message_id": 42,
2158
+ "text": "hello from topic",
2159
+ "from": {
2160
+ "id": 555,
2161
+ "username": "alice"
2162
+ },
2163
+ "chat": {
2164
+ "id": -100_200_300
2165
+ },
2166
+ "message_thread_id": 789
2167
+ }
2168
+ });
2169
+
2170
+ let msg = ch
2171
+ .parse_update_message(&update)
2172
+ .expect("message with thread_id should parse");
2173
+
2174
+ assert_eq!(msg.sender, "alice");
2175
+ assert_eq!(msg.reply_target, "-100200300:789");
2176
+ assert_eq!(msg.content, "hello from topic");
2177
+ assert_eq!(msg.id, "telegram_-100200300_42");
2178
+ }
2179
+
2180
+ // ── File sending API URL tests ──────────────────────────────────
2181
+
2182
+ #[test]
2183
+ fn telegram_api_url_send_document() {
2184
+ let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
2185
+ assert_eq!(
2186
+ ch.api_url("sendDocument"),
2187
+ "https://api.telegram.org/bot123:ABC/sendDocument"
2188
+ );
2189
+ }
2190
+
2191
+ #[test]
2192
+ fn telegram_api_url_send_photo() {
2193
+ let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
2194
+ assert_eq!(
2195
+ ch.api_url("sendPhoto"),
2196
+ "https://api.telegram.org/bot123:ABC/sendPhoto"
2197
+ );
2198
+ }
2199
+
2200
+ #[test]
2201
+ fn telegram_api_url_send_video() {
2202
+ let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
2203
+ assert_eq!(
2204
+ ch.api_url("sendVideo"),
2205
+ "https://api.telegram.org/bot123:ABC/sendVideo"
2206
+ );
2207
+ }
2208
+
2209
+ #[test]
2210
+ fn telegram_api_url_send_audio() {
2211
+ let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
2212
+ assert_eq!(
2213
+ ch.api_url("sendAudio"),
2214
+ "https://api.telegram.org/bot123:ABC/sendAudio"
2215
+ );
2216
+ }
2217
+
2218
+ #[test]
2219
+ fn telegram_api_url_send_voice() {
2220
+ let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
2221
+ assert_eq!(
2222
+ ch.api_url("sendVoice"),
2223
+ "https://api.telegram.org/bot123:ABC/sendVoice"
2224
+ );
2225
+ }
2226
+
2227
+ // ── File sending integration tests (with mock server) ──────────
2228
+
2229
+ #[tokio::test]
2230
+ async fn telegram_send_document_bytes_builds_correct_form() {
2231
+ // This test verifies the method doesn't panic and handles bytes correctly
2232
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2233
+ let file_bytes = b"Hello, this is a test file content".to_vec();
2234
+
2235
+ // The actual API call will fail (no real server), but we verify the method exists
2236
+ // and handles the input correctly up to the network call
2237
+ let result = ch
2238
+ .send_document_bytes("123456", None, file_bytes, "test.txt", Some("Test caption"))
2239
+ .await;
2240
+
2241
+ // Should fail with network error, not a panic or type error
2242
+ assert!(result.is_err());
2243
+ let err = result.unwrap_err().to_string();
2244
+ // Error should be network-related, not a code bug
2245
+ assert!(
2246
+ err.contains("error") || err.contains("failed") || err.contains("connect"),
2247
+ "Expected network error, got: {err}"
2248
+ );
2249
+ }
2250
+
2251
+ #[tokio::test]
2252
+ async fn telegram_send_photo_bytes_builds_correct_form() {
2253
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2254
+ // Minimal valid PNG header bytes
2255
+ let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
2256
+
2257
+ let result = ch
2258
+ .send_photo_bytes("123456", None, file_bytes, "test.png", None)
2259
+ .await;
2260
+
2261
+ assert!(result.is_err());
2262
+ }
2263
+
2264
+ #[tokio::test]
2265
+ async fn telegram_send_document_by_url_builds_correct_json() {
2266
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2267
+
2268
+ let result = ch
2269
+ .send_document_by_url(
2270
+ "123456",
2271
+ None,
2272
+ "https://example.com/file.pdf",
2273
+ Some("PDF doc"),
2274
+ )
2275
+ .await;
2276
+
2277
+ assert!(result.is_err());
2278
+ }
2279
+
2280
+ #[tokio::test]
2281
+ async fn telegram_send_photo_by_url_builds_correct_json() {
2282
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2283
+
2284
+ let result = ch
2285
+ .send_photo_by_url("123456", None, "https://example.com/image.jpg", None)
2286
+ .await;
2287
+
2288
+ assert!(result.is_err());
2289
+ }
2290
+
2291
+ // ── File path handling tests ────────────────────────────────────
2292
+
2293
+ #[tokio::test]
2294
+ async fn telegram_send_document_nonexistent_file() {
2295
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2296
+ let path = Path::new("/nonexistent/path/to/file.txt");
2297
+
2298
+ let result = ch.send_document("123456", None, path, None).await;
2299
+
2300
+ assert!(result.is_err());
2301
+ let err = result.unwrap_err().to_string();
2302
+ // Should fail with file not found error
2303
+ assert!(
2304
+ err.contains("No such file") || err.contains("not found") || err.contains("os error"),
2305
+ "Expected file not found error, got: {err}"
2306
+ );
2307
+ }
2308
+
2309
+ #[tokio::test]
2310
+ async fn telegram_send_photo_nonexistent_file() {
2311
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2312
+ let path = Path::new("/nonexistent/path/to/photo.jpg");
2313
+
2314
+ let result = ch.send_photo("123456", None, path, None).await;
2315
+
2316
+ assert!(result.is_err());
2317
+ }
2318
+
2319
+ #[tokio::test]
2320
+ async fn telegram_send_video_nonexistent_file() {
2321
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2322
+ let path = Path::new("/nonexistent/path/to/video.mp4");
2323
+
2324
+ let result = ch.send_video("123456", None, path, None).await;
2325
+
2326
+ assert!(result.is_err());
2327
+ }
2328
+
2329
+ #[tokio::test]
2330
+ async fn telegram_send_audio_nonexistent_file() {
2331
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2332
+ let path = Path::new("/nonexistent/path/to/audio.mp3");
2333
+
2334
+ let result = ch.send_audio("123456", None, path, None).await;
2335
+
2336
+ assert!(result.is_err());
2337
+ }
2338
+
2339
+ #[tokio::test]
2340
+ async fn telegram_send_voice_nonexistent_file() {
2341
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2342
+ let path = Path::new("/nonexistent/path/to/voice.ogg");
2343
+
2344
+ let result = ch.send_voice("123456", None, path, None).await;
2345
+
2346
+ assert!(result.is_err());
2347
+ }
2348
+
2349
+ // ── Message splitting tests ─────────────────────────────────────
2350
+
2351
+ #[test]
2352
+ fn telegram_split_short_message() {
2353
+ let msg = "Hello, world!";
2354
+ let chunks = split_message_for_telegram(msg);
2355
+ assert_eq!(chunks.len(), 1);
2356
+ assert_eq!(chunks[0], msg);
2357
+ }
2358
+
2359
+ #[test]
2360
+ fn telegram_split_exact_limit() {
2361
+ let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH);
2362
+ let chunks = split_message_for_telegram(&msg);
2363
+ assert_eq!(chunks.len(), 1);
2364
+ assert_eq!(chunks[0].len(), TELEGRAM_MAX_MESSAGE_LENGTH);
2365
+ }
2366
+
2367
+ #[test]
2368
+ fn telegram_split_over_limit() {
2369
+ let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 100);
2370
+ let chunks = split_message_for_telegram(&msg);
2371
+ assert_eq!(chunks.len(), 2);
2372
+ assert!(chunks[0].len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
2373
+ assert!(chunks[1].len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
2374
+ }
2375
+
2376
+ #[test]
2377
+ fn telegram_split_at_word_boundary() {
2378
+ let msg = format!(
2379
+ "{} more text here",
2380
+ "word ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5)
2381
+ );
2382
+ let chunks = split_message_for_telegram(&msg);
2383
+ assert!(chunks.len() >= 2);
2384
+ // First chunk should end with a complete word (space at the end)
2385
+ for chunk in &chunks[..chunks.len() - 1] {
2386
+ assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
2387
+ }
2388
+ }
2389
+
2390
+ #[test]
2391
+ fn telegram_split_at_newline() {
2392
+ let text_block = "Line of text\n".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 13 + 1);
2393
+ let chunks = split_message_for_telegram(&text_block);
2394
+ assert!(chunks.len() >= 2);
2395
+ for chunk in chunks {
2396
+ assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
2397
+ }
2398
+ }
2399
+
2400
+ #[test]
2401
+ fn telegram_split_preserves_content() {
2402
+ let msg = "test ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5 + 100);
2403
+ let chunks = split_message_for_telegram(&msg);
2404
+ let rejoined = chunks.join("");
2405
+ assert_eq!(rejoined, msg);
2406
+ }
2407
+
2408
+ #[test]
2409
+ fn telegram_split_empty_message() {
2410
+ let chunks = split_message_for_telegram("");
2411
+ assert_eq!(chunks.len(), 1);
2412
+ assert_eq!(chunks[0], "");
2413
+ }
2414
+
2415
+ #[test]
2416
+ fn telegram_split_very_long_message() {
2417
+ let msg = "x".repeat(TELEGRAM_MAX_MESSAGE_LENGTH * 3);
2418
+ let chunks = split_message_for_telegram(&msg);
2419
+ assert!(chunks.len() >= 3);
2420
+ for chunk in chunks {
2421
+ assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
2422
+ }
2423
+ }
2424
+
2425
+ // ── Caption handling tests ──────────────────────────────────────
2426
+
2427
+ #[tokio::test]
2428
+ async fn telegram_send_document_bytes_with_caption() {
2429
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2430
+ let file_bytes = b"test content".to_vec();
2431
+
2432
+ // With caption
2433
+ let result = ch
2434
+ .send_document_bytes(
2435
+ "123456",
2436
+ None,
2437
+ file_bytes.clone(),
2438
+ "test.txt",
2439
+ Some("My caption"),
2440
+ )
2441
+ .await;
2442
+ assert!(result.is_err()); // Network error expected
2443
+
2444
+ // Without caption
2445
+ let result = ch
2446
+ .send_document_bytes("123456", None, file_bytes, "test.txt", None)
2447
+ .await;
2448
+ assert!(result.is_err()); // Network error expected
2449
+ }
2450
+
2451
+ #[tokio::test]
2452
+ async fn telegram_send_photo_bytes_with_caption() {
2453
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2454
+ let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
2455
+
2456
+ // With caption
2457
+ let result = ch
2458
+ .send_photo_bytes(
2459
+ "123456",
2460
+ None,
2461
+ file_bytes.clone(),
2462
+ "test.png",
2463
+ Some("Photo caption"),
2464
+ )
2465
+ .await;
2466
+ assert!(result.is_err());
2467
+
2468
+ // Without caption
2469
+ let result = ch
2470
+ .send_photo_bytes("123456", None, file_bytes, "test.png", None)
2471
+ .await;
2472
+ assert!(result.is_err());
2473
+ }
2474
+
2475
+ // ── Empty/edge case tests ───────────────────────────────────────
2476
+
2477
+ #[tokio::test]
2478
+ async fn telegram_send_document_bytes_empty_file() {
2479
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2480
+ let file_bytes: Vec<u8> = vec![];
2481
+
2482
+ let result = ch
2483
+ .send_document_bytes("123456", None, file_bytes, "empty.txt", None)
2484
+ .await;
2485
+
2486
+ // Should not panic, will fail at API level
2487
+ assert!(result.is_err());
2488
+ }
2489
+
2490
+ #[tokio::test]
2491
+ async fn telegram_send_document_bytes_empty_filename() {
2492
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2493
+ let file_bytes = b"content".to_vec();
2494
+
2495
+ let result = ch
2496
+ .send_document_bytes("123456", None, file_bytes, "", None)
2497
+ .await;
2498
+
2499
+ // Should not panic
2500
+ assert!(result.is_err());
2501
+ }
2502
+
2503
+ #[tokio::test]
2504
+ async fn telegram_send_document_bytes_empty_chat_id() {
2505
+ let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
2506
+ let file_bytes = b"content".to_vec();
2507
+
2508
+ let result = ch
2509
+ .send_document_bytes("", None, file_bytes, "test.txt", None)
2510
+ .await;
2511
+
2512
+ // Should not panic
2513
+ assert!(result.is_err());
2514
+ }
2515
+
2516
+ // ── Message ID edge cases ─────────────────────────────────────
2517
+
2518
+ #[test]
2519
+ fn telegram_message_id_format_includes_chat_and_message_id() {
2520
+ // Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
2521
+ let chat_id = "123456";
2522
+ let message_id = 789;
2523
+ let expected_id = format!("telegram_{chat_id}_{message_id}");
2524
+ assert_eq!(expected_id, "telegram_123456_789");
2525
+ }
2526
+
2527
+ #[test]
2528
+ fn telegram_message_id_is_deterministic() {
2529
+ // Same chat_id + same message_id = same ID (prevents duplicates after restart)
2530
+ let chat_id = "123456";
2531
+ let message_id = 789;
2532
+ let id1 = format!("telegram_{chat_id}_{message_id}");
2533
+ let id2 = format!("telegram_{chat_id}_{message_id}");
2534
+ assert_eq!(id1, id2);
2535
+ }
2536
+
2537
+ #[test]
2538
+ fn telegram_message_id_different_message_different_id() {
2539
+ // Different message IDs produce different IDs
2540
+ let chat_id = "123456";
2541
+ let id1 = format!("telegram_{chat_id}_789");
2542
+ let id2 = format!("telegram_{chat_id}_790");
2543
+ assert_ne!(id1, id2);
2544
+ }
2545
+
2546
+ #[test]
2547
+ fn telegram_message_id_different_chat_different_id() {
2548
+ // Different chats produce different IDs even with same message_id
2549
+ let message_id = 789;
2550
+ let id1 = format!("telegram_123456_{message_id}");
2551
+ let id2 = format!("telegram_789012_{message_id}");
2552
+ assert_ne!(id1, id2);
2553
+ }
2554
+
2555
+ #[test]
2556
+ fn telegram_message_id_no_uuid_randomness() {
2557
+ // Verify format doesn't contain random UUID components
2558
+ let chat_id = "123456";
2559
+ let message_id = 789;
2560
+ let id = format!("telegram_{chat_id}_{message_id}");
2561
+ assert!(!id.contains('-')); // No UUID dashes
2562
+ assert!(id.starts_with("telegram_"));
2563
+ }
2564
+
2565
+ #[test]
2566
+ fn telegram_message_id_handles_zero_message_id() {
2567
+ // Edge case: message_id can be 0 (fallback/missing case)
2568
+ let chat_id = "123456";
2569
+ let message_id = 0;
2570
+ let id = format!("telegram_{chat_id}_{message_id}");
2571
+ assert_eq!(id, "telegram_123456_0");
2572
+ }
2573
+
2574
+ // ── Tool call tag stripping tests ───────────────────────────────────
2575
+
2576
+ #[test]
2577
+ fn strip_tool_call_tags_removes_standard_tags() {
2578
+ let input =
2579
+ "Hello <tool>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool> world";
2580
+ let result = strip_tool_call_tags(input);
2581
+ assert_eq!(result, "Hello world");
2582
+ }
2583
+
2584
+ #[test]
2585
+ fn strip_tool_call_tags_removes_alias_tags() {
2586
+ let input = "Hello <toolcall>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</toolcall> world";
2587
+ let result = strip_tool_call_tags(input);
2588
+ assert_eq!(result, "Hello world");
2589
+ }
2590
+
2591
+ #[test]
2592
+ fn strip_tool_call_tags_removes_dash_tags() {
2593
+ let input = "Hello <tool-call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool-call> world";
2594
+ let result = strip_tool_call_tags(input);
2595
+ assert_eq!(result, "Hello world");
2596
+ }
2597
+
2598
+ #[test]
2599
+ fn strip_tool_call_tags_removes_tool_call_tags() {
2600
+ let input = "Hello <tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call> world";
2601
+ let result = strip_tool_call_tags(input);
2602
+ assert_eq!(result, "Hello world");
2603
+ }
2604
+
2605
+ #[test]
2606
+ fn strip_tool_call_tags_removes_invoke_tags() {
2607
+ let input = "Hello <invoke>{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}</invoke> world";
2608
+ let result = strip_tool_call_tags(input);
2609
+ assert_eq!(result, "Hello world");
2610
+ }
2611
+
2612
+ #[test]
2613
+ fn strip_tool_call_tags_handles_multiple_tags() {
2614
+ let input = "Start <tool>a</tool> middle <tool>b</tool> end";
2615
+ let result = strip_tool_call_tags(input);
2616
+ assert_eq!(result, "Start middle end");
2617
+ }
2618
+
2619
+ #[test]
2620
+ fn strip_tool_call_tags_handles_mixed_tags() {
2621
+ let input = "A <tool>a</tool> B <toolcall>b</toolcall> C <tool-call>c</tool-call> D";
2622
+ let result = strip_tool_call_tags(input);
2623
+ assert_eq!(result, "A B C D");
2624
+ }
2625
+
2626
+ #[test]
2627
+ fn strip_tool_call_tags_preserves_normal_text() {
2628
+ let input = "Hello world! This is a test.";
2629
+ let result = strip_tool_call_tags(input);
2630
+ assert_eq!(result, "Hello world! This is a test.");
2631
+ }
2632
+
2633
+ #[test]
2634
+ fn strip_tool_call_tags_handles_unclosed_tags() {
2635
+ let input = "Hello <tool>world";
2636
+ let result = strip_tool_call_tags(input);
2637
+ assert_eq!(result, "Hello <tool>world");
2638
+ }
2639
+
2640
+ #[test]
2641
+ fn strip_tool_call_tags_handles_unclosed_tool_call_with_json() {
2642
+ let input =
2643
+ "Status:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}";
2644
+ let result = strip_tool_call_tags(input);
2645
+ assert_eq!(result, "Status:");
2646
+ }
2647
+
2648
+ #[test]
2649
+ fn strip_tool_call_tags_handles_mismatched_close_tag() {
2650
+ let input =
2651
+ "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}</arg_value>";
2652
+ let result = strip_tool_call_tags(input);
2653
+ assert_eq!(result, "");
2654
+ }
2655
+
2656
+ #[test]
2657
+ fn strip_tool_call_tags_cleans_extra_newlines() {
2658
+ let input = "Hello\n\n<tool>\ntest\n</tool>\n\n\nworld";
2659
+ let result = strip_tool_call_tags(input);
2660
+ assert_eq!(result, "Hello\n\nworld");
2661
+ }
2662
+
2663
+ #[test]
2664
+ fn strip_tool_call_tags_handles_empty_input() {
2665
+ let input = "";
2666
+ let result = strip_tool_call_tags(input);
2667
+ assert_eq!(result, "");
2668
+ }
2669
+
2670
+ #[test]
2671
+ fn strip_tool_call_tags_handles_only_tags() {
2672
+ let input = "<tool>{\"name\":\"test\"}</tool>";
2673
+ let result = strip_tool_call_tags(input);
2674
+ assert_eq!(result, "");
2675
+ }
2676
+
2677
+ #[test]
2678
+ fn telegram_contains_bot_mention_finds_mention() {
2679
+ assert!(TelegramChannel::contains_bot_mention(
2680
+ "Hello @mybot",
2681
+ "mybot"
2682
+ ));
2683
+ assert!(TelegramChannel::contains_bot_mention(
2684
+ "@mybot help",
2685
+ "mybot"
2686
+ ));
2687
+ assert!(TelegramChannel::contains_bot_mention(
2688
+ "Hey @mybot how are you?",
2689
+ "mybot"
2690
+ ));
2691
+ assert!(TelegramChannel::contains_bot_mention(
2692
+ "Hello @MyBot, can you help?",
2693
+ "mybot"
2694
+ ));
2695
+ }
2696
+
2697
+ #[test]
2698
+ fn telegram_contains_bot_mention_no_false_positives() {
2699
+ assert!(!TelegramChannel::contains_bot_mention(
2700
+ "Hello @otherbot",
2701
+ "mybot"
2702
+ ));
2703
+ assert!(!TelegramChannel::contains_bot_mention(
2704
+ "Hello mybot",
2705
+ "mybot"
2706
+ ));
2707
+ assert!(!TelegramChannel::contains_bot_mention(
2708
+ "Hello @mybot2",
2709
+ "mybot"
2710
+ ));
2711
+ assert!(!TelegramChannel::contains_bot_mention("", "mybot"));
2712
+ }
2713
+
2714
+ #[test]
2715
+ fn telegram_normalize_incoming_content_strips_mention() {
2716
+ let result = TelegramChannel::normalize_incoming_content("@mybot hello", "mybot");
2717
+ assert_eq!(result, Some("hello".to_string()));
2718
+ }
2719
+
2720
+ #[test]
2721
+ fn telegram_normalize_incoming_content_handles_multiple_mentions() {
2722
+ let result = TelegramChannel::normalize_incoming_content("@mybot @mybot test", "mybot");
2723
+ assert_eq!(result, Some("test".to_string()));
2724
+ }
2725
+
2726
+ #[test]
2727
+ fn telegram_normalize_incoming_content_returns_none_for_empty() {
2728
+ let result = TelegramChannel::normalize_incoming_content("@mybot", "mybot");
2729
+ assert_eq!(result, None);
2730
+ }
2731
+
2732
+ #[test]
2733
+ fn parse_update_message_mention_only_group_requires_exact_mention() {
2734
+ let ch = TelegramChannel::new("token".into(), vec!["*".into()], true);
2735
+ {
2736
+ let mut cache = ch.bot_username.lock();
2737
+ *cache = Some("mybot".to_string());
2738
+ }
2739
+
2740
+ let update = serde_json::json!({
2741
+ "update_id": 10,
2742
+ "message": {
2743
+ "message_id": 44,
2744
+ "text": "hello @mybot2",
2745
+ "from": {
2746
+ "id": 555,
2747
+ "username": "alice"
2748
+ },
2749
+ "chat": {
2750
+ "id": -100_200_300,
2751
+ "type": "group"
2752
+ }
2753
+ }
2754
+ });
2755
+
2756
+ assert!(ch.parse_update_message(&update).is_none());
2757
+ }
2758
+
2759
+ #[test]
2760
+ fn parse_update_message_mention_only_group_strips_mention_and_drops_empty() {
2761
+ let ch = TelegramChannel::new("token".into(), vec!["*".into()], true);
2762
+ {
2763
+ let mut cache = ch.bot_username.lock();
2764
+ *cache = Some("mybot".to_string());
2765
+ }
2766
+
2767
+ let update = serde_json::json!({
2768
+ "update_id": 11,
2769
+ "message": {
2770
+ "message_id": 45,
2771
+ "text": "Hi @MyBot status please",
2772
+ "from": {
2773
+ "id": 555,
2774
+ "username": "alice"
2775
+ },
2776
+ "chat": {
2777
+ "id": -100_200_300,
2778
+ "type": "group"
2779
+ }
2780
+ }
2781
+ });
2782
+
2783
+ let parsed = ch
2784
+ .parse_update_message(&update)
2785
+ .expect("mention should parse");
2786
+ assert_eq!(parsed.content, "Hi status please");
2787
+
2788
+ let empty_update = serde_json::json!({
2789
+ "update_id": 12,
2790
+ "message": {
2791
+ "message_id": 46,
2792
+ "text": "@mybot",
2793
+ "from": {
2794
+ "id": 555,
2795
+ "username": "alice"
2796
+ },
2797
+ "chat": {
2798
+ "id": -100_200_300,
2799
+ "type": "group"
2800
+ }
2801
+ }
2802
+ });
2803
+
2804
+ assert!(ch.parse_update_message(&empty_update).is_none());
2805
+ }
2806
+
2807
+ #[test]
2808
+ fn telegram_is_group_message_detects_groups() {
2809
+ let group_msg = serde_json::json!({
2810
+ "chat": { "type": "group" }
2811
+ });
2812
+ assert!(TelegramChannel::is_group_message(&group_msg));
2813
+
2814
+ let supergroup_msg = serde_json::json!({
2815
+ "chat": { "type": "supergroup" }
2816
+ });
2817
+ assert!(TelegramChannel::is_group_message(&supergroup_msg));
2818
+
2819
+ let private_msg = serde_json::json!({
2820
+ "chat": { "type": "private" }
2821
+ });
2822
+ assert!(!TelegramChannel::is_group_message(&private_msg));
2823
+ }
2824
+
2825
+ #[test]
2826
+ fn telegram_mention_only_enabled_by_config() {
2827
+ let ch = TelegramChannel::new("token".into(), vec!["*".into()], true);
2828
+ assert!(ch.mention_only);
2829
+
2830
+ let ch_disabled = TelegramChannel::new("token".into(), vec!["*".into()], false);
2831
+ assert!(!ch_disabled.mention_only);
2832
+ }
2833
+ }