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,375 @@
|
|
|
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 Telegram
|
|
11
|
+
# Telegram Bot API adapter.
|
|
12
|
+
#
|
|
13
|
+
# Transport: HTTPS long-poll via getUpdates (no public domain required).
|
|
14
|
+
# Auth: single bot token obtained from @BotFather.
|
|
15
|
+
# Group rule: bots only react when @-mentioned or replied to (matches Feishu).
|
|
16
|
+
#
|
|
17
|
+
# Config keys (channels.yml `telegram`):
|
|
18
|
+
# bot_token String required — from @BotFather
|
|
19
|
+
# base_url String default "https://api.telegram.org"
|
|
20
|
+
# (override for self-hosted Bot API / proxy)
|
|
21
|
+
# parse_mode String default "Markdown" — set "" / nil to disable
|
|
22
|
+
# allowed_users Array optional whitelist of from.id (numeric, as String)
|
|
23
|
+
class Adapter < Base
|
|
24
|
+
# Telegram messages cap at 4096 UTF-16 code units; we leave a small margin.
|
|
25
|
+
MAX_MESSAGE_CHARS = 4000
|
|
26
|
+
|
|
27
|
+
MAX_IMAGE_BYTES = Octo::Utils::FileProcessor::MAX_IMAGE_BYTES
|
|
28
|
+
|
|
29
|
+
def self.platform_id
|
|
30
|
+
:telegram
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.env_keys
|
|
34
|
+
%w[IM_TELEGRAM_BOT_TOKEN IM_TELEGRAM_BASE_URL IM_TELEGRAM_PARSE_MODE IM_TELEGRAM_ALLOWED_USERS]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.platform_config(data)
|
|
38
|
+
{
|
|
39
|
+
bot_token: data["IM_TELEGRAM_BOT_TOKEN"] || data["bot_token"],
|
|
40
|
+
base_url: data["IM_TELEGRAM_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL,
|
|
41
|
+
parse_mode: data.key?("parse_mode") ? data["parse_mode"] : (data["IM_TELEGRAM_PARSE_MODE"] || "Markdown"),
|
|
42
|
+
allowed_users: (data["IM_TELEGRAM_ALLOWED_USERS"] || data["allowed_users"] || "")
|
|
43
|
+
.then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) }
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.set_env_data(data, config)
|
|
48
|
+
data["IM_TELEGRAM_BOT_TOKEN"] = config[:bot_token]
|
|
49
|
+
data["IM_TELEGRAM_BASE_URL"] = config[:base_url] if config[:base_url]
|
|
50
|
+
data["IM_TELEGRAM_PARSE_MODE"] = config[:parse_mode] if config[:parse_mode]
|
|
51
|
+
data["IM_TELEGRAM_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Verify credentials by calling getMe.
|
|
55
|
+
# @param fields [Hash] symbol-keyed credential fields
|
|
56
|
+
# @return [Hash] { ok: Boolean, message:/error: String }
|
|
57
|
+
def self.test_connection(fields)
|
|
58
|
+
token = fields[:bot_token].to_s.strip
|
|
59
|
+
return { ok: false, error: "bot_token is required" } if token.empty?
|
|
60
|
+
|
|
61
|
+
base_url = fields[:base_url].to_s.strip
|
|
62
|
+
base_url = ApiClient::DEFAULT_BASE_URL if base_url.empty?
|
|
63
|
+
|
|
64
|
+
client = ApiClient.new(token: token, base_url: base_url)
|
|
65
|
+
me = client.post("getMe", {})
|
|
66
|
+
{ ok: true, message: "Connected — bot @#{me["username"]} (id #{me["id"]})" }
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
{ ok: false, error: e.message }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(config)
|
|
72
|
+
@config = config
|
|
73
|
+
@token = config[:bot_token].to_s
|
|
74
|
+
@base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL
|
|
75
|
+
@parse_mode = config.key?(:parse_mode) ? config[:parse_mode] : "Markdown"
|
|
76
|
+
@parse_mode = nil if @parse_mode.to_s.empty?
|
|
77
|
+
@allowed_users = Array(config[:allowed_users]).map(&:to_s)
|
|
78
|
+
|
|
79
|
+
@api = ApiClient.new(token: @token, base_url: @base_url)
|
|
80
|
+
@running = false
|
|
81
|
+
@on_message = nil
|
|
82
|
+
@last_offset = nil
|
|
83
|
+
|
|
84
|
+
# Cached bot identity (used for @-mention check in groups).
|
|
85
|
+
@bot_username = nil
|
|
86
|
+
@bot_id = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Lifecycle ──────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def start(&on_message)
|
|
92
|
+
@running = true
|
|
93
|
+
@on_message = on_message
|
|
94
|
+
|
|
95
|
+
ensure_bot_identity
|
|
96
|
+
|
|
97
|
+
Octo::Logger.info("[TelegramAdapter] starting long-poll (base_url=#{@base_url})")
|
|
98
|
+
|
|
99
|
+
consecutive_errors = 0
|
|
100
|
+
while @running
|
|
101
|
+
begin
|
|
102
|
+
updates = @api.get_updates(offset: @last_offset)
|
|
103
|
+
consecutive_errors = 0
|
|
104
|
+
|
|
105
|
+
updates.each do |update|
|
|
106
|
+
@last_offset = update["update_id"] + 1
|
|
107
|
+
process_update(update)
|
|
108
|
+
rescue => e
|
|
109
|
+
Octo::Logger.warn("[TelegramAdapter] process_update error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
110
|
+
end
|
|
111
|
+
rescue ApiClient::TimeoutError
|
|
112
|
+
# Long-poll cycle ended with no updates — just loop.
|
|
113
|
+
rescue ApiClient::ApiError => e
|
|
114
|
+
consecutive_errors += 1
|
|
115
|
+
Octo::Logger.warn("[TelegramAdapter] API #{e.code}: #{e.description}")
|
|
116
|
+
sleep(consecutive_errors > 3 ? 30 : 5)
|
|
117
|
+
rescue => e
|
|
118
|
+
consecutive_errors += 1
|
|
119
|
+
Octo::Logger.error("[TelegramAdapter] poll error: #{e.message}")
|
|
120
|
+
break unless @running
|
|
121
|
+
sleep(consecutive_errors > 3 ? 30 : 5)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stop
|
|
127
|
+
@running = false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ── Outbound (called by ChannelUIController) ────────────────────────
|
|
131
|
+
|
|
132
|
+
# Send a text message. Splits content longer than Telegram's 4096-char
|
|
133
|
+
# cap into multiple consecutive messages. Returns { message_id: } of
|
|
134
|
+
# the LAST chunk (matches the contract used by the other adapters).
|
|
135
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
136
|
+
chunks = split_message(text.to_s)
|
|
137
|
+
return { message_id: nil } if chunks.empty?
|
|
138
|
+
|
|
139
|
+
last_message_id = nil
|
|
140
|
+
chunks.each_with_index do |chunk, i|
|
|
141
|
+
params = {
|
|
142
|
+
chat_id: chat_id.to_s,
|
|
143
|
+
text: chunk,
|
|
144
|
+
disable_web_page_preview: true
|
|
145
|
+
}
|
|
146
|
+
params[:parse_mode] = @parse_mode if @parse_mode
|
|
147
|
+
params[:reply_to_message_id] = reply_to.to_i if reply_to && i == 0
|
|
148
|
+
msg = @api.post("sendMessage", params)
|
|
149
|
+
last_message_id = msg["message_id"]
|
|
150
|
+
end
|
|
151
|
+
{ message_id: last_message_id }
|
|
152
|
+
rescue ApiClient::ApiError => e
|
|
153
|
+
# Markdown parse failures fall back to plain text — most common cause
|
|
154
|
+
# is unescaped Markdown reserved chars in the agent's output.
|
|
155
|
+
if @parse_mode && e.description.to_s =~ /can't parse entities|markdown/i
|
|
156
|
+
Octo::Logger.warn("[TelegramAdapter] parse_mode failed, retrying as plain text: #{e.description}")
|
|
157
|
+
fallback = {
|
|
158
|
+
chat_id: chat_id.to_s,
|
|
159
|
+
text: text.to_s,
|
|
160
|
+
disable_web_page_preview: true
|
|
161
|
+
}
|
|
162
|
+
fallback[:reply_to_message_id] = reply_to.to_i if reply_to
|
|
163
|
+
msg = @api.post("sendMessage", fallback)
|
|
164
|
+
return { message_id: msg["message_id"] }
|
|
165
|
+
end
|
|
166
|
+
Octo::Logger.error("[TelegramAdapter] send_text failed: #{e.message}")
|
|
167
|
+
{ message_id: nil }
|
|
168
|
+
rescue => e
|
|
169
|
+
Octo::Logger.error("[TelegramAdapter] send_text failed: #{e.message}")
|
|
170
|
+
{ message_id: nil }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def send_file(chat_id, path, name: nil, reply_to: nil)
|
|
174
|
+
return { message_id: nil } unless File.exist?(path)
|
|
175
|
+
|
|
176
|
+
is_image = path.to_s.downcase.match?(/\.(png|jpe?g|gif|webp)\z/)
|
|
177
|
+
msg = if is_image
|
|
178
|
+
@api.send_photo(
|
|
179
|
+
chat_id: chat_id.to_s,
|
|
180
|
+
photo_path: path,
|
|
181
|
+
reply_to_message_id: reply_to&.to_i
|
|
182
|
+
)
|
|
183
|
+
else
|
|
184
|
+
@api.send_document(
|
|
185
|
+
chat_id: chat_id.to_s,
|
|
186
|
+
document_path: path,
|
|
187
|
+
filename: name,
|
|
188
|
+
reply_to_message_id: reply_to&.to_i
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
{ message_id: msg["message_id"] }
|
|
192
|
+
rescue => e
|
|
193
|
+
Octo::Logger.error("[TelegramAdapter] send_file failed for #{path}: #{e.message}")
|
|
194
|
+
{ message_id: nil }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def update_message(chat_id, message_id, text)
|
|
198
|
+
@api.edit_message_text(
|
|
199
|
+
chat_id: chat_id.to_s,
|
|
200
|
+
message_id: message_id.to_i,
|
|
201
|
+
text: text,
|
|
202
|
+
parse_mode: @parse_mode
|
|
203
|
+
)
|
|
204
|
+
true
|
|
205
|
+
rescue => e
|
|
206
|
+
Octo::Logger.warn("[TelegramAdapter] update_message failed: #{e.message}")
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def supports_message_updates?
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_config(config)
|
|
215
|
+
errors = []
|
|
216
|
+
errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].to_s.strip.empty?
|
|
217
|
+
errors
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── Inbound ─────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
def ensure_bot_identity
|
|
223
|
+
me = @api.post("getMe", {})
|
|
224
|
+
@bot_id = me["id"]
|
|
225
|
+
@bot_username = me["username"]
|
|
226
|
+
Octo::Logger.info("[TelegramAdapter] bot identity: @#{@bot_username} (id=#{@bot_id})")
|
|
227
|
+
rescue => e
|
|
228
|
+
Octo::Logger.warn("[TelegramAdapter] getMe failed: #{e.message} — group @-mentions will be dropped")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def process_update(update)
|
|
232
|
+
msg = update["message"]
|
|
233
|
+
return unless msg
|
|
234
|
+
|
|
235
|
+
chat = msg["chat"] || {}
|
|
236
|
+
from = msg["from"] || {}
|
|
237
|
+
chat_id = chat["id"]
|
|
238
|
+
user_id = from["id"]
|
|
239
|
+
return unless chat_id && user_id
|
|
240
|
+
|
|
241
|
+
chat_type = chat["type"].to_s
|
|
242
|
+
is_group = %w[group supergroup].include?(chat_type)
|
|
243
|
+
text = msg["text"].to_s
|
|
244
|
+
|
|
245
|
+
if is_group
|
|
246
|
+
return unless group_mention?(msg, text)
|
|
247
|
+
text = strip_bot_mention(text)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if @allowed_users.any? && !@allowed_users.include?(user_id.to_s)
|
|
251
|
+
Octo::Logger.debug("[TelegramAdapter] ignoring message from #{user_id} (not in allowed_users)")
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
files = collect_files(msg)
|
|
256
|
+
caption = msg["caption"].to_s
|
|
257
|
+
text = caption if text.empty? && !caption.empty?
|
|
258
|
+
return if text.strip.empty? && files.empty?
|
|
259
|
+
|
|
260
|
+
event = {
|
|
261
|
+
type: :message,
|
|
262
|
+
platform: :telegram,
|
|
263
|
+
chat_id: chat_id.to_s,
|
|
264
|
+
user_id: user_id.to_s,
|
|
265
|
+
text: text.strip,
|
|
266
|
+
files: files,
|
|
267
|
+
message_id: msg["message_id"].to_s,
|
|
268
|
+
timestamp: msg["date"] ? Time.at(msg["date"]) : Time.now,
|
|
269
|
+
chat_type: is_group ? :group : :direct,
|
|
270
|
+
raw: msg
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
Octo::Logger.info("[TelegramAdapter] msg from #{user_id} in #{chat_id} (#{chat_type}): #{text.slice(0, 80)}")
|
|
274
|
+
@on_message&.call(event)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The bot reacts to a group message only if:
|
|
278
|
+
# 1. text contains @<bot_username> as a mention entity, or
|
|
279
|
+
# 2. the message is a reply to a message authored by the bot
|
|
280
|
+
# Fail closed when bot identity is unknown — drop the message rather
|
|
281
|
+
# than respond to every line and spam the group.
|
|
282
|
+
def group_mention?(msg, text)
|
|
283
|
+
return false unless @bot_id
|
|
284
|
+
|
|
285
|
+
reply = msg["reply_to_message"]
|
|
286
|
+
return true if reply && reply.dig("from", "id") == @bot_id
|
|
287
|
+
|
|
288
|
+
entities = msg["entities"] || []
|
|
289
|
+
entities.any? do |e|
|
|
290
|
+
e["type"] == "mention" &&
|
|
291
|
+
text[e["offset"], e["length"]].to_s.casecmp?("@#{@bot_username}")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def strip_bot_mention(text)
|
|
296
|
+
return text unless @bot_username
|
|
297
|
+
text.gsub(/@#{Regexp.escape(@bot_username)}\b/i, "").strip
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Build file-attachment hashes for the agent's vision / file pipeline.
|
|
301
|
+
def collect_files(msg)
|
|
302
|
+
files = []
|
|
303
|
+
|
|
304
|
+
if msg["photo"].is_a?(Array) && !msg["photo"].empty?
|
|
305
|
+
# `photo` is an array of size variants — pick the largest.
|
|
306
|
+
largest = msg["photo"].max_by { |p| p["file_size"].to_i }
|
|
307
|
+
begin
|
|
308
|
+
raw = @api.download_file(largest["file_id"])
|
|
309
|
+
if raw.bytesize > MAX_IMAGE_BYTES
|
|
310
|
+
Octo::Logger.warn("[TelegramAdapter] image too large (#{raw.bytesize}B), dropping")
|
|
311
|
+
else
|
|
312
|
+
mime = detect_image_mime(raw)
|
|
313
|
+
files << {
|
|
314
|
+
type: :image,
|
|
315
|
+
name: "image.jpg",
|
|
316
|
+
mime_type: mime,
|
|
317
|
+
data_url: "data:#{mime};base64,#{Base64.strict_encode64(raw)}"
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
rescue => e
|
|
321
|
+
Octo::Logger.warn("[TelegramAdapter] image download failed: #{e.message}")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
if (doc = msg["document"])
|
|
326
|
+
begin
|
|
327
|
+
raw = @api.download_file(doc["file_id"])
|
|
328
|
+
filename = doc["file_name"].to_s
|
|
329
|
+
filename = "attachment" if filename.empty?
|
|
330
|
+
saved = Octo::Utils::FileProcessor.save(body: raw, filename: filename)
|
|
331
|
+
files << { type: :file, name: saved[:name], path: saved[:path] }
|
|
332
|
+
rescue => e
|
|
333
|
+
Octo::Logger.warn("[TelegramAdapter] document download failed: #{e.message}")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
files
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def detect_image_mime(bytes)
|
|
341
|
+
return "image/jpeg" unless bytes && bytes.bytesize >= 4
|
|
342
|
+
head = bytes.byteslice(0, 8).bytes
|
|
343
|
+
return "image/png" if head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47
|
|
344
|
+
return "image/gif" if head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46
|
|
345
|
+
return "image/webp" if head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46
|
|
346
|
+
"image/jpeg"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
# Split text at Telegram's 4096-char cap (we use 4000 as a margin).
|
|
352
|
+
# Prefers paragraph / line / space boundaries; hard-cuts as a last resort.
|
|
353
|
+
def split_message(text)
|
|
354
|
+
return [] if text.nil? || text.empty?
|
|
355
|
+
return [text] if text.length <= MAX_MESSAGE_CHARS
|
|
356
|
+
|
|
357
|
+
chunks = []
|
|
358
|
+
remaining = text.dup
|
|
359
|
+
while remaining.length > MAX_MESSAGE_CHARS
|
|
360
|
+
window = remaining[0, MAX_MESSAGE_CHARS]
|
|
361
|
+
cut = window.rindex("\n\n") || window.rindex("\n") || window.rindex(" ") || MAX_MESSAGE_CHARS
|
|
362
|
+
cut = MAX_MESSAGE_CHARS if cut.zero?
|
|
363
|
+
chunks << remaining[0, cut].rstrip
|
|
364
|
+
remaining = remaining[cut..].lstrip
|
|
365
|
+
end
|
|
366
|
+
chunks << remaining unless remaining.empty?
|
|
367
|
+
chunks
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
Adapters.register(:telegram, Adapter)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "net/https"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Octo
|
|
11
|
+
module Channel
|
|
12
|
+
module Adapters
|
|
13
|
+
module Telegram
|
|
14
|
+
# Telegram Bot API HTTP client.
|
|
15
|
+
# Spec: https://core.telegram.org/bots/api
|
|
16
|
+
#
|
|
17
|
+
# All requests POST JSON to https://<base>/bot<TOKEN>/<method>.
|
|
18
|
+
# File downloads use https://<base>/file/bot<TOKEN>/<file_path>.
|
|
19
|
+
#
|
|
20
|
+
# `base_url` is configurable to allow self-hosted Bot API servers
|
|
21
|
+
# (https://github.com/tdlib/telegram-bot-api), which is the practical
|
|
22
|
+
# escape hatch for users on networks where api.telegram.org is blocked.
|
|
23
|
+
class ApiClient
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.telegram.org"
|
|
25
|
+
LONG_POLL_TIMEOUT = 25 # seconds; server holds the request open up to this long
|
|
26
|
+
OPEN_TIMEOUT = 10
|
|
27
|
+
# Read timeout must comfortably exceed the long-poll window so we
|
|
28
|
+
# don't tear down healthy connections mid-poll.
|
|
29
|
+
POLL_READ_TIMEOUT = LONG_POLL_TIMEOUT + 10
|
|
30
|
+
|
|
31
|
+
class ApiError < StandardError
|
|
32
|
+
attr_reader :code, :description
|
|
33
|
+
def initialize(code, description)
|
|
34
|
+
@code = code
|
|
35
|
+
@description = description
|
|
36
|
+
super("Telegram API error #{code}: #{description}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class TimeoutError < StandardError; end
|
|
41
|
+
|
|
42
|
+
def initialize(token:, base_url: DEFAULT_BASE_URL)
|
|
43
|
+
@token = token.to_s
|
|
44
|
+
@base_url = (base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url).chomp("/")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Long-poll for updates. Returns the raw `result` array (possibly empty).
|
|
48
|
+
# `offset` is the highest update_id + 1 from the previous batch.
|
|
49
|
+
def get_updates(offset: nil, allowed_updates: %w[message])
|
|
50
|
+
params = { timeout: LONG_POLL_TIMEOUT, allowed_updates: allowed_updates }
|
|
51
|
+
params[:offset] = offset if offset
|
|
52
|
+
post("getUpdates", params, read_timeout: POLL_READ_TIMEOUT)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Send a plain or Markdown-formatted message. Returns the Message hash.
|
|
56
|
+
def send_message(chat_id:, text:, parse_mode: nil, reply_to_message_id: nil, message_thread_id: nil, disable_web_page_preview: true)
|
|
57
|
+
params = {
|
|
58
|
+
chat_id: chat_id,
|
|
59
|
+
text: text,
|
|
60
|
+
disable_web_page_preview: disable_web_page_preview
|
|
61
|
+
}
|
|
62
|
+
params[:parse_mode] = parse_mode if parse_mode
|
|
63
|
+
params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
|
64
|
+
params[:message_thread_id] = message_thread_id if message_thread_id
|
|
65
|
+
post("sendMessage", params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Edit the text of a previously sent message. Returns the edited Message hash.
|
|
69
|
+
def edit_message_text(chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true)
|
|
70
|
+
params = {
|
|
71
|
+
chat_id: chat_id,
|
|
72
|
+
message_id: message_id,
|
|
73
|
+
text: text,
|
|
74
|
+
disable_web_page_preview: disable_web_page_preview
|
|
75
|
+
}
|
|
76
|
+
params[:parse_mode] = parse_mode if parse_mode
|
|
77
|
+
post("editMessageText", params)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Send a chat action (e.g. "typing") — auto-expires after 5s client-side.
|
|
81
|
+
def send_chat_action(chat_id:, action: "typing", message_thread_id: nil)
|
|
82
|
+
params = { chat_id: chat_id, action: action }
|
|
83
|
+
params[:message_thread_id] = message_thread_id if message_thread_id
|
|
84
|
+
post("sendChatAction", params)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Send a photo by local file path. Returns the Message hash.
|
|
88
|
+
def send_photo(chat_id:, photo_path:, caption: nil, reply_to_message_id: nil)
|
|
89
|
+
params = { chat_id: chat_id }
|
|
90
|
+
params[:caption] = caption if caption
|
|
91
|
+
params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
|
92
|
+
post_multipart("sendPhoto", params, file_field: "photo", file_path: photo_path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Send a document (arbitrary file). Returns the Message hash.
|
|
96
|
+
def send_document(chat_id:, document_path:, filename: nil, caption: nil, reply_to_message_id: nil)
|
|
97
|
+
params = { chat_id: chat_id }
|
|
98
|
+
params[:caption] = caption if caption
|
|
99
|
+
params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
|
100
|
+
post_multipart("sendDocument", params, file_field: "document", file_path: document_path, filename: filename)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Resolve a file_id to a file_path via getFile, then download the bytes.
|
|
104
|
+
# Returns the raw byte string.
|
|
105
|
+
def download_file(file_id)
|
|
106
|
+
file = post("getFile", { file_id: file_id })
|
|
107
|
+
path = file["file_path"]
|
|
108
|
+
raise ApiError.new(0, "getFile returned no file_path") if path.to_s.empty?
|
|
109
|
+
|
|
110
|
+
uri = URI("#{@base_url}/file/bot#{@token}/#{path}")
|
|
111
|
+
http_get_raw(uri)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def post(method_name, params, read_timeout: 30)
|
|
116
|
+
uri = URI("#{@base_url}/bot#{@token}/#{method_name}")
|
|
117
|
+
http = build_http(uri, read_timeout: read_timeout)
|
|
118
|
+
|
|
119
|
+
req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
120
|
+
req.body = JSON.generate(params)
|
|
121
|
+
|
|
122
|
+
res = http.request(req)
|
|
123
|
+
body = parse_body(res)
|
|
124
|
+
unwrap(body, method_name)
|
|
125
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
126
|
+
raise TimeoutError, "#{method_name} timed out"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def post_multipart(method_name, params, file_field:, file_path:, filename: nil)
|
|
130
|
+
uri = URI("#{@base_url}/bot#{@token}/#{method_name}")
|
|
131
|
+
boundary = "----octo-tg-#{SecureRandom.hex(8)}"
|
|
132
|
+
body = String.new(encoding: "BINARY")
|
|
133
|
+
|
|
134
|
+
params.each do |k, v|
|
|
135
|
+
body << "--#{boundary}\r\n"
|
|
136
|
+
body << %(Content-Disposition: form-data; name="#{k}"\r\n\r\n)
|
|
137
|
+
body << v.to_s.dup.force_encoding("BINARY")
|
|
138
|
+
body << "\r\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
file_bytes = File.binread(file_path)
|
|
142
|
+
body << "--#{boundary}\r\n"
|
|
143
|
+
body << %(Content-Disposition: form-data; name="#{file_field}"; filename="#{filename || File.basename(file_path)}"\r\n)
|
|
144
|
+
body << "Content-Type: #{mime_for(file_path)}\r\n\r\n"
|
|
145
|
+
body << file_bytes
|
|
146
|
+
body << "\r\n--#{boundary}--\r\n"
|
|
147
|
+
|
|
148
|
+
http = build_http(uri, read_timeout: 60)
|
|
149
|
+
req = Net::HTTP::Post.new(uri.request_uri,
|
|
150
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}")
|
|
151
|
+
req.body = body
|
|
152
|
+
|
|
153
|
+
unwrap(parse_body(http.request(req)), method_name)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def http_get_raw(uri)
|
|
157
|
+
http = build_http(uri, read_timeout: 60)
|
|
158
|
+
res = http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
159
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
160
|
+
raise ApiError.new(res.code.to_i, "GET #{uri.path} → HTTP #{res.code}: #{res.body.to_s.slice(0, 200)}")
|
|
161
|
+
end
|
|
162
|
+
res.body
|
|
163
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
164
|
+
raise TimeoutError, "file download timed out"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_http(uri, read_timeout:)
|
|
168
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
169
|
+
http.use_ssl = uri.scheme == "https"
|
|
170
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
171
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
172
|
+
http.read_timeout = read_timeout
|
|
173
|
+
http
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_body(res)
|
|
177
|
+
JSON.parse(res.body)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
raise ApiError.new(res.code.to_i, "non-JSON response from Telegram: #{res.body.to_s.slice(0, 200)}")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def unwrap(body, method_name)
|
|
183
|
+
if body["ok"]
|
|
184
|
+
body["result"]
|
|
185
|
+
else
|
|
186
|
+
raise ApiError.new(body["error_code"].to_i, "#{method_name}: #{body["description"]}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mime_for(path)
|
|
191
|
+
case File.extname(path).downcase
|
|
192
|
+
when ".png" then "image/png"
|
|
193
|
+
when ".gif" then "image/gif"
|
|
194
|
+
when ".webp" then "image/webp"
|
|
195
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
196
|
+
when ".pdf" then "application/pdf"
|
|
197
|
+
when ".txt", ".md" then "text/plain"
|
|
198
|
+
else "application/octet-stream"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|