octo-agent 0.11.2

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 (319) hide show
  1. checksums.yaml +7 -0
  2. data/.clacky/skills/commit/SKILL.md +423 -0
  3. data/.clacky/skills/gem-release/SKILL.md +199 -0
  4. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  5. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  6. data/.octorules +106 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +8 -0
  9. data/CHANGELOG.md +76 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +92 -0
  12. data/Dockerfile +28 -0
  13. data/LICENSE.txt +22 -0
  14. data/POSITIONING.md +46 -0
  15. data/README.md +134 -0
  16. data/README_CN.md +134 -0
  17. data/Rakefile +34 -0
  18. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  19. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  20. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  21. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  22. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  23. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  24. data/benchmark/results/baseline_20260511_174424.json +128 -0
  25. data/benchmark/results/report_20260511_175256.json +271 -0
  26. data/benchmark/results/report_20260511_175444.json +271 -0
  27. data/benchmark/results/treatment_20260511_175103.json +130 -0
  28. data/benchmark/runner.rb +441 -0
  29. data/bin/octo +7 -0
  30. data/docs/agent-first-ui-design.md +77 -0
  31. data/docs/billing-system.md +318 -0
  32. data/docs/channel-architecture.md +235 -0
  33. data/docs/engineering-article.md +343 -0
  34. data/docs/session-skill-invocation.md +69 -0
  35. data/docs/time_machine_design.md +247 -0
  36. data/docs/ui2-architecture.md +124 -0
  37. data/homebrew/README.md +96 -0
  38. data/homebrew/openocto.rb +24 -0
  39. data/lib/octo/agent/hook_manager.rb +61 -0
  40. data/lib/octo/agent/llm_caller.rb +800 -0
  41. data/lib/octo/agent/memory_updater.rb +246 -0
  42. data/lib/octo/agent/message_compressor.rb +225 -0
  43. data/lib/octo/agent/message_compressor_helper.rb +869 -0
  44. data/lib/octo/agent/next_message_suggester.rb +215 -0
  45. data/lib/octo/agent/session_serializer.rb +685 -0
  46. data/lib/octo/agent/skill_auto_creator.rb +114 -0
  47. data/lib/octo/agent/skill_evolution.rb +61 -0
  48. data/lib/octo/agent/skill_manager.rb +466 -0
  49. data/lib/octo/agent/skill_reflector.rb +89 -0
  50. data/lib/octo/agent/system_prompt_builder.rb +101 -0
  51. data/lib/octo/agent/time_machine.rb +214 -0
  52. data/lib/octo/agent/tool_executor.rb +454 -0
  53. data/lib/octo/agent/tool_registry.rb +150 -0
  54. data/lib/octo/agent.rb +2180 -0
  55. data/lib/octo/agent_config.rb +989 -0
  56. data/lib/octo/agent_profile.rb +112 -0
  57. data/lib/octo/anthropic_stream_aggregator.rb +137 -0
  58. data/lib/octo/background_task_registry.rb +324 -0
  59. data/lib/octo/banner.rb +34 -0
  60. data/lib/octo/bedrock_stream_aggregator.rb +137 -0
  61. data/lib/octo/block_font.rb +331 -0
  62. data/lib/octo/cli.rb +968 -0
  63. data/lib/octo/client.rb +623 -0
  64. data/lib/octo/default_agents/SOUL.md +3 -0
  65. data/lib/octo/default_agents/USER.md +1 -0
  66. data/lib/octo/default_agents/base_prompt.md +66 -0
  67. data/lib/octo/default_agents/coding/profile.yml +2 -0
  68. data/lib/octo/default_agents/coding/system_prompt.md +67 -0
  69. data/lib/octo/default_agents/general/profile.yml +2 -0
  70. data/lib/octo/default_agents/general/system_prompt.md +16 -0
  71. data/lib/octo/default_parsers/doc_parser.rb +69 -0
  72. data/lib/octo/default_parsers/docx_parser.rb +188 -0
  73. data/lib/octo/default_parsers/pdf_parser.rb +120 -0
  74. data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
  75. data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
  76. data/lib/octo/default_parsers/pptx_parser.rb +140 -0
  77. data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
  78. data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
  79. data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
  80. data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  81. data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
  82. data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
  83. data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
  84. data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  85. data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
  86. data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
  87. data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
  88. data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
  89. data/lib/octo/default_skills/onboard/SKILL.md +578 -0
  90. data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
  91. data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
  92. data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
  93. data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
  94. data/lib/octo/default_skills/personal-website/publish.rb +235 -0
  95. data/lib/octo/default_skills/product-help/SKILL.md +123 -0
  96. data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
  97. data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
  98. data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
  99. data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
  100. data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
  101. data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
  102. data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
  103. data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
  104. data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
  105. data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
  106. data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
  107. data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
  108. data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
  109. data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
  110. data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
  111. data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
  112. data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
  113. data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
  114. data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
  115. data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
  116. data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
  117. data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
  118. data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
  119. data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
  120. data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
  121. data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
  122. data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
  123. data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
  124. data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
  125. data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  126. data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
  127. data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
  128. data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
  129. data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
  130. data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
  131. data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
  132. data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
  133. data/lib/octo/idle_compression_timer.rb +115 -0
  134. data/lib/octo/json_ui_controller.rb +204 -0
  135. data/lib/octo/message_format/anthropic.rb +409 -0
  136. data/lib/octo/message_format/bedrock.rb +361 -0
  137. data/lib/octo/message_format/open_ai.rb +222 -0
  138. data/lib/octo/message_history.rb +373 -0
  139. data/lib/octo/openai_stream_aggregator.rb +130 -0
  140. data/lib/octo/plain_ui_controller.rb +166 -0
  141. data/lib/octo/providers.rb +534 -0
  142. data/lib/octo/server/browser_manager.rb +397 -0
  143. data/lib/octo/server/channel/adapters/base.rb +82 -0
  144. data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
  145. data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
  146. data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
  147. data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
  148. data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
  149. data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
  150. data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
  151. data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
  152. data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
  153. data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
  154. data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
  155. data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
  156. data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
  157. data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
  158. data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
  159. data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
  160. data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
  161. data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
  162. data/lib/octo/server/channel/channel_config.rb +178 -0
  163. data/lib/octo/server/channel/channel_manager.rb +468 -0
  164. data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
  165. data/lib/octo/server/channel.rb +33 -0
  166. data/lib/octo/server/discover.rb +77 -0
  167. data/lib/octo/server/epipe_safe_io.rb +105 -0
  168. data/lib/octo/server/http_server.rb +3554 -0
  169. data/lib/octo/server/scheduler.rb +317 -0
  170. data/lib/octo/server/server_master.rb +325 -0
  171. data/lib/octo/server/session_registry.rb +431 -0
  172. data/lib/octo/server/web_ui_controller.rb +487 -0
  173. data/lib/octo/session_manager.rb +385 -0
  174. data/lib/octo/skill.rb +466 -0
  175. data/lib/octo/skill_loader.rb +328 -0
  176. data/lib/octo/tools/base.rb +118 -0
  177. data/lib/octo/tools/browser.rb +625 -0
  178. data/lib/octo/tools/edit.rb +165 -0
  179. data/lib/octo/tools/file_reader.rb +549 -0
  180. data/lib/octo/tools/glob.rb +162 -0
  181. data/lib/octo/tools/grep.rb +356 -0
  182. data/lib/octo/tools/invoke_skill.rb +96 -0
  183. data/lib/octo/tools/list_tasks.rb +54 -0
  184. data/lib/octo/tools/redo_task.rb +41 -0
  185. data/lib/octo/tools/request_user_feedback.rb +84 -0
  186. data/lib/octo/tools/security.rb +333 -0
  187. data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
  188. data/lib/octo/tools/terminal/persistent_session.rb +268 -0
  189. data/lib/octo/tools/terminal/safe_rm.sh +106 -0
  190. data/lib/octo/tools/terminal/session_manager.rb +213 -0
  191. data/lib/octo/tools/terminal.rb +1828 -0
  192. data/lib/octo/tools/todo_manager.rb +374 -0
  193. data/lib/octo/tools/trash_manager.rb +388 -0
  194. data/lib/octo/tools/undo_task.rb +35 -0
  195. data/lib/octo/tools/web_fetch.rb +242 -0
  196. data/lib/octo/tools/web_search.rb +260 -0
  197. data/lib/octo/tools/write.rb +77 -0
  198. data/lib/octo/ui2/block_font.rb +10 -0
  199. data/lib/octo/ui2/components/base_component.rb +163 -0
  200. data/lib/octo/ui2/components/command_suggestions.rb +290 -0
  201. data/lib/octo/ui2/components/common_component.rb +96 -0
  202. data/lib/octo/ui2/components/inline_input.rb +226 -0
  203. data/lib/octo/ui2/components/input_area.rb +1338 -0
  204. data/lib/octo/ui2/components/message_component.rb +99 -0
  205. data/lib/octo/ui2/components/modal_component.rb +419 -0
  206. data/lib/octo/ui2/components/todo_area.rb +149 -0
  207. data/lib/octo/ui2/components/tool_component.rb +107 -0
  208. data/lib/octo/ui2/components/welcome_banner.rb +139 -0
  209. data/lib/octo/ui2/layout_manager.rb +807 -0
  210. data/lib/octo/ui2/line_editor.rb +363 -0
  211. data/lib/octo/ui2/markdown_renderer.rb +100 -0
  212. data/lib/octo/ui2/output_buffer.rb +370 -0
  213. data/lib/octo/ui2/progress_handle.rb +362 -0
  214. data/lib/octo/ui2/progress_indicator.rb +55 -0
  215. data/lib/octo/ui2/screen_buffer.rb +273 -0
  216. data/lib/octo/ui2/terminal_detector.rb +119 -0
  217. data/lib/octo/ui2/theme_manager.rb +85 -0
  218. data/lib/octo/ui2/themes/base_theme.rb +105 -0
  219. data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
  220. data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
  221. data/lib/octo/ui2/thinking_verbs.rb +26 -0
  222. data/lib/octo/ui2/ui_controller.rb +1625 -0
  223. data/lib/octo/ui2/view_renderer.rb +177 -0
  224. data/lib/octo/ui2.rb +40 -0
  225. data/lib/octo/ui_interface.rb +154 -0
  226. data/lib/octo/utils/arguments_parser.rb +191 -0
  227. data/lib/octo/utils/browser_detector.rb +195 -0
  228. data/lib/octo/utils/encoding.rb +92 -0
  229. data/lib/octo/utils/environment_detector.rb +140 -0
  230. data/lib/octo/utils/file_ignore_helper.rb +170 -0
  231. data/lib/octo/utils/file_processor.rb +601 -0
  232. data/lib/octo/utils/gitignore_parser.rb +154 -0
  233. data/lib/octo/utils/limit_stack.rb +152 -0
  234. data/lib/octo/utils/logger.rb +124 -0
  235. data/lib/octo/utils/login_shell.rb +72 -0
  236. data/lib/octo/utils/model_pricing.rb +646 -0
  237. data/lib/octo/utils/parser_manager.rb +165 -0
  238. data/lib/octo/utils/path_helper.rb +15 -0
  239. data/lib/octo/utils/scripts_manager.rb +59 -0
  240. data/lib/octo/utils/string_matcher.rb +158 -0
  241. data/lib/octo/utils/trash_directory.rb +112 -0
  242. data/lib/octo/utils/workspace_rules.rb +46 -0
  243. data/lib/octo/version.rb +5 -0
  244. data/lib/octo/web/app.css +7141 -0
  245. data/lib/octo/web/app.js +543 -0
  246. data/lib/octo/web/apple-touch-icon.png +0 -0
  247. data/lib/octo/web/auth.js +150 -0
  248. data/lib/octo/web/channels.js +276 -0
  249. data/lib/octo/web/datepicker.js +205 -0
  250. data/lib/octo/web/favicon.png +0 -0
  251. data/lib/octo/web/i18n.js +1073 -0
  252. data/lib/octo/web/icon-512.png +0 -0
  253. data/lib/octo/web/icon-dark.svg +25 -0
  254. data/lib/octo/web/icon.svg +29 -0
  255. data/lib/octo/web/index.html +871 -0
  256. data/lib/octo/web/marked.min.js +69 -0
  257. data/lib/octo/web/onboard.js +491 -0
  258. data/lib/octo/web/profile.js +442 -0
  259. data/lib/octo/web/sessions.js +4421 -0
  260. data/lib/octo/web/settings.js +913 -0
  261. data/lib/octo/web/sidebar.js +32 -0
  262. data/lib/octo/web/skills.js +885 -0
  263. data/lib/octo/web/tasks.js +297 -0
  264. data/lib/octo/web/theme.js +105 -0
  265. data/lib/octo/web/trash.js +343 -0
  266. data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
  267. data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
  268. data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
  269. data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  270. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  271. data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  272. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  273. data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  274. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  275. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  276. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  277. data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  278. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  279. data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  280. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  281. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  282. data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  283. data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  284. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  285. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  286. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  287. data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  288. data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  289. data/lib/octo/web/vendor/katex/katex.min.css +1 -0
  290. data/lib/octo/web/vendor/katex/katex.min.js +1 -0
  291. data/lib/octo/web/version.js +449 -0
  292. data/lib/octo/web/weixin-qr.html +209 -0
  293. data/lib/octo/web/ws-dispatcher.js +357 -0
  294. data/lib/octo/web/ws.js +128 -0
  295. data/lib/octo.rb +145 -0
  296. data/scripts/build/build.sh +329 -0
  297. data/scripts/build/lib/apt.sh +56 -0
  298. data/scripts/build/lib/brew.sh +89 -0
  299. data/scripts/build/lib/colors.sh +17 -0
  300. data/scripts/build/lib/gem.sh +95 -0
  301. data/scripts/build/lib/mise.sh +125 -0
  302. data/scripts/build/lib/network.sh +157 -0
  303. data/scripts/build/lib/os.sh +57 -0
  304. data/scripts/build/lib/shell.sh +37 -0
  305. data/scripts/build/src/install.sh.cc +174 -0
  306. data/scripts/build/src/install_browser.sh.cc +101 -0
  307. data/scripts/build/src/install_full.sh.cc +290 -0
  308. data/scripts/build/src/install_rails_deps.sh.cc +145 -0
  309. data/scripts/build/src/install_system_deps.sh.cc +123 -0
  310. data/scripts/build/src/uninstall.sh.cc +101 -0
  311. data/scripts/install.ps1 +532 -0
  312. data/scripts/install.sh +567 -0
  313. data/scripts/install_browser.sh +479 -0
  314. data/scripts/install_full.sh +838 -0
  315. data/scripts/install_rails_deps.sh +746 -0
  316. data/scripts/install_system_deps.sh +518 -0
  317. data/scripts/uninstall.sh +287 -0
  318. data/sig/octo.rbs +4 -0
  319. metadata +614 -0
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "../../adapters/base"
5
+ require_relative "api_client"
6
+
7
+ module Octo
8
+ module Channel
9
+ module Adapters
10
+ module Telegram
11
+ # Telegram Bot API adapter.
12
+ #
13
+ # Transport: HTTPS long-poll via getUpdates (no public domain required).
14
+ # Auth: single bot token obtained from @BotFather.
15
+ # Group rule: bots only react when @-mentioned or replied to (matches Feishu).
16
+ #
17
+ # Config keys (channels.yml `telegram`):
18
+ # bot_token String required — from @BotFather
19
+ # base_url String default "https://api.telegram.org"
20
+ # (override for self-hosted Bot API / proxy)
21
+ # parse_mode String default "Markdown" — set "" / nil to disable
22
+ # allowed_users Array optional whitelist of from.id (numeric, as String)
23
+ class Adapter < Base
24
+ # Telegram messages cap at 4096 UTF-16 code units; we leave a small margin.
25
+ MAX_MESSAGE_CHARS = 4000
26
+
27
+ MAX_IMAGE_BYTES = Octo::Utils::FileProcessor::MAX_IMAGE_BYTES
28
+
29
+ def self.platform_id
30
+ :telegram
31
+ end
32
+
33
+ def self.env_keys
34
+ %w[IM_TELEGRAM_BOT_TOKEN IM_TELEGRAM_BASE_URL IM_TELEGRAM_PARSE_MODE IM_TELEGRAM_ALLOWED_USERS]
35
+ end
36
+
37
+ def self.platform_config(data)
38
+ {
39
+ bot_token: data["IM_TELEGRAM_BOT_TOKEN"] || data["bot_token"],
40
+ base_url: data["IM_TELEGRAM_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL,
41
+ parse_mode: data.key?("parse_mode") ? data["parse_mode"] : (data["IM_TELEGRAM_PARSE_MODE"] || "Markdown"),
42
+ allowed_users: (data["IM_TELEGRAM_ALLOWED_USERS"] || data["allowed_users"] || "")
43
+ .then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) }
44
+ }.compact
45
+ end
46
+
47
+ def self.set_env_data(data, config)
48
+ data["IM_TELEGRAM_BOT_TOKEN"] = config[:bot_token]
49
+ data["IM_TELEGRAM_BASE_URL"] = config[:base_url] if config[:base_url]
50
+ data["IM_TELEGRAM_PARSE_MODE"] = config[:parse_mode] if config[:parse_mode]
51
+ data["IM_TELEGRAM_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
52
+ end
53
+
54
+ # Verify credentials by calling getMe.
55
+ # @param fields [Hash] symbol-keyed credential fields
56
+ # @return [Hash] { ok: Boolean, message:/error: String }
57
+ def self.test_connection(fields)
58
+ token = fields[:bot_token].to_s.strip
59
+ return { ok: false, error: "bot_token is required" } if token.empty?
60
+
61
+ base_url = fields[:base_url].to_s.strip
62
+ base_url = ApiClient::DEFAULT_BASE_URL if base_url.empty?
63
+
64
+ client = ApiClient.new(token: token, base_url: base_url)
65
+ me = client.post("getMe", {})
66
+ { ok: true, message: "Connected — bot @#{me["username"]} (id #{me["id"]})" }
67
+ rescue StandardError => e
68
+ { ok: false, error: e.message }
69
+ end
70
+
71
+ def initialize(config)
72
+ @config = config
73
+ @token = config[:bot_token].to_s
74
+ @base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL
75
+ @parse_mode = config.key?(:parse_mode) ? config[:parse_mode] : "Markdown"
76
+ @parse_mode = nil if @parse_mode.to_s.empty?
77
+ @allowed_users = Array(config[:allowed_users]).map(&:to_s)
78
+
79
+ @api = ApiClient.new(token: @token, base_url: @base_url)
80
+ @running = false
81
+ @on_message = nil
82
+ @last_offset = nil
83
+
84
+ # Cached bot identity (used for @-mention check in groups).
85
+ @bot_username = nil
86
+ @bot_id = nil
87
+ end
88
+
89
+ # ── Lifecycle ──────────────────────────────────────────────────────
90
+
91
+ def start(&on_message)
92
+ @running = true
93
+ @on_message = on_message
94
+
95
+ ensure_bot_identity
96
+
97
+ Octo::Logger.info("[TelegramAdapter] starting long-poll (base_url=#{@base_url})")
98
+
99
+ consecutive_errors = 0
100
+ while @running
101
+ begin
102
+ updates = @api.get_updates(offset: @last_offset)
103
+ consecutive_errors = 0
104
+
105
+ updates.each do |update|
106
+ @last_offset = update["update_id"] + 1
107
+ process_update(update)
108
+ rescue => e
109
+ Octo::Logger.warn("[TelegramAdapter] process_update error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
110
+ end
111
+ rescue ApiClient::TimeoutError
112
+ # Long-poll cycle ended with no updates — just loop.
113
+ rescue ApiClient::ApiError => e
114
+ consecutive_errors += 1
115
+ Octo::Logger.warn("[TelegramAdapter] API #{e.code}: #{e.description}")
116
+ sleep(consecutive_errors > 3 ? 30 : 5)
117
+ rescue => e
118
+ consecutive_errors += 1
119
+ Octo::Logger.error("[TelegramAdapter] poll error: #{e.message}")
120
+ break unless @running
121
+ sleep(consecutive_errors > 3 ? 30 : 5)
122
+ end
123
+ end
124
+ end
125
+
126
+ def stop
127
+ @running = false
128
+ end
129
+
130
+ # ── Outbound (called by ChannelUIController) ────────────────────────
131
+
132
+ # Send a text message. Splits content longer than Telegram's 4096-char
133
+ # cap into multiple consecutive messages. Returns { message_id: } of
134
+ # the LAST chunk (matches the contract used by the other adapters).
135
+ def send_text(chat_id, text, reply_to: nil)
136
+ chunks = split_message(text.to_s)
137
+ return { message_id: nil } if chunks.empty?
138
+
139
+ last_message_id = nil
140
+ chunks.each_with_index do |chunk, i|
141
+ params = {
142
+ chat_id: chat_id.to_s,
143
+ text: chunk,
144
+ disable_web_page_preview: true
145
+ }
146
+ params[:parse_mode] = @parse_mode if @parse_mode
147
+ params[:reply_to_message_id] = reply_to.to_i if reply_to && i == 0
148
+ msg = @api.post("sendMessage", params)
149
+ last_message_id = msg["message_id"]
150
+ end
151
+ { message_id: last_message_id }
152
+ rescue ApiClient::ApiError => e
153
+ # Markdown parse failures fall back to plain text — most common cause
154
+ # is unescaped Markdown reserved chars in the agent's output.
155
+ if @parse_mode && e.description.to_s =~ /can't parse entities|markdown/i
156
+ Octo::Logger.warn("[TelegramAdapter] parse_mode failed, retrying as plain text: #{e.description}")
157
+ fallback = {
158
+ chat_id: chat_id.to_s,
159
+ text: text.to_s,
160
+ disable_web_page_preview: true
161
+ }
162
+ fallback[:reply_to_message_id] = reply_to.to_i if reply_to
163
+ msg = @api.post("sendMessage", fallback)
164
+ return { message_id: msg["message_id"] }
165
+ end
166
+ Octo::Logger.error("[TelegramAdapter] send_text failed: #{e.message}")
167
+ { message_id: nil }
168
+ rescue => e
169
+ Octo::Logger.error("[TelegramAdapter] send_text failed: #{e.message}")
170
+ { message_id: nil }
171
+ end
172
+
173
+ def send_file(chat_id, path, name: nil, reply_to: nil)
174
+ return { message_id: nil } unless File.exist?(path)
175
+
176
+ is_image = path.to_s.downcase.match?(/\.(png|jpe?g|gif|webp)\z/)
177
+ msg = if is_image
178
+ @api.send_photo(
179
+ chat_id: chat_id.to_s,
180
+ photo_path: path,
181
+ reply_to_message_id: reply_to&.to_i
182
+ )
183
+ else
184
+ @api.send_document(
185
+ chat_id: chat_id.to_s,
186
+ document_path: path,
187
+ filename: name,
188
+ reply_to_message_id: reply_to&.to_i
189
+ )
190
+ end
191
+ { message_id: msg["message_id"] }
192
+ rescue => e
193
+ Octo::Logger.error("[TelegramAdapter] send_file failed for #{path}: #{e.message}")
194
+ { message_id: nil }
195
+ end
196
+
197
+ def update_message(chat_id, message_id, text)
198
+ @api.edit_message_text(
199
+ chat_id: chat_id.to_s,
200
+ message_id: message_id.to_i,
201
+ text: text,
202
+ parse_mode: @parse_mode
203
+ )
204
+ true
205
+ rescue => e
206
+ Octo::Logger.warn("[TelegramAdapter] update_message failed: #{e.message}")
207
+ false
208
+ end
209
+
210
+ def supports_message_updates?
211
+ true
212
+ end
213
+
214
+ def validate_config(config)
215
+ errors = []
216
+ errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].to_s.strip.empty?
217
+ errors
218
+ end
219
+
220
+ # ── Inbound ─────────────────────────────────────────────────────────
221
+
222
+ def ensure_bot_identity
223
+ me = @api.post("getMe", {})
224
+ @bot_id = me["id"]
225
+ @bot_username = me["username"]
226
+ Octo::Logger.info("[TelegramAdapter] bot identity: @#{@bot_username} (id=#{@bot_id})")
227
+ rescue => e
228
+ Octo::Logger.warn("[TelegramAdapter] getMe failed: #{e.message} — group @-mentions will be dropped")
229
+ end
230
+
231
+ def process_update(update)
232
+ msg = update["message"]
233
+ return unless msg
234
+
235
+ chat = msg["chat"] || {}
236
+ from = msg["from"] || {}
237
+ chat_id = chat["id"]
238
+ user_id = from["id"]
239
+ return unless chat_id && user_id
240
+
241
+ chat_type = chat["type"].to_s
242
+ is_group = %w[group supergroup].include?(chat_type)
243
+ text = msg["text"].to_s
244
+
245
+ if is_group
246
+ return unless group_mention?(msg, text)
247
+ text = strip_bot_mention(text)
248
+ end
249
+
250
+ if @allowed_users.any? && !@allowed_users.include?(user_id.to_s)
251
+ Octo::Logger.debug("[TelegramAdapter] ignoring message from #{user_id} (not in allowed_users)")
252
+ return
253
+ end
254
+
255
+ files = collect_files(msg)
256
+ caption = msg["caption"].to_s
257
+ text = caption if text.empty? && !caption.empty?
258
+ return if text.strip.empty? && files.empty?
259
+
260
+ event = {
261
+ type: :message,
262
+ platform: :telegram,
263
+ chat_id: chat_id.to_s,
264
+ user_id: user_id.to_s,
265
+ text: text.strip,
266
+ files: files,
267
+ message_id: msg["message_id"].to_s,
268
+ timestamp: msg["date"] ? Time.at(msg["date"]) : Time.now,
269
+ chat_type: is_group ? :group : :direct,
270
+ raw: msg
271
+ }
272
+
273
+ Octo::Logger.info("[TelegramAdapter] msg from #{user_id} in #{chat_id} (#{chat_type}): #{text.slice(0, 80)}")
274
+ @on_message&.call(event)
275
+ end
276
+
277
+ # The bot reacts to a group message only if:
278
+ # 1. text contains @<bot_username> as a mention entity, or
279
+ # 2. the message is a reply to a message authored by the bot
280
+ # Fail closed when bot identity is unknown — drop the message rather
281
+ # than respond to every line and spam the group.
282
+ def group_mention?(msg, text)
283
+ return false unless @bot_id
284
+
285
+ reply = msg["reply_to_message"]
286
+ return true if reply && reply.dig("from", "id") == @bot_id
287
+
288
+ entities = msg["entities"] || []
289
+ entities.any? do |e|
290
+ e["type"] == "mention" &&
291
+ text[e["offset"], e["length"]].to_s.casecmp?("@#{@bot_username}")
292
+ end
293
+ end
294
+
295
+ def strip_bot_mention(text)
296
+ return text unless @bot_username
297
+ text.gsub(/@#{Regexp.escape(@bot_username)}\b/i, "").strip
298
+ end
299
+
300
+ # Build file-attachment hashes for the agent's vision / file pipeline.
301
+ def collect_files(msg)
302
+ files = []
303
+
304
+ if msg["photo"].is_a?(Array) && !msg["photo"].empty?
305
+ # `photo` is an array of size variants — pick the largest.
306
+ largest = msg["photo"].max_by { |p| p["file_size"].to_i }
307
+ begin
308
+ raw = @api.download_file(largest["file_id"])
309
+ if raw.bytesize > MAX_IMAGE_BYTES
310
+ Octo::Logger.warn("[TelegramAdapter] image too large (#{raw.bytesize}B), dropping")
311
+ else
312
+ mime = detect_image_mime(raw)
313
+ files << {
314
+ type: :image,
315
+ name: "image.jpg",
316
+ mime_type: mime,
317
+ data_url: "data:#{mime};base64,#{Base64.strict_encode64(raw)}"
318
+ }
319
+ end
320
+ rescue => e
321
+ Octo::Logger.warn("[TelegramAdapter] image download failed: #{e.message}")
322
+ end
323
+ end
324
+
325
+ if (doc = msg["document"])
326
+ begin
327
+ raw = @api.download_file(doc["file_id"])
328
+ filename = doc["file_name"].to_s
329
+ filename = "attachment" if filename.empty?
330
+ saved = Octo::Utils::FileProcessor.save(body: raw, filename: filename)
331
+ files << { type: :file, name: saved[:name], path: saved[:path] }
332
+ rescue => e
333
+ Octo::Logger.warn("[TelegramAdapter] document download failed: #{e.message}")
334
+ end
335
+ end
336
+
337
+ files
338
+ end
339
+
340
+ def detect_image_mime(bytes)
341
+ return "image/jpeg" unless bytes && bytes.bytesize >= 4
342
+ head = bytes.byteslice(0, 8).bytes
343
+ return "image/png" if head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47
344
+ return "image/gif" if head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46
345
+ return "image/webp" if head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46
346
+ "image/jpeg"
347
+ end
348
+
349
+ # ── Helpers ─────────────────────────────────────────────────────────
350
+
351
+ # Split text at Telegram's 4096-char cap (we use 4000 as a margin).
352
+ # Prefers paragraph / line / space boundaries; hard-cuts as a last resort.
353
+ def split_message(text)
354
+ return [] if text.nil? || text.empty?
355
+ return [text] if text.length <= MAX_MESSAGE_CHARS
356
+
357
+ chunks = []
358
+ remaining = text.dup
359
+ while remaining.length > MAX_MESSAGE_CHARS
360
+ window = remaining[0, MAX_MESSAGE_CHARS]
361
+ cut = window.rindex("\n\n") || window.rindex("\n") || window.rindex(" ") || MAX_MESSAGE_CHARS
362
+ cut = MAX_MESSAGE_CHARS if cut.zero?
363
+ chunks << remaining[0, cut].rstrip
364
+ remaining = remaining[cut..].lstrip
365
+ end
366
+ chunks << remaining unless remaining.empty?
367
+ chunks
368
+ end
369
+ end
370
+
371
+ Adapters.register(:telegram, Adapter)
372
+ end
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "net/https"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "uri"
9
+
10
+ module Octo
11
+ module Channel
12
+ module Adapters
13
+ module Telegram
14
+ # Telegram Bot API HTTP client.
15
+ # Spec: https://core.telegram.org/bots/api
16
+ #
17
+ # All requests POST JSON to https://<base>/bot<TOKEN>/<method>.
18
+ # File downloads use https://<base>/file/bot<TOKEN>/<file_path>.
19
+ #
20
+ # `base_url` is configurable to allow self-hosted Bot API servers
21
+ # (https://github.com/tdlib/telegram-bot-api), which is the practical
22
+ # escape hatch for users on networks where api.telegram.org is blocked.
23
+ class ApiClient
24
+ DEFAULT_BASE_URL = "https://api.telegram.org"
25
+ LONG_POLL_TIMEOUT = 25 # seconds; server holds the request open up to this long
26
+ OPEN_TIMEOUT = 10
27
+ # Read timeout must comfortably exceed the long-poll window so we
28
+ # don't tear down healthy connections mid-poll.
29
+ POLL_READ_TIMEOUT = LONG_POLL_TIMEOUT + 10
30
+
31
+ class ApiError < StandardError
32
+ attr_reader :code, :description
33
+ def initialize(code, description)
34
+ @code = code
35
+ @description = description
36
+ super("Telegram API error #{code}: #{description}")
37
+ end
38
+ end
39
+
40
+ class TimeoutError < StandardError; end
41
+
42
+ def initialize(token:, base_url: DEFAULT_BASE_URL)
43
+ @token = token.to_s
44
+ @base_url = (base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url).chomp("/")
45
+ end
46
+
47
+ # Long-poll for updates. Returns the raw `result` array (possibly empty).
48
+ # `offset` is the highest update_id + 1 from the previous batch.
49
+ def get_updates(offset: nil, allowed_updates: %w[message])
50
+ params = { timeout: LONG_POLL_TIMEOUT, allowed_updates: allowed_updates }
51
+ params[:offset] = offset if offset
52
+ post("getUpdates", params, read_timeout: POLL_READ_TIMEOUT)
53
+ end
54
+
55
+ # Send a plain or Markdown-formatted message. Returns the Message hash.
56
+ def send_message(chat_id:, text:, parse_mode: nil, reply_to_message_id: nil, message_thread_id: nil, disable_web_page_preview: true)
57
+ params = {
58
+ chat_id: chat_id,
59
+ text: text,
60
+ disable_web_page_preview: disable_web_page_preview
61
+ }
62
+ params[:parse_mode] = parse_mode if parse_mode
63
+ params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
64
+ params[:message_thread_id] = message_thread_id if message_thread_id
65
+ post("sendMessage", params)
66
+ end
67
+
68
+ # Edit the text of a previously sent message. Returns the edited Message hash.
69
+ def edit_message_text(chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true)
70
+ params = {
71
+ chat_id: chat_id,
72
+ message_id: message_id,
73
+ text: text,
74
+ disable_web_page_preview: disable_web_page_preview
75
+ }
76
+ params[:parse_mode] = parse_mode if parse_mode
77
+ post("editMessageText", params)
78
+ end
79
+
80
+ # Send a chat action (e.g. "typing") — auto-expires after 5s client-side.
81
+ def send_chat_action(chat_id:, action: "typing", message_thread_id: nil)
82
+ params = { chat_id: chat_id, action: action }
83
+ params[:message_thread_id] = message_thread_id if message_thread_id
84
+ post("sendChatAction", params)
85
+ end
86
+
87
+ # Send a photo by local file path. Returns the Message hash.
88
+ def send_photo(chat_id:, photo_path:, caption: nil, reply_to_message_id: nil)
89
+ params = { chat_id: chat_id }
90
+ params[:caption] = caption if caption
91
+ params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
92
+ post_multipart("sendPhoto", params, file_field: "photo", file_path: photo_path)
93
+ end
94
+
95
+ # Send a document (arbitrary file). Returns the Message hash.
96
+ def send_document(chat_id:, document_path:, filename: nil, caption: nil, reply_to_message_id: nil)
97
+ params = { chat_id: chat_id }
98
+ params[:caption] = caption if caption
99
+ params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
100
+ post_multipart("sendDocument", params, file_field: "document", file_path: document_path, filename: filename)
101
+ end
102
+
103
+ # Resolve a file_id to a file_path via getFile, then download the bytes.
104
+ # Returns the raw byte string.
105
+ def download_file(file_id)
106
+ file = post("getFile", { file_id: file_id })
107
+ path = file["file_path"]
108
+ raise ApiError.new(0, "getFile returned no file_path") if path.to_s.empty?
109
+
110
+ uri = URI("#{@base_url}/file/bot#{@token}/#{path}")
111
+ http_get_raw(uri)
112
+ end
113
+
114
+
115
+ def post(method_name, params, read_timeout: 30)
116
+ uri = URI("#{@base_url}/bot#{@token}/#{method_name}")
117
+ http = build_http(uri, read_timeout: read_timeout)
118
+
119
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
120
+ req.body = JSON.generate(params)
121
+
122
+ res = http.request(req)
123
+ body = parse_body(res)
124
+ unwrap(body, method_name)
125
+ rescue Net::ReadTimeout, Net::OpenTimeout
126
+ raise TimeoutError, "#{method_name} timed out"
127
+ end
128
+
129
+ def post_multipart(method_name, params, file_field:, file_path:, filename: nil)
130
+ uri = URI("#{@base_url}/bot#{@token}/#{method_name}")
131
+ boundary = "----octo-tg-#{SecureRandom.hex(8)}"
132
+ body = String.new(encoding: "BINARY")
133
+
134
+ params.each do |k, v|
135
+ body << "--#{boundary}\r\n"
136
+ body << %(Content-Disposition: form-data; name="#{k}"\r\n\r\n)
137
+ body << v.to_s.dup.force_encoding("BINARY")
138
+ body << "\r\n"
139
+ end
140
+
141
+ file_bytes = File.binread(file_path)
142
+ body << "--#{boundary}\r\n"
143
+ body << %(Content-Disposition: form-data; name="#{file_field}"; filename="#{filename || File.basename(file_path)}"\r\n)
144
+ body << "Content-Type: #{mime_for(file_path)}\r\n\r\n"
145
+ body << file_bytes
146
+ body << "\r\n--#{boundary}--\r\n"
147
+
148
+ http = build_http(uri, read_timeout: 60)
149
+ req = Net::HTTP::Post.new(uri.request_uri,
150
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}")
151
+ req.body = body
152
+
153
+ unwrap(parse_body(http.request(req)), method_name)
154
+ end
155
+
156
+ def http_get_raw(uri)
157
+ http = build_http(uri, read_timeout: 60)
158
+ res = http.request(Net::HTTP::Get.new(uri.request_uri))
159
+ unless res.is_a?(Net::HTTPSuccess)
160
+ raise ApiError.new(res.code.to_i, "GET #{uri.path} → HTTP #{res.code}: #{res.body.to_s.slice(0, 200)}")
161
+ end
162
+ res.body
163
+ rescue Net::ReadTimeout, Net::OpenTimeout
164
+ raise TimeoutError, "file download timed out"
165
+ end
166
+
167
+ def build_http(uri, read_timeout:)
168
+ http = Net::HTTP.new(uri.host, uri.port)
169
+ http.use_ssl = uri.scheme == "https"
170
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
171
+ http.open_timeout = OPEN_TIMEOUT
172
+ http.read_timeout = read_timeout
173
+ http
174
+ end
175
+
176
+ def parse_body(res)
177
+ JSON.parse(res.body)
178
+ rescue JSON::ParserError
179
+ raise ApiError.new(res.code.to_i, "non-JSON response from Telegram: #{res.body.to_s.slice(0, 200)}")
180
+ end
181
+
182
+ def unwrap(body, method_name)
183
+ if body["ok"]
184
+ body["result"]
185
+ else
186
+ raise ApiError.new(body["error_code"].to_i, "#{method_name}: #{body["description"]}")
187
+ end
188
+ end
189
+
190
+ def mime_for(path)
191
+ case File.extname(path).downcase
192
+ when ".png" then "image/png"
193
+ when ".gif" then "image/gif"
194
+ when ".webp" then "image/webp"
195
+ when ".jpg", ".jpeg" then "image/jpeg"
196
+ when ".pdf" then "application/pdf"
197
+ when ".txt", ".md" then "text/plain"
198
+ else "application/octet-stream"
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end