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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../adapters/base"
4
+ require_relative "ws_client"
5
+ require_relative "media_downloader"
6
+ require_relative "../feishu/file_processor"
7
+
8
+ module Octo
9
+ module Channel
10
+ module Adapters
11
+ module Wecom
12
+ # WeCom (Enterprise WeChat) adapter.
13
+ # Receives messages via WebSocket long connection and sends via bot API.
14
+ class Adapter < Base
15
+ def self.platform_id
16
+ :wecom
17
+ end
18
+
19
+ def self.env_keys
20
+ %w[IM_WECOM_BOT_ID IM_WECOM_SECRET]
21
+ end
22
+
23
+ def self.platform_config(data)
24
+ {
25
+ bot_id: data["IM_WECOM_BOT_ID"],
26
+ secret: data["IM_WECOM_SECRET"]
27
+ }
28
+ end
29
+
30
+ def self.set_env_data(data, config)
31
+ data["IM_WECOM_BOT_ID"] = config[:bot_id]
32
+ data["IM_WECOM_SECRET"] = config[:secret]
33
+ end
34
+
35
+ def initialize(config)
36
+ @config = config
37
+ @ws_client = WSClient.new(
38
+ bot_id: config[:bot_id],
39
+ secret: config[:secret],
40
+ ws_url: config[:ws_url] || WSClient::WS_URL
41
+ )
42
+ @running = false
43
+ @on_message = nil
44
+ end
45
+
46
+ def start(&on_message)
47
+ @running = true
48
+ @on_message = on_message
49
+
50
+ @ws_client.start do |raw|
51
+ handle_raw_message(raw)
52
+ end
53
+ rescue WSClient::AuthError => e
54
+ Octo::Logger.error("[WecomAdapter] Authentication failed, not retrying: #{e.message}")
55
+ end
56
+
57
+ def stop
58
+ @running = false
59
+ @ws_client.stop
60
+ end
61
+
62
+ def send_text(chat_id, text, reply_to: nil)
63
+ @ws_client.send_message(chat_id, text)
64
+ end
65
+
66
+ def send_file(chat_id, path, name: nil)
67
+ @ws_client.send_file(chat_id, path, name: name)
68
+ end
69
+
70
+ def validate_config(config)
71
+ errors = []
72
+ errors << "bot_id is required" if config[:bot_id].nil? || config[:bot_id].empty?
73
+ errors << "secret is required" if config[:secret].nil? || config[:secret].empty?
74
+ errors
75
+ end
76
+
77
+
78
+ def handle_raw_message(raw)
79
+ msgtype = raw["msgtype"]
80
+ return unless %w[text image file].include?(msgtype)
81
+
82
+ chat_id = raw["chatid"] || raw.dig("from", "userid")
83
+ return unless chat_id
84
+
85
+ user_id = raw.dig("from", "userid")
86
+ chat_type = raw["chattype"] == "group" ? :group : :direct
87
+ text = ""
88
+ files = []
89
+
90
+ case msgtype
91
+ when "text"
92
+ text = raw.dig("text", "content").to_s.strip
93
+ return if text.empty?
94
+ when "image"
95
+ url = raw.dig("image", "url")
96
+ aeskey = raw.dig("image", "aeskey")
97
+ return unless url
98
+ result = MediaDownloader.download(url, aeskey)
99
+ mime = MediaDownloader.detect_mime(result[:body])
100
+ if result[:body].bytesize > MAX_IMAGE_BYTES
101
+ @ws_client.send_message(chat_id, "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
102
+ return
103
+ end
104
+ require "base64"
105
+ data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
106
+ files = [{ name: "image.jpg", mime_type: mime, data_url: data_url }]
107
+ when "file"
108
+ url = raw.dig("file", "url")
109
+ aeskey = raw.dig("file", "aeskey")
110
+ return unless url
111
+ filename = raw.dig("file", "name") || raw.dig("file", "filename") || "attachment"
112
+ result = MediaDownloader.download(url, aeskey)
113
+ filename = result[:filename] || filename
114
+ saved = Octo::Utils::FileProcessor.save(body: result[:body], filename: filename)
115
+ files = [saved]
116
+ end
117
+
118
+ event = {
119
+ type: :message,
120
+ platform: :wecom,
121
+ chat_id: chat_id,
122
+ user_id: user_id,
123
+ text: text,
124
+ files: files,
125
+ message_id: raw["msgid"],
126
+ timestamp: raw["create_time"] ? Time.at(raw["create_time"]) : Time.now,
127
+ chat_type: chat_type,
128
+ raw: raw
129
+ }
130
+
131
+ @on_message&.call(event)
132
+ rescue => e
133
+ Octo::Logger.error("[WecomAdapter] handle_raw_message error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
134
+ begin
135
+ @ws_client.send_message(chat_id, "Error processing message: #{e.message}") if chat_id
136
+ rescue
137
+ nil
138
+ end
139
+ end
140
+
141
+ MAX_IMAGE_BYTES = Octo::Utils::FileProcessor::MAX_IMAGE_BYTES
142
+ end
143
+
144
+ Adapters.register(:wecom, Adapter)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "faraday"
5
+ require "uri"
6
+
7
+ module Octo
8
+ module Channel
9
+ module Adapters
10
+ module Wecom
11
+ # Downloads and decrypts media files from WeCom message URLs.
12
+ #
13
+ # WeCom long-connection bot messages include a per-resource aeskey.
14
+ # Encryption: AES-256-CBC, key=base64_decode(aeskey), iv=key[0,16], no PKCS7 padding.
15
+ # However some files are sent unencrypted — detect via magic bytes before decrypting.
16
+ module MediaDownloader
17
+ HTTP_TIMEOUT = 30
18
+
19
+ # Download and decrypt a WeCom media resource.
20
+ # @param url [String] Signed download URL from the message
21
+ # @param aeskey [String] Per-resource AES key string
22
+ # @return [Hash] { body: String (binary), content_type: String }
23
+ def self.download(url, aeskey)
24
+ response = fetch(url)
25
+ body = response.body.dup.force_encoding("BINARY")
26
+
27
+ if aeskey && !aeskey.empty? && !looks_plain?(body)
28
+ body = decrypt(body, aeskey)
29
+ end
30
+
31
+ content_type = detect_mime(body)
32
+ filename = extract_filename(response.headers["content-disposition"].to_s)
33
+ { body: body, content_type: content_type, filename: filename }
34
+ end
35
+
36
+ def self.extract_filename(content_disposition)
37
+ return nil if content_disposition.empty?
38
+ # filename*=UTF-8''name.ext or filename="name.ext"
39
+ if (m = content_disposition.match(/filename\*=UTF-8''([^;\s]+)/i))
40
+ URI.decode_www_form_component(m[1])
41
+ elsif (m = content_disposition.match(/filename="?([^";\s]+)"?/i))
42
+ URI.decode_www_form_component(m[1])
43
+ end
44
+ end
45
+
46
+ # --- private ---
47
+
48
+ def self.fetch(url)
49
+ conn = Faraday.new do |f|
50
+ f.options.timeout = HTTP_TIMEOUT
51
+ f.options.open_timeout = HTTP_TIMEOUT
52
+ f.ssl.verify = false
53
+ f.adapter Faraday.default_adapter
54
+ end
55
+ response = conn.get(url)
56
+ raise "Failed to download media: HTTP #{response.status}" unless response.success?
57
+ response
58
+ end
59
+
60
+ # AES-256-CBC decrypt, no PKCS7 padding.
61
+ # Key = base64_decode(aeskey), IV = first 16 bytes of decoded key.
62
+ def self.decrypt(data, aeskey)
63
+ require "base64"
64
+ padded = aeskey + "=" * ((4 - aeskey.length % 4) % 4)
65
+ key = Base64.decode64(padded)
66
+ iv = key.byteslice(0, 16)
67
+
68
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
69
+ cipher.decrypt
70
+ cipher.key = key
71
+ cipher.iv = iv
72
+ cipher.padding = 0
73
+ cipher.update(data) + cipher.final
74
+ rescue OpenSSL::Cipher::CipherError => e
75
+ warn "[WeCom] AES decrypt failed: #{e.message}"
76
+ # Decryption failed — return raw data as-is
77
+ data
78
+ end
79
+
80
+ # Check if data looks like a plain (unencrypted) file via magic bytes.
81
+ MAGIC_SIGNATURES = [
82
+ "\xFF\xD8\xFF", # JPEG
83
+ "\x89PNG\r\n\x1a\n", # PNG
84
+ "GIF8", # GIF
85
+ "%PDF", # PDF
86
+ "PK\x03\x04", # ZIP (docx/xlsx)
87
+ "\xD0\xCF\x11\xE0", # OLE2 (doc/xls)
88
+ "RIFF", # WAV/WebP
89
+ ].map { |s| s.b }.freeze
90
+
91
+ def self.looks_plain?(data)
92
+ return false if data.empty?
93
+ MAGIC_SIGNATURES.any? { |sig| data.start_with?(sig) }
94
+ end
95
+
96
+ # Detect MIME type from magic bytes
97
+ # @param data [String] Binary data
98
+ # @return [String] MIME type
99
+ def self.detect_mime(data)
100
+ return "application/octet-stream" if data.nil? || data.empty?
101
+ d = data.b
102
+ return "image/jpeg" if d.start_with?("\xFF\xD8\xFF".b)
103
+ return "image/png" if d.start_with?("\x89PNG\r\n\x1a\n".b)
104
+ return "image/gif" if d.start_with?("GIF8".b)
105
+ return "image/webp" if d.start_with?("RIFF".b) && d.byteslice(8, 4) == "WEBP".b
106
+ return "image/bmp" if d.start_with?("BM".b)
107
+ "image/jpeg" # fallback for unknown image formats
108
+ end
109
+
110
+ private_class_method :fetch, :decrypt, :looks_plain?, :extract_filename
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket"
4
+ require "json"
5
+ require "uri"
6
+ require "securerandom"
7
+
8
+ module Octo
9
+ module Channel
10
+ module Adapters
11
+ module Wecom
12
+ # WebSocket client for WeCom (Enterprise WeChat) intelligent robot long connection.
13
+ # Protocol: plain JSON frames over wss://openws.work.weixin.qq.com
14
+ #
15
+ # Frame format: { cmd, headers: { req_id }, body }
16
+ # Commands:
17
+ # aibot_subscribe - auth (client → server)
18
+ # ping - heartbeat (client → server)
19
+ # aibot_msg_callback - inbound message (server → client)
20
+ # aibot_respond_msg - send reply (client → server)
21
+ class WSClient
22
+ WS_URL = "wss://openws.work.weixin.qq.com"
23
+ HEARTBEAT_INTERVAL = 30 # seconds
24
+ RECONNECT_DELAY = 5 # seconds
25
+
26
+ # Raised when WeCom rejects credentials — signals caller not to retry.
27
+ class AuthError < StandardError; end
28
+
29
+ def initialize(bot_id:, secret:, ws_url: WS_URL)
30
+ @bot_id = bot_id
31
+ @secret = secret
32
+ @ws_url = ws_url
33
+ @running = false
34
+ @ws = nil
35
+ @ping_thread = nil
36
+ @mutex = Mutex.new
37
+ @pending_acks = {}
38
+ end
39
+
40
+ def start(&on_message)
41
+ @running = true
42
+ @on_message = on_message
43
+
44
+ while @running
45
+ begin
46
+ connect_and_listen
47
+ rescue AuthError => e
48
+ Octo::Logger.error("[WecomWSClient] Authentication failed (not retrying): #{e.message}")
49
+ @running = false
50
+ raise
51
+ rescue => e
52
+ Octo::Logger.error("[WecomWSClient] WebSocket error: #{e.message}")
53
+ sleep RECONNECT_DELAY if @running
54
+ end
55
+ end
56
+ end
57
+
58
+ def stop
59
+ @running = false
60
+ @ping_thread&.kill
61
+ send_raw_frame(:close, "") rescue nil
62
+ @ws_socket&.close rescue nil
63
+ end
64
+
65
+ # Proactively send a text message
66
+ # @param chatid [String] chat ID
67
+ # @param content [String] text content
68
+ def send_message(chatid, content)
69
+ Octo::Logger.info("[WecomWSClient] send_message chat=#{chatid} length=#{content.length}")
70
+ send_frame_and_wait(
71
+ cmd: "aibot_send_msg",
72
+ req_id: generate_req_id("send"),
73
+ body: {
74
+ chatid: chatid,
75
+ msgtype: "markdown",
76
+ markdown: { content: content }
77
+ }
78
+ )
79
+ end
80
+
81
+ # Upload a local file as a temporary media asset and send it to a chat.
82
+ # Uses the three-step chunked upload protocol:
83
+ # aibot_upload_media_init → aibot_upload_media_chunk × N → aibot_upload_media_finish
84
+ # Then sends the resulting media_id via aibot_send_msg.
85
+ #
86
+ # @param chatid [String] target chat ID
87
+ # @param path [String] absolute path to the local file
88
+ # @param name [String, nil] display filename (defaults to File.basename(path))
89
+ # @param type [String] media type — "file" or "image"
90
+ def send_file(chatid, path, name: nil, type: nil)
91
+ Octo::Logger.info("[WecomWSClient] send_file chat=#{chatid} path=#{path}")
92
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
93
+
94
+ data = File.binread(path)
95
+ filename = name || File.basename(path)
96
+ media_type = type || detect_media_type(path)
97
+
98
+ Octo::Logger.info("[WecomWSClient] uploading #{filename} (#{data.bytesize} bytes, type=#{media_type})")
99
+ media_id = upload_media(data, filename: filename, type: media_type)
100
+ Octo::Logger.info("[WecomWSClient] upload done media_id=#{media_id}")
101
+
102
+ req_id = generate_req_id("send_file")
103
+ send_frame_and_wait(
104
+ cmd: "aibot_send_msg",
105
+ req_id: req_id,
106
+ body: {
107
+ chatid: chatid,
108
+ msgtype: media_type,
109
+ media_type => { media_id: media_id }
110
+ }
111
+ )
112
+ Octo::Logger.info("[WecomWSClient] send_file frame sent chat=#{chatid} filename=#{filename}")
113
+ rescue => e
114
+ Octo::Logger.error("[WecomWSClient] send_file failed (#{File.basename(path)}): #{e.message}")
115
+ raise
116
+ end
117
+
118
+
119
+ # Timeout for IO.select on the read loop. If no data arrives within this
120
+ # window we treat the connection as dead and reconnect. This catches the
121
+ # silent-drop case where the TCP stack never delivers a FIN/RST (e.g.
122
+ # NAT timeout, firewall idle-kill). The WeCom server sends pings every
123
+ # ~30 s, so 75 s gives two missed pings before we give up.
124
+ READ_TIMEOUT_S = 75
125
+
126
+ def connect_and_listen
127
+ uri = URI.parse(@ws_url)
128
+ port = uri.port || 443
129
+
130
+ Octo::Logger.info("[WecomWSClient] connecting to #{uri.host}:#{port}")
131
+
132
+ require "openssl"
133
+ tcp = TCPSocket.new(uri.host, port)
134
+ ssl_context = OpenSSL::SSL::SSLContext.new
135
+ ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
136
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
137
+ ssl.sync_close = true
138
+ ssl.connect
139
+
140
+ # WebSocket handshake
141
+ handshake = WebSocket::Handshake::Client.new(url: @ws_url)
142
+ ssl.write(handshake.to_s)
143
+
144
+ until handshake.finished?
145
+ handshake << ssl.readpartial(4096)
146
+ end
147
+ raise "WebSocket handshake failed" unless handshake.valid?
148
+
149
+ Octo::Logger.info("[WecomWSClient] connected, authenticating")
150
+ @ws_version = handshake.version
151
+ @ws_socket = ssl
152
+ @ws_open = true
153
+ @incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
154
+
155
+ authenticate
156
+ start_ping_thread
157
+
158
+ loop do
159
+ break unless @running
160
+
161
+ # Use IO.select with a timeout so we detect silent connection drops
162
+ # (e.g. NAT expiry) that never deliver a TCP FIN/RST. Without this,
163
+ # readpartial blocks forever and the thread hangs permanently.
164
+ ready = IO.select([ssl], nil, nil, READ_TIMEOUT_S)
165
+ unless ready
166
+ Octo::Logger.warn("[WecomWSClient] read timeout (#{READ_TIMEOUT_S}s), reconnecting...")
167
+ return
168
+ end
169
+
170
+ data = ssl.read_nonblock(4096)
171
+ @incoming << data
172
+ while (frame = @incoming.next)
173
+ case frame.type
174
+ when :text
175
+ handle_message(frame.data)
176
+ when :ping
177
+ send_raw_frame(:pong, frame.data)
178
+ when :close
179
+ Octo::Logger.info("[WecomWSClient] connection closed by server")
180
+ return
181
+ end
182
+ end
183
+ end
184
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
185
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
186
+ Octo::Logger.info("[WecomWSClient] connection lost (#{e.class}: #{e.message}), reconnecting...")
187
+ ensure
188
+ @ws_open = false
189
+ @ws_socket = nil
190
+ ssl&.close rescue nil
191
+ @ping_thread&.kill
192
+ end
193
+
194
+ def authenticate
195
+ Octo::Logger.info("[WecomWSClient] sending auth (bot_id=#{@bot_id})")
196
+ send_frame(
197
+ cmd: "aibot_subscribe",
198
+ req_id: generate_req_id("subscribe"),
199
+ body: { bot_id: @bot_id, secret: @secret }
200
+ )
201
+ end
202
+
203
+ def handle_message(data)
204
+ frame = JSON.parse(data)
205
+ cmd = frame["cmd"]
206
+ body = frame["body"] || {}
207
+ req_id = frame.dig("headers", "req_id") || ""
208
+
209
+ # Dispatch ack to any waiting send_frame_and_wait caller
210
+ if req_id && !req_id.empty?
211
+ queue = @mutex.synchronize { @pending_acks&.[](req_id) }
212
+ if queue
213
+ queue.push(frame)
214
+ return
215
+ end
216
+ end
217
+
218
+ case cmd
219
+ when "aibot_msg_callback"
220
+ Octo::Logger.info("[WecomWSClient] inbound message req_id=#{req_id}")
221
+ cb_body = body.merge("_req_id" => req_id)
222
+ Thread.new { @on_message&.call(cb_body) }
223
+ when "aibot_event_callback"
224
+ Octo::Logger.info("[WecomWSClient] event_callback (ignored)")
225
+ when nil
226
+ errcode = frame["errcode"] || body["errcode"]
227
+ if errcode && errcode != 0
228
+ Octo::Logger.error("[WecomWSClient] error response: #{frame.inspect}")
229
+ if req_id.start_with?("subscribe_")
230
+ errmsg = frame["errmsg"] || body["errmsg"] || "unknown error"
231
+ @running = false
232
+ raise AuthError, "WeCom authentication failed (errcode=#{errcode}): #{errmsg}"
233
+ end
234
+ else
235
+ if req_id.start_with?("ping_")
236
+ Octo::Logger.debug("[WecomWSClient] ack/heartbeat req_id=#{req_id}")
237
+ else
238
+ Octo::Logger.info("[WecomWSClient] ack/heartbeat req_id=#{req_id}")
239
+ end
240
+ end
241
+ else
242
+ Octo::Logger.info("[WecomWSClient] unknown cmd=#{cmd}")
243
+ end
244
+ rescue JSON::ParserError => e
245
+ Octo::Logger.error("[WecomWSClient] failed to parse message: #{e.message}")
246
+ end
247
+
248
+ def send_frame(cmd:, req_id:, body: nil)
249
+ frame = { cmd: cmd, headers: { req_id: req_id } }
250
+ frame[:body] = body if body
251
+ if cmd == "ping"
252
+ Octo::Logger.debug("[WecomWSClient] >> cmd=#{cmd} req_id=#{req_id}")
253
+ else
254
+ Octo::Logger.info("[WecomWSClient] >> cmd=#{cmd} req_id=#{req_id}")
255
+ end
256
+ send_raw_frame(:text, JSON.generate(frame))
257
+ rescue => e
258
+ Octo::Logger.error("[WecomWSClient] failed to send frame cmd=#{cmd}: #{e.message}")
259
+ end
260
+
261
+ def send_raw_frame(type, data)
262
+ return unless @ws_socket && @ws_open
263
+ outgoing = WebSocket::Frame::Outgoing::Client.new(
264
+ version: @ws_version || 13,
265
+ data: data,
266
+ type: type
267
+ )
268
+ @ws_socket.write(outgoing.to_s)
269
+ end
270
+
271
+ def start_ping_thread
272
+ @ping_thread&.kill
273
+ @ping_thread = Thread.new do
274
+ loop do
275
+ sleep HEARTBEAT_INTERVAL
276
+ break unless @running
277
+ begin
278
+ send_frame(cmd: "ping", req_id: generate_req_id("ping"))
279
+ rescue => e
280
+ Octo::Logger.warn("[WecomWSClient] ping failed (#{e.class}: #{e.message}), forcing reconnect")
281
+ # Close the socket so IO.select in the read loop immediately
282
+ # returns nil / read_nonblock raises IOError, triggering reconnect.
283
+ @ws_socket&.close rescue nil
284
+ break
285
+ end
286
+ end
287
+ end
288
+ end
289
+
290
+ def generate_req_id(prefix)
291
+ "#{prefix}_#{SecureRandom.hex(8)}"
292
+ end
293
+
294
+ CHUNK_SIZE = 512 * 1024 # 512 KB per chunk (before Base64)
295
+ MAX_CHUNKS = 100
296
+
297
+ # Three-step chunked media upload over WebSocket.
298
+ # Returns media_id on success.
299
+ def upload_media(data, filename:, type: "file")
300
+ require "base64"
301
+ require "digest"
302
+
303
+ total_size = data.bytesize
304
+ total_chunks = (total_size.to_f / CHUNK_SIZE).ceil
305
+ total_chunks = 1 if total_chunks == 0
306
+ raise ArgumentError, "File too large: #{total_chunks} chunks (max #{MAX_CHUNKS})" if total_chunks > MAX_CHUNKS
307
+
308
+ md5 = Digest::MD5.hexdigest(data)
309
+
310
+ Octo::Logger.info("[WecomWSClient] upload_media_init filename=#{filename} size=#{total_size} chunks=#{total_chunks} md5=#{md5}")
311
+
312
+ # Step 1: init
313
+ init_req_id = generate_req_id("upload_init")
314
+ init_result = send_frame_and_wait(
315
+ cmd: "aibot_upload_media_init",
316
+ req_id: init_req_id,
317
+ body: { type: type, filename: filename, total_size: total_size, total_chunks: total_chunks, md5: md5 }
318
+ )
319
+ upload_id = init_result.dig("body", "upload_id")
320
+ raise "upload_media init failed: #{init_result.inspect}" unless upload_id
321
+ Octo::Logger.info("[WecomWSClient] upload_id=#{upload_id}")
322
+
323
+ # Step 2: chunks
324
+ total_chunks.times do |i|
325
+ chunk_start = i * CHUNK_SIZE
326
+ chunk = data[chunk_start, CHUNK_SIZE]
327
+ b64 = Base64.strict_encode64(chunk)
328
+
329
+ Octo::Logger.info("[WecomWSClient] uploading chunk #{i + 1}/#{total_chunks}")
330
+ chunk_req_id = generate_req_id("upload_chunk")
331
+ send_frame_and_wait(
332
+ cmd: "aibot_upload_media_chunk",
333
+ req_id: chunk_req_id,
334
+ body: { upload_id: upload_id, chunk_index: i, base64_data: b64 }
335
+ )
336
+ end
337
+
338
+ # Step 3: finish
339
+ Octo::Logger.info("[WecomWSClient] upload_media_finish upload_id=#{upload_id}")
340
+ finish_req_id = generate_req_id("upload_finish")
341
+ finish_result = send_frame_and_wait(
342
+ cmd: "aibot_upload_media_finish",
343
+ req_id: finish_req_id,
344
+ body: { upload_id: upload_id }
345
+ )
346
+ media_id = finish_result.dig("body", "media_id")
347
+ raise "upload_media finish failed: #{finish_result.inspect}" unless media_id
348
+
349
+ media_id
350
+ end
351
+
352
+ # Send a frame and block until an ack frame with the same req_id arrives.
353
+ # Timeout after 30s. Returns the ack frame hash.
354
+ def send_frame_and_wait(cmd:, req_id:, body: nil)
355
+ queue = Queue.new
356
+
357
+ @mutex.synchronize do
358
+ @pending_acks ||= {}
359
+ @pending_acks[req_id] = queue
360
+ end
361
+
362
+ send_frame(cmd: cmd, req_id: req_id, body: body)
363
+
364
+ timeout_thread = Thread.new { sleep 30; queue.push(nil) }
365
+ result = queue.pop
366
+ timeout_thread.kill
367
+ raise "Timeout waiting for ack (req_id=#{req_id}, cmd=#{cmd})" if result.nil?
368
+
369
+ errcode = result["errcode"] || result.dig("body", "errcode")
370
+ if errcode && errcode != 0
371
+ errmsg = result["errmsg"] || result.dig("body", "errmsg") || "unknown"
372
+ raise "WeCom API error #{errcode}: #{errmsg} (cmd=#{cmd})"
373
+ end
374
+
375
+ result
376
+ ensure
377
+ @mutex.synchronize { @pending_acks&.delete(req_id) }
378
+ end
379
+
380
+ # Detect media type from file extension
381
+ def detect_media_type(path)
382
+ case File.extname(path).downcase
383
+ when ".jpg", ".jpeg", ".png", ".gif", ".webp" then "image"
384
+ when ".mp4", ".avi", ".mov", ".mkv" then "video"
385
+ when ".mp3", ".wav", ".amr", ".m4a" then "voice"
386
+ else "file"
387
+ end
388
+ end
389
+
390
+
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end