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,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module Octo
9
+ module Channel
10
+ module Adapters
11
+ module DingTalk
12
+ # DingTalk Bot API client — sends messages via session webhook (Stream Mode).
13
+ class ApiClient
14
+ OPENAPI_BASE = "https://api.dingtalk.com"
15
+ OAPI_BASE = "https://oapi.dingtalk.com"
16
+
17
+ # File extensions accepted by DingTalk sampleFile message (fileType field).
18
+ # Anything outside this list is rejected by DingTalk's API — we surface
19
+ # a friendly text notice to the user instead of attempting upload.
20
+ #
21
+ # Why 9 entries instead of the 6 the public doc lists? The official
22
+ # doc (open.dingtalk.com / sampleFile) explicitly names only:
23
+ # xlsx, pdf, zip, rar, doc, docx
24
+ # However old-format Office types (xls / ppt / pptx) are accepted in
25
+ # practice and were verified by hand during the C-5597 rollout.
26
+ # We deliberately keep the empirical 9-entry list because
27
+ # downgrading to the doc's 6 would silently reject files users
28
+ # routinely send. If DingTalk ever tightens enforcement and the
29
+ # extra 3 start failing, prefer adding a converter (e.g. xls→xlsx)
30
+ # over shrinking the list — the goal is "things users send arrive".
31
+ SUPPORTED_FILE_EXTS = %w[doc docx xls xlsx ppt pptx pdf zip rar].freeze
32
+
33
+ def initialize(client_id:, client_secret:)
34
+ @client_id = client_id
35
+ @client_secret = client_secret
36
+ @token = nil
37
+ @token_expires_at = 0
38
+ @oapi_token = nil
39
+ @oapi_token_expires_at = 0
40
+ end
41
+
42
+ # Send a text (or Markdown) message via the session webhook URL.
43
+ # In Stream Mode, inbound events carry a `sessionWebhook` — use that directly.
44
+ # @param webhook_url [String]
45
+ # @param text [String]
46
+ # @param msg_type [:text, :markdown] (default :text)
47
+ def send_via_webhook(webhook_url, text, msg_type: :text)
48
+ body = if msg_type == :markdown
49
+ { msgtype: "markdown", markdown: { title: "Reply", text: text } }
50
+ else
51
+ { msgtype: "text", text: { content: text } }
52
+ end
53
+
54
+ uri = URI.parse(webhook_url)
55
+ http = Net::HTTP.new(uri.host, uri.port)
56
+ http.use_ssl = uri.scheme == "https"
57
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
58
+ req.body = JSON.generate(body)
59
+ resp = http.request(req)
60
+ data = JSON.parse(resp.body) rescue {}
61
+ if resp.code.to_i != 200 || (data["errcode"] && data["errcode"] != 0)
62
+ Octo::Logger.warn("[dingtalk] webhook send rejected (#{resp.code}): #{resp.body}")
63
+ end
64
+ data
65
+ rescue => e
66
+ Octo::Logger.warn("[dingtalk] webhook send failed: #{e.message}")
67
+ {}
68
+ end
69
+
70
+ # Fetch a short-lived access token (cached for its lifetime).
71
+ def access_token
72
+ return @token if @token && Time.now.to_i < @token_expires_at - 60
73
+
74
+ uri = URI.parse("#{OPENAPI_BASE}/v1.0/oauth2/accessToken")
75
+ http = Net::HTTP.new(uri.host, uri.port)
76
+ http.use_ssl = true
77
+ req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
78
+ req.body = JSON.generate({ appKey: @client_id, appSecret: @client_secret })
79
+
80
+ resp = http.request(req)
81
+ data = JSON.parse(resp.body)
82
+
83
+ raise "DingTalk token error (#{resp.code}): #{data["message"] || resp.body}" unless resp.code.to_i == 200
84
+
85
+ @token = data["accessToken"] || raise("Missing accessToken in response")
86
+ @token_expires_at = Time.now.to_i + (data["expireIn"] || 7200).to_i
87
+ @token
88
+ end
89
+
90
+ # OAPI access token — required by legacy /media/upload endpoint.
91
+ # Independent token system from /v1.0/oauth2/accessToken.
92
+ def oapi_access_token
93
+ return @oapi_token if @oapi_token && Time.now.to_i < @oapi_token_expires_at - 60
94
+
95
+ uri = URI.parse("#{OAPI_BASE}/gettoken?appkey=#{@client_id}&appsecret=#{@client_secret}")
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.use_ssl = true
98
+ resp = http.request(Net::HTTP::Get.new(uri.request_uri))
99
+ data = JSON.parse(resp.body)
100
+
101
+ unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["access_token"]
102
+ raise "DingTalk OAPI token error (#{resp.code}): #{data["errmsg"] || resp.body}"
103
+ end
104
+
105
+ @oapi_token = data["access_token"]
106
+ @oapi_token_expires_at = Time.now.to_i + (data["expires_in"] || 7200).to_i
107
+ @oapi_token
108
+ end
109
+
110
+ # Validate credentials by fetching a token.
111
+ # @return [Hash] { ok: Boolean, error: String? }
112
+ def test_connection
113
+ access_token
114
+ { ok: true, message: "DingTalk access token obtained" }
115
+ rescue => e
116
+ { ok: false, error: e.message }
117
+ end
118
+
119
+ # Download a file the bot received, given its downloadCode + robotCode
120
+ # from the inbound event. Two-step: exchange downloadCode for a temporary
121
+ # downloadUrl, then persist bytes to UPLOAD_DIR via FileProcessor.save.
122
+ # @param download_code [String]
123
+ # @param robot_code [String]
124
+ # @param prefer_name [String, nil] original filename from inbound event
125
+ # (DingTalk's content.fileName) — used to pick the file extension so
126
+ # downstream consumers (parsers, vision models) route by suffix correctly.
127
+ # @return [Hash, nil] { name:, path:, mime: } or nil on failure
128
+ def download_message_file(download_code, robot_code, prefer_name: nil)
129
+ return nil if download_code.to_s.empty? || robot_code.to_s.empty?
130
+
131
+ url = fetch_download_url(download_code, robot_code)
132
+ return nil unless url
133
+
134
+ download_to_disk(url, prefer_name: prefer_name)
135
+ rescue => e
136
+ Octo::Logger.warn("[dingtalk] download_message_file failed: #{e.message}")
137
+ nil
138
+ end
139
+
140
+ private def fetch_download_url(download_code, robot_code)
141
+ uri = URI.parse("#{OPENAPI_BASE}/v1.0/robot/messageFiles/download")
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = true
144
+ req = Net::HTTP::Post.new(uri.path,
145
+ "Content-Type" => "application/json",
146
+ "x-acs-dingtalk-access-token" => access_token)
147
+ req.body = JSON.generate(downloadCode: download_code, robotCode: robot_code)
148
+ resp = http.request(req)
149
+ data = JSON.parse(resp.body) rescue {}
150
+ unless resp.code.to_i == 200 && data["downloadUrl"]
151
+ Octo::Logger.warn("[dingtalk] fetch_download_url rejected (#{resp.code}): #{resp.body}")
152
+ return nil
153
+ end
154
+ data["downloadUrl"]
155
+ end
156
+
157
+ private def download_to_disk(url, prefer_name: nil)
158
+ uri = URI.parse(url)
159
+ http = Net::HTTP.new(uri.host, uri.port)
160
+ http.use_ssl = uri.scheme == "https"
161
+ resp = http.get(uri.request_uri)
162
+ return nil unless resp.code.to_i == 200
163
+
164
+ mime = resp["content-type"].to_s
165
+ # Prefer extension from the original fileName supplied by DingTalk
166
+ # (e.g. report.pdf, notes.txt). Fall back to MIME mapping, then .bin.
167
+ ext = ext_from_name(prefer_name) || guess_ext(mime) || ".bin"
168
+
169
+ base = prefer_name && !prefer_name.to_s.empty? ?
170
+ File.basename(prefer_name.to_s, ".*") : "dingtalk"
171
+ base = sanitize_basename(base)
172
+ filename = "#{base}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(3)}#{ext}"
173
+ saved = Octo::Utils::FileProcessor.save(body: resp.body, filename: filename)
174
+ saved.merge(mime: mime)
175
+ end
176
+
177
+ private def ext_from_name(name)
178
+ return nil if name.to_s.empty?
179
+ ext = File.extname(name.to_s).downcase
180
+ ext.empty? ? nil : ext
181
+ end
182
+
183
+ private def sanitize_basename(name)
184
+ # Keep ASCII letters/digits/dash/underscore; drop everything else
185
+ # (CJK, spaces, slashes) so the final filename stays filesystem-safe.
186
+ cleaned = name.to_s.gsub(/[^A-Za-z0-9_\-]/, "_").gsub(/_+/, "_").gsub(/^_+|_+$/, "")
187
+ cleaned.empty? ? "dingtalk" : cleaned
188
+ end
189
+
190
+ private def guess_ext(mime)
191
+ case mime.to_s.split(";").first.to_s.strip.downcase
192
+ # Images
193
+ when "image/jpeg", "image/jpg" then ".jpg"
194
+ when "image/png" then ".png"
195
+ when "image/gif" then ".gif"
196
+ when "image/webp" then ".webp"
197
+ when "image/bmp" then ".bmp"
198
+ when "image/svg+xml" then ".svg"
199
+ # Documents
200
+ when "application/pdf" then ".pdf"
201
+ when "application/msword" then ".doc"
202
+ when "application/vnd.openxmlformats-officedocument.wordprocessingml.document" then ".docx"
203
+ when "application/vnd.ms-excel" then ".xls"
204
+ when "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" then ".xlsx"
205
+ when "application/vnd.ms-powerpoint" then ".ppt"
206
+ when "application/vnd.openxmlformats-officedocument.presentationml.presentation" then ".pptx"
207
+ # Archives
208
+ when "application/zip" then ".zip"
209
+ when "application/x-rar-compressed", "application/vnd.rar" then ".rar"
210
+ when "application/x-7z-compressed" then ".7z"
211
+ when "application/x-tar" then ".tar"
212
+ when "application/gzip" then ".gz"
213
+ # Text
214
+ when "text/plain" then ".txt"
215
+ when "text/markdown" then ".md"
216
+ when "text/csv" then ".csv"
217
+ when "text/html" then ".html"
218
+ when "application/json" then ".json"
219
+ when "application/xml", "text/xml" then ".xml"
220
+ when "application/x-yaml", "text/yaml" then ".yml"
221
+ # Audio / video
222
+ when "audio/mpeg" then ".mp3"
223
+ when "audio/wav", "audio/x-wav" then ".wav"
224
+ when "audio/aac" then ".aac"
225
+ when "audio/ogg" then ".ogg"
226
+ when "video/mp4" then ".mp4"
227
+ when "video/quicktime" then ".mov"
228
+ end
229
+ end
230
+
231
+ # Upload a local file to DingTalk and return its media_id.
232
+ # Webhook delivery doesn't support image/file attachments — uploaded
233
+ # mediaId is used by the OAPI sendMessage path below.
234
+ # @param path [String]
235
+ # @param kind [Symbol] :image | :file
236
+ # @return [String, nil] media_id
237
+ def upload_media(path, kind:)
238
+ type_str = kind == :image ? "image" : "file"
239
+ # NB: legacy OAPI /media/upload — the new /v1.0/robot/messageFiles/*
240
+ # path returns 404, this is the only working endpoint as of 2026-05.
241
+ token = oapi_access_token
242
+ uri = URI.parse("#{OAPI_BASE}/media/upload?access_token=#{token}&type=#{type_str}")
243
+ boundary = "----DingTalkBoundary#{rand(1 << 64).to_s(16)}"
244
+ body = build_multipart(path, boundary, type_str)
245
+
246
+ http = Net::HTTP.new(uri.host, uri.port)
247
+ http.use_ssl = true
248
+ req = Net::HTTP::Post.new(uri.request_uri,
249
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}")
250
+ req.body = body
251
+ resp = http.request(req)
252
+ data = JSON.parse(resp.body) rescue {}
253
+ unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["media_id"]
254
+ Octo::Logger.warn("[dingtalk] upload_media rejected (#{resp.code}): #{resp.body}")
255
+ return nil
256
+ end
257
+ # Operational log: confirm upload succeeded and surface the
258
+ # media_id shape (length + first 4 chars). We keep this at info
259
+ # level because outbound failures correlate strongly with
260
+ # media_id format drift (e.g. DingTalk silently changing the
261
+ # `@` prefix policy). Avoid logging the full body to keep the
262
+ # token / id from leaking into shared log channels.
263
+ mid = data["media_id"].to_s
264
+ Octo::Logger.info("[dingtalk] upload_media ok type=#{type_str} media_id_len=#{mid.length} media_id_prefix=#{mid[0, 4].inspect}")
265
+ mid
266
+ rescue => e
267
+ Octo::Logger.warn("[dingtalk] upload_media failed: #{e.message}")
268
+ nil
269
+ end
270
+
271
+ # Send a media message via OAPI (not webhook).
272
+ # DM → /v1.0/robot/oToMessages/batchSend (needs userIds)
273
+ # Group → /v1.0/robot/groupMessages/send (needs openConversationId)
274
+ # @param conv_type [String] "1"=DM, "2"=group
275
+ # @param kind [Symbol] :image | :file
276
+ def send_media(robot_code:, conv_type:, conv_id:, user_id:, media_id:, kind:, file_name: nil)
277
+ msg_key, msg_param = build_media_message(media_id, kind, file_name)
278
+
279
+ if conv_type == "2"
280
+ path = "/v1.0/robot/groupMessages/send"
281
+ body = {
282
+ msgKey: msg_key,
283
+ msgParam: JSON.generate(msg_param),
284
+ openConversationId: conv_id,
285
+ robotCode: robot_code
286
+ }
287
+ else
288
+ path = "/v1.0/robot/oToMessages/batchSend"
289
+ body = {
290
+ msgKey: msg_key,
291
+ msgParam: JSON.generate(msg_param),
292
+ userIds: [user_id],
293
+ robotCode: robot_code
294
+ }
295
+ end
296
+
297
+ uri = URI.parse("#{OPENAPI_BASE}#{path}")
298
+ http = Net::HTTP.new(uri.host, uri.port)
299
+ http.use_ssl = true
300
+ req = Net::HTTP::Post.new(uri.request_uri,
301
+ "Content-Type" => "application/json",
302
+ "x-acs-dingtalk-access-token" => access_token)
303
+ req.body = JSON.generate(body)
304
+ resp = http.request(req)
305
+ data = JSON.parse(resp.body) rescue {}
306
+ if resp.code.to_i != 200
307
+ Octo::Logger.warn("[dingtalk] send_media rejected (#{resp.code}): #{resp.body}")
308
+ return { ok: false, error: data["message"] || resp.body }
309
+ end
310
+ # Operational log: success path. We log msgKey (image vs file)
311
+ # so the operator can correlate "sampleImageMsg" with image
312
+ # delivery and "sampleFile" with file delivery without parsing
313
+ # the full request body.
314
+ Octo::Logger.info("[dingtalk] send_media ok kind=#{kind} msgKey=#{msg_key}")
315
+ { ok: true, data: data }
316
+ rescue => e
317
+ Octo::Logger.warn("[dingtalk] send_media failed: #{e.message}")
318
+ { ok: false, error: e.message }
319
+ end
320
+
321
+ private def build_media_message(media_id, kind, file_name)
322
+ # Images: `sampleImageMsg` with the OAPI-uploaded mediaId (type=image)
323
+ # renders an inline original-resolution image in the chat. The doc
324
+ # field is named `photoURL`, but DingTalk explicitly documents that
325
+ # photoURL accepts either a public URL OR a mediaId — and the
326
+ # mediaId form is what we use (the only way to send local files
327
+ # without standing up a public file server).
328
+ #
329
+ # NOTE — implementation pitfalls hard-won (2026-05-19):
330
+ # 1. Pass the raw mediaId returned by /media/upload AS-IS.
331
+ # Despite the sampleLink doc example showing `picUrl: "@lADO..."`
332
+ # with an `@` prefix, sampleImageMsg.photoURL must NOT be
333
+ # prefixed — the upload response already includes whatever
334
+ # shape DingTalk wants. Adding `@` produces "原图加载失败".
335
+ # 2. msgKey is `sampleImageMsg`, NOT `msgtype: "image"`.
336
+ # `msgtype:image` belongs to the corpconversation/asyncsend_v2
337
+ # work-notification protocol and does NOT work on the group
338
+ # robot endpoint we use here.
339
+ # 3. msgParam must be a JSON-stringified object (handled by the
340
+ # caller), not a nested hash.
341
+ return ["sampleImageMsg", { photoURL: media_id }] if kind == :image
342
+
343
+ # Generic files: sampleFile. fileType must be one of
344
+ # SUPPORTED_FILE_EXTS (see top of file for why we keep the
345
+ # empirically-verified 9-entry list rather than the 6-entry
346
+ # doc list). Upstream `Adapter#send_file` already screens out
347
+ # unsupported extensions before we reach here, so we just pass
348
+ # `ext` through.
349
+ ext = File.extname(file_name.to_s).delete(".")
350
+ ["sampleFile", { mediaId: media_id, fileName: file_name.to_s, fileType: ext }]
351
+ end
352
+
353
+ private def build_multipart(path, boundary, type_str)
354
+ filename = File.basename(path)
355
+ mime = mime_for(filename)
356
+ content = File.binread(path)
357
+
358
+ # All parts must be ASCII-8BIT before joining; mixing UTF-8 (e.g. a
359
+ # filename with CJK chars) with binary file content raises
360
+ # "incompatible character encodings: UTF-8 and BINARY".
361
+ parts = []
362
+ parts << "--#{boundary}\r\n".b
363
+ parts << "Content-Disposition: form-data; name=\"type\"\r\n\r\n#{type_str}\r\n".b
364
+ parts << "--#{boundary}\r\n".b
365
+ parts << "Content-Disposition: form-data; name=\"media\"; filename=\"#{filename}\"\r\n".b
366
+ parts << "Content-Type: #{mime}\r\n\r\n".b
367
+ parts << content.b
368
+ parts << "\r\n--#{boundary}--\r\n".b
369
+ parts.join
370
+ end
371
+
372
+ private def mime_for(filename)
373
+ case File.extname(filename).downcase
374
+ when ".jpg", ".jpeg" then "image/jpeg"
375
+ when ".png" then "image/png"
376
+ when ".gif" then "image/gif"
377
+ when ".webp" then "image/webp"
378
+ when ".txt", ".log" then "text/plain"
379
+ when ".md" then "text/markdown"
380
+ when ".pdf" then "application/pdf"
381
+ when ".json" then "application/json"
382
+ when ".csv" then "text/csv"
383
+ when ".zip" then "application/zip"
384
+ else "application/octet-stream"
385
+ end
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,203 @@
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 DingTalk
12
+ DINGTALK_API_BASE = "https://api.dingtalk.com"
13
+ RECONNECT_DELAY = 5
14
+
15
+ # WebSocket Stream Mode client for DingTalk.
16
+ # DingTalk Stream Mode uses JSON frames (unlike Feishu's protobuf).
17
+ # Frame shape:
18
+ # { "specVersion": "1.0", "type": "SYSTEM"|"EVENT"|"CALLBACK",
19
+ # "headers": { "messageId": "...", "time": "...", "topic": "...", ... },
20
+ # "data": "..." }
21
+ class StreamClient
22
+ def initialize(client_id:, client_secret:)
23
+ @client_id = client_id
24
+ @client_secret = client_secret
25
+ @running = false
26
+ end
27
+
28
+ def start(&on_event)
29
+ @running = true
30
+ @on_event = on_event
31
+ Octo::Logger.info("[dingtalk-ws] Starting stream client (client_id=#{@client_id})")
32
+
33
+ while @running
34
+ begin
35
+ connect_and_listen
36
+ rescue => e
37
+ Octo::Logger.warn("[dingtalk-ws] Connection error: #{e.class}: #{e.message}")
38
+ sleep RECONNECT_DELAY if @running
39
+ end
40
+ end
41
+ end
42
+
43
+ def stop
44
+ @running = false
45
+ @ws_socket&.close rescue nil
46
+ end
47
+
48
+ private def connect_and_listen
49
+ Octo::Logger.info("[dingtalk-ws] Fetching stream endpoint...")
50
+ endpoint, ticket = fetch_stream_endpoint
51
+ full_url = "#{endpoint}?ticket=#{ticket}"
52
+ Octo::Logger.info("[dingtalk-ws] Connecting to #{endpoint}")
53
+
54
+ uri = URI.parse(full_url)
55
+ port = uri.port || 443
56
+ tcp = TCPSocket.new(uri.host, port)
57
+
58
+ socket = begin
59
+ require "openssl"
60
+ ctx = OpenSSL::SSL::SSLContext.new
61
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
62
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
63
+ ssl.hostname = uri.host
64
+ ssl.sync_close = true
65
+ ssl.connect
66
+ ssl
67
+ end
68
+
69
+ handshake = WebSocket::Handshake::Client.new(url: full_url)
70
+ socket.write(handshake.to_s)
71
+ until handshake.finished?
72
+ handshake << socket.readpartial(4096)
73
+ end
74
+ raise "WebSocket handshake failed" unless handshake.valid?
75
+
76
+ Octo::Logger.info("[dingtalk-ws] WebSocket connected")
77
+ @ws_version = handshake.version
78
+ @ws_socket = socket
79
+ @incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
80
+
81
+ loop do
82
+ break unless @running
83
+
84
+ ready = IO.select([socket], nil, nil, 120)
85
+ unless ready
86
+ Octo::Logger.warn("[dingtalk-ws] read timeout, reconnecting...")
87
+ return
88
+ end
89
+
90
+ data = socket.read_nonblock(4096)
91
+ @incoming << data
92
+ while (frame = @incoming.next)
93
+ case frame.type
94
+ when :text then handle_frame(frame.data)
95
+ when :ping then send_frame(:pong, frame.data)
96
+ when :close
97
+ Octo::Logger.info("[dingtalk-ws] Server closed connection, will reconnect")
98
+ return
99
+ end
100
+ end
101
+ end
102
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
103
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
104
+ raise
105
+ ensure
106
+ @ws_socket = nil
107
+ socket&.close rescue nil
108
+ end
109
+
110
+ private def fetch_stream_endpoint
111
+ uri = URI.parse("#{DINGTALK_API_BASE}/v1.0/gateway/connections/open")
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = true
114
+
115
+ req = Net::HTTP::Post.new(uri.path)
116
+ req["Content-Type"] = "application/json"
117
+ req["Accept"] = "application/json"
118
+ req.body = JSON.generate({
119
+ clientId: @client_id,
120
+ clientSecret: @client_secret,
121
+ subscriptions: [
122
+ { type: "CALLBACK", topic: "/v1.0/im/bot/messages/get" }
123
+ ],
124
+ ua: self.class.client_identifier,
125
+ localIp: "127.0.0.1"
126
+ })
127
+
128
+ resp = http.request(req)
129
+ data = JSON.parse(resp.body)
130
+
131
+ unless resp.code.to_i == 200
132
+ raise "Stream endpoint error (#{resp.code}): #{data["message"] || resp.body}"
133
+ end
134
+
135
+ endpoint = data.dig("endpoint") || raise("Missing endpoint in response")
136
+ ticket = data.dig("ticket") || ""
137
+ [endpoint, ticket]
138
+ end
139
+
140
+ def self.client_identifier
141
+ "Octo/#{Octo::VERSION}"
142
+ end
143
+
144
+ private def handle_frame(json_text)
145
+ frame = JSON.parse(json_text)
146
+ type = frame["type"]
147
+
148
+ case type
149
+ when "SYSTEM"
150
+ handle_system(frame)
151
+ when "EVENT", "CALLBACK"
152
+ send_ack(frame)
153
+ @on_event&.call(frame)
154
+ end
155
+ rescue JSON::ParserError => e
156
+ Octo::Logger.warn("[dingtalk-ws] JSON parse error: #{e.message}")
157
+ end
158
+
159
+ private def handle_system(frame)
160
+ topic = frame.dig("headers", "topic").to_s
161
+ case topic
162
+ when "ping"
163
+ pong = frame.merge("type" => "SYSTEM",
164
+ "headers" => frame["headers"].merge("topic" => "pong"))
165
+ send_text(JSON.generate(pong))
166
+ Octo::Logger.info("[dingtalk-ws] pong sent")
167
+ when "disconnect"
168
+ Octo::Logger.warn("[dingtalk-ws] Server requested disconnect, will reconnect")
169
+ @ws_socket&.close rescue nil
170
+ end
171
+ end
172
+
173
+ private def send_ack(frame)
174
+ ack = {
175
+ "code" => 200,
176
+ "headers" => {
177
+ "messageId" => frame.dig("headers", "messageId"),
178
+ "topic" => "ack"
179
+ },
180
+ "message" => "OK",
181
+ "data" => ""
182
+ }
183
+ send_text(JSON.generate(ack))
184
+ end
185
+
186
+ private def send_frame(type, data)
187
+ return unless @ws_socket
188
+ outgoing = WebSocket::Frame::Outgoing::Client.new(
189
+ version: @ws_version || 13,
190
+ data: data,
191
+ type: type
192
+ )
193
+ @ws_socket.write(outgoing.to_s)
194
+ end
195
+
196
+ private def send_text(text)
197
+ send_frame(:text, text)
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end