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,478 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/multipart"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Octo
|
|
8
|
+
module Channel
|
|
9
|
+
module Adapters
|
|
10
|
+
module Feishu
|
|
11
|
+
# Raised when the app lacks read permission for a specific Feishu document (error code 91403).
|
|
12
|
+
# The user needs to add the app as a collaborator on the document.
|
|
13
|
+
class FeishuDocPermissionError < StandardError
|
|
14
|
+
attr_reader :doc_token
|
|
15
|
+
|
|
16
|
+
def initialize(doc_token)
|
|
17
|
+
@doc_token = doc_token
|
|
18
|
+
super("App has no permission to access document: #{doc_token}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raised when the app hasn't been granted the API scope for documents (error code 99991672).
|
|
23
|
+
# The admin needs to approve the scope via the returned auth_url.
|
|
24
|
+
class FeishuDocScopeError < StandardError
|
|
25
|
+
attr_reader :auth_url
|
|
26
|
+
|
|
27
|
+
def initialize(auth_url)
|
|
28
|
+
@auth_url = auth_url
|
|
29
|
+
super("App is missing docx API scope")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Feishu Bot API client.
|
|
34
|
+
# Handles authentication, message sending, and API calls.
|
|
35
|
+
class Bot
|
|
36
|
+
API_TIMEOUT = 10
|
|
37
|
+
DOWNLOAD_TIMEOUT = 60
|
|
38
|
+
|
|
39
|
+
def initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN)
|
|
40
|
+
@app_id = app_id
|
|
41
|
+
@app_secret = app_secret
|
|
42
|
+
@domain = domain
|
|
43
|
+
@token_cache = nil
|
|
44
|
+
@token_expires_at = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Send plain text message
|
|
48
|
+
# @param chat_id [String] Chat ID (open_chat_id)
|
|
49
|
+
# @param text [String] Message text
|
|
50
|
+
# @param reply_to [String, nil] Message ID to reply to
|
|
51
|
+
# @return [Hash] Response with :message_id
|
|
52
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
53
|
+
content, msg_type = build_message_payload(text)
|
|
54
|
+
payload = {
|
|
55
|
+
receive_id: chat_id,
|
|
56
|
+
msg_type: msg_type,
|
|
57
|
+
content: content
|
|
58
|
+
}
|
|
59
|
+
payload[:reply_to_message_id] = reply_to if reply_to
|
|
60
|
+
|
|
61
|
+
response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" })
|
|
62
|
+
|
|
63
|
+
code = response["code"]
|
|
64
|
+
if code != 0
|
|
65
|
+
Octo::Logger.warn("[feishu] send_text failed",
|
|
66
|
+
code: code, msg: response["msg"],
|
|
67
|
+
chat_id: chat_id, msg_type: msg_type)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ message_id: response.dig("data", "message_id") }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Update an existing message
|
|
74
|
+
# @param message_id [String] Message ID to update
|
|
75
|
+
# @param text [String] New text content
|
|
76
|
+
# @return [Boolean] Success status
|
|
77
|
+
def update_message(message_id, text)
|
|
78
|
+
content, msg_type = build_message_payload(text)
|
|
79
|
+
payload = {
|
|
80
|
+
msg_type: msg_type,
|
|
81
|
+
content: content
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
response = patch("/open-apis/im/v1/messages/#{message_id}", payload)
|
|
85
|
+
response["code"] == 0
|
|
86
|
+
rescue => e
|
|
87
|
+
Octo::Logger.warn("[feishu] Failed to update message: #{e.message}")
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Upload a local file to Feishu and send it to a chat.
|
|
92
|
+
# Images use /im/v1/images + msg_type "image".
|
|
93
|
+
# All other files use /im/v1/files + msg_type "file".
|
|
94
|
+
# @param chat_id [String] Chat ID
|
|
95
|
+
# @param path [String] Local file path
|
|
96
|
+
# @param name [String, nil] Display filename
|
|
97
|
+
# @param reply_to [String, nil] Message ID to reply to
|
|
98
|
+
# @return [Hash] Response with :message_id
|
|
99
|
+
def send_file(chat_id, path, name: nil, reply_to: nil)
|
|
100
|
+
raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
|
|
101
|
+
|
|
102
|
+
# Always derive filename from the real path for type detection and upload.
|
|
103
|
+
# The `name` param (often markdown alt text) may lack an extension,
|
|
104
|
+
# causing images to be mis-detected as generic files.
|
|
105
|
+
filename = File.basename(path)
|
|
106
|
+
file_data = File.binread(path)
|
|
107
|
+
ext = File.extname(filename).downcase
|
|
108
|
+
|
|
109
|
+
if %w[.jpg .jpeg .png .gif .webp].include?(ext)
|
|
110
|
+
image_key = upload_image(file_data, filename)
|
|
111
|
+
content = JSON.generate({ image_key: image_key })
|
|
112
|
+
msg_type = "image"
|
|
113
|
+
else
|
|
114
|
+
file_key = upload_file(file_data, filename)
|
|
115
|
+
content = JSON.generate({ file_key: file_key })
|
|
116
|
+
msg_type = "file"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
payload = { receive_id: chat_id, msg_type: msg_type, content: content }
|
|
120
|
+
payload[:reply_to_message_id] = reply_to if reply_to
|
|
121
|
+
|
|
122
|
+
response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" })
|
|
123
|
+
{ message_id: response.dig("data", "message_id") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Download a message resource (image or file) from Feishu.
|
|
127
|
+
# For message attachments, must use messageResource API — not im/v1/images.
|
|
128
|
+
# @param message_id [String] Message ID containing the resource
|
|
129
|
+
# @param file_key [String] Resource key (image_key or file_key from message content)
|
|
130
|
+
# @param type [String] "image" or "file"
|
|
131
|
+
# @return [Hash] { body: String, content_type: String }
|
|
132
|
+
def download_message_resource(message_id, file_key, type: "image")
|
|
133
|
+
conn = Faraday.new(url: @domain) do |f|
|
|
134
|
+
f.options.timeout = DOWNLOAD_TIMEOUT
|
|
135
|
+
f.options.open_timeout = API_TIMEOUT
|
|
136
|
+
f.ssl.verify = false
|
|
137
|
+
f.adapter Faraday.default_adapter
|
|
138
|
+
end
|
|
139
|
+
response = conn.get("/open-apis/im/v1/messages/#{message_id}/resources/#{file_key}") do |req|
|
|
140
|
+
req.headers["Authorization"] = "Bearer #{tenant_access_token}"
|
|
141
|
+
req.params["type"] = type
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
unless response.success?
|
|
145
|
+
raise "Failed to download message resource: HTTP #{response.status}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
body: response.body,
|
|
150
|
+
content_type: response.headers["content-type"].to_s.split(";").first.strip
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Fetch the plain-text content of a Feishu document (docx / docs / wiki).
|
|
155
|
+
# Raises FeishuDocPermissionError (code 91403) when the app has no access.
|
|
156
|
+
# @param url [String] Feishu document URL
|
|
157
|
+
# @return [String] Document plain text
|
|
158
|
+
def fetch_doc_content(url)
|
|
159
|
+
doc_token, doc_type = parse_doc_url(url)
|
|
160
|
+
raise ArgumentError, "Unsupported Feishu doc URL: #{url}" unless doc_token
|
|
161
|
+
|
|
162
|
+
if doc_type == :wiki
|
|
163
|
+
# Wiki: first resolve the real docToken via get_node
|
|
164
|
+
node = fetch_wiki_node(doc_token)
|
|
165
|
+
actual_token = node["obj_token"]
|
|
166
|
+
actual_type = node["obj_type"] # "docx" / "doc" / etc.
|
|
167
|
+
raise "Unsupported wiki node type: #{actual_type}" unless %w[docx doc].include?(actual_type)
|
|
168
|
+
fetch_docx_raw_content(actual_token)
|
|
169
|
+
else
|
|
170
|
+
fetch_docx_raw_content(doc_token)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Build message content and type based on text content.
|
|
176
|
+
# Uses interactive card (schema 2.0) for code blocks and tables,
|
|
177
|
+
# post/md for everything else.
|
|
178
|
+
# @param text [String]
|
|
179
|
+
# @return [Array<String, String>] [content_json, msg_type]
|
|
180
|
+
def build_message_payload(text)
|
|
181
|
+
if has_code_block_or_table?(text)
|
|
182
|
+
safe_text = sanitize_images_for_card(text)
|
|
183
|
+
content = JSON.generate({
|
|
184
|
+
schema: "2.0",
|
|
185
|
+
config: { wide_screen_mode: true },
|
|
186
|
+
body: { elements: [{ tag: "markdown", content: safe_text }] }
|
|
187
|
+
})
|
|
188
|
+
[content, "interactive"]
|
|
189
|
+
else
|
|
190
|
+
safe_text = sanitize_images_for_card(text)
|
|
191
|
+
content = JSON.generate({
|
|
192
|
+
zh_cn: { content: [[{ tag: "md", text: safe_text }]] }
|
|
193
|
+
})
|
|
194
|
+
[content, "post"]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def has_code_block_or_table?(text)
|
|
199
|
+
text.match?(/```[\s\S]*?```/) || text.match?(/\|.+\|[\r\n]+\|[-:| ]+\|/)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Convert Markdown image syntax  to plain links [alt](url)
|
|
203
|
+
# inside interactive card content. Feishu interactive cards do NOT
|
|
204
|
+
# support image markdown — sending it triggers error 230099 and the
|
|
205
|
+
# entire message is silently dropped.
|
|
206
|
+
#
|
|
207
|
+
# Code blocks are preserved untouched (images inside ``` fences are
|
|
208
|
+
# left as-is since they are literal text, not rendered markdown).
|
|
209
|
+
#
|
|
210
|
+
# This is a pure function with no side effects — thread-safe by design.
|
|
211
|
+
# @param text [String] raw markdown text
|
|
212
|
+
# @return [String] sanitised text safe for interactive cards
|
|
213
|
+
def sanitize_images_for_card(text)
|
|
214
|
+
# Split on code fences to avoid transforming inside code blocks
|
|
215
|
+
parts = text.split(/(```[\s\S]*?```)/)
|
|
216
|
+
parts.map { |segment|
|
|
217
|
+
if segment.start_with?("```")
|
|
218
|
+
segment # code block — leave untouched
|
|
219
|
+
else
|
|
220
|
+
#  → [alt](url) (drop the leading !)
|
|
221
|
+
segment.gsub(/!\[([^\]]*)\]\(([^)]+)\)/) do
|
|
222
|
+
alt, url = Regexp.last_match(1), Regexp.last_match(2)
|
|
223
|
+
alt.empty? ? url : "[#{alt}](#{url})"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
}.join
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Get this bot's own open_id (cached, fetched lazily on first use).
|
|
230
|
+
# Used to detect @bot mentions in group chats.
|
|
231
|
+
# @return [String, nil] bot open_id, or nil if the API call fails
|
|
232
|
+
def bot_open_id
|
|
233
|
+
@bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
|
|
234
|
+
rescue => e
|
|
235
|
+
Octo::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Get tenant access token (cached)
|
|
240
|
+
# @return [String] Access token
|
|
241
|
+
def tenant_access_token
|
|
242
|
+
return @token_cache if @token_cache && @token_expires_at && Time.now < @token_expires_at
|
|
243
|
+
|
|
244
|
+
response = post_without_auth("/open-apis/auth/v3/tenant_access_token/internal", {
|
|
245
|
+
app_id: @app_id,
|
|
246
|
+
app_secret: @app_secret
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
raise "Failed to get tenant access token: #{response['msg']}" if response["code"] != 0
|
|
250
|
+
|
|
251
|
+
@token_cache = response["tenant_access_token"]
|
|
252
|
+
# Token expires in 2 hours, refresh 5 minutes early
|
|
253
|
+
@token_expires_at = Time.now + (2 * 60 * 60 - 5 * 60)
|
|
254
|
+
@token_cache
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Make authenticated GET request
|
|
258
|
+
# @param path [String] API path
|
|
259
|
+
# @param params [Hash] Query parameters
|
|
260
|
+
# @return [Hash] Parsed response
|
|
261
|
+
def get(path, params: {})
|
|
262
|
+
conn = build_connection
|
|
263
|
+
response = conn.get(path) do |req|
|
|
264
|
+
req.headers["Authorization"] = "Bearer #{tenant_access_token}"
|
|
265
|
+
req.params.update(params)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
parse_response(response)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Make authenticated POST request
|
|
272
|
+
# @param path [String] API path
|
|
273
|
+
# @param body [Hash] Request body
|
|
274
|
+
# @param params [Hash] Query parameters
|
|
275
|
+
# @return [Hash] Parsed response
|
|
276
|
+
def post(path, body, params: {})
|
|
277
|
+
conn = build_connection
|
|
278
|
+
response = conn.post(path) do |req|
|
|
279
|
+
req.headers["Authorization"] = "Bearer #{tenant_access_token}"
|
|
280
|
+
req.headers["Content-Type"] = "application/json"
|
|
281
|
+
req.params.update(params)
|
|
282
|
+
req.body = JSON.generate(body)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
parse_response(response)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Make authenticated PATCH request
|
|
289
|
+
# @param path [String] API path
|
|
290
|
+
# @param body [Hash] Request body
|
|
291
|
+
# @return [Hash] Parsed response
|
|
292
|
+
def patch(path, body)
|
|
293
|
+
conn = build_connection
|
|
294
|
+
response = conn.patch(path) do |req|
|
|
295
|
+
req.headers["Authorization"] = "Bearer #{tenant_access_token}"
|
|
296
|
+
req.headers["Content-Type"] = "application/json"
|
|
297
|
+
req.body = JSON.generate(body)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
parse_response(response)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Make POST request without authentication (for token endpoint)
|
|
304
|
+
# @param path [String] API path
|
|
305
|
+
# @param body [Hash] Request body
|
|
306
|
+
# @return [Hash] Parsed response
|
|
307
|
+
def post_without_auth(path, body)
|
|
308
|
+
conn = build_connection
|
|
309
|
+
response = conn.post(path) do |req|
|
|
310
|
+
req.headers["Content-Type"] = "application/json"
|
|
311
|
+
req.body = JSON.generate(body)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
parse_response(response)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Upload an image to Feishu and return image_key.
|
|
318
|
+
# @param data [String] Binary file content
|
|
319
|
+
# @param filename [String] Display filename
|
|
320
|
+
# @return [String] image_key
|
|
321
|
+
def upload_image(data, filename)
|
|
322
|
+
conn = Faraday.new(url: @domain) do |f|
|
|
323
|
+
f.options.timeout = DOWNLOAD_TIMEOUT
|
|
324
|
+
f.options.open_timeout = API_TIMEOUT
|
|
325
|
+
f.ssl.verify = false
|
|
326
|
+
f.request :multipart
|
|
327
|
+
f.adapter Faraday.default_adapter
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
response = conn.post("/open-apis/im/v1/images") do |req|
|
|
331
|
+
req.headers["Authorization"] = "Bearer #{tenant_access_token}"
|
|
332
|
+
req.body = {
|
|
333
|
+
image_type: "message",
|
|
334
|
+
image: Faraday::Multipart::FilePart.new(
|
|
335
|
+
StringIO.new(data), detect_mime(filename), filename
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
result = JSON.parse(response.body)
|
|
341
|
+
raise "Failed to upload image: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0
|
|
342
|
+
|
|
343
|
+
result.dig("data", "image_key") or raise "No image_key returned"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Upload a file to Feishu and return file_key.
|
|
347
|
+
# @param data [String] Binary file content
|
|
348
|
+
# @param filename [String] Display filename
|
|
349
|
+
# @return [String] file_key
|
|
350
|
+
def upload_file(data, filename)
|
|
351
|
+
conn = Faraday.new(url: @domain) do |f|
|
|
352
|
+
f.options.timeout = DOWNLOAD_TIMEOUT
|
|
353
|
+
f.options.open_timeout = API_TIMEOUT
|
|
354
|
+
f.ssl.verify = false
|
|
355
|
+
f.request :multipart
|
|
356
|
+
f.adapter Faraday.default_adapter
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
response = conn.post("/open-apis/im/v1/files") do |req|
|
|
360
|
+
req.headers["Authorization"] = "Bearer #{tenant_access_token}"
|
|
361
|
+
req.body = {
|
|
362
|
+
file_type: feishu_file_type(filename),
|
|
363
|
+
file_name: filename,
|
|
364
|
+
file: Faraday::Multipart::FilePart.new(
|
|
365
|
+
StringIO.new(data), detect_mime(filename), filename
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
result = JSON.parse(response.body)
|
|
371
|
+
raise "Failed to upload file: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0
|
|
372
|
+
|
|
373
|
+
result.dig("data", "file_key") or raise "No file_key returned"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Map file extension to Feishu file_type enum.
|
|
377
|
+
# Feishu accepts: opus, mp4, pdf, doc, xls, ppt, stream (others)
|
|
378
|
+
def feishu_file_type(filename)
|
|
379
|
+
case File.extname(filename).downcase
|
|
380
|
+
when ".pdf" then "pdf"
|
|
381
|
+
when ".doc", ".docx" then "doc"
|
|
382
|
+
when ".xls", ".xlsx" then "xls"
|
|
383
|
+
when ".ppt", ".pptx" then "ppt"
|
|
384
|
+
when ".mp4" then "mp4"
|
|
385
|
+
when ".opus" then "opus"
|
|
386
|
+
else "stream"
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Detect MIME type from filename extension.
|
|
391
|
+
def detect_mime(filename)
|
|
392
|
+
case File.extname(filename).downcase
|
|
393
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
394
|
+
when ".png" then "image/png"
|
|
395
|
+
when ".gif" then "image/gif"
|
|
396
|
+
when ".webp" then "image/webp"
|
|
397
|
+
when ".pdf" then "application/pdf"
|
|
398
|
+
when ".mp4" then "video/mp4"
|
|
399
|
+
else "application/octet-stream"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Parse Feishu doc URL and return [doc_token, type]
|
|
404
|
+
# type is :docx, :docs, or :wiki
|
|
405
|
+
# @param url [String]
|
|
406
|
+
# @return [Array<String, Symbol>, nil]
|
|
407
|
+
def parse_doc_url(url)
|
|
408
|
+
if (m = url.match(%r{/(?:docx|docs)/([A-Za-z0-9_-]+)}))
|
|
409
|
+
[m[1], :docx]
|
|
410
|
+
elsif (m = url.match(%r{/wiki/([A-Za-z0-9_-]+)}))
|
|
411
|
+
[m[1], :wiki]
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Fetch raw text content of a docx document.
|
|
416
|
+
# Raises FeishuDocPermissionError on 91403.
|
|
417
|
+
# @param doc_token [String]
|
|
418
|
+
# @return [String]
|
|
419
|
+
def fetch_docx_raw_content(doc_token)
|
|
420
|
+
response = get("/open-apis/docx/v1/documents/#{doc_token}/raw_content")
|
|
421
|
+
check_doc_error!(response, doc_token)
|
|
422
|
+
response.dig("data", "content").to_s.strip
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Resolve wiki node to get real obj_token and obj_type.
|
|
426
|
+
# @param wiki_token [String]
|
|
427
|
+
# @return [Hash] node data with "obj_token" and "obj_type"
|
|
428
|
+
def fetch_wiki_node(wiki_token)
|
|
429
|
+
response = get("/open-apis/wiki/v2/spaces/get_node", params: { token: wiki_token, obj_type: "wiki" })
|
|
430
|
+
check_doc_error!(response, wiki_token)
|
|
431
|
+
response.dig("data", "node") or raise "No node in wiki response"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Check doc API response for known permission errors and raise accordingly.
|
|
435
|
+
def check_doc_error!(response, token)
|
|
436
|
+
code = response["code"].to_i
|
|
437
|
+
return if code == 0
|
|
438
|
+
|
|
439
|
+
if code == 91403
|
|
440
|
+
raise FeishuDocPermissionError, token
|
|
441
|
+
elsif code == 99991672
|
|
442
|
+
# Extract auth URL from the error message if present
|
|
443
|
+
auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
|
|
444
|
+
response["msg"].to_s[/https:\/\/open\.feishu\.cn\/app\/[^\s"]+/]
|
|
445
|
+
raise FeishuDocScopeError.new(auth_url)
|
|
446
|
+
else
|
|
447
|
+
raise "Failed to fetch doc: code=#{code} msg=#{response["msg"]}"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Build Faraday connection
|
|
452
|
+
# @return [Faraday::Connection]
|
|
453
|
+
def build_connection
|
|
454
|
+
Faraday.new(url: @domain) do |f|
|
|
455
|
+
f.options.timeout = API_TIMEOUT
|
|
456
|
+
f.options.open_timeout = API_TIMEOUT
|
|
457
|
+
f.ssl.verify = false
|
|
458
|
+
f.adapter Faraday.default_adapter
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Parse API response
|
|
463
|
+
# @param response [Faraday::Response]
|
|
464
|
+
# @return [Hash] Parsed JSON
|
|
465
|
+
def parse_response(response)
|
|
466
|
+
# Feishu returns JSON even on 4xx — parse it so callers can inspect error codes
|
|
467
|
+
parsed = JSON.parse(response.body)
|
|
468
|
+
return parsed if response.success? || parsed.key?("code")
|
|
469
|
+
|
|
470
|
+
raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}"
|
|
471
|
+
rescue JSON::ParserError
|
|
472
|
+
raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}"
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "octo/utils/file_processor"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module Channel
|
|
7
|
+
module Adapters
|
|
8
|
+
module Feishu
|
|
9
|
+
# Processes file attachments downloaded from Feishu messages.
|
|
10
|
+
# Delegates to the unified Octo::Utils::FileProcessor pipeline:
|
|
11
|
+
# - saves original file to disk
|
|
12
|
+
# - generates structured Markdown preview for Office/ZIP files
|
|
13
|
+
# - returns a formatted prompt snippet describing both paths
|
|
14
|
+
module FileProcessor
|
|
15
|
+
MAX_FILE_BYTES = Octo::Utils::FileProcessor::MAX_FILE_BYTES
|
|
16
|
+
|
|
17
|
+
# Process a downloaded file and return a text snippet for the agent prompt.
|
|
18
|
+
# @param body [String] Raw file bytes
|
|
19
|
+
# @param file_name [String] Original file name
|
|
20
|
+
# @return [String] Text to inject into the prompt
|
|
21
|
+
def self.process(body, file_name)
|
|
22
|
+
if body.bytesize > MAX_FILE_BYTES
|
|
23
|
+
return "[Attachment: #{file_name}]\nFile too large " \
|
|
24
|
+
"(#{body.bytesize / 1024 / 1024}MB, max #{MAX_FILE_BYTES / 1024 / 1024}MB)."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
ref = Octo::Utils::FileProcessor.process(body: body, filename: file_name)
|
|
28
|
+
ref.to_prompt
|
|
29
|
+
rescue => e
|
|
30
|
+
"[Attachment: #{file_name}]\n(Processing failed: #{e.message})"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Octo
|
|
6
|
+
module Channel
|
|
7
|
+
module Adapters
|
|
8
|
+
module Feishu
|
|
9
|
+
# Parses incoming Feishu webhook events into a standardized InboundMessage format.
|
|
10
|
+
class MessageParser
|
|
11
|
+
# Parse a Feishu webhook event body
|
|
12
|
+
# @param body [String, Hash] Raw webhook body
|
|
13
|
+
# @return [Hash, nil] Standardized inbound message, or nil if not a message event
|
|
14
|
+
def self.parse(body)
|
|
15
|
+
data = body.is_a?(Hash) ? body : JSON.parse(body)
|
|
16
|
+
new(data).parse
|
|
17
|
+
rescue JSON::ParserError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(data)
|
|
22
|
+
@data = data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Hash, nil] Inbound message or nil
|
|
26
|
+
def parse
|
|
27
|
+
# Handle verification challenge
|
|
28
|
+
if @data["type"] == "url_verification"
|
|
29
|
+
return { type: :challenge, challenge: @data["challenge"] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
header = @data["header"]
|
|
33
|
+
return nil unless header
|
|
34
|
+
|
|
35
|
+
event_type = header["event_type"]
|
|
36
|
+
|
|
37
|
+
case event_type
|
|
38
|
+
when "im.message.receive_v1"
|
|
39
|
+
parse_message_event
|
|
40
|
+
else
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Parse message.receive event
|
|
47
|
+
# @return [Hash, nil]
|
|
48
|
+
def parse_message_event
|
|
49
|
+
event = @data["event"]
|
|
50
|
+
return nil unless event
|
|
51
|
+
|
|
52
|
+
message = event["message"]
|
|
53
|
+
sender = event["sender"]
|
|
54
|
+
return nil unless message && sender
|
|
55
|
+
|
|
56
|
+
msg_type = message["message_type"]
|
|
57
|
+
Octo::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
|
|
58
|
+
return nil unless %w[text image file].include?(msg_type)
|
|
59
|
+
|
|
60
|
+
content_raw = message["content"]
|
|
61
|
+
return nil unless content_raw
|
|
62
|
+
|
|
63
|
+
content = JSON.parse(content_raw)
|
|
64
|
+
text = ""
|
|
65
|
+
image_keys = []
|
|
66
|
+
file_attachments = []
|
|
67
|
+
|
|
68
|
+
case msg_type
|
|
69
|
+
when "text"
|
|
70
|
+
text = strip_mentions(content["text"].to_s.strip)
|
|
71
|
+
return nil if text.empty?
|
|
72
|
+
when "image"
|
|
73
|
+
image_keys = [content["image_key"]].compact
|
|
74
|
+
return nil if image_keys.empty?
|
|
75
|
+
when "file"
|
|
76
|
+
file_key = content["file_key"]
|
|
77
|
+
file_name = content["file_name"]
|
|
78
|
+
return nil unless file_key
|
|
79
|
+
file_attachments = [{ key: file_key, name: file_name.to_s }]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
chat_id = message["chat_id"]
|
|
83
|
+
message_id = message["message_id"]
|
|
84
|
+
user_id = sender.dig("sender_id", "open_id")
|
|
85
|
+
chat_type = message["chat_type"] == "p2p" ? :direct : :group
|
|
86
|
+
create_time = message["create_time"]&.to_i
|
|
87
|
+
timestamp = create_time ? Time.at(create_time / 1000.0) : Time.now
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
type: :message,
|
|
91
|
+
platform: :feishu,
|
|
92
|
+
chat_id: chat_id,
|
|
93
|
+
user_id: user_id,
|
|
94
|
+
text: text,
|
|
95
|
+
image_keys: image_keys,
|
|
96
|
+
file_attachments: file_attachments,
|
|
97
|
+
doc_urls: extract_doc_urls(text),
|
|
98
|
+
message_id: message_id,
|
|
99
|
+
timestamp: timestamp,
|
|
100
|
+
chat_type: chat_type,
|
|
101
|
+
mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
|
|
102
|
+
raw: @data
|
|
103
|
+
}
|
|
104
|
+
rescue JSON::ParserError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Extract Feishu document URLs from text.
|
|
109
|
+
# Matches: /docx/TOKEN, /docs/TOKEN, /wiki/TOKEN
|
|
110
|
+
# @param text [String]
|
|
111
|
+
# @return [Array<String>] matched URLs
|
|
112
|
+
def extract_doc_urls(text)
|
|
113
|
+
return [] if text.nil? || text.empty?
|
|
114
|
+
|
|
115
|
+
text.scan(%r{https?://[a-zA-Z0-9._-]+\.(?:feishu\.cn|larksuite\.com)/(?:docx|docs|wiki)/[A-Za-z0-9_-]+(?:\?[^\s]*)?})
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Strip bot @mentions from message text
|
|
119
|
+
# @param text [String]
|
|
120
|
+
# @return [String]
|
|
121
|
+
def strip_mentions(text)
|
|
122
|
+
# Feishu mentions are formatted as <at user_id="...">Name</at>
|
|
123
|
+
text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|