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,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Octo
9
+ module Channel
10
+ module Adapters
11
+ module Feishu
12
+ # WebSocket client for Feishu long connection mode.
13
+ # Feishu uses protobuf-encoded binary frames (pbbp2.Frame) over WebSocket.
14
+ # Frame fields: SeqID(1), LogID(2), service(3), method(4), headers(5),
15
+ # payloadType(7), payload(8), LogIDNew(9)
16
+ # method=0 → control (ping/pong/handshake), method=1 → data (event)
17
+ class WSClient
18
+ RECONNECT_DELAY = 5 # seconds
19
+
20
+ def initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN)
21
+ @app_id = app_id
22
+ @app_secret = app_secret
23
+ @domain = domain
24
+ @running = false
25
+ @ws = nil
26
+ @ping_thread = nil
27
+ @ping_interval = 90 # overridden by server config
28
+ @seq_id = 0
29
+ @service_id = 0
30
+ end
31
+
32
+ def start(&on_event)
33
+ @running = true
34
+ @on_event = on_event
35
+ Octo::Logger.info("[feishu-ws] Starting WebSocket client (app_id=#{@app_id})")
36
+
37
+ while @running
38
+ begin
39
+ connect_and_listen
40
+ rescue => e
41
+ Octo::Logger.warn("[feishu-ws] Connection error: #{e.message}")
42
+ sleep RECONNECT_DELAY if @running
43
+ end
44
+ end
45
+ end
46
+
47
+ def stop
48
+ @running = false
49
+ @ping_thread&.kill
50
+ send_raw_frame(:close, "") rescue nil
51
+ @ws_socket&.close rescue nil
52
+ end
53
+
54
+
55
+ # Timeout for IO.select on the read loop. Feishu server sends pings every
56
+ # @ping_interval seconds (default 90s). Allow two missed pings before
57
+ # treating the connection as dead.
58
+ READ_TIMEOUT_MULTIPLIER = 2.5
59
+
60
+ def connect_and_listen
61
+ Octo::Logger.info("[feishu-ws] Fetching WebSocket endpoint...")
62
+ endpoint = fetch_ws_endpoint
63
+ Octo::Logger.info("[feishu-ws] Connecting to #{endpoint.split("?").first}")
64
+ uri = URI.parse(endpoint)
65
+
66
+ port = uri.port || (uri.scheme == "wss" ? 443 : 80)
67
+ tcp = TCPSocket.new(uri.host, port)
68
+
69
+ socket = if uri.scheme == "wss"
70
+ require "openssl"
71
+ ssl_context = OpenSSL::SSL::SSLContext.new
72
+ ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
73
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
74
+ ssl.sync_close = true
75
+ ssl.connect
76
+ ssl
77
+ else
78
+ tcp
79
+ end
80
+
81
+ # WebSocket handshake
82
+ handshake = WebSocket::Handshake::Client.new(url: endpoint)
83
+ socket.write(handshake.to_s)
84
+
85
+ # Read until handshake complete
86
+ until handshake.finished?
87
+ handshake << socket.readpartial(4096)
88
+ end
89
+ raise "WebSocket handshake failed" unless handshake.valid?
90
+
91
+ Octo::Logger.info("[feishu-ws] WebSocket connected")
92
+ @ws_version = handshake.version
93
+ @ws_socket = socket
94
+ @ws_open = true
95
+ @incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
96
+
97
+ start_ping_thread
98
+
99
+ # read_timeout is based on the server-provided ping interval so it
100
+ # automatically adapts if Feishu changes the cadence.
101
+ read_timeout = (@ping_interval * READ_TIMEOUT_MULTIPLIER).ceil
102
+
103
+ loop do
104
+ break unless @running
105
+
106
+ # Use IO.select with a timeout to detect silent connection drops
107
+ # (NAT expiry, firewall idle-kill) that never send a TCP FIN/RST.
108
+ ready = IO.select([socket], nil, nil, read_timeout)
109
+ unless ready
110
+ Octo::Logger.warn("[feishu-ws] read timeout (#{read_timeout}s), reconnecting...")
111
+ return
112
+ end
113
+
114
+ data = socket.read_nonblock(4096)
115
+ @incoming << data
116
+ while (frame = @incoming.next)
117
+ case frame.type
118
+ when :binary
119
+ raw = frame.data
120
+ handle_frame(raw.respond_to?(:b) ? raw.b : raw)
121
+ when :text
122
+ handle_frame(frame.data)
123
+ when :ping
124
+ send_raw_frame(:pong, frame.data)
125
+ when :close
126
+ Octo::Logger.info("[feishu-ws] WebSocket closed by server, will reconnect")
127
+ return
128
+ end
129
+ end
130
+ end
131
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
132
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
133
+ # Let the exception bubble up to start() where it will log and sleep before retry
134
+ raise
135
+ ensure
136
+ @ws_open = false
137
+ @ws_socket = nil
138
+ socket&.close rescue nil
139
+ @ping_thread&.kill
140
+ end
141
+
142
+ def fetch_ws_endpoint
143
+ uri = URI.parse("#{@domain}/callback/ws/endpoint")
144
+ http = Net::HTTP.new(uri.host, uri.port)
145
+ http.use_ssl = uri.scheme == "https"
146
+
147
+ request = Net::HTTP::Post.new(uri.path)
148
+ request["Content-Type"] = "application/json"
149
+ request["locale"] = "en"
150
+ request.body = JSON.generate({ AppID: @app_id, AppSecret: @app_secret })
151
+
152
+ response = http.request(request)
153
+ data = JSON.parse(response.body)
154
+
155
+ if data["code"] != 0
156
+ Octo::Logger.warn("[feishu-ws] Failed to get endpoint: code=#{data["code"]} msg=#{data["msg"]}")
157
+ raise "Failed to get WebSocket endpoint: #{data['msg']}"
158
+ end
159
+
160
+ client_config = data.dig("data", "ClientConfig") || {}
161
+ @ping_interval = (client_config["PingInterval"] || 90).to_i
162
+
163
+ url = data.dig("data", "URL")
164
+ if url.nil? || url.strip.empty?
165
+ Octo::Logger.error("[feishu-ws] WebSocket endpoint URL is missing from response. " \
166
+ "Please verify your Feishu App ID and App Secret are correct.")
167
+ raise "Failed to get WebSocket endpoint: URL is missing (check your Feishu App ID / App Secret)"
168
+ end
169
+
170
+ if url =~ /service_id=(\d+)/
171
+ @service_id = $1.to_i
172
+ end
173
+ url
174
+ end
175
+
176
+ # Parse and dispatch a Feishu protobuf binary frame
177
+ def handle_frame(raw)
178
+ raw = raw.b if raw.respond_to?(:b)
179
+ frame = ProtoFrame.decode(raw)
180
+
181
+ method_type = frame[:method]
182
+ headers = frame[:headers] || {}
183
+
184
+ case method_type
185
+ when 0 # control frame
186
+ handle_control_frame(frame, headers["type"])
187
+ when 1 # data frame (event)
188
+ Octo::Logger.info("[feishu-ws] Received data frame (type=#{headers["type"]})")
189
+ handle_data_frame(frame, headers)
190
+ end
191
+ rescue => e
192
+ Octo::Logger.warn("[feishu-ws] Failed to handle frame: #{e.message}")
193
+ end
194
+
195
+ def handle_control_frame(frame, msg_type)
196
+ case msg_type
197
+ when "ping"
198
+ send_frame(
199
+ seq_id: frame[:seq_id],
200
+ log_id: frame[:log_id],
201
+ service: frame[:service],
202
+ method: 0,
203
+ headers: frame[:headers].merge("type" => "pong")
204
+ )
205
+ when "handshake"
206
+ status = frame[:headers]["handshake-status"]
207
+ if status == "200"
208
+ Octo::Logger.info("[feishu-ws] Handshake successful")
209
+ else
210
+ Octo::Logger.warn("[feishu-ws] Handshake failed: #{frame[:headers]['handshake-msg']}")
211
+ end
212
+ end
213
+ end
214
+
215
+ def handle_data_frame(frame, headers)
216
+ return unless headers["type"] == "event"
217
+
218
+ payload_bytes = frame[:payload]
219
+ return unless payload_bytes && !payload_bytes.empty?
220
+
221
+ event_json = payload_bytes.force_encoding("UTF-8")
222
+ event_data = JSON.parse(event_json)
223
+
224
+ # Send ACK response
225
+ send_frame(
226
+ seq_id: frame[:seq_id],
227
+ log_id: frame[:log_id],
228
+ service: frame[:service],
229
+ method: 1,
230
+ headers: frame[:headers],
231
+ payload: JSON.generate({ code: 200 })
232
+ )
233
+
234
+ event_type = event_data.dig("header", "event_type") || event_data["type"]
235
+ Octo::Logger.info("[feishu-ws] Dispatching event: #{event_type}")
236
+ @on_event&.call(event_data)
237
+ rescue JSON::ParserError => e
238
+ Octo::Logger.warn("[feishu-ws] Failed to parse event payload: #{e.message}")
239
+ end
240
+
241
+ def send_frame(seq_id:, log_id:, service:, method:, headers:, payload: nil)
242
+ frame = {
243
+ seq_id: seq_id,
244
+ log_id: log_id,
245
+ service: service,
246
+ method: method,
247
+ headers: headers,
248
+ payload: payload
249
+ }
250
+ encoded = ProtoFrame.encode(frame)
251
+ send_raw_frame(:binary, encoded)
252
+ rescue => e
253
+ warn "[feishu-ws] failed to send frame: #{e.message}"
254
+ end
255
+
256
+ def send_raw_frame(type, data)
257
+ return unless @ws_socket && @ws_open
258
+ outgoing = WebSocket::Frame::Outgoing::Client.new(
259
+ version: @ws_version || 13,
260
+ data: data,
261
+ type: type
262
+ )
263
+ @ws_socket.write(outgoing.to_s)
264
+ end
265
+
266
+ def start_ping_thread
267
+ @ping_thread&.kill
268
+ @ping_thread = Thread.new do
269
+ loop do
270
+ sleep @ping_interval
271
+ break unless @running
272
+ begin
273
+ @seq_id += 1
274
+ send_frame(
275
+ seq_id: @seq_id,
276
+ log_id: 0,
277
+ service: @service_id,
278
+ method: 0,
279
+ headers: { "type" => "ping" }
280
+ )
281
+ rescue => e
282
+ Octo::Logger.warn("[feishu-ws] ping failed (#{e.class}: #{e.message}), forcing reconnect")
283
+ # Close the socket so IO.select in the read loop immediately
284
+ # returns nil / read_nonblock raises IOError, triggering reconnect.
285
+ @ws_socket&.close rescue nil
286
+ break
287
+ end
288
+ end
289
+ end
290
+ end
291
+
292
+ # Minimal protobuf encoder/decoder for pbbp2.Frame.
293
+ # Fields: 1=SeqID(uint64), 2=LogID(uint64), 3=service(int32),
294
+ # 4=method(int32), 5=headers(repeated msg{1=key,2=value}),
295
+ # 8=payload(bytes)
296
+ module ProtoFrame
297
+ def self.encode(frame)
298
+ buf = "".b
299
+ buf << encode_varint(1, frame[:seq_id] || 0)
300
+ buf << encode_varint(2, frame[:log_id] || 0)
301
+ buf << encode_varint(3, frame[:service] || 0)
302
+ buf << encode_varint(4, frame[:method] || 0)
303
+ (frame[:headers] || {}).each do |k, v|
304
+ header_bytes = encode_string(1, k.to_s) + encode_string(2, v.to_s)
305
+ buf << encode_length_delimited(5, header_bytes)
306
+ end
307
+ if frame[:payload]
308
+ payload_bytes = frame[:payload].respond_to?(:b) ? frame[:payload].b : frame[:payload].to_s.b
309
+ buf << encode_length_delimited(8, payload_bytes)
310
+ end
311
+ buf
312
+ end
313
+
314
+ def self.decode(buf)
315
+ buf = buf.b
316
+ pos = 0
317
+ result = { headers: {}, payload: "".b }
318
+
319
+ while pos < buf.bytesize
320
+ tag_byte, pos = read_varint(buf, pos)
321
+ field_number = tag_byte >> 3
322
+ wire_type = tag_byte & 0x7
323
+
324
+ case wire_type
325
+ when 0 # varint
326
+ val, pos = read_varint(buf, pos)
327
+ case field_number
328
+ when 1 then result[:seq_id] = val
329
+ when 2 then result[:log_id] = val
330
+ when 3 then result[:service] = val
331
+ when 4 then result[:method] = val
332
+ end
333
+ when 2 # length-delimited
334
+ len, pos = read_varint(buf, pos)
335
+ bytes = buf.byteslice(pos, len)
336
+ pos += len
337
+ case field_number
338
+ when 5 # header entry
339
+ k, v = decode_header(bytes)
340
+ result[:headers][k] = v if k
341
+ when 7 then result[:payload_type] = bytes.force_encoding("UTF-8")
342
+ when 8 then result[:payload] = bytes
343
+ end
344
+ else
345
+ break # unknown wire type, stop parsing
346
+ end
347
+ end
348
+
349
+ result
350
+ end
351
+
352
+ def self.decode_header(buf)
353
+ buf = buf.b
354
+ pos = 0
355
+ key = nil
356
+ val = nil
357
+ while pos < buf.bytesize
358
+ tag_byte, pos = read_varint(buf, pos)
359
+ field_number = tag_byte >> 3
360
+ wire_type = tag_byte & 0x7
361
+ if wire_type == 2
362
+ len, pos = read_varint(buf, pos)
363
+ bytes = buf.byteslice(pos, len)
364
+ pos += len
365
+ case field_number
366
+ when 1 then key = bytes.force_encoding("UTF-8")
367
+ when 2 then val = bytes.force_encoding("UTF-8")
368
+ end
369
+ else
370
+ break
371
+ end
372
+ end
373
+ [key, val]
374
+ end
375
+
376
+ def self.read_varint(buf, pos)
377
+ result = 0
378
+ shift = 0
379
+ loop do
380
+ byte = buf.getbyte(pos)
381
+ raise "unexpected end of buffer at pos #{pos}" if byte.nil?
382
+ pos += 1
383
+ result |= (byte & 0x7F) << shift
384
+ break unless byte & 0x80 != 0
385
+ shift += 7
386
+ end
387
+ [result, pos]
388
+ end
389
+
390
+ def self.encode_varint(field_number, value)
391
+ tag = (field_number << 3) | 0 # wire type 0
392
+ encode_raw_varint(tag) + encode_raw_varint(value)
393
+ end
394
+
395
+ def self.encode_raw_varint(value)
396
+ bytes = "".b
397
+ loop do
398
+ byte = value & 0x7F
399
+ value >>= 7
400
+ byte |= 0x80 if value > 0
401
+ bytes << byte
402
+ break if value == 0
403
+ end
404
+ bytes
405
+ end
406
+
407
+ def self.encode_string(field_number, str)
408
+ bytes = str.encode("UTF-8").b
409
+ encode_length_delimited(field_number, bytes)
410
+ end
411
+
412
+ def self.encode_length_delimited(field_number, bytes)
413
+ tag = (field_number << 3) | 2 # wire type 2
414
+ encode_raw_varint(tag) + encode_raw_varint(bytes.bytesize) + bytes
415
+ end
416
+ end
417
+
418
+
419
+ end
420
+ end
421
+ end
422
+ end
423
+ end