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,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+
7
+ module Octo
8
+ module Channel
9
+ module Adapters
10
+ module Feishu
11
+ # Raised when the app lacks read permission for a specific Feishu document (error code 91403).
12
+ # The user needs to add the app as a collaborator on the document.
13
+ class FeishuDocPermissionError < StandardError
14
+ attr_reader :doc_token
15
+
16
+ def initialize(doc_token)
17
+ @doc_token = doc_token
18
+ super("App has no permission to access document: #{doc_token}")
19
+ end
20
+ end
21
+
22
+ # Raised when the app hasn't been granted the API scope for documents (error code 99991672).
23
+ # The admin needs to approve the scope via the returned auth_url.
24
+ class FeishuDocScopeError < StandardError
25
+ attr_reader :auth_url
26
+
27
+ def initialize(auth_url)
28
+ @auth_url = auth_url
29
+ super("App is missing docx API scope")
30
+ end
31
+ end
32
+
33
+ # Feishu Bot API client.
34
+ # Handles authentication, message sending, and API calls.
35
+ class Bot
36
+ API_TIMEOUT = 10
37
+ DOWNLOAD_TIMEOUT = 60
38
+
39
+ def initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN)
40
+ @app_id = app_id
41
+ @app_secret = app_secret
42
+ @domain = domain
43
+ @token_cache = nil
44
+ @token_expires_at = nil
45
+ end
46
+
47
+ # Send plain text message
48
+ # @param chat_id [String] Chat ID (open_chat_id)
49
+ # @param text [String] Message text
50
+ # @param reply_to [String, nil] Message ID to reply to
51
+ # @return [Hash] Response with :message_id
52
+ def send_text(chat_id, text, reply_to: nil)
53
+ content, msg_type = build_message_payload(text)
54
+ payload = {
55
+ receive_id: chat_id,
56
+ msg_type: msg_type,
57
+ content: content
58
+ }
59
+ payload[:reply_to_message_id] = reply_to if reply_to
60
+
61
+ response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" })
62
+
63
+ code = response["code"]
64
+ if code != 0
65
+ Octo::Logger.warn("[feishu] send_text failed",
66
+ code: code, msg: response["msg"],
67
+ chat_id: chat_id, msg_type: msg_type)
68
+ end
69
+
70
+ { message_id: response.dig("data", "message_id") }
71
+ end
72
+
73
+ # Update an existing message
74
+ # @param message_id [String] Message ID to update
75
+ # @param text [String] New text content
76
+ # @return [Boolean] Success status
77
+ def update_message(message_id, text)
78
+ content, msg_type = build_message_payload(text)
79
+ payload = {
80
+ msg_type: msg_type,
81
+ content: content
82
+ }
83
+
84
+ response = patch("/open-apis/im/v1/messages/#{message_id}", payload)
85
+ response["code"] == 0
86
+ rescue => e
87
+ Octo::Logger.warn("[feishu] Failed to update message: #{e.message}")
88
+ false
89
+ end
90
+
91
+ # Upload a local file to Feishu and send it to a chat.
92
+ # Images use /im/v1/images + msg_type "image".
93
+ # All other files use /im/v1/files + msg_type "file".
94
+ # @param chat_id [String] Chat ID
95
+ # @param path [String] Local file path
96
+ # @param name [String, nil] Display filename
97
+ # @param reply_to [String, nil] Message ID to reply to
98
+ # @return [Hash] Response with :message_id
99
+ def send_file(chat_id, path, name: nil, reply_to: nil)
100
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
101
+
102
+ # Always derive filename from the real path for type detection and upload.
103
+ # The `name` param (often markdown alt text) may lack an extension,
104
+ # causing images to be mis-detected as generic files.
105
+ filename = File.basename(path)
106
+ file_data = File.binread(path)
107
+ ext = File.extname(filename).downcase
108
+
109
+ if %w[.jpg .jpeg .png .gif .webp].include?(ext)
110
+ image_key = upload_image(file_data, filename)
111
+ content = JSON.generate({ image_key: image_key })
112
+ msg_type = "image"
113
+ else
114
+ file_key = upload_file(file_data, filename)
115
+ content = JSON.generate({ file_key: file_key })
116
+ msg_type = "file"
117
+ end
118
+
119
+ payload = { receive_id: chat_id, msg_type: msg_type, content: content }
120
+ payload[:reply_to_message_id] = reply_to if reply_to
121
+
122
+ response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" })
123
+ { message_id: response.dig("data", "message_id") }
124
+ end
125
+
126
+ # Download a message resource (image or file) from Feishu.
127
+ # For message attachments, must use messageResource API — not im/v1/images.
128
+ # @param message_id [String] Message ID containing the resource
129
+ # @param file_key [String] Resource key (image_key or file_key from message content)
130
+ # @param type [String] "image" or "file"
131
+ # @return [Hash] { body: String, content_type: String }
132
+ def download_message_resource(message_id, file_key, type: "image")
133
+ conn = Faraday.new(url: @domain) do |f|
134
+ f.options.timeout = DOWNLOAD_TIMEOUT
135
+ f.options.open_timeout = API_TIMEOUT
136
+ f.ssl.verify = false
137
+ f.adapter Faraday.default_adapter
138
+ end
139
+ response = conn.get("/open-apis/im/v1/messages/#{message_id}/resources/#{file_key}") do |req|
140
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
141
+ req.params["type"] = type
142
+ end
143
+
144
+ unless response.success?
145
+ raise "Failed to download message resource: HTTP #{response.status}"
146
+ end
147
+
148
+ {
149
+ body: response.body,
150
+ content_type: response.headers["content-type"].to_s.split(";").first.strip
151
+ }
152
+ end
153
+
154
+ # Fetch the plain-text content of a Feishu document (docx / docs / wiki).
155
+ # Raises FeishuDocPermissionError (code 91403) when the app has no access.
156
+ # @param url [String] Feishu document URL
157
+ # @return [String] Document plain text
158
+ def fetch_doc_content(url)
159
+ doc_token, doc_type = parse_doc_url(url)
160
+ raise ArgumentError, "Unsupported Feishu doc URL: #{url}" unless doc_token
161
+
162
+ if doc_type == :wiki
163
+ # Wiki: first resolve the real docToken via get_node
164
+ node = fetch_wiki_node(doc_token)
165
+ actual_token = node["obj_token"]
166
+ actual_type = node["obj_type"] # "docx" / "doc" / etc.
167
+ raise "Unsupported wiki node type: #{actual_type}" unless %w[docx doc].include?(actual_type)
168
+ fetch_docx_raw_content(actual_token)
169
+ else
170
+ fetch_docx_raw_content(doc_token)
171
+ end
172
+ end
173
+
174
+
175
+ # Build message content and type based on text content.
176
+ # Uses interactive card (schema 2.0) for code blocks and tables,
177
+ # post/md for everything else.
178
+ # @param text [String]
179
+ # @return [Array<String, String>] [content_json, msg_type]
180
+ def build_message_payload(text)
181
+ if has_code_block_or_table?(text)
182
+ safe_text = sanitize_images_for_card(text)
183
+ content = JSON.generate({
184
+ schema: "2.0",
185
+ config: { wide_screen_mode: true },
186
+ body: { elements: [{ tag: "markdown", content: safe_text }] }
187
+ })
188
+ [content, "interactive"]
189
+ else
190
+ safe_text = sanitize_images_for_card(text)
191
+ content = JSON.generate({
192
+ zh_cn: { content: [[{ tag: "md", text: safe_text }]] }
193
+ })
194
+ [content, "post"]
195
+ end
196
+ end
197
+
198
+ def has_code_block_or_table?(text)
199
+ text.match?(/```[\s\S]*?```/) || text.match?(/\|.+\|[\r\n]+\|[-:| ]+\|/)
200
+ end
201
+
202
+ # Convert Markdown image syntax ![alt](url) to plain links [alt](url)
203
+ # inside interactive card content. Feishu interactive cards do NOT
204
+ # support image markdown — sending it triggers error 230099 and the
205
+ # entire message is silently dropped.
206
+ #
207
+ # Code blocks are preserved untouched (images inside ``` fences are
208
+ # left as-is since they are literal text, not rendered markdown).
209
+ #
210
+ # This is a pure function with no side effects — thread-safe by design.
211
+ # @param text [String] raw markdown text
212
+ # @return [String] sanitised text safe for interactive cards
213
+ def sanitize_images_for_card(text)
214
+ # Split on code fences to avoid transforming inside code blocks
215
+ parts = text.split(/(```[\s\S]*?```)/)
216
+ parts.map { |segment|
217
+ if segment.start_with?("```")
218
+ segment # code block — leave untouched
219
+ else
220
+ # ![alt](url) → [alt](url) (drop the leading !)
221
+ segment.gsub(/!\[([^\]]*)\]\(([^)]+)\)/) do
222
+ alt, url = Regexp.last_match(1), Regexp.last_match(2)
223
+ alt.empty? ? url : "[#{alt}](#{url})"
224
+ end
225
+ end
226
+ }.join
227
+ end
228
+
229
+ # Get this bot's own open_id (cached, fetched lazily on first use).
230
+ # Used to detect @bot mentions in group chats.
231
+ # @return [String, nil] bot open_id, or nil if the API call fails
232
+ def bot_open_id
233
+ @bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
234
+ rescue => e
235
+ Octo::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
236
+ nil
237
+ end
238
+
239
+ # Get tenant access token (cached)
240
+ # @return [String] Access token
241
+ def tenant_access_token
242
+ return @token_cache if @token_cache && @token_expires_at && Time.now < @token_expires_at
243
+
244
+ response = post_without_auth("/open-apis/auth/v3/tenant_access_token/internal", {
245
+ app_id: @app_id,
246
+ app_secret: @app_secret
247
+ })
248
+
249
+ raise "Failed to get tenant access token: #{response['msg']}" if response["code"] != 0
250
+
251
+ @token_cache = response["tenant_access_token"]
252
+ # Token expires in 2 hours, refresh 5 minutes early
253
+ @token_expires_at = Time.now + (2 * 60 * 60 - 5 * 60)
254
+ @token_cache
255
+ end
256
+
257
+ # Make authenticated GET request
258
+ # @param path [String] API path
259
+ # @param params [Hash] Query parameters
260
+ # @return [Hash] Parsed response
261
+ def get(path, params: {})
262
+ conn = build_connection
263
+ response = conn.get(path) do |req|
264
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
265
+ req.params.update(params)
266
+ end
267
+
268
+ parse_response(response)
269
+ end
270
+
271
+ # Make authenticated POST request
272
+ # @param path [String] API path
273
+ # @param body [Hash] Request body
274
+ # @param params [Hash] Query parameters
275
+ # @return [Hash] Parsed response
276
+ def post(path, body, params: {})
277
+ conn = build_connection
278
+ response = conn.post(path) do |req|
279
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
280
+ req.headers["Content-Type"] = "application/json"
281
+ req.params.update(params)
282
+ req.body = JSON.generate(body)
283
+ end
284
+
285
+ parse_response(response)
286
+ end
287
+
288
+ # Make authenticated PATCH request
289
+ # @param path [String] API path
290
+ # @param body [Hash] Request body
291
+ # @return [Hash] Parsed response
292
+ def patch(path, body)
293
+ conn = build_connection
294
+ response = conn.patch(path) do |req|
295
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
296
+ req.headers["Content-Type"] = "application/json"
297
+ req.body = JSON.generate(body)
298
+ end
299
+
300
+ parse_response(response)
301
+ end
302
+
303
+ # Make POST request without authentication (for token endpoint)
304
+ # @param path [String] API path
305
+ # @param body [Hash] Request body
306
+ # @return [Hash] Parsed response
307
+ def post_without_auth(path, body)
308
+ conn = build_connection
309
+ response = conn.post(path) do |req|
310
+ req.headers["Content-Type"] = "application/json"
311
+ req.body = JSON.generate(body)
312
+ end
313
+
314
+ parse_response(response)
315
+ end
316
+
317
+ # Upload an image to Feishu and return image_key.
318
+ # @param data [String] Binary file content
319
+ # @param filename [String] Display filename
320
+ # @return [String] image_key
321
+ def upload_image(data, filename)
322
+ conn = Faraday.new(url: @domain) do |f|
323
+ f.options.timeout = DOWNLOAD_TIMEOUT
324
+ f.options.open_timeout = API_TIMEOUT
325
+ f.ssl.verify = false
326
+ f.request :multipart
327
+ f.adapter Faraday.default_adapter
328
+ end
329
+
330
+ response = conn.post("/open-apis/im/v1/images") do |req|
331
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
332
+ req.body = {
333
+ image_type: "message",
334
+ image: Faraday::Multipart::FilePart.new(
335
+ StringIO.new(data), detect_mime(filename), filename
336
+ )
337
+ }
338
+ end
339
+
340
+ result = JSON.parse(response.body)
341
+ raise "Failed to upload image: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0
342
+
343
+ result.dig("data", "image_key") or raise "No image_key returned"
344
+ end
345
+
346
+ # Upload a file to Feishu and return file_key.
347
+ # @param data [String] Binary file content
348
+ # @param filename [String] Display filename
349
+ # @return [String] file_key
350
+ def upload_file(data, filename)
351
+ conn = Faraday.new(url: @domain) do |f|
352
+ f.options.timeout = DOWNLOAD_TIMEOUT
353
+ f.options.open_timeout = API_TIMEOUT
354
+ f.ssl.verify = false
355
+ f.request :multipart
356
+ f.adapter Faraday.default_adapter
357
+ end
358
+
359
+ response = conn.post("/open-apis/im/v1/files") do |req|
360
+ req.headers["Authorization"] = "Bearer #{tenant_access_token}"
361
+ req.body = {
362
+ file_type: feishu_file_type(filename),
363
+ file_name: filename,
364
+ file: Faraday::Multipart::FilePart.new(
365
+ StringIO.new(data), detect_mime(filename), filename
366
+ )
367
+ }
368
+ end
369
+
370
+ result = JSON.parse(response.body)
371
+ raise "Failed to upload file: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0
372
+
373
+ result.dig("data", "file_key") or raise "No file_key returned"
374
+ end
375
+
376
+ # Map file extension to Feishu file_type enum.
377
+ # Feishu accepts: opus, mp4, pdf, doc, xls, ppt, stream (others)
378
+ def feishu_file_type(filename)
379
+ case File.extname(filename).downcase
380
+ when ".pdf" then "pdf"
381
+ when ".doc", ".docx" then "doc"
382
+ when ".xls", ".xlsx" then "xls"
383
+ when ".ppt", ".pptx" then "ppt"
384
+ when ".mp4" then "mp4"
385
+ when ".opus" then "opus"
386
+ else "stream"
387
+ end
388
+ end
389
+
390
+ # Detect MIME type from filename extension.
391
+ def detect_mime(filename)
392
+ case File.extname(filename).downcase
393
+ when ".jpg", ".jpeg" then "image/jpeg"
394
+ when ".png" then "image/png"
395
+ when ".gif" then "image/gif"
396
+ when ".webp" then "image/webp"
397
+ when ".pdf" then "application/pdf"
398
+ when ".mp4" then "video/mp4"
399
+ else "application/octet-stream"
400
+ end
401
+ end
402
+
403
+ # Parse Feishu doc URL and return [doc_token, type]
404
+ # type is :docx, :docs, or :wiki
405
+ # @param url [String]
406
+ # @return [Array<String, Symbol>, nil]
407
+ def parse_doc_url(url)
408
+ if (m = url.match(%r{/(?:docx|docs)/([A-Za-z0-9_-]+)}))
409
+ [m[1], :docx]
410
+ elsif (m = url.match(%r{/wiki/([A-Za-z0-9_-]+)}))
411
+ [m[1], :wiki]
412
+ end
413
+ end
414
+
415
+ # Fetch raw text content of a docx document.
416
+ # Raises FeishuDocPermissionError on 91403.
417
+ # @param doc_token [String]
418
+ # @return [String]
419
+ def fetch_docx_raw_content(doc_token)
420
+ response = get("/open-apis/docx/v1/documents/#{doc_token}/raw_content")
421
+ check_doc_error!(response, doc_token)
422
+ response.dig("data", "content").to_s.strip
423
+ end
424
+
425
+ # Resolve wiki node to get real obj_token and obj_type.
426
+ # @param wiki_token [String]
427
+ # @return [Hash] node data with "obj_token" and "obj_type"
428
+ def fetch_wiki_node(wiki_token)
429
+ response = get("/open-apis/wiki/v2/spaces/get_node", params: { token: wiki_token, obj_type: "wiki" })
430
+ check_doc_error!(response, wiki_token)
431
+ response.dig("data", "node") or raise "No node in wiki response"
432
+ end
433
+
434
+ # Check doc API response for known permission errors and raise accordingly.
435
+ def check_doc_error!(response, token)
436
+ code = response["code"].to_i
437
+ return if code == 0
438
+
439
+ if code == 91403
440
+ raise FeishuDocPermissionError, token
441
+ elsif code == 99991672
442
+ # Extract auth URL from the error message if present
443
+ auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
444
+ response["msg"].to_s[/https:\/\/open\.feishu\.cn\/app\/[^\s"]+/]
445
+ raise FeishuDocScopeError.new(auth_url)
446
+ else
447
+ raise "Failed to fetch doc: code=#{code} msg=#{response["msg"]}"
448
+ end
449
+ end
450
+
451
+ # Build Faraday connection
452
+ # @return [Faraday::Connection]
453
+ def build_connection
454
+ Faraday.new(url: @domain) do |f|
455
+ f.options.timeout = API_TIMEOUT
456
+ f.options.open_timeout = API_TIMEOUT
457
+ f.ssl.verify = false
458
+ f.adapter Faraday.default_adapter
459
+ end
460
+ end
461
+
462
+ # Parse API response
463
+ # @param response [Faraday::Response]
464
+ # @return [Hash] Parsed JSON
465
+ def parse_response(response)
466
+ # Feishu returns JSON even on 4xx — parse it so callers can inspect error codes
467
+ parsed = JSON.parse(response.body)
468
+ return parsed if response.success? || parsed.key?("code")
469
+
470
+ raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}"
471
+ rescue JSON::ParserError
472
+ raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}"
473
+ end
474
+ end
475
+ end
476
+ end
477
+ end
478
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octo/utils/file_processor"
4
+
5
+ module Octo
6
+ module Channel
7
+ module Adapters
8
+ module Feishu
9
+ # Processes file attachments downloaded from Feishu messages.
10
+ # Delegates to the unified Octo::Utils::FileProcessor pipeline:
11
+ # - saves original file to disk
12
+ # - generates structured Markdown preview for Office/ZIP files
13
+ # - returns a formatted prompt snippet describing both paths
14
+ module FileProcessor
15
+ MAX_FILE_BYTES = Octo::Utils::FileProcessor::MAX_FILE_BYTES
16
+
17
+ # Process a downloaded file and return a text snippet for the agent prompt.
18
+ # @param body [String] Raw file bytes
19
+ # @param file_name [String] Original file name
20
+ # @return [String] Text to inject into the prompt
21
+ def self.process(body, file_name)
22
+ if body.bytesize > MAX_FILE_BYTES
23
+ return "[Attachment: #{file_name}]\nFile too large " \
24
+ "(#{body.bytesize / 1024 / 1024}MB, max #{MAX_FILE_BYTES / 1024 / 1024}MB)."
25
+ end
26
+
27
+ ref = Octo::Utils::FileProcessor.process(body: body, filename: file_name)
28
+ ref.to_prompt
29
+ rescue => e
30
+ "[Attachment: #{file_name}]\n(Processing failed: #{e.message})"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Octo
6
+ module Channel
7
+ module Adapters
8
+ module Feishu
9
+ # Parses incoming Feishu webhook events into a standardized InboundMessage format.
10
+ class MessageParser
11
+ # Parse a Feishu webhook event body
12
+ # @param body [String, Hash] Raw webhook body
13
+ # @return [Hash, nil] Standardized inbound message, or nil if not a message event
14
+ def self.parse(body)
15
+ data = body.is_a?(Hash) ? body : JSON.parse(body)
16
+ new(data).parse
17
+ rescue JSON::ParserError
18
+ nil
19
+ end
20
+
21
+ def initialize(data)
22
+ @data = data
23
+ end
24
+
25
+ # @return [Hash, nil] Inbound message or nil
26
+ def parse
27
+ # Handle verification challenge
28
+ if @data["type"] == "url_verification"
29
+ return { type: :challenge, challenge: @data["challenge"] }
30
+ end
31
+
32
+ header = @data["header"]
33
+ return nil unless header
34
+
35
+ event_type = header["event_type"]
36
+
37
+ case event_type
38
+ when "im.message.receive_v1"
39
+ parse_message_event
40
+ else
41
+ nil
42
+ end
43
+ end
44
+
45
+
46
+ # Parse message.receive event
47
+ # @return [Hash, nil]
48
+ def parse_message_event
49
+ event = @data["event"]
50
+ return nil unless event
51
+
52
+ message = event["message"]
53
+ sender = event["sender"]
54
+ return nil unless message && sender
55
+
56
+ msg_type = message["message_type"]
57
+ Octo::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
58
+ return nil unless %w[text image file].include?(msg_type)
59
+
60
+ content_raw = message["content"]
61
+ return nil unless content_raw
62
+
63
+ content = JSON.parse(content_raw)
64
+ text = ""
65
+ image_keys = []
66
+ file_attachments = []
67
+
68
+ case msg_type
69
+ when "text"
70
+ text = strip_mentions(content["text"].to_s.strip)
71
+ return nil if text.empty?
72
+ when "image"
73
+ image_keys = [content["image_key"]].compact
74
+ return nil if image_keys.empty?
75
+ when "file"
76
+ file_key = content["file_key"]
77
+ file_name = content["file_name"]
78
+ return nil unless file_key
79
+ file_attachments = [{ key: file_key, name: file_name.to_s }]
80
+ end
81
+
82
+ chat_id = message["chat_id"]
83
+ message_id = message["message_id"]
84
+ user_id = sender.dig("sender_id", "open_id")
85
+ chat_type = message["chat_type"] == "p2p" ? :direct : :group
86
+ create_time = message["create_time"]&.to_i
87
+ timestamp = create_time ? Time.at(create_time / 1000.0) : Time.now
88
+
89
+ {
90
+ type: :message,
91
+ platform: :feishu,
92
+ chat_id: chat_id,
93
+ user_id: user_id,
94
+ text: text,
95
+ image_keys: image_keys,
96
+ file_attachments: file_attachments,
97
+ doc_urls: extract_doc_urls(text),
98
+ message_id: message_id,
99
+ timestamp: timestamp,
100
+ chat_type: chat_type,
101
+ mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
102
+ raw: @data
103
+ }
104
+ rescue JSON::ParserError
105
+ nil
106
+ end
107
+
108
+ # Extract Feishu document URLs from text.
109
+ # Matches: /docx/TOKEN, /docs/TOKEN, /wiki/TOKEN
110
+ # @param text [String]
111
+ # @return [Array<String>] matched URLs
112
+ def extract_doc_urls(text)
113
+ return [] if text.nil? || text.empty?
114
+
115
+ text.scan(%r{https?://[a-zA-Z0-9._-]+\.(?:feishu\.cn|larksuite\.com)/(?:docx|docs|wiki)/[A-Za-z0-9_-]+(?:\?[^\s]*)?})
116
+ end
117
+
118
+ # Strip bot @mentions from message text
119
+ # @param text [String]
120
+ # @return [String]
121
+ def strip_mentions(text)
122
+ # Feishu mentions are formatted as <at user_id="...">Name</at>
123
+ text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end