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