Class: Clacky::Channel::Adapters::Telegram::ApiClient
- Inherits:
-
Object
- Object
- Clacky::Channel::Adapters::Telegram::ApiClient
- Defined in:
- lib/clacky/server/channel/adapters/telegram/api_client.rb
Overview
Telegram Bot API HTTP client. Spec: core.telegram.org/bots/api
All requests POST JSON to https://<base>/bot<TOKEN>/<method>. File downloads use https://<base>/file/bot<TOKEN>/<file_path>.
‘base_url` is configurable to allow self-hosted Bot API servers (github.com/tdlib/telegram-bot-api), which is the practical escape hatch for users on networks where api.telegram.org is blocked.
Defined Under Namespace
Classes: ApiError, TimeoutError
Constant Summary collapse
- DEFAULT_BASE_URL =
"https://api.telegram.org"- LONG_POLL_TIMEOUT =
seconds; server holds the request open up to this long
25- OPEN_TIMEOUT =
10- POLL_READ_TIMEOUT =
Read timeout must comfortably exceed the long-poll window so we don’t tear down healthy connections mid-poll.
LONG_POLL_TIMEOUT + 10
Instance Method Summary collapse
- #build_http(uri, read_timeout:) ⇒ Object
-
#download_file(file_id) ⇒ Object
Resolve a file_id to a file_path via getFile, then download the bytes.
-
#edit_message_text(chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true) ⇒ Object
Edit the text of a previously sent message.
-
#get_updates(offset: nil, allowed_updates: %w[message])) ⇒ Object
Long-poll for updates.
- #http_get_raw(uri) ⇒ Object
-
#initialize(token:, base_url: DEFAULT_BASE_URL) ⇒ ApiClient
constructor
A new instance of ApiClient.
- #mime_for(path) ⇒ Object
- #parse_body(res) ⇒ Object
- #post(method_name, params, read_timeout: 30) ⇒ Object
- #post_multipart(method_name, params, file_field:, file_path:, filename: nil) ⇒ Object
-
#send_chat_action(chat_id:, action: "typing", message_thread_id: nil) ⇒ Object
Send a chat action (e.g. “typing”) — auto-expires after 5s client-side.
-
#send_document(chat_id:, document_path:, filename: nil, caption: nil, reply_to_message_id: nil) ⇒ Object
Send a document (arbitrary file).
-
#send_message(chat_id:, text:, parse_mode: nil, reply_to_message_id: nil, message_thread_id: nil, disable_web_page_preview: true) ⇒ Object
Send a plain or Markdown-formatted message.
-
#send_photo(chat_id:, photo_path:, caption: nil, reply_to_message_id: nil) ⇒ Object
Send a photo by local file path.
- #unwrap(body, method_name) ⇒ Object
Constructor Details
#initialize(token:, base_url: DEFAULT_BASE_URL) ⇒ ApiClient
Returns a new instance of ApiClient.
42 43 44 45 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 42 def initialize(token:, base_url: DEFAULT_BASE_URL) @token = token.to_s @base_url = (base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url).chomp("/") end |
Instance Method Details
#build_http(uri, read_timeout:) ⇒ Object
167 168 169 170 171 172 173 174 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 167 def build_http(uri, read_timeout:) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl? http.open_timeout = OPEN_TIMEOUT http.read_timeout = read_timeout http end |
#download_file(file_id) ⇒ Object
Resolve a file_id to a file_path via getFile, then download the bytes. Returns the raw byte string.
105 106 107 108 109 110 111 112 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 105 def download_file(file_id) file = post("getFile", { file_id: file_id }) path = file["file_path"] raise ApiError.new(0, "getFile returned no file_path") if path.to_s.empty? uri = URI("#{@base_url}/file/bot#{@token}/#{path}") http_get_raw(uri) end |
#edit_message_text(chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true) ⇒ Object
Edit the text of a previously sent message. Returns the edited Message hash.
69 70 71 72 73 74 75 76 77 78 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 69 def (chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true) params = { chat_id: chat_id, message_id: , text: text, disable_web_page_preview: disable_web_page_preview } params[:parse_mode] = parse_mode if parse_mode post("editMessageText", params) end |
#get_updates(offset: nil, allowed_updates: %w[message])) ⇒ Object
Long-poll for updates. Returns the raw ‘result` array (possibly empty). `offset` is the highest update_id + 1 from the previous batch.
49 50 51 52 53 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 49 def get_updates(offset: nil, allowed_updates: %w[message]) params = { timeout: LONG_POLL_TIMEOUT, allowed_updates: allowed_updates } params[:offset] = offset if offset post("getUpdates", params, read_timeout: POLL_READ_TIMEOUT) end |
#http_get_raw(uri) ⇒ Object
156 157 158 159 160 161 162 163 164 165 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 156 def http_get_raw(uri) http = build_http(uri, read_timeout: 60) res = http.request(Net::HTTP::Get.new(uri.request_uri)) unless res.is_a?(Net::HTTPSuccess) raise ApiError.new(res.code.to_i, "GET #{uri.path} → HTTP #{res.code}: #{res.body.to_s.slice(0, 200)}") end res.body rescue Net::ReadTimeout, Net::OpenTimeout raise TimeoutError, "file download timed out" end |
#mime_for(path) ⇒ Object
190 191 192 193 194 195 196 197 198 199 200 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 190 def mime_for(path) case File.extname(path).downcase when ".png" then "image/png" when ".gif" then "image/gif" when ".webp" then "image/webp" when ".jpg", ".jpeg" then "image/jpeg" when ".pdf" then "application/pdf" when ".txt", ".md" then "text/plain" else "application/octet-stream" end end |
#parse_body(res) ⇒ Object
176 177 178 179 180 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 176 def parse_body(res) JSON.parse(res.body) rescue JSON::ParserError raise ApiError.new(res.code.to_i, "non-JSON response from Telegram: #{res.body.to_s.slice(0, 200)}") end |
#post(method_name, params, read_timeout: 30) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 115 def post(method_name, params, read_timeout: 30) uri = URI("#{@base_url}/bot#{@token}/#{method_name}") http = build_http(uri, read_timeout: read_timeout) req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json") req.body = JSON.generate(params) res = http.request(req) body = parse_body(res) unwrap(body, method_name) rescue Net::ReadTimeout, Net::OpenTimeout raise TimeoutError, "#{method_name} timed out" end |
#post_multipart(method_name, params, file_field:, file_path:, filename: nil) ⇒ Object
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 129 def post_multipart(method_name, params, file_field:, file_path:, filename: nil) uri = URI("#{@base_url}/bot#{@token}/#{method_name}") boundary = "----clacky-tg-#{SecureRandom.hex(8)}" body = String.new(encoding: "BINARY") params.each do |k, v| body << "--#{boundary}\r\n" body << %(Content-Disposition: form-data; name="#{k}"\r\n\r\n) body << v.to_s.dup.force_encoding("BINARY") body << "\r\n" end file_bytes = File.binread(file_path) body << "--#{boundary}\r\n" body << %(Content-Disposition: form-data; name="#{file_field}"; filename="#{filename || File.basename(file_path)}"\r\n) body << "Content-Type: #{mime_for(file_path)}\r\n\r\n" body << file_bytes body << "\r\n--#{boundary}--\r\n" http = build_http(uri, read_timeout: 60) req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "multipart/form-data; boundary=#{boundary}") req.body = body unwrap(parse_body(http.request(req)), method_name) end |
#send_chat_action(chat_id:, action: "typing", message_thread_id: nil) ⇒ Object
Send a chat action (e.g. “typing”) — auto-expires after 5s client-side.
81 82 83 84 85 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 81 def send_chat_action(chat_id:, action: "typing", message_thread_id: nil) params = { chat_id: chat_id, action: action } params[:message_thread_id] = if post("sendChatAction", params) end |
#send_document(chat_id:, document_path:, filename: nil, caption: nil, reply_to_message_id: nil) ⇒ Object
Send a document (arbitrary file). Returns the Message hash.
96 97 98 99 100 101 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 96 def send_document(chat_id:, document_path:, filename: nil, caption: nil, reply_to_message_id: nil) params = { chat_id: chat_id } params[:caption] = caption if caption params[:reply_to_message_id] = if post_multipart("sendDocument", params, file_field: "document", file_path: document_path, filename: filename) end |
#send_message(chat_id:, text:, parse_mode: nil, reply_to_message_id: nil, message_thread_id: nil, disable_web_page_preview: true) ⇒ Object
Send a plain or Markdown-formatted message. Returns the Message hash.
56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 56 def (chat_id:, text:, parse_mode: nil, reply_to_message_id: nil, message_thread_id: nil, disable_web_page_preview: true) params = { chat_id: chat_id, text: text, disable_web_page_preview: disable_web_page_preview } params[:parse_mode] = parse_mode if parse_mode params[:reply_to_message_id] = if params[:message_thread_id] = if post("sendMessage", params) end |
#send_photo(chat_id:, photo_path:, caption: nil, reply_to_message_id: nil) ⇒ Object
Send a photo by local file path. Returns the Message hash.
88 89 90 91 92 93 |
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 88 def send_photo(chat_id:, photo_path:, caption: nil, reply_to_message_id: nil) params = { chat_id: chat_id } params[:caption] = caption if caption params[:reply_to_message_id] = if post_multipart("sendPhoto", params, file_field: "photo", file_path: photo_path) end |