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,402 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "digest"
|
|
9
|
+
require "tempfile"
|
|
10
|
+
|
|
11
|
+
module Octo
|
|
12
|
+
module Channel
|
|
13
|
+
module Adapters
|
|
14
|
+
module Weixin
|
|
15
|
+
# HTTP API client for Weixin iLink bot protocol.
|
|
16
|
+
#
|
|
17
|
+
# All requests POST JSON to <base_url>/<endpoint>.
|
|
18
|
+
# Required headers per request:
|
|
19
|
+
# Content-Type: application/json
|
|
20
|
+
# AuthorizationType: ilink_bot_token
|
|
21
|
+
# Authorization: Bearer <token>
|
|
22
|
+
# X-WECHAT-UIN: base64(random uint32 as decimal string)
|
|
23
|
+
class ApiClient
|
|
24
|
+
DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
25
|
+
CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
|
26
|
+
API_PATH_PREFIX = "ilink/bot"
|
|
27
|
+
CHANNEL_VERSION = "1.0.2"
|
|
28
|
+
LONG_POLL_TIMEOUT_S = 40 # slightly above the server's 35s
|
|
29
|
+
API_TIMEOUT_S = 15
|
|
30
|
+
|
|
31
|
+
# media_type values for getuploadurl
|
|
32
|
+
MEDIA_TYPE_IMAGE = 1
|
|
33
|
+
MEDIA_TYPE_VIDEO = 2
|
|
34
|
+
MEDIA_TYPE_FILE = 3
|
|
35
|
+
MEDIA_TYPE_VOICE = 4
|
|
36
|
+
|
|
37
|
+
# Raised for non-zero API return codes or HTTP errors.
|
|
38
|
+
class ApiError < StandardError
|
|
39
|
+
attr_reader :code
|
|
40
|
+
def initialize(code, msg)
|
|
41
|
+
@code = code
|
|
42
|
+
super("WeixinApiError(#{code}): #{msg.to_s.slice(0, 200)}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Raised on network/read timeouts.
|
|
47
|
+
class TimeoutError < StandardError; end
|
|
48
|
+
|
|
49
|
+
# Server errcode for expired sessions.
|
|
50
|
+
SESSION_EXPIRED_ERRCODE = -14
|
|
51
|
+
|
|
52
|
+
def initialize(base_url:, token:)
|
|
53
|
+
@base_url = base_url.to_s.chomp("/")
|
|
54
|
+
@token = token.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Long-poll for new messages.
|
|
58
|
+
# @param get_updates_buf [String] cursor from last response ("" for first call)
|
|
59
|
+
# @return [Hash] { ret:, msgs: [], get_updates_buf:, longpolling_timeout_ms: }
|
|
60
|
+
def get_updates(get_updates_buf:)
|
|
61
|
+
post("getupdates", { get_updates_buf: get_updates_buf }, timeout: LONG_POLL_TIMEOUT_S)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Retrieve a typing_ticket for the given user.
|
|
65
|
+
# context_token is optional but recommended per protocol spec.
|
|
66
|
+
# @return [String] typing_ticket
|
|
67
|
+
def get_typing_ticket(ilink_user_id:, context_token: nil)
|
|
68
|
+
body = { ilink_user_id: ilink_user_id }
|
|
69
|
+
body[:context_token] = context_token if context_token
|
|
70
|
+
resp = post("getconfig", body)
|
|
71
|
+
resp["typing_ticket"].to_s
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Send/keep/cancel typing indicator.
|
|
75
|
+
# @param status [Integer] 1 = typing, 2 = cancel
|
|
76
|
+
def send_typing(ilink_user_id:, typing_ticket:, status:)
|
|
77
|
+
post("sendtyping", {
|
|
78
|
+
ilink_user_id: ilink_user_id,
|
|
79
|
+
typing_ticket: typing_ticket,
|
|
80
|
+
status: status
|
|
81
|
+
})
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Send a plain text message.
|
|
85
|
+
# context_token is required by the Weixin protocol for conversation association.
|
|
86
|
+
def send_text(to_user_id:, text:, context_token:)
|
|
87
|
+
body = {
|
|
88
|
+
msg: {
|
|
89
|
+
from_user_id: "",
|
|
90
|
+
to_user_id: to_user_id,
|
|
91
|
+
client_id: "octo-#{SecureRandom.hex(8)}",
|
|
92
|
+
message_type: 2, # BOT
|
|
93
|
+
message_state: 2, # FINISH
|
|
94
|
+
item_list: [{ type: 1, text_item: { text: text } }],
|
|
95
|
+
context_token: context_token
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
post("sendmessage", body)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Send a file (any type) to a user.
|
|
102
|
+
#
|
|
103
|
+
# @param to_user_id [String]
|
|
104
|
+
# @param file_path [String] local path to the file
|
|
105
|
+
# @param file_name [String] display name (defaults to basename)
|
|
106
|
+
# @param context_token [String]
|
|
107
|
+
# @param media_type [Integer] MEDIA_TYPE_* constant (default: auto-detect)
|
|
108
|
+
# @return [Hash] API response
|
|
109
|
+
def send_file(to_user_id:, file_path:, context_token:, file_name: nil, media_type: nil)
|
|
110
|
+
file_name ||= File.basename(file_path)
|
|
111
|
+
media_type ||= detect_media_type(file_name)
|
|
112
|
+
raw_bytes = File.binread(file_path)
|
|
113
|
+
|
|
114
|
+
cdn_media = upload_media(
|
|
115
|
+
raw_bytes: raw_bytes,
|
|
116
|
+
file_name: file_name,
|
|
117
|
+
media_type: media_type,
|
|
118
|
+
to_user_id: to_user_id
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
item = build_media_item(media_type, cdn_media, raw_bytes, file_name)
|
|
122
|
+
body = {
|
|
123
|
+
msg: {
|
|
124
|
+
from_user_id: "",
|
|
125
|
+
to_user_id: to_user_id,
|
|
126
|
+
client_id: "octo-#{SecureRandom.hex(8)}",
|
|
127
|
+
message_type: 2, # BOT
|
|
128
|
+
message_state: 2, # FINISH
|
|
129
|
+
item_list: [item],
|
|
130
|
+
context_token: context_token
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
Octo::Logger.debug("[WeixinApiClient] send_file item: #{item.to_json}")
|
|
134
|
+
post("sendmessage", body)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Download and decrypt a media file from the Weixin CDN.
|
|
138
|
+
#
|
|
139
|
+
# @param cdn_media [Hash] { "encrypt_query_param" => String, "aes_key" => String }
|
|
140
|
+
# Keys may be Symbol or String.
|
|
141
|
+
# @param media_type [Integer] MEDIA_TYPE_* constant — controls aeskey decoding.
|
|
142
|
+
# @return [String] raw (decrypted) file bytes.
|
|
143
|
+
def download_media(cdn_media, media_type)
|
|
144
|
+
encrypted_param = cdn_media[:encrypt_query_param] || cdn_media["encrypt_query_param"]
|
|
145
|
+
aeskey_b64 = cdn_media[:aes_key] || cdn_media["aes_key"]
|
|
146
|
+
|
|
147
|
+
raise ApiError.new(0, "download_media: missing encrypt_query_param") unless encrypted_param
|
|
148
|
+
raise ApiError.new(0, "download_media: missing aes_key") unless aeskey_b64
|
|
149
|
+
|
|
150
|
+
# Decode aes_key. The encoding depends on who generated the key:
|
|
151
|
+
#
|
|
152
|
+
# Outbound (we upload): image → base64(raw 16 bytes), others → base64(hex 32 chars)
|
|
153
|
+
# Inbound (WeChat client uploaded): aes_key is a plain hex string (32 hex chars, no base64)
|
|
154
|
+
#
|
|
155
|
+
# Detection strategy — try to figure out the actual key by checking decoded size:
|
|
156
|
+
# decoded 16 bytes → raw AES key (our outbound image encoding)
|
|
157
|
+
# decoded 24 bytes → aes_key was a plain hex string (32 chars) passed as-is,
|
|
158
|
+
# meaning aeskey_b64 IS the hex string, not base64 at all.
|
|
159
|
+
# Use the original string directly: [aeskey_b64].pack("H*")
|
|
160
|
+
# decoded 32 bytes → base64(hex 32 chars) → [decoded].pack("H*") → 16 bytes
|
|
161
|
+
raw_aes_key = begin
|
|
162
|
+
decoded = Base64.strict_decode64(aeskey_b64)
|
|
163
|
+
case decoded.bytesize
|
|
164
|
+
when 16
|
|
165
|
+
# Our outbound image encoding: base64(raw 16 bytes)
|
|
166
|
+
decoded
|
|
167
|
+
when 32
|
|
168
|
+
# Our outbound non-image encoding: base64(hex 32 chars)
|
|
169
|
+
[decoded].pack("H*")
|
|
170
|
+
else
|
|
171
|
+
# Unexpected — fall through to hex-string path
|
|
172
|
+
raise ArgumentError, "unexpected decoded size #{decoded.bytesize}"
|
|
173
|
+
end
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
# aes_key is a plain hex string (32 hex chars), not base64.
|
|
176
|
+
# This is the inbound format used by WeChat clients.
|
|
177
|
+
if aeskey_b64.match?(/\A[0-9a-fA-F]{32}\z/)
|
|
178
|
+
[aeskey_b64].pack("H*")
|
|
179
|
+
else
|
|
180
|
+
Octo::Logger.warn("[WeixinApiClient] unknown aeskey format: len=#{aeskey_b64.bytesize}")
|
|
181
|
+
aeskey_b64[0, 16] # last-resort: first 16 bytes
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Octo::Logger.debug("[WeixinApiClient] download_media key_bytes=#{raw_aes_key.bytesize} media_type=#{media_type}")
|
|
186
|
+
|
|
187
|
+
# GET encrypted bytes from CDN.
|
|
188
|
+
cdn_url = "#{CDN_BASE_URL}/download" \
|
|
189
|
+
"?encrypted_query_param=#{URI.encode_uri_component(encrypted_param)}"
|
|
190
|
+
encrypted_bytes = cdn_get(cdn_url)
|
|
191
|
+
|
|
192
|
+
# Decrypt with AES-128-ECB.
|
|
193
|
+
aes_ecb_decrypt(encrypted_bytes, raw_aes_key)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Full upload pipeline: encrypt → getuploadurl → CDN PUT → return CDNMedia hash.
|
|
198
|
+
def upload_media(raw_bytes:, file_name:, media_type:, to_user_id:)
|
|
199
|
+
# Generate a random 16-byte AES key.
|
|
200
|
+
aes_key_raw = SecureRandom.bytes(16)
|
|
201
|
+
|
|
202
|
+
# Encrypt file bytes with AES-128-ECB + PKCS7.
|
|
203
|
+
encrypted_bytes = aes_ecb_encrypt(raw_bytes, aes_key_raw)
|
|
204
|
+
|
|
205
|
+
# filekey: arbitrary unique string (use hex of random bytes).
|
|
206
|
+
filekey = SecureRandom.hex(16)
|
|
207
|
+
|
|
208
|
+
# aeskey for getuploadurl: hex string of raw 16 bytes (32 hex chars), NOT base64.
|
|
209
|
+
# Confirmed from @tencent-weixin/openclaw-weixin source: aeskey.toString("hex")
|
|
210
|
+
aeskey_hex = aes_key_raw.unpack1("H*")
|
|
211
|
+
|
|
212
|
+
# aes_key for CDNMedia: base64 of the hex string as UTF-8 bytes.
|
|
213
|
+
# Confirmed: Buffer.from(aeskey_hex).toString("base64") in Node.js = base64 of hex string bytes
|
|
214
|
+
aeskey_b64 = Base64.strict_encode64(aeskey_hex)
|
|
215
|
+
|
|
216
|
+
raw_md5 = Digest::MD5.hexdigest(raw_bytes)
|
|
217
|
+
|
|
218
|
+
# Step 1: get CDN upload URL from iLink API.
|
|
219
|
+
upload_resp = post("getuploadurl", {
|
|
220
|
+
filekey: filekey,
|
|
221
|
+
media_type: media_type,
|
|
222
|
+
to_user_id: to_user_id,
|
|
223
|
+
rawsize: raw_bytes.bytesize,
|
|
224
|
+
rawfilemd5: raw_md5,
|
|
225
|
+
filesize: encrypted_bytes.bytesize,
|
|
226
|
+
aeskey: aeskey_hex,
|
|
227
|
+
no_need_thumb: true
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
upload_param = upload_resp["upload_param"]
|
|
231
|
+
Octo::Logger.debug("[WeixinApiClient] getuploadurl resp: #{upload_resp.to_json}")
|
|
232
|
+
raise ApiError.new(0, "getuploadurl: missing upload_param") unless upload_param
|
|
233
|
+
|
|
234
|
+
# Step 2: upload encrypted bytes to CDN.
|
|
235
|
+
download_param = cdn_upload(
|
|
236
|
+
upload_param: upload_param,
|
|
237
|
+
filekey: filekey,
|
|
238
|
+
encrypted_bytes: encrypted_bytes
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Return CDNMedia structure for use in sendmessage item_list.
|
|
242
|
+
# encrypt_type: 1 confirmed from @tencent-weixin/openclaw-weixin source.
|
|
243
|
+
{
|
|
244
|
+
encrypt_query_param: download_param,
|
|
245
|
+
aes_key: aeskey_b64,
|
|
246
|
+
encrypt_type: 1
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# POST encrypted bytes to CDN. Returns the x-encrypted-param header value.
|
|
251
|
+
def cdn_upload(upload_param:, filekey:, encrypted_bytes:)
|
|
252
|
+
cdn_url = "#{CDN_BASE_URL}/upload" \
|
|
253
|
+
"?encrypted_query_param=#{URI.encode_uri_component(upload_param)}" \
|
|
254
|
+
"&filekey=#{URI.encode_uri_component(filekey)}"
|
|
255
|
+
uri = URI(cdn_url)
|
|
256
|
+
|
|
257
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
258
|
+
http.use_ssl = true
|
|
259
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
260
|
+
http.read_timeout = API_TIMEOUT_S
|
|
261
|
+
http.open_timeout = 10
|
|
262
|
+
|
|
263
|
+
req = Net::HTTP::Post.new("#{uri.path}?#{uri.query}")
|
|
264
|
+
req["Content-Type"] = "application/octet-stream"
|
|
265
|
+
req["Content-Length"] = encrypted_bytes.bytesize.to_s
|
|
266
|
+
req.body = encrypted_bytes
|
|
267
|
+
|
|
268
|
+
Octo::Logger.debug("[WeixinApiClient] CDN upload #{encrypted_bytes.bytesize} bytes")
|
|
269
|
+
|
|
270
|
+
res = http.request(req)
|
|
271
|
+
raise ApiError.new(res.code.to_i, res.body.to_s.slice(0, 200)), "CDN upload HTTP #{res.code}" \
|
|
272
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
273
|
+
|
|
274
|
+
download_param = res["x-encrypted-param"]
|
|
275
|
+
raise ApiError.new(0, "CDN upload: missing x-encrypted-param header") unless download_param
|
|
276
|
+
|
|
277
|
+
download_param
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# GET raw bytes from a CDN URL (no iLink auth headers needed for download).
|
|
281
|
+
def cdn_get(url)
|
|
282
|
+
uri = URI(url)
|
|
283
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
284
|
+
http.use_ssl = true
|
|
285
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
286
|
+
http.read_timeout = API_TIMEOUT_S
|
|
287
|
+
http.open_timeout = 10
|
|
288
|
+
|
|
289
|
+
req = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
|
|
290
|
+
Octo::Logger.debug("[WeixinApiClient] CDN GET #{uri.host}#{uri.path}")
|
|
291
|
+
|
|
292
|
+
res = http.request(req)
|
|
293
|
+
raise ApiError.new(res.code.to_i, "CDN download HTTP #{res.code}") \
|
|
294
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
295
|
+
|
|
296
|
+
res.body.force_encoding("BINARY")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Decrypt bytes with AES-128-ECB + PKCS7 unpadding using OpenSSL.
|
|
300
|
+
def aes_ecb_decrypt(data, key)
|
|
301
|
+
cipher = OpenSSL::Cipher.new("AES-128-ECB")
|
|
302
|
+
cipher.decrypt
|
|
303
|
+
cipher.key = key
|
|
304
|
+
cipher.update(data) + cipher.final
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Encrypt bytes with AES-128-ECB + PKCS7 padding using OpenSSL.
|
|
308
|
+
def aes_ecb_encrypt(data, key)
|
|
309
|
+
cipher = OpenSSL::Cipher.new("AES-128-ECB")
|
|
310
|
+
cipher.encrypt
|
|
311
|
+
cipher.key = key
|
|
312
|
+
cipher.update(data) + cipher.final
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Guess media_type from file extension.
|
|
316
|
+
def detect_media_type(file_name)
|
|
317
|
+
ext = File.extname(file_name).downcase
|
|
318
|
+
case ext
|
|
319
|
+
when ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"
|
|
320
|
+
MEDIA_TYPE_IMAGE
|
|
321
|
+
when ".mp4", ".mov", ".avi", ".mkv", ".flv"
|
|
322
|
+
MEDIA_TYPE_VIDEO
|
|
323
|
+
when ".mp3", ".m4a", ".amr", ".wav", ".ogg"
|
|
324
|
+
MEDIA_TYPE_VOICE
|
|
325
|
+
else
|
|
326
|
+
MEDIA_TYPE_FILE
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Build the item_list entry for sendmessage based on media type.
|
|
331
|
+
def build_media_item(media_type, cdn_media, raw_bytes, file_name)
|
|
332
|
+
case media_type
|
|
333
|
+
when MEDIA_TYPE_IMAGE
|
|
334
|
+
{ type: 2, image_item: { media: cdn_media } }
|
|
335
|
+
when MEDIA_TYPE_VIDEO
|
|
336
|
+
{ type: 5, video_item: { media: cdn_media } }
|
|
337
|
+
when MEDIA_TYPE_VOICE
|
|
338
|
+
{ type: 3, voice_item: { media: cdn_media } }
|
|
339
|
+
else
|
|
340
|
+
{
|
|
341
|
+
type: 4,
|
|
342
|
+
file_item: {
|
|
343
|
+
media: cdn_media,
|
|
344
|
+
file_name: file_name,
|
|
345
|
+
md5: Digest::MD5.hexdigest(raw_bytes),
|
|
346
|
+
len: raw_bytes.bytesize.to_s
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def post(endpoint, body_hash, timeout: API_TIMEOUT_S)
|
|
353
|
+
uri = URI("#{@base_url}/#{API_PATH_PREFIX}/#{endpoint}")
|
|
354
|
+
# All POST bodies must include base_info per iLink protocol spec.
|
|
355
|
+
body = JSON.generate(body_hash.merge(base_info: { channel_version: CHANNEL_VERSION }))
|
|
356
|
+
|
|
357
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
358
|
+
http.use_ssl = uri.scheme == "https"
|
|
359
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
360
|
+
http.read_timeout = timeout
|
|
361
|
+
http.open_timeout = 10
|
|
362
|
+
|
|
363
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
364
|
+
req["Content-Type"] = "application/json"
|
|
365
|
+
req["AuthorizationType"] = "ilink_bot_token"
|
|
366
|
+
req["Content-Length"] = body.bytesize.to_s
|
|
367
|
+
req["X-WECHAT-UIN"] = random_wechat_uin
|
|
368
|
+
req["Authorization"] = "Bearer #{@token}" unless @token.empty?
|
|
369
|
+
req.body = body
|
|
370
|
+
|
|
371
|
+
Octo::Logger.debug("[WeixinApiClient] POST #{endpoint}")
|
|
372
|
+
|
|
373
|
+
res = http.request(req)
|
|
374
|
+
raise ApiError.new(res.code.to_i, res.body), "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
375
|
+
|
|
376
|
+
raw_body = res.body
|
|
377
|
+
data = JSON.parse(raw_body)
|
|
378
|
+
ret = data["ret"] || data["errcode"]
|
|
379
|
+
if ret && ret != 0
|
|
380
|
+
# Include full response body for easier debugging (errmsg is often empty)
|
|
381
|
+
detail = data["errmsg"].to_s.strip
|
|
382
|
+
detail = raw_body.slice(0, 300) if detail.empty?
|
|
383
|
+
raise ApiError.new(ret, detail)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
data
|
|
387
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
388
|
+
raise TimeoutError, "#{endpoint} timed out"
|
|
389
|
+
rescue JSON::ParserError => e
|
|
390
|
+
raise ApiError.new(0, "Invalid JSON: #{e.message}")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# X-WECHAT-UIN: random uint32 → decimal string → base64
|
|
394
|
+
def random_wechat_uin
|
|
395
|
+
uint32 = SecureRandom.random_bytes(4).unpack1("N")
|
|
396
|
+
Base64.strict_encode64(uint32.to_s)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
# ChannelConfig manages IM platform credentials (Feishu, WeCom, etc.).
|
|
9
|
+
#
|
|
10
|
+
# Config is stored in ~/.octo/channels.yml:
|
|
11
|
+
#
|
|
12
|
+
# channels:
|
|
13
|
+
# feishu:
|
|
14
|
+
# enabled: true
|
|
15
|
+
# app_id: cli_xxx
|
|
16
|
+
# app_secret: xxx
|
|
17
|
+
# domain: https://open.feishu.cn
|
|
18
|
+
# allowed_users:
|
|
19
|
+
# - ou_xxx
|
|
20
|
+
# wecom:
|
|
21
|
+
# enabled: false
|
|
22
|
+
# bot_id: xxx
|
|
23
|
+
# secret: xxx
|
|
24
|
+
#
|
|
25
|
+
# This class is only responsible for platform credentials.
|
|
26
|
+
# working_dir and permission_mode live in AgentConfig.
|
|
27
|
+
class ChannelConfig
|
|
28
|
+
CONFIG_DIR = File.join(Dir.home, ".octo")
|
|
29
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "channels.yml")
|
|
30
|
+
|
|
31
|
+
# @param channels [Hash<String, Hash>] string-keyed platform configs (raw from YAML)
|
|
32
|
+
def initialize(channels: {})
|
|
33
|
+
@channels = channels || {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Load from disk. Returns an empty instance if the file does not exist.
|
|
37
|
+
# @param config_file [String]
|
|
38
|
+
# @return [ChannelConfig]
|
|
39
|
+
def self.load(config_file = CONFIG_FILE)
|
|
40
|
+
if File.exist?(config_file)
|
|
41
|
+
data = YAMLCompat.safe_load(File.read(config_file), permitted_classes: [Symbol]) || {}
|
|
42
|
+
else
|
|
43
|
+
data = {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
new(channels: data["channels"] || {})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Persist to disk.
|
|
50
|
+
# @param config_file [String]
|
|
51
|
+
def save(config_file = CONFIG_FILE)
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(config_file))
|
|
53
|
+
File.write(config_file, to_yaml)
|
|
54
|
+
FileUtils.chmod(0o600, config_file)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Serialize to YAML string.
|
|
58
|
+
# @return [String]
|
|
59
|
+
def to_yaml
|
|
60
|
+
YAML.dump({ "channels" => @channels })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns true if at least one channel is enabled.
|
|
64
|
+
def any_enabled?
|
|
65
|
+
@channels.any? { |_, cfg| cfg["enabled"] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the list of enabled platform symbols.
|
|
69
|
+
# @return [Array<Symbol>]
|
|
70
|
+
def enabled_platforms
|
|
71
|
+
@channels
|
|
72
|
+
.select { |_, cfg| cfg["enabled"] }
|
|
73
|
+
.keys
|
|
74
|
+
.map(&:to_sym)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns true if the given platform is configured and enabled.
|
|
78
|
+
# @param platform [Symbol, String]
|
|
79
|
+
def enabled?(platform)
|
|
80
|
+
cfg = @channels[platform.to_s]
|
|
81
|
+
cfg && cfg["enabled"]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Return the symbol-keyed config hash expected by each adapter's initializer.
|
|
85
|
+
# Returns nil if the platform is not configured.
|
|
86
|
+
#
|
|
87
|
+
# @param platform [Symbol, String]
|
|
88
|
+
# @return [Hash, nil]
|
|
89
|
+
def platform_config(platform)
|
|
90
|
+
raw = @channels[platform.to_s]
|
|
91
|
+
return nil unless raw
|
|
92
|
+
|
|
93
|
+
case platform.to_sym
|
|
94
|
+
when :feishu
|
|
95
|
+
{
|
|
96
|
+
app_id: raw["app_id"],
|
|
97
|
+
app_secret: raw["app_secret"],
|
|
98
|
+
domain: raw["domain"],
|
|
99
|
+
allowed_users: raw["allowed_users"]
|
|
100
|
+
}.compact
|
|
101
|
+
when :wecom
|
|
102
|
+
{
|
|
103
|
+
bot_id: raw["bot_id"],
|
|
104
|
+
secret: raw["secret"]
|
|
105
|
+
}.compact
|
|
106
|
+
when :weixin
|
|
107
|
+
{
|
|
108
|
+
token: raw["token"],
|
|
109
|
+
base_url: raw["base_url"],
|
|
110
|
+
allowed_users: raw["allowed_users"]
|
|
111
|
+
}.compact
|
|
112
|
+
when :discord
|
|
113
|
+
{
|
|
114
|
+
bot_token: raw["bot_token"]
|
|
115
|
+
}.compact
|
|
116
|
+
when :dingtalk
|
|
117
|
+
{
|
|
118
|
+
client_id: raw["client_id"],
|
|
119
|
+
client_secret: raw["client_secret"],
|
|
120
|
+
allowed_users: raw["allowed_users"]
|
|
121
|
+
}.compact
|
|
122
|
+
when :telegram
|
|
123
|
+
{
|
|
124
|
+
bot_token: raw["bot_token"],
|
|
125
|
+
base_url: raw["base_url"],
|
|
126
|
+
parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
|
|
127
|
+
allowed_users: raw["allowed_users"]
|
|
128
|
+
}.compact
|
|
129
|
+
else
|
|
130
|
+
# Unknown platform — pass all non-meta keys as symbol-keyed hash
|
|
131
|
+
raw.reject { |k, _| k == "enabled" }
|
|
132
|
+
.transform_keys(&:to_sym)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Set or update a platform's credentials.
|
|
137
|
+
# Merges provided fields into the existing entry.
|
|
138
|
+
# Automatically sets enabled: true unless explicitly provided.
|
|
139
|
+
#
|
|
140
|
+
# @param platform [Symbol, String]
|
|
141
|
+
# @param fields [Hash] symbol-keyed credential fields
|
|
142
|
+
def set_platform(platform, **fields)
|
|
143
|
+
key = platform.to_s
|
|
144
|
+
@channels[key] ||= {}
|
|
145
|
+
fields.each { |k, v| @channels[key][k.to_s] = v }
|
|
146
|
+
@channels[key]["enabled"] = true unless @channels[key].key?("enabled")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Enable a platform (requires it to already be configured).
|
|
150
|
+
# @param platform [Symbol, String]
|
|
151
|
+
# @raise [ArgumentError] if the platform has no stored credentials yet.
|
|
152
|
+
def enable_platform(platform)
|
|
153
|
+
key = platform.to_s
|
|
154
|
+
raise ArgumentError, "Platform #{platform} is not configured" unless @channels.key?(key)
|
|
155
|
+
@channels[key]["enabled"] = true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Disable a platform (keeps credentials, just sets enabled: false).
|
|
159
|
+
# @param platform [Symbol, String]
|
|
160
|
+
def disable_platform(platform)
|
|
161
|
+
key = platform.to_s
|
|
162
|
+
return unless @channels.key?(key)
|
|
163
|
+
@channels[key]["enabled"] = false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Remove a platform entry entirely.
|
|
167
|
+
# @param platform [Symbol, String]
|
|
168
|
+
def remove_platform(platform)
|
|
169
|
+
@channels.delete(platform.to_s)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Deep copy — prevents callers from mutating shared config state.
|
|
173
|
+
# @return [ChannelConfig]
|
|
174
|
+
def deep_copy
|
|
175
|
+
self.class.new(channels: JSON.parse(JSON.generate(@channels)))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|