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,692 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "../../adapters/base"
5
+ require_relative "api_client"
6
+
7
+ module Octo
8
+ module Channel
9
+ module Adapters
10
+ module Weixin
11
+ # Per-user send queue with buffering, throttling, and retry for Weixin iLink.
12
+ #
13
+ # Design:
14
+ # - Each chat_id has a pending buffer of text fragments.
15
+ # - A background flusher thread periodically checks all buffers.
16
+ # - Flush triggers: char threshold reached, time interval elapsed, or explicit flush.
17
+ # - Actual send calls are spaced by MIN_SEND_INTERVAL to avoid rate-limiting.
18
+ # - ret:-2 (rate-limited) triggers exponential backoff retry.
19
+ class SendQueue
20
+ FLUSH_CHAR_THRESHOLD = 400
21
+ FLUSH_INTERVAL = 0.8
22
+ MIN_SEND_INTERVAL = 1.0
23
+ RETRY_BACKOFFS = [1.0, 2.0, 4.0]
24
+
25
+ Entry = Struct.new(:text, :context_token, :enqueued_at, keyword_init: true)
26
+
27
+ def initialize(api_client, logger: Octo::Logger)
28
+ @api_client = api_client
29
+ @logger = logger
30
+ @buffers = {}
31
+ @buffer_mutex = Mutex.new
32
+ @last_sent_at = {}
33
+ @last_mutex = Mutex.new
34
+ @running = true
35
+ @flusher = Thread.new { flush_loop }
36
+ end
37
+
38
+ # Enqueue text for a chat_id. Non-blocking.
39
+ def enqueue(chat_id, text, context_token)
40
+ @buffer_mutex.synchronize do
41
+ @buffers[chat_id] ||= []
42
+ @buffers[chat_id] << Entry.new(text: text, context_token: context_token, enqueued_at: Time.now)
43
+ end
44
+ end
45
+
46
+ # Force-flush all pending text for a chat_id. Non-blocking.
47
+ def flush(chat_id)
48
+ entries = @buffer_mutex.synchronize { @buffers.delete(chat_id) || [] }
49
+ send_entries(chat_id, entries) unless entries.empty?
50
+ end
51
+
52
+ # Stop the flusher thread. Waits up to 30s for pending messages to drain.
53
+ def stop
54
+ @running = false
55
+ @flusher.join(30)
56
+ # Force-flush any remaining entries regardless of threshold.
57
+ drain_all_buffers
58
+ end
59
+
60
+ private def flush_loop
61
+ while @running
62
+ sleep 0.2
63
+ begin
64
+ drain_buffers
65
+ rescue => e
66
+ @logger.error("[WeixinSendQueue] drain_buffers error: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+
71
+ private def drain_buffers
72
+ now = Time.now
73
+ ready = {}
74
+
75
+ @buffer_mutex.synchronize do
76
+ @buffers.each do |chat_id, entries|
77
+ next if entries.empty?
78
+ total_chars = entries.sum { |e| e.text.chars.length }
79
+ elapsed = now - entries.first.enqueued_at
80
+ if total_chars >= FLUSH_CHAR_THRESHOLD || elapsed >= FLUSH_INTERVAL
81
+ ready[chat_id] = entries
82
+ end
83
+ end
84
+ ready.each_key { |chat_id| @buffers.delete(chat_id) }
85
+ end
86
+
87
+ ready.each do |chat_id, entries|
88
+ send_entries(chat_id, entries)
89
+ end
90
+ end
91
+
92
+ # Unconditionally drain every buffer. Used on stop to guarantee delivery.
93
+ private def drain_all_buffers
94
+ ready = @buffer_mutex.synchronize do
95
+ snapshot = @buffers.reject { |_, entries| entries.empty? }
96
+ @buffers.clear
97
+ snapshot
98
+ end
99
+
100
+ ready.each do |chat_id, entries|
101
+ begin
102
+ send_entries(chat_id, entries)
103
+ rescue => e
104
+ @logger.error("[WeixinSendQueue] final drain error for #{chat_id}: #{e.message}")
105
+ end
106
+ end
107
+ end
108
+
109
+ private def send_entries(chat_id, entries)
110
+ return if entries.empty?
111
+
112
+ combined = entries.map(&:text).join("\n")
113
+ ctoken = entries.last.context_token
114
+
115
+ # Split into ≤2000 char chunks
116
+ chunks = split_message(combined)
117
+ chunks.each do |chunk|
118
+ throttle
119
+ send_with_retry(chat_id, chunk, ctoken)
120
+ end
121
+ end
122
+
123
+ private def throttle
124
+ @last_mutex.synchronize do
125
+ last = @last_sent_at[:global] || Time.at(0)
126
+ wait = MIN_SEND_INTERVAL - (Time.now - last)
127
+ sleep(wait) if wait > 0
128
+ @last_sent_at[:global] = Time.now
129
+ end
130
+ end
131
+
132
+ private def send_with_retry(chat_id, text, context_token)
133
+ RETRY_BACKOFFS.each_with_index do |delay, idx|
134
+ begin
135
+ @api_client.send_text(to_user_id: chat_id, text: text, context_token: context_token)
136
+ return
137
+ rescue ApiClient::ApiError => e
138
+ if e.code == -2 && idx < RETRY_BACKOFFS.length - 1
139
+ @logger.warn("[WeixinSendQueue] ret=-2 for #{chat_id}, retry in #{delay}s (#{idx + 1}/#{RETRY_BACKOFFS.length})")
140
+ sleep delay
141
+ next
142
+ end
143
+ raise
144
+ end
145
+ end
146
+ rescue => e
147
+ @logger.error("[WeixinSendQueue] send_text failed for #{chat_id}: #{e.message}")
148
+ end
149
+
150
+ # Split text into ≤2000 Unicode character chunks.
151
+ private def split_message(text, limit: 2000)
152
+ return [text] if text.chars.length <= limit
153
+ chunks = []
154
+ while text.chars.length > limit
155
+ window = text.chars.first(limit).join
156
+ cut = window.rindex("\n\n")
157
+ cut = window.rindex("\n") if cut.nil?
158
+ cut = window.rindex(" ") if cut.nil?
159
+ cut = limit if cut.nil? || cut.zero?
160
+ chunks << text.chars.first(cut).join.rstrip
161
+ text = text.chars.drop(cut).join.lstrip
162
+ end
163
+ chunks << text unless text.empty?
164
+ chunks
165
+ end
166
+ end
167
+
168
+ # Weixin (WeChat iLink) adapter.
169
+ #
170
+ # Protocol: HTTP long-poll via ilinkai.weixin.qq.com
171
+ # Auth: token obtained from QR login (stored in channels.yml as `token`)
172
+ #
173
+ # Config keys (channels.yml):
174
+ # token: [String] bot token from QR login
175
+ # base_url: [String] API base URL (default: https://ilinkai.weixin.qq.com)
176
+ # allowed_users: [Array<String>] optional whitelist of from_user_id values
177
+ #
178
+ # Event fields yielded to ChannelManager:
179
+ # platform: :weixin
180
+ # chat_id: String (from_user_id — used for replies)
181
+ # user_id: String (from_user_id)
182
+ # text: String
183
+ # files: Array<Hash>
184
+ # message_id: String
185
+ # timestamp: Time
186
+ # chat_type: :direct
187
+ # context_token: String (must be echoed in every reply)
188
+ class Adapter < Base
189
+ RECONNECT_DELAY = 5
190
+
191
+ def self.platform_id
192
+ :weixin
193
+ end
194
+
195
+ def self.env_keys
196
+ %w[IM_WEIXIN_TOKEN IM_WEIXIN_BASE_URL IM_WEIXIN_ALLOWED_USERS]
197
+ end
198
+
199
+ def self.platform_config(data)
200
+ {
201
+ token: data["IM_WEIXIN_TOKEN"] || data["token"],
202
+ base_url: data["IM_WEIXIN_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL,
203
+ allowed_users: (data["IM_WEIXIN_ALLOWED_USERS"] || data["allowed_users"] || "")
204
+ .then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) }
205
+ }.compact
206
+ end
207
+
208
+ def self.set_env_data(data, config)
209
+ data["IM_WEIXIN_TOKEN"] = config[:token]
210
+ data["IM_WEIXIN_BASE_URL"] = config[:base_url] if config[:base_url]
211
+ data["IM_WEIXIN_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
212
+ end
213
+
214
+ def self.test_connection(fields)
215
+ token = fields[:token].to_s.strip
216
+
217
+ return { ok: false, error: "token is required" } if token.empty?
218
+
219
+ # Weixin iLink token is obtained via the QR scan flow and is already
220
+ # confirmed valid by the iLink API before we store it. There is no
221
+ # lightweight ping endpoint, so we just verify the token is present.
222
+ { ok: true, message: "Connected to Weixin iLink" }
223
+ end
224
+
225
+ def initialize(config)
226
+ @config = config
227
+ @token = config[:token].to_s
228
+ @base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL
229
+ @allowed_users = Array(config[:allowed_users])
230
+ @running = false
231
+ @on_message = nil
232
+ # In-memory store: user_id → context_token (for reply threading)
233
+ @context_tokens = {}
234
+ @ctx_mutex = Mutex.new
235
+ @api_client = ApiClient.new(base_url: @base_url, token: @token)
236
+ @send_queue = SendQueue.new(@api_client)
237
+ # Typing keepalive: user_id → { ticket:, thread:, cached_at: }
238
+ @typing_tickets = {}
239
+ @typing_mutex = Mutex.new
240
+ # Active keepalive threads: user_id → Thread
241
+ @keepalive_threads = {}
242
+ @keepalive_mutex = Mutex.new
243
+ end
244
+
245
+ def start(&on_message)
246
+ @running = true
247
+ @on_message = on_message
248
+
249
+ get_updates_buf = ""
250
+ consecutive_errors = 0
251
+
252
+ Octo::Logger.info("[WeixinAdapter] starting long-poll (base_url=#{@base_url})")
253
+
254
+ while @running
255
+ begin
256
+ resp = @api_client.get_updates(get_updates_buf: get_updates_buf)
257
+
258
+ consecutive_errors = 0
259
+ new_buf = resp["get_updates_buf"].to_s
260
+ get_updates_buf = new_buf unless new_buf.empty?
261
+
262
+ (resp["msgs"] || []).each do |msg|
263
+ process_message(msg)
264
+ rescue => e
265
+ Octo::Logger.warn("[WeixinAdapter] process_message error: #{e.message}")
266
+ end
267
+
268
+ rescue ApiClient::TimeoutError
269
+ # Long-poll server-side timeout is expected — just retry
270
+ rescue ApiClient::ApiError => e
271
+ if e.code == ApiClient::SESSION_EXPIRED_ERRCODE
272
+ Octo::Logger.warn("[WeixinAdapter] Session expired (token may need refresh), backing off 60s")
273
+ sleep 60
274
+ else
275
+ consecutive_errors += 1
276
+ Octo::Logger.warn("[WeixinAdapter] API error #{e.code}: #{e.message}")
277
+ sleep(consecutive_errors > 3 ? 30 : RECONNECT_DELAY)
278
+ end
279
+ rescue => e
280
+ consecutive_errors += 1
281
+ Octo::Logger.error("[WeixinAdapter] poll error: #{e.message}")
282
+ break unless @running
283
+ sleep(consecutive_errors > 3 ? 30 : RECONNECT_DELAY)
284
+ end
285
+ end
286
+ end
287
+
288
+ def stop
289
+ @running = false
290
+ @send_queue.stop
291
+ end
292
+
293
+ # Send a plain text reply to a user.
294
+ # The context_token from the inbound message is required by the Weixin protocol.
295
+ # Text is enqueued and sent in batches by the background flusher to avoid rate-limiting.
296
+ def send_text(chat_id, text, reply_to: nil)
297
+ ctoken = lookup_context_token(chat_id)
298
+ unless ctoken
299
+ Octo::Logger.warn("[WeixinAdapter] send_text: no context_token for #{chat_id}, dropping message")
300
+ return { message_id: nil }
301
+ end
302
+
303
+ plain = markdown_to_plain(text)
304
+ return { message_id: nil } if plain.empty?
305
+
306
+ @send_queue.enqueue(chat_id, plain, ctoken)
307
+ { message_id: nil }
308
+ end
309
+
310
+ # Force-flush pending text for a chat_id. Called before sending files or on task completion.
311
+ def flush_pending(chat_id)
312
+ @send_queue.flush(chat_id)
313
+ end
314
+
315
+ # Send a file to a user.
316
+ # file_path: local path to the file to send
317
+ # file_name: optional display name (defaults to basename)
318
+ def send_file(chat_id, file_path, name: nil, reply_to: nil)
319
+ ctoken = lookup_context_token(chat_id)
320
+ unless ctoken
321
+ Octo::Logger.warn("[WeixinAdapter] send_file: no context_token for #{chat_id}, dropping")
322
+ return { message_id: nil }
323
+ end
324
+
325
+ @send_queue.flush(chat_id)
326
+
327
+ @api_client.send_file(
328
+ to_user_id: chat_id,
329
+ file_path: file_path,
330
+ file_name: name || File.basename(file_path),
331
+ context_token: ctoken
332
+ )
333
+ { message_id: nil }
334
+ rescue => e
335
+ Octo::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.message}")
336
+ { message_id: nil }
337
+ end
338
+
339
+ def validate_config(config)
340
+ errors = []
341
+ errors << "token is required" if config[:token].nil? || config[:token].to_s.strip.empty?
342
+ errors
343
+ end
344
+
345
+ def supports_message_updates?
346
+ false
347
+ end
348
+
349
+
350
+ def process_message(msg)
351
+ # Only process inbound USER messages (message_type 1 = USER)
352
+ return unless msg["message_type"] == 1
353
+
354
+ from_user_id = msg["from_user_id"].to_s
355
+ context_token = msg["context_token"].to_s
356
+ return if from_user_id.empty? || context_token.empty?
357
+
358
+ if @allowed_users.any? && !@allowed_users.include?(from_user_id)
359
+ Octo::Logger.debug("[WeixinAdapter] ignoring message from #{from_user_id} (not in allowed_users)")
360
+ return
361
+ end
362
+
363
+ # Cache context_token — needed when sending replies
364
+ store_context_token(from_user_id, context_token)
365
+
366
+ item_list = msg["item_list"] || []
367
+ Octo::Logger.debug("[WeixinAdapter] item_list raw: #{item_list.to_json}")
368
+ text = extract_text(item_list)
369
+ files = extract_files(item_list)
370
+
371
+ # Require at least some content (text or files)
372
+ return if text.strip.empty? && files.empty?
373
+
374
+ event = {
375
+ type: :message,
376
+ platform: :weixin,
377
+ chat_id: from_user_id,
378
+ user_id: from_user_id,
379
+ text: text.strip,
380
+ files: files,
381
+ message_id: msg["message_id"]&.to_s,
382
+ timestamp: msg["create_time_ms"] ? Time.at(msg["create_time_ms"] / 1000.0) : Time.now,
383
+ chat_type: :direct,
384
+ context_token: context_token,
385
+ raw: msg
386
+ }
387
+
388
+ log_parts = []
389
+ log_parts << text.slice(0, 80) unless text.strip.empty?
390
+ log_parts << "#{files.size} file(s)" unless files.empty?
391
+ Octo::Logger.info("[WeixinAdapter] message from #{from_user_id}: #{log_parts.join(" + ")}")
392
+ @on_message&.call(event)
393
+ end
394
+
395
+ def extract_text(item_list)
396
+ parts = []
397
+ item_list.each do |item|
398
+ case item["type"]
399
+ when 1 # TEXT
400
+ raw_text = item.dig("text_item", "text").to_s.strip
401
+ ref = item["ref_msg"]
402
+ if ref && !ref.empty?
403
+ ref_parts = []
404
+ ref_parts << ref["title"] if ref["title"] && !ref["title"].empty?
405
+ if (ri = ref["message_item"]) && ri["type"] == 1
406
+ rt = ri.dig("text_item", "text").to_s.strip
407
+ ref_parts << rt unless rt.empty?
408
+ end
409
+ parts << "[引用: #{ref_parts.join(" | ")}]" unless ref_parts.empty?
410
+ end
411
+ parts << raw_text unless raw_text.empty?
412
+ when 3 # VOICE — use transcription if available
413
+ vt = item.dig("voice_item", "text").to_s.strip
414
+ parts << vt unless vt.empty?
415
+ end
416
+ end
417
+ parts.join("\n")
418
+ end
419
+
420
+ # Extract file attachments from item_list for inbound messages.
421
+ # Returns array of hashes: { type:, name:, cdn_media: }
422
+ # cdn_media contains { encrypt_query_param:, aes_key: } for potential download.
423
+ # Extract and materialize file attachments from an inbound item_list.
424
+ #
425
+ # Images are downloaded from CDN and converted to data_url so the agent's
426
+ # vision pipeline (partition_files → resolve_vision_images) picks them up.
427
+ # Files (PDF, DOCX, etc.) are downloaded to octo-uploads so the agent's
428
+ # file processing pipeline (process_path) can parse them.
429
+ # Voice/video are kept as cdn_media metadata only (no local download).
430
+ #
431
+ # Returns Array of Hashes. Image entries:
432
+ # { type: :image, name: String, mime_type: String, data_url: String }
433
+ # File entries (downloaded):
434
+ # { type: :file, name: String, path: String }
435
+ # Voice/video entries:
436
+ # { type: :voice/:video, name: String, cdn_media: Hash }
437
+ def extract_files(item_list)
438
+ files = []
439
+ item_list.each do |item|
440
+ case item["type"]
441
+ when 2 # IMAGE — download + convert to data_url for agent vision
442
+ img = item["image_item"]
443
+ next unless img
444
+ cdn_media = img["media"]
445
+ next unless cdn_media
446
+
447
+ # Protocol: image_item may have a top-level aeskey field that overrides
448
+ # the one inside media. Use image_item.aeskey first, fall back to media.aes_key.
449
+ top_level_aeskey = img["aeskey"]
450
+ effective_cdn_media = if top_level_aeskey && !top_level_aeskey.empty?
451
+ cdn_media.merge("aes_key" => top_level_aeskey)
452
+ else
453
+ cdn_media
454
+ end
455
+
456
+ Octo::Logger.debug("[WeixinAdapter] image cdn_media: #{effective_cdn_media.to_json}")
457
+
458
+ begin
459
+ raw_bytes = @api_client.download_media(effective_cdn_media, ApiClient::MEDIA_TYPE_IMAGE)
460
+ mime_type = detect_image_mime(raw_bytes)
461
+ data_url = "data:#{mime_type};base64,#{Base64.strict_encode64(raw_bytes)}"
462
+ files << {
463
+ type: :image,
464
+ name: "image.jpg",
465
+ mime_type: mime_type,
466
+ data_url: data_url
467
+ }
468
+ rescue => e
469
+ Octo::Logger.warn("[WeixinAdapter] Failed to download image: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
470
+ end
471
+
472
+ when 3 # VOICE
473
+ v = item["voice_item"]
474
+ next unless v
475
+ files << {
476
+ type: :voice,
477
+ name: "voice.amr",
478
+ cdn_media: v["media"]
479
+ }
480
+ when 4 # FILE — download to disk so agent can parse it
481
+ fi = item["file_item"]
482
+ next unless fi
483
+ cdn_media = fi["media"]
484
+ file_name = fi["file_name"].to_s
485
+ file_name = "attachment" if file_name.empty?
486
+ file_md5 = fi["md5"].to_s
487
+ file_len = fi["len"].to_s
488
+
489
+ if cdn_media
490
+ begin
491
+ raw_bytes = @api_client.download_media(cdn_media, ApiClient::MEDIA_TYPE_FILE)
492
+ saved = Octo::Utils::FileProcessor.save(body: raw_bytes, filename: file_name)
493
+ Octo::Logger.info("[WeixinAdapter] file downloaded to #{saved[:path]} (#{raw_bytes.bytesize} bytes)")
494
+ files << {
495
+ type: :file,
496
+ name: saved[:name],
497
+ path: saved[:path],
498
+ md5: file_md5.empty? ? nil : file_md5,
499
+ len: file_len.empty? ? nil : file_len
500
+ }
501
+ rescue => e
502
+ Octo::Logger.warn("[WeixinAdapter] Failed to download file #{file_name}: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
503
+ # Fall back to metadata-only so the agent at least knows a file was attached
504
+ files << {
505
+ type: :file,
506
+ name: file_name,
507
+ cdn_media: cdn_media,
508
+ md5: file_md5.empty? ? nil : file_md5,
509
+ len: file_len.empty? ? nil : file_len
510
+ }
511
+ end
512
+ else
513
+ files << {
514
+ type: :file,
515
+ name: file_name,
516
+ md5: file_md5.empty? ? nil : file_md5,
517
+ len: file_len.empty? ? nil : file_len
518
+ }
519
+ end
520
+ when 5 # VIDEO
521
+ vi = item["video_item"]
522
+ next unless vi
523
+ files << {
524
+ type: :video,
525
+ name: "video.mp4",
526
+ cdn_media: vi["media"]
527
+ }
528
+ end
529
+ end
530
+ files
531
+ end
532
+
533
+ # Detect image MIME type from magic bytes.
534
+ # Falls back to image/jpeg if unknown.
535
+ def detect_image_mime(bytes)
536
+ return "image/jpeg" unless bytes && bytes.bytesize >= 4
537
+ head = bytes.byteslice(0, 8).bytes
538
+ if head[0] == 0xFF && head[1] == 0xD8
539
+ "image/jpeg"
540
+ elsif head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47
541
+ "image/png"
542
+ elsif head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46
543
+ "image/gif"
544
+ elsif head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46
545
+ "image/webp"
546
+ else
547
+ "image/jpeg"
548
+ end
549
+ end
550
+
551
+ def store_context_token(user_id, token)
552
+ @ctx_mutex.synchronize { @context_tokens[user_id] = token }
553
+ end
554
+
555
+ def lookup_context_token(user_id)
556
+ @ctx_mutex.synchronize { @context_tokens[user_id] }
557
+ end
558
+
559
+ # Return all user IDs for which we have a cached context_token.
560
+ # Used by ChannelManager#known_users so callers can enumerate
561
+ # users reachable for proactive messaging.
562
+ def context_token_user_ids
563
+ @ctx_mutex.synchronize { @context_tokens.keys.dup }
564
+ end
565
+
566
+ # Split text into ≤2000 Unicode character chunks per iLink protocol recommendation.
567
+ # Priority: split at \n\n, then \n, then space, then hard cut.
568
+ def split_message(text, limit: 2000)
569
+ return [text] if text.chars.length <= limit
570
+ chunks = []
571
+ while text.chars.length > limit
572
+ window = text.chars.first(limit).join
573
+ # Prefer double-newline boundary
574
+ cut = window.rindex("\n\n")
575
+ cut = window.rindex("\n") if cut.nil?
576
+ cut = window.rindex(" ") if cut.nil?
577
+ cut = limit if cut.nil? || cut.zero?
578
+ chunks << text.chars.first(cut).join.rstrip
579
+ text = text.chars.drop(cut).join.lstrip
580
+ end
581
+ chunks << text unless text.empty?
582
+ chunks
583
+ end
584
+
585
+ # Strip markdown syntax for WeChat (no markdown rendering).
586
+ def markdown_to_plain(text)
587
+ r = text.dup
588
+ r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
589
+ r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
590
+ r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\\1')
591
+ r.gsub!(/\*\*([^*]+)\*\*/, '\\1')
592
+ r.gsub!(/\*([^*]+)\*/, '\\1')
593
+ r.gsub!(/__([^_]+)__/, '\\1')
594
+ r.gsub!(/_([^_]+)_/, '\\1')
595
+ r.gsub!(/^#+\s+/, "")
596
+ r.gsub!(/^[-*_]{3,}\s*$/, "")
597
+ r.strip
598
+ end
599
+
600
+ # ── Typing keepalive ─────────────────────────────────────────────────
601
+ # sendtyping(status=1) serves dual purpose: maintains typing indicator AND
602
+ # renews the context_token TTL. Official @tencent-weixin/openclaw-weixin
603
+ # npm package uses keepaliveIntervalMs: 5000. We match that exactly.
604
+ TYPING_KEEPALIVE_INTERVAL = 5
605
+ # typing_ticket is valid for ~24h; cache and reuse it.
606
+ TYPING_TICKET_TTL = 86_400
607
+
608
+ # Fetch (or return cached) typing_ticket for user_id.
609
+ # Returns nil on failure — keepalive will just skip without crashing.
610
+ def fetch_typing_ticket(user_id, context_token)
611
+ @typing_mutex.synchronize do
612
+ entry = @typing_tickets[user_id]
613
+ if entry && (Time.now.to_i - entry[:cached_at]) < TYPING_TICKET_TTL
614
+ return entry[:ticket]
615
+ end
616
+ end
617
+
618
+ ticket = @api_client.get_typing_ticket(
619
+ ilink_user_id: user_id,
620
+ context_token: context_token
621
+ )
622
+ return nil if ticket.empty?
623
+
624
+ @typing_mutex.synchronize do
625
+ @typing_tickets[user_id] = { ticket: ticket, cached_at: Time.now.to_i }
626
+ end
627
+ ticket
628
+ rescue => e
629
+ Octo::Logger.warn("[WeixinAdapter] getconfig failed for #{user_id}: #{e.message}")
630
+ nil
631
+ end
632
+
633
+ # Start a background thread that sends sendtyping(1) every TYPING_KEEPALIVE_INTERVAL.
634
+ # Any existing keepalive for this user is stopped first.
635
+ def start_typing_keepalive(user_id, context_token)
636
+ stop_typing_keepalive(user_id)
637
+
638
+ ticket = fetch_typing_ticket(user_id, context_token)
639
+ unless ticket
640
+ Octo::Logger.debug("[WeixinAdapter] no typing_ticket for #{user_id}, skipping keepalive")
641
+ return
642
+ end
643
+
644
+ thread = Thread.new do
645
+ loop do
646
+ begin
647
+ @api_client.send_typing(
648
+ ilink_user_id: user_id,
649
+ typing_ticket: ticket,
650
+ status: 1
651
+ )
652
+ Octo::Logger.debug("[WeixinAdapter] typing keepalive sent for #{user_id}")
653
+ rescue => e
654
+ Octo::Logger.debug("[WeixinAdapter] typing keepalive error: #{e.message}")
655
+ end
656
+ sleep TYPING_KEEPALIVE_INTERVAL
657
+ end
658
+ end
659
+
660
+ @keepalive_mutex.synchronize { @keepalive_threads[user_id] = thread }
661
+ Octo::Logger.debug("[WeixinAdapter] typing keepalive started for #{user_id}")
662
+ end
663
+
664
+ # Stop keepalive thread and send sendtyping(status=2) to cancel "typing" indicator.
665
+ def stop_typing_keepalive(user_id)
666
+ thread = @keepalive_mutex.synchronize { @keepalive_threads.delete(user_id) }
667
+ return unless thread
668
+
669
+ thread.kill
670
+ thread.join(1)
671
+
672
+ ticket = @typing_mutex.synchronize { @typing_tickets.dig(user_id, :ticket) }
673
+ if ticket
674
+ begin
675
+ @api_client.send_typing(
676
+ ilink_user_id: user_id,
677
+ typing_ticket: ticket,
678
+ status: 2
679
+ )
680
+ rescue => e
681
+ Octo::Logger.debug("[WeixinAdapter] stop typing error: #{e.message}")
682
+ end
683
+ end
684
+ Octo::Logger.debug("[WeixinAdapter] typing keepalive stopped for #{user_id}")
685
+ end
686
+ end
687
+
688
+ Adapters.register(:weixin, Adapter)
689
+ end
690
+ end
691
+ end
692
+ end