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.
- checksums.yaml +7 -0
- data/.clacky/skills/commit/SKILL.md +423 -0
- data/.clacky/skills/gem-release/SKILL.md +199 -0
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/.octorules +106 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +76 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +92 -0
- data/Dockerfile +28 -0
- data/LICENSE.txt +22 -0
- data/POSITIONING.md +46 -0
- data/README.md +134 -0
- data/README_CN.md +134 -0
- data/Rakefile +34 -0
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/bin/octo +7 -0
- data/docs/agent-first-ui-design.md +77 -0
- data/docs/billing-system.md +318 -0
- data/docs/channel-architecture.md +235 -0
- data/docs/engineering-article.md +343 -0
- data/docs/session-skill-invocation.md +69 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/ui2-architecture.md +124 -0
- data/homebrew/README.md +96 -0
- data/homebrew/openocto.rb +24 -0
- data/lib/octo/agent/hook_manager.rb +61 -0
- data/lib/octo/agent/llm_caller.rb +800 -0
- data/lib/octo/agent/memory_updater.rb +246 -0
- data/lib/octo/agent/message_compressor.rb +225 -0
- data/lib/octo/agent/message_compressor_helper.rb +869 -0
- data/lib/octo/agent/next_message_suggester.rb +215 -0
- data/lib/octo/agent/session_serializer.rb +685 -0
- data/lib/octo/agent/skill_auto_creator.rb +114 -0
- data/lib/octo/agent/skill_evolution.rb +61 -0
- data/lib/octo/agent/skill_manager.rb +466 -0
- data/lib/octo/agent/skill_reflector.rb +89 -0
- data/lib/octo/agent/system_prompt_builder.rb +101 -0
- data/lib/octo/agent/time_machine.rb +214 -0
- data/lib/octo/agent/tool_executor.rb +454 -0
- data/lib/octo/agent/tool_registry.rb +150 -0
- data/lib/octo/agent.rb +2180 -0
- data/lib/octo/agent_config.rb +989 -0
- data/lib/octo/agent_profile.rb +112 -0
- data/lib/octo/anthropic_stream_aggregator.rb +137 -0
- data/lib/octo/background_task_registry.rb +324 -0
- data/lib/octo/banner.rb +34 -0
- data/lib/octo/bedrock_stream_aggregator.rb +137 -0
- data/lib/octo/block_font.rb +331 -0
- data/lib/octo/cli.rb +968 -0
- data/lib/octo/client.rb +623 -0
- data/lib/octo/default_agents/SOUL.md +3 -0
- data/lib/octo/default_agents/USER.md +1 -0
- data/lib/octo/default_agents/base_prompt.md +66 -0
- data/lib/octo/default_agents/coding/profile.yml +2 -0
- data/lib/octo/default_agents/coding/system_prompt.md +67 -0
- data/lib/octo/default_agents/general/profile.yml +2 -0
- data/lib/octo/default_agents/general/system_prompt.md +16 -0
- data/lib/octo/default_parsers/doc_parser.rb +69 -0
- data/lib/octo/default_parsers/docx_parser.rb +188 -0
- data/lib/octo/default_parsers/pdf_parser.rb +120 -0
- data/lib/octo/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/octo/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/octo/default_parsers/pptx_parser.rb +140 -0
- data/lib/octo/default_parsers/xlsx_parser.rb +121 -0
- data/lib/octo/default_skills/browser-setup/SKILL.md +426 -0
- data/lib/octo/default_skills/channel-manager/SKILL.md +623 -0
- data/lib/octo/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/octo/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/octo/default_skills/channel-manager/feishu_setup.rb +574 -0
- data/lib/octo/default_skills/channel-manager/import_lark_skills.rb +97 -0
- data/lib/octo/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/octo/default_skills/channel-manager/weixin_setup.rb +274 -0
- data/lib/octo/default_skills/code-explorer/SKILL.md +36 -0
- data/lib/octo/default_skills/cron-task-creator/SKILL.md +257 -0
- data/lib/octo/default_skills/cron-task-creator/evals/evals.json +38 -0
- data/lib/octo/default_skills/onboard/SKILL.md +578 -0
- data/lib/octo/default_skills/onboard/scripts/import_external_skills.rb +413 -0
- data/lib/octo/default_skills/onboard/scripts/install_builtin_skills.rb +97 -0
- data/lib/octo/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/octo/default_skills/personal-website/SKILL.md +113 -0
- data/lib/octo/default_skills/personal-website/publish.rb +235 -0
- data/lib/octo/default_skills/product-help/SKILL.md +123 -0
- data/lib/octo/default_skills/product-help/docs/agent-config.md +74 -0
- data/lib/octo/default_skills/product-help/docs/best-practices.md +49 -0
- data/lib/octo/default_skills/product-help/docs/browser-tool.md +53 -0
- data/lib/octo/default_skills/product-help/docs/built-in-skills.md +43 -0
- data/lib/octo/default_skills/product-help/docs/cli-reference.md +82 -0
- data/lib/octo/default_skills/product-help/docs/create-your-first-skill.md +47 -0
- data/lib/octo/default_skills/product-help/docs/faq.md +98 -0
- data/lib/octo/default_skills/product-help/docs/how-to-use-a-skill.md +58 -0
- data/lib/octo/default_skills/product-help/docs/installation.md +59 -0
- data/lib/octo/default_skills/product-help/docs/memory-system.md +61 -0
- data/lib/octo/default_skills/product-help/docs/octorules.md +62 -0
- data/lib/octo/default_skills/product-help/docs/session-management.md +63 -0
- data/lib/octo/default_skills/product-help/docs/skill-basics.md +55 -0
- data/lib/octo/default_skills/product-help/docs/skill-frontmatter.md +61 -0
- data/lib/octo/default_skills/product-help/docs/web-server.md +49 -0
- data/lib/octo/default_skills/product-help/docs/what-is-octo.md +37 -0
- data/lib/octo/default_skills/product-help/docs/windows-installation.md +36 -0
- data/lib/octo/default_skills/product-help/docs/writing-tips.md +53 -0
- data/lib/octo/default_skills/recall-memory/SKILL.md +65 -0
- data/lib/octo/default_skills/skill-add/SKILL.md +59 -0
- data/lib/octo/default_skills/skill-add/scripts/install_from_zip.rb +295 -0
- data/lib/octo/default_skills/skill-creator/SKILL.md +602 -0
- data/lib/octo/default_skills/skill-creator/agents/analyzer.md +274 -0
- data/lib/octo/default_skills/skill-creator/agents/comparator.md +202 -0
- data/lib/octo/default_skills/skill-creator/agents/grader.md +223 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/generate_review.py +471 -0
- data/lib/octo/default_skills/skill-creator/eval-viewer/viewer.html +1325 -0
- data/lib/octo/default_skills/skill-creator/references/schemas.md +430 -0
- data/lib/octo/default_skills/skill-creator/scripts/__init__.py +0 -0
- data/lib/octo/default_skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- data/lib/octo/default_skills/skill-creator/scripts/generate_report.py +326 -0
- data/lib/octo/default_skills/skill-creator/scripts/improve_description.py +310 -0
- data/lib/octo/default_skills/skill-creator/scripts/quick_validate.py +103 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_eval.py +317 -0
- data/lib/octo/default_skills/skill-creator/scripts/run_loop.py +331 -0
- data/lib/octo/default_skills/skill-creator/scripts/utils.py +47 -0
- data/lib/octo/default_skills/skill-creator/scripts/validate_skill_frontmatter.rb +143 -0
- data/lib/octo/idle_compression_timer.rb +115 -0
- data/lib/octo/json_ui_controller.rb +204 -0
- data/lib/octo/message_format/anthropic.rb +409 -0
- data/lib/octo/message_format/bedrock.rb +361 -0
- data/lib/octo/message_format/open_ai.rb +222 -0
- data/lib/octo/message_history.rb +373 -0
- data/lib/octo/openai_stream_aggregator.rb +130 -0
- data/lib/octo/plain_ui_controller.rb +166 -0
- data/lib/octo/providers.rb +534 -0
- data/lib/octo/server/browser_manager.rb +397 -0
- data/lib/octo/server/channel/adapters/base.rb +82 -0
- data/lib/octo/server/channel/adapters/dingtalk/adapter.rb +314 -0
- data/lib/octo/server/channel/adapters/dingtalk/api_client.rb +391 -0
- data/lib/octo/server/channel/adapters/dingtalk/stream_client.rb +203 -0
- data/lib/octo/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/octo/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/octo/server/channel/adapters/discord/gateway_client.rb +270 -0
- data/lib/octo/server/channel/adapters/feishu/adapter.rb +320 -0
- data/lib/octo/server/channel/adapters/feishu/bot.rb +478 -0
- data/lib/octo/server/channel/adapters/feishu/file_processor.rb +36 -0
- data/lib/octo/server/channel/adapters/feishu/message_parser.rb +129 -0
- data/lib/octo/server/channel/adapters/feishu/ws_client.rb +423 -0
- data/lib/octo/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/octo/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/octo/server/channel/adapters/wecom/adapter.rb +148 -0
- data/lib/octo/server/channel/adapters/wecom/media_downloader.rb +115 -0
- data/lib/octo/server/channel/adapters/wecom/ws_client.rb +395 -0
- data/lib/octo/server/channel/adapters/weixin/adapter.rb +692 -0
- data/lib/octo/server/channel/adapters/weixin/api_client.rb +402 -0
- data/lib/octo/server/channel/channel_config.rb +178 -0
- data/lib/octo/server/channel/channel_manager.rb +468 -0
- data/lib/octo/server/channel/channel_ui_controller.rb +224 -0
- data/lib/octo/server/channel.rb +33 -0
- data/lib/octo/server/discover.rb +77 -0
- data/lib/octo/server/epipe_safe_io.rb +105 -0
- data/lib/octo/server/http_server.rb +3554 -0
- data/lib/octo/server/scheduler.rb +317 -0
- data/lib/octo/server/server_master.rb +325 -0
- data/lib/octo/server/session_registry.rb +431 -0
- data/lib/octo/server/web_ui_controller.rb +487 -0
- data/lib/octo/session_manager.rb +385 -0
- data/lib/octo/skill.rb +466 -0
- data/lib/octo/skill_loader.rb +328 -0
- data/lib/octo/tools/base.rb +118 -0
- data/lib/octo/tools/browser.rb +625 -0
- data/lib/octo/tools/edit.rb +165 -0
- data/lib/octo/tools/file_reader.rb +549 -0
- data/lib/octo/tools/glob.rb +162 -0
- data/lib/octo/tools/grep.rb +356 -0
- data/lib/octo/tools/invoke_skill.rb +96 -0
- data/lib/octo/tools/list_tasks.rb +54 -0
- data/lib/octo/tools/redo_task.rb +41 -0
- data/lib/octo/tools/request_user_feedback.rb +84 -0
- data/lib/octo/tools/security.rb +333 -0
- data/lib/octo/tools/terminal/output_cleaner.rb +63 -0
- data/lib/octo/tools/terminal/persistent_session.rb +268 -0
- data/lib/octo/tools/terminal/safe_rm.sh +106 -0
- data/lib/octo/tools/terminal/session_manager.rb +213 -0
- data/lib/octo/tools/terminal.rb +1828 -0
- data/lib/octo/tools/todo_manager.rb +374 -0
- data/lib/octo/tools/trash_manager.rb +388 -0
- data/lib/octo/tools/undo_task.rb +35 -0
- data/lib/octo/tools/web_fetch.rb +242 -0
- data/lib/octo/tools/web_search.rb +260 -0
- data/lib/octo/tools/write.rb +77 -0
- data/lib/octo/ui2/block_font.rb +10 -0
- data/lib/octo/ui2/components/base_component.rb +163 -0
- data/lib/octo/ui2/components/command_suggestions.rb +290 -0
- data/lib/octo/ui2/components/common_component.rb +96 -0
- data/lib/octo/ui2/components/inline_input.rb +226 -0
- data/lib/octo/ui2/components/input_area.rb +1338 -0
- data/lib/octo/ui2/components/message_component.rb +99 -0
- data/lib/octo/ui2/components/modal_component.rb +419 -0
- data/lib/octo/ui2/components/todo_area.rb +149 -0
- data/lib/octo/ui2/components/tool_component.rb +107 -0
- data/lib/octo/ui2/components/welcome_banner.rb +139 -0
- data/lib/octo/ui2/layout_manager.rb +807 -0
- data/lib/octo/ui2/line_editor.rb +363 -0
- data/lib/octo/ui2/markdown_renderer.rb +100 -0
- data/lib/octo/ui2/output_buffer.rb +370 -0
- data/lib/octo/ui2/progress_handle.rb +362 -0
- data/lib/octo/ui2/progress_indicator.rb +55 -0
- data/lib/octo/ui2/screen_buffer.rb +273 -0
- data/lib/octo/ui2/terminal_detector.rb +119 -0
- data/lib/octo/ui2/theme_manager.rb +85 -0
- data/lib/octo/ui2/themes/base_theme.rb +105 -0
- data/lib/octo/ui2/themes/hacker_theme.rb +62 -0
- data/lib/octo/ui2/themes/minimal_theme.rb +56 -0
- data/lib/octo/ui2/thinking_verbs.rb +26 -0
- data/lib/octo/ui2/ui_controller.rb +1625 -0
- data/lib/octo/ui2/view_renderer.rb +177 -0
- data/lib/octo/ui2.rb +40 -0
- data/lib/octo/ui_interface.rb +154 -0
- data/lib/octo/utils/arguments_parser.rb +191 -0
- data/lib/octo/utils/browser_detector.rb +195 -0
- data/lib/octo/utils/encoding.rb +92 -0
- data/lib/octo/utils/environment_detector.rb +140 -0
- data/lib/octo/utils/file_ignore_helper.rb +170 -0
- data/lib/octo/utils/file_processor.rb +601 -0
- data/lib/octo/utils/gitignore_parser.rb +154 -0
- data/lib/octo/utils/limit_stack.rb +152 -0
- data/lib/octo/utils/logger.rb +124 -0
- data/lib/octo/utils/login_shell.rb +72 -0
- data/lib/octo/utils/model_pricing.rb +646 -0
- data/lib/octo/utils/parser_manager.rb +165 -0
- data/lib/octo/utils/path_helper.rb +15 -0
- data/lib/octo/utils/scripts_manager.rb +59 -0
- data/lib/octo/utils/string_matcher.rb +158 -0
- data/lib/octo/utils/trash_directory.rb +112 -0
- data/lib/octo/utils/workspace_rules.rb +46 -0
- data/lib/octo/version.rb +5 -0
- data/lib/octo/web/app.css +7141 -0
- data/lib/octo/web/app.js +543 -0
- data/lib/octo/web/apple-touch-icon.png +0 -0
- data/lib/octo/web/auth.js +150 -0
- data/lib/octo/web/channels.js +276 -0
- data/lib/octo/web/datepicker.js +205 -0
- data/lib/octo/web/favicon.png +0 -0
- data/lib/octo/web/i18n.js +1073 -0
- data/lib/octo/web/icon-512.png +0 -0
- data/lib/octo/web/icon-dark.svg +25 -0
- data/lib/octo/web/icon.svg +29 -0
- data/lib/octo/web/index.html +871 -0
- data/lib/octo/web/marked.min.js +69 -0
- data/lib/octo/web/onboard.js +491 -0
- data/lib/octo/web/profile.js +442 -0
- data/lib/octo/web/sessions.js +4421 -0
- data/lib/octo/web/settings.js +913 -0
- data/lib/octo/web/sidebar.js +32 -0
- data/lib/octo/web/skills.js +885 -0
- data/lib/octo/web/tasks.js +297 -0
- data/lib/octo/web/theme.js +105 -0
- data/lib/octo/web/trash.js +343 -0
- data/lib/octo/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/octo/web/vendor/hljs/hljs-theme.css +95 -0
- data/lib/octo/web/vendor/katex/auto-render.min.js +1 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/lib/octo/web/vendor/katex/katex.min.css +1 -0
- data/lib/octo/web/vendor/katex/katex.min.js +1 -0
- data/lib/octo/web/version.js +449 -0
- data/lib/octo/web/weixin-qr.html +209 -0
- data/lib/octo/web/ws-dispatcher.js +357 -0
- data/lib/octo/web/ws.js +128 -0
- data/lib/octo.rb +145 -0
- data/scripts/build/build.sh +329 -0
- data/scripts/build/lib/apt.sh +56 -0
- data/scripts/build/lib/brew.sh +89 -0
- data/scripts/build/lib/colors.sh +17 -0
- data/scripts/build/lib/gem.sh +95 -0
- data/scripts/build/lib/mise.sh +125 -0
- data/scripts/build/lib/network.sh +157 -0
- data/scripts/build/lib/os.sh +57 -0
- data/scripts/build/lib/shell.sh +37 -0
- data/scripts/build/src/install.sh.cc +174 -0
- data/scripts/build/src/install_browser.sh.cc +101 -0
- data/scripts/build/src/install_full.sh.cc +290 -0
- data/scripts/build/src/install_rails_deps.sh.cc +145 -0
- data/scripts/build/src/install_system_deps.sh.cc +123 -0
- data/scripts/build/src/uninstall.sh.cc +101 -0
- data/scripts/install.ps1 +532 -0
- data/scripts/install.sh +567 -0
- data/scripts/install_browser.sh +479 -0
- data/scripts/install_full.sh +838 -0
- data/scripts/install_rails_deps.sh +746 -0
- data/scripts/install_system_deps.sh +518 -0
- data/scripts/uninstall.sh +287 -0
- data/sig/octo.rbs +4 -0
- 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
|