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,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "base64"
8
+ require "digest"
9
+ require "tempfile"
10
+
11
+ module Octo
12
+ module Channel
13
+ module Adapters
14
+ module Weixin
15
+ # HTTP API client for Weixin iLink bot protocol.
16
+ #
17
+ # All requests POST JSON to <base_url>/<endpoint>.
18
+ # Required headers per request:
19
+ # Content-Type: application/json
20
+ # AuthorizationType: ilink_bot_token
21
+ # Authorization: Bearer <token>
22
+ # X-WECHAT-UIN: base64(random uint32 as decimal string)
23
+ class ApiClient
24
+ DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
25
+ CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
26
+ API_PATH_PREFIX = "ilink/bot"
27
+ CHANNEL_VERSION = "1.0.2"
28
+ LONG_POLL_TIMEOUT_S = 40 # slightly above the server's 35s
29
+ API_TIMEOUT_S = 15
30
+
31
+ # media_type values for getuploadurl
32
+ MEDIA_TYPE_IMAGE = 1
33
+ MEDIA_TYPE_VIDEO = 2
34
+ MEDIA_TYPE_FILE = 3
35
+ MEDIA_TYPE_VOICE = 4
36
+
37
+ # Raised for non-zero API return codes or HTTP errors.
38
+ class ApiError < StandardError
39
+ attr_reader :code
40
+ def initialize(code, msg)
41
+ @code = code
42
+ super("WeixinApiError(#{code}): #{msg.to_s.slice(0, 200)}")
43
+ end
44
+ end
45
+
46
+ # Raised on network/read timeouts.
47
+ class TimeoutError < StandardError; end
48
+
49
+ # Server errcode for expired sessions.
50
+ SESSION_EXPIRED_ERRCODE = -14
51
+
52
+ def initialize(base_url:, token:)
53
+ @base_url = base_url.to_s.chomp("/")
54
+ @token = token.to_s
55
+ end
56
+
57
+ # Long-poll for new messages.
58
+ # @param get_updates_buf [String] cursor from last response ("" for first call)
59
+ # @return [Hash] { ret:, msgs: [], get_updates_buf:, longpolling_timeout_ms: }
60
+ def get_updates(get_updates_buf:)
61
+ post("getupdates", { get_updates_buf: get_updates_buf }, timeout: LONG_POLL_TIMEOUT_S)
62
+ end
63
+
64
+ # Retrieve a typing_ticket for the given user.
65
+ # context_token is optional but recommended per protocol spec.
66
+ # @return [String] typing_ticket
67
+ def get_typing_ticket(ilink_user_id:, context_token: nil)
68
+ body = { ilink_user_id: ilink_user_id }
69
+ body[:context_token] = context_token if context_token
70
+ resp = post("getconfig", body)
71
+ resp["typing_ticket"].to_s
72
+ end
73
+
74
+ # Send/keep/cancel typing indicator.
75
+ # @param status [Integer] 1 = typing, 2 = cancel
76
+ def send_typing(ilink_user_id:, typing_ticket:, status:)
77
+ post("sendtyping", {
78
+ ilink_user_id: ilink_user_id,
79
+ typing_ticket: typing_ticket,
80
+ status: status
81
+ })
82
+ end
83
+
84
+ # Send a plain text message.
85
+ # context_token is required by the Weixin protocol for conversation association.
86
+ def send_text(to_user_id:, text:, context_token:)
87
+ body = {
88
+ msg: {
89
+ from_user_id: "",
90
+ to_user_id: to_user_id,
91
+ client_id: "octo-#{SecureRandom.hex(8)}",
92
+ message_type: 2, # BOT
93
+ message_state: 2, # FINISH
94
+ item_list: [{ type: 1, text_item: { text: text } }],
95
+ context_token: context_token
96
+ }
97
+ }
98
+ post("sendmessage", body)
99
+ end
100
+
101
+ # Send a file (any type) to a user.
102
+ #
103
+ # @param to_user_id [String]
104
+ # @param file_path [String] local path to the file
105
+ # @param file_name [String] display name (defaults to basename)
106
+ # @param context_token [String]
107
+ # @param media_type [Integer] MEDIA_TYPE_* constant (default: auto-detect)
108
+ # @return [Hash] API response
109
+ def send_file(to_user_id:, file_path:, context_token:, file_name: nil, media_type: nil)
110
+ file_name ||= File.basename(file_path)
111
+ media_type ||= detect_media_type(file_name)
112
+ raw_bytes = File.binread(file_path)
113
+
114
+ cdn_media = upload_media(
115
+ raw_bytes: raw_bytes,
116
+ file_name: file_name,
117
+ media_type: media_type,
118
+ to_user_id: to_user_id
119
+ )
120
+
121
+ item = build_media_item(media_type, cdn_media, raw_bytes, file_name)
122
+ body = {
123
+ msg: {
124
+ from_user_id: "",
125
+ to_user_id: to_user_id,
126
+ client_id: "octo-#{SecureRandom.hex(8)}",
127
+ message_type: 2, # BOT
128
+ message_state: 2, # FINISH
129
+ item_list: [item],
130
+ context_token: context_token
131
+ }
132
+ }
133
+ Octo::Logger.debug("[WeixinApiClient] send_file item: #{item.to_json}")
134
+ post("sendmessage", body)
135
+ end
136
+
137
+ # Download and decrypt a media file from the Weixin CDN.
138
+ #
139
+ # @param cdn_media [Hash] { "encrypt_query_param" => String, "aes_key" => String }
140
+ # Keys may be Symbol or String.
141
+ # @param media_type [Integer] MEDIA_TYPE_* constant — controls aeskey decoding.
142
+ # @return [String] raw (decrypted) file bytes.
143
+ def download_media(cdn_media, media_type)
144
+ encrypted_param = cdn_media[:encrypt_query_param] || cdn_media["encrypt_query_param"]
145
+ aeskey_b64 = cdn_media[:aes_key] || cdn_media["aes_key"]
146
+
147
+ raise ApiError.new(0, "download_media: missing encrypt_query_param") unless encrypted_param
148
+ raise ApiError.new(0, "download_media: missing aes_key") unless aeskey_b64
149
+
150
+ # Decode aes_key. The encoding depends on who generated the key:
151
+ #
152
+ # Outbound (we upload): image → base64(raw 16 bytes), others → base64(hex 32 chars)
153
+ # Inbound (WeChat client uploaded): aes_key is a plain hex string (32 hex chars, no base64)
154
+ #
155
+ # Detection strategy — try to figure out the actual key by checking decoded size:
156
+ # decoded 16 bytes → raw AES key (our outbound image encoding)
157
+ # decoded 24 bytes → aes_key was a plain hex string (32 chars) passed as-is,
158
+ # meaning aeskey_b64 IS the hex string, not base64 at all.
159
+ # Use the original string directly: [aeskey_b64].pack("H*")
160
+ # decoded 32 bytes → base64(hex 32 chars) → [decoded].pack("H*") → 16 bytes
161
+ raw_aes_key = begin
162
+ decoded = Base64.strict_decode64(aeskey_b64)
163
+ case decoded.bytesize
164
+ when 16
165
+ # Our outbound image encoding: base64(raw 16 bytes)
166
+ decoded
167
+ when 32
168
+ # Our outbound non-image encoding: base64(hex 32 chars)
169
+ [decoded].pack("H*")
170
+ else
171
+ # Unexpected — fall through to hex-string path
172
+ raise ArgumentError, "unexpected decoded size #{decoded.bytesize}"
173
+ end
174
+ rescue ArgumentError
175
+ # aes_key is a plain hex string (32 hex chars), not base64.
176
+ # This is the inbound format used by WeChat clients.
177
+ if aeskey_b64.match?(/\A[0-9a-fA-F]{32}\z/)
178
+ [aeskey_b64].pack("H*")
179
+ else
180
+ Octo::Logger.warn("[WeixinApiClient] unknown aeskey format: len=#{aeskey_b64.bytesize}")
181
+ aeskey_b64[0, 16] # last-resort: first 16 bytes
182
+ end
183
+ end
184
+
185
+ Octo::Logger.debug("[WeixinApiClient] download_media key_bytes=#{raw_aes_key.bytesize} media_type=#{media_type}")
186
+
187
+ # GET encrypted bytes from CDN.
188
+ cdn_url = "#{CDN_BASE_URL}/download" \
189
+ "?encrypted_query_param=#{URI.encode_uri_component(encrypted_param)}"
190
+ encrypted_bytes = cdn_get(cdn_url)
191
+
192
+ # Decrypt with AES-128-ECB.
193
+ aes_ecb_decrypt(encrypted_bytes, raw_aes_key)
194
+ end
195
+
196
+
197
+ # Full upload pipeline: encrypt → getuploadurl → CDN PUT → return CDNMedia hash.
198
+ def upload_media(raw_bytes:, file_name:, media_type:, to_user_id:)
199
+ # Generate a random 16-byte AES key.
200
+ aes_key_raw = SecureRandom.bytes(16)
201
+
202
+ # Encrypt file bytes with AES-128-ECB + PKCS7.
203
+ encrypted_bytes = aes_ecb_encrypt(raw_bytes, aes_key_raw)
204
+
205
+ # filekey: arbitrary unique string (use hex of random bytes).
206
+ filekey = SecureRandom.hex(16)
207
+
208
+ # aeskey for getuploadurl: hex string of raw 16 bytes (32 hex chars), NOT base64.
209
+ # Confirmed from @tencent-weixin/openclaw-weixin source: aeskey.toString("hex")
210
+ aeskey_hex = aes_key_raw.unpack1("H*")
211
+
212
+ # aes_key for CDNMedia: base64 of the hex string as UTF-8 bytes.
213
+ # Confirmed: Buffer.from(aeskey_hex).toString("base64") in Node.js = base64 of hex string bytes
214
+ aeskey_b64 = Base64.strict_encode64(aeskey_hex)
215
+
216
+ raw_md5 = Digest::MD5.hexdigest(raw_bytes)
217
+
218
+ # Step 1: get CDN upload URL from iLink API.
219
+ upload_resp = post("getuploadurl", {
220
+ filekey: filekey,
221
+ media_type: media_type,
222
+ to_user_id: to_user_id,
223
+ rawsize: raw_bytes.bytesize,
224
+ rawfilemd5: raw_md5,
225
+ filesize: encrypted_bytes.bytesize,
226
+ aeskey: aeskey_hex,
227
+ no_need_thumb: true
228
+ })
229
+
230
+ upload_param = upload_resp["upload_param"]
231
+ Octo::Logger.debug("[WeixinApiClient] getuploadurl resp: #{upload_resp.to_json}")
232
+ raise ApiError.new(0, "getuploadurl: missing upload_param") unless upload_param
233
+
234
+ # Step 2: upload encrypted bytes to CDN.
235
+ download_param = cdn_upload(
236
+ upload_param: upload_param,
237
+ filekey: filekey,
238
+ encrypted_bytes: encrypted_bytes
239
+ )
240
+
241
+ # Return CDNMedia structure for use in sendmessage item_list.
242
+ # encrypt_type: 1 confirmed from @tencent-weixin/openclaw-weixin source.
243
+ {
244
+ encrypt_query_param: download_param,
245
+ aes_key: aeskey_b64,
246
+ encrypt_type: 1
247
+ }
248
+ end
249
+
250
+ # POST encrypted bytes to CDN. Returns the x-encrypted-param header value.
251
+ def cdn_upload(upload_param:, filekey:, encrypted_bytes:)
252
+ cdn_url = "#{CDN_BASE_URL}/upload" \
253
+ "?encrypted_query_param=#{URI.encode_uri_component(upload_param)}" \
254
+ "&filekey=#{URI.encode_uri_component(filekey)}"
255
+ uri = URI(cdn_url)
256
+
257
+ http = Net::HTTP.new(uri.host, uri.port)
258
+ http.use_ssl = true
259
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
260
+ http.read_timeout = API_TIMEOUT_S
261
+ http.open_timeout = 10
262
+
263
+ req = Net::HTTP::Post.new("#{uri.path}?#{uri.query}")
264
+ req["Content-Type"] = "application/octet-stream"
265
+ req["Content-Length"] = encrypted_bytes.bytesize.to_s
266
+ req.body = encrypted_bytes
267
+
268
+ Octo::Logger.debug("[WeixinApiClient] CDN upload #{encrypted_bytes.bytesize} bytes")
269
+
270
+ res = http.request(req)
271
+ raise ApiError.new(res.code.to_i, res.body.to_s.slice(0, 200)), "CDN upload HTTP #{res.code}" \
272
+ unless res.is_a?(Net::HTTPSuccess)
273
+
274
+ download_param = res["x-encrypted-param"]
275
+ raise ApiError.new(0, "CDN upload: missing x-encrypted-param header") unless download_param
276
+
277
+ download_param
278
+ end
279
+
280
+ # GET raw bytes from a CDN URL (no iLink auth headers needed for download).
281
+ def cdn_get(url)
282
+ uri = URI(url)
283
+ http = Net::HTTP.new(uri.host, uri.port)
284
+ http.use_ssl = true
285
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
286
+ http.read_timeout = API_TIMEOUT_S
287
+ http.open_timeout = 10
288
+
289
+ req = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
290
+ Octo::Logger.debug("[WeixinApiClient] CDN GET #{uri.host}#{uri.path}")
291
+
292
+ res = http.request(req)
293
+ raise ApiError.new(res.code.to_i, "CDN download HTTP #{res.code}") \
294
+ unless res.is_a?(Net::HTTPSuccess)
295
+
296
+ res.body.force_encoding("BINARY")
297
+ end
298
+
299
+ # Decrypt bytes with AES-128-ECB + PKCS7 unpadding using OpenSSL.
300
+ def aes_ecb_decrypt(data, key)
301
+ cipher = OpenSSL::Cipher.new("AES-128-ECB")
302
+ cipher.decrypt
303
+ cipher.key = key
304
+ cipher.update(data) + cipher.final
305
+ end
306
+
307
+ # Encrypt bytes with AES-128-ECB + PKCS7 padding using OpenSSL.
308
+ def aes_ecb_encrypt(data, key)
309
+ cipher = OpenSSL::Cipher.new("AES-128-ECB")
310
+ cipher.encrypt
311
+ cipher.key = key
312
+ cipher.update(data) + cipher.final
313
+ end
314
+
315
+ # Guess media_type from file extension.
316
+ def detect_media_type(file_name)
317
+ ext = File.extname(file_name).downcase
318
+ case ext
319
+ when ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"
320
+ MEDIA_TYPE_IMAGE
321
+ when ".mp4", ".mov", ".avi", ".mkv", ".flv"
322
+ MEDIA_TYPE_VIDEO
323
+ when ".mp3", ".m4a", ".amr", ".wav", ".ogg"
324
+ MEDIA_TYPE_VOICE
325
+ else
326
+ MEDIA_TYPE_FILE
327
+ end
328
+ end
329
+
330
+ # Build the item_list entry for sendmessage based on media type.
331
+ def build_media_item(media_type, cdn_media, raw_bytes, file_name)
332
+ case media_type
333
+ when MEDIA_TYPE_IMAGE
334
+ { type: 2, image_item: { media: cdn_media } }
335
+ when MEDIA_TYPE_VIDEO
336
+ { type: 5, video_item: { media: cdn_media } }
337
+ when MEDIA_TYPE_VOICE
338
+ { type: 3, voice_item: { media: cdn_media } }
339
+ else
340
+ {
341
+ type: 4,
342
+ file_item: {
343
+ media: cdn_media,
344
+ file_name: file_name,
345
+ md5: Digest::MD5.hexdigest(raw_bytes),
346
+ len: raw_bytes.bytesize.to_s
347
+ }
348
+ }
349
+ end
350
+ end
351
+
352
+ def post(endpoint, body_hash, timeout: API_TIMEOUT_S)
353
+ uri = URI("#{@base_url}/#{API_PATH_PREFIX}/#{endpoint}")
354
+ # All POST bodies must include base_info per iLink protocol spec.
355
+ body = JSON.generate(body_hash.merge(base_info: { channel_version: CHANNEL_VERSION }))
356
+
357
+ http = Net::HTTP.new(uri.host, uri.port)
358
+ http.use_ssl = uri.scheme == "https"
359
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
360
+ http.read_timeout = timeout
361
+ http.open_timeout = 10
362
+
363
+ req = Net::HTTP::Post.new(uri.path)
364
+ req["Content-Type"] = "application/json"
365
+ req["AuthorizationType"] = "ilink_bot_token"
366
+ req["Content-Length"] = body.bytesize.to_s
367
+ req["X-WECHAT-UIN"] = random_wechat_uin
368
+ req["Authorization"] = "Bearer #{@token}" unless @token.empty?
369
+ req.body = body
370
+
371
+ Octo::Logger.debug("[WeixinApiClient] POST #{endpoint}")
372
+
373
+ res = http.request(req)
374
+ raise ApiError.new(res.code.to_i, res.body), "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
375
+
376
+ raw_body = res.body
377
+ data = JSON.parse(raw_body)
378
+ ret = data["ret"] || data["errcode"]
379
+ if ret && ret != 0
380
+ # Include full response body for easier debugging (errmsg is often empty)
381
+ detail = data["errmsg"].to_s.strip
382
+ detail = raw_body.slice(0, 300) if detail.empty?
383
+ raise ApiError.new(ret, detail)
384
+ end
385
+
386
+ data
387
+ rescue Net::ReadTimeout, Net::OpenTimeout
388
+ raise TimeoutError, "#{endpoint} timed out"
389
+ rescue JSON::ParserError => e
390
+ raise ApiError.new(0, "Invalid JSON: #{e.message}")
391
+ end
392
+
393
+ # X-WECHAT-UIN: random uint32 → decimal string → base64
394
+ def random_wechat_uin
395
+ uint32 = SecureRandom.random_bytes(4).unpack1("N")
396
+ Base64.strict_encode64(uint32.to_s)
397
+ end
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Octo
8
+ # ChannelConfig manages IM platform credentials (Feishu, WeCom, etc.).
9
+ #
10
+ # Config is stored in ~/.octo/channels.yml:
11
+ #
12
+ # channels:
13
+ # feishu:
14
+ # enabled: true
15
+ # app_id: cli_xxx
16
+ # app_secret: xxx
17
+ # domain: https://open.feishu.cn
18
+ # allowed_users:
19
+ # - ou_xxx
20
+ # wecom:
21
+ # enabled: false
22
+ # bot_id: xxx
23
+ # secret: xxx
24
+ #
25
+ # This class is only responsible for platform credentials.
26
+ # working_dir and permission_mode live in AgentConfig.
27
+ class ChannelConfig
28
+ CONFIG_DIR = File.join(Dir.home, ".octo")
29
+ CONFIG_FILE = File.join(CONFIG_DIR, "channels.yml")
30
+
31
+ # @param channels [Hash<String, Hash>] string-keyed platform configs (raw from YAML)
32
+ def initialize(channels: {})
33
+ @channels = channels || {}
34
+ end
35
+
36
+ # Load from disk. Returns an empty instance if the file does not exist.
37
+ # @param config_file [String]
38
+ # @return [ChannelConfig]
39
+ def self.load(config_file = CONFIG_FILE)
40
+ if File.exist?(config_file)
41
+ data = YAMLCompat.safe_load(File.read(config_file), permitted_classes: [Symbol]) || {}
42
+ else
43
+ data = {}
44
+ end
45
+
46
+ new(channels: data["channels"] || {})
47
+ end
48
+
49
+ # Persist to disk.
50
+ # @param config_file [String]
51
+ def save(config_file = CONFIG_FILE)
52
+ FileUtils.mkdir_p(File.dirname(config_file))
53
+ File.write(config_file, to_yaml)
54
+ FileUtils.chmod(0o600, config_file)
55
+ end
56
+
57
+ # Serialize to YAML string.
58
+ # @return [String]
59
+ def to_yaml
60
+ YAML.dump({ "channels" => @channels })
61
+ end
62
+
63
+ # Returns true if at least one channel is enabled.
64
+ def any_enabled?
65
+ @channels.any? { |_, cfg| cfg["enabled"] }
66
+ end
67
+
68
+ # Returns the list of enabled platform symbols.
69
+ # @return [Array<Symbol>]
70
+ def enabled_platforms
71
+ @channels
72
+ .select { |_, cfg| cfg["enabled"] }
73
+ .keys
74
+ .map(&:to_sym)
75
+ end
76
+
77
+ # Returns true if the given platform is configured and enabled.
78
+ # @param platform [Symbol, String]
79
+ def enabled?(platform)
80
+ cfg = @channels[platform.to_s]
81
+ cfg && cfg["enabled"]
82
+ end
83
+
84
+ # Return the symbol-keyed config hash expected by each adapter's initializer.
85
+ # Returns nil if the platform is not configured.
86
+ #
87
+ # @param platform [Symbol, String]
88
+ # @return [Hash, nil]
89
+ def platform_config(platform)
90
+ raw = @channels[platform.to_s]
91
+ return nil unless raw
92
+
93
+ case platform.to_sym
94
+ when :feishu
95
+ {
96
+ app_id: raw["app_id"],
97
+ app_secret: raw["app_secret"],
98
+ domain: raw["domain"],
99
+ allowed_users: raw["allowed_users"]
100
+ }.compact
101
+ when :wecom
102
+ {
103
+ bot_id: raw["bot_id"],
104
+ secret: raw["secret"]
105
+ }.compact
106
+ when :weixin
107
+ {
108
+ token: raw["token"],
109
+ base_url: raw["base_url"],
110
+ allowed_users: raw["allowed_users"]
111
+ }.compact
112
+ when :discord
113
+ {
114
+ bot_token: raw["bot_token"]
115
+ }.compact
116
+ when :dingtalk
117
+ {
118
+ client_id: raw["client_id"],
119
+ client_secret: raw["client_secret"],
120
+ allowed_users: raw["allowed_users"]
121
+ }.compact
122
+ when :telegram
123
+ {
124
+ bot_token: raw["bot_token"],
125
+ base_url: raw["base_url"],
126
+ parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
127
+ allowed_users: raw["allowed_users"]
128
+ }.compact
129
+ else
130
+ # Unknown platform — pass all non-meta keys as symbol-keyed hash
131
+ raw.reject { |k, _| k == "enabled" }
132
+ .transform_keys(&:to_sym)
133
+ end
134
+ end
135
+
136
+ # Set or update a platform's credentials.
137
+ # Merges provided fields into the existing entry.
138
+ # Automatically sets enabled: true unless explicitly provided.
139
+ #
140
+ # @param platform [Symbol, String]
141
+ # @param fields [Hash] symbol-keyed credential fields
142
+ def set_platform(platform, **fields)
143
+ key = platform.to_s
144
+ @channels[key] ||= {}
145
+ fields.each { |k, v| @channels[key][k.to_s] = v }
146
+ @channels[key]["enabled"] = true unless @channels[key].key?("enabled")
147
+ end
148
+
149
+ # Enable a platform (requires it to already be configured).
150
+ # @param platform [Symbol, String]
151
+ # @raise [ArgumentError] if the platform has no stored credentials yet.
152
+ def enable_platform(platform)
153
+ key = platform.to_s
154
+ raise ArgumentError, "Platform #{platform} is not configured" unless @channels.key?(key)
155
+ @channels[key]["enabled"] = true
156
+ end
157
+
158
+ # Disable a platform (keeps credentials, just sets enabled: false).
159
+ # @param platform [Symbol, String]
160
+ def disable_platform(platform)
161
+ key = platform.to_s
162
+ return unless @channels.key?(key)
163
+ @channels[key]["enabled"] = false
164
+ end
165
+
166
+ # Remove a platform entry entirely.
167
+ # @param platform [Symbol, String]
168
+ def remove_platform(platform)
169
+ @channels.delete(platform.to_s)
170
+ end
171
+
172
+ # Deep copy — prevents callers from mutating shared config state.
173
+ # @return [ChannelConfig]
174
+ def deep_copy
175
+ self.class.new(channels: JSON.parse(JSON.generate(@channels)))
176
+ end
177
+ end
178
+ end