Class: Clacky::Channel::Adapters::Telegram::ApiClient

Inherits:
Object
  • Object
show all
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

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.

Raises:



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 edit_message_text(chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true)
  params = {
    chat_id:                  chat_id,
    message_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] = message_thread_id if message_thread_id
  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] = reply_to_message_id if reply_to_message_id
  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 send_message(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]     = reply_to_message_id if reply_to_message_id
  params[:message_thread_id]       = message_thread_id   if message_thread_id
  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] = reply_to_message_id if reply_to_message_id
  post_multipart("sendPhoto", params, file_field: "photo", file_path: photo_path)
end

#unwrap(body, method_name) ⇒ Object



182
183
184
185
186
187
188
# File 'lib/clacky/server/channel/adapters/telegram/api_client.rb', line 182

def unwrap(body, method_name)
  if body["ok"]
    body["result"]
  else
    raise ApiError.new(body["error_code"].to_i, "#{method_name}: #{body["description"]}")
  end
end