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