Class: Clacky::Channel::Adapters::Telegram::Adapter
- Defined in:
- lib/clacky/server/channel/adapters/telegram/adapter.rb
Overview
Telegram Bot API adapter.
Transport: HTTPS long-poll via getUpdates (no public domain required). Auth: single bot token obtained from @BotFather. Group rule: bots only react when @-mentioned or replied to (matches Feishu).
Config keys (channels.yml ‘telegram`):
bot_token String required — from @BotFather
base_url String default "https://api.telegram.org"
(override for self-hosted Bot API / proxy)
parse_mode String default "Markdown" — set "" / nil to disable
allowed_users Array optional whitelist of from.id (numeric, as String)
Constant Summary collapse
- MAX_MESSAGE_CHARS =
Telegram messages cap at 4096 UTF-16 code units; we leave a small margin.
4000- MAX_IMAGE_BYTES =
Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
Class Method Summary collapse
- .env_keys ⇒ Object
- .platform_config(data) ⇒ Object
- .platform_id ⇒ Object
- .set_env_data(data, config) ⇒ Object
-
.test_connection(fields) ⇒ Hash
Verify credentials by calling getMe.
Instance Method Summary collapse
-
#collect_files(msg) ⇒ Object
Build file-attachment hashes for the agent’s vision / file pipeline.
- #detect_image_mime(bytes) ⇒ Object
-
#ensure_bot_identity ⇒ Object
── Inbound ─────────────────────────────────────────────────────────.
-
#group_mention?(msg, text) ⇒ Boolean
The bot reacts to a group message only if: 1.
-
#initialize(config) ⇒ Adapter
constructor
A new instance of Adapter.
- #process_update(update) ⇒ Object
- #send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Object
-
#send_text(chat_id, text, reply_to: nil) ⇒ Object
Send a text message.
-
#split_message(text) ⇒ Object
Split text at Telegram’s 4096-char cap (we use 4000 as a margin).
-
#start(&on_message) ⇒ Object
── Lifecycle ──────────────────────────────────────────────────────.
- #stop ⇒ Object
- #strip_bot_mention(text) ⇒ Object
- #supports_message_updates? ⇒ Boolean
- #update_message(chat_id, message_id, text) ⇒ Object
- #validate_config(config) ⇒ Object
Methods inherited from Base
Constructor Details
#initialize(config) ⇒ Adapter
Returns a new instance of Adapter.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 71 def initialize(config) @config = config @token = config[:bot_token].to_s @base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL @parse_mode = config.key?(:parse_mode) ? config[:parse_mode] : "Markdown" @parse_mode = nil if @parse_mode.to_s.empty? @allowed_users = Array(config[:allowed_users]).map(&:to_s) @api = ApiClient.new(token: @token, base_url: @base_url) @running = false @on_message = nil @last_offset = nil # Cached bot identity (used for @-mention check in groups). @bot_username = nil @bot_id = nil end |
Class Method Details
.env_keys ⇒ Object
33 34 35 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 33 def self.env_keys %w[IM_TELEGRAM_BOT_TOKEN IM_TELEGRAM_BASE_URL IM_TELEGRAM_PARSE_MODE IM_TELEGRAM_ALLOWED_USERS] end |
.platform_config(data) ⇒ Object
37 38 39 40 41 42 43 44 45 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 37 def self.platform_config(data) { bot_token: data["IM_TELEGRAM_BOT_TOKEN"] || data["bot_token"], base_url: data["IM_TELEGRAM_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL, parse_mode: data.key?("parse_mode") ? data["parse_mode"] : (data["IM_TELEGRAM_PARSE_MODE"] || "Markdown"), allowed_users: (data["IM_TELEGRAM_ALLOWED_USERS"] || data["allowed_users"] || "") .then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) } }.compact end |
.platform_id ⇒ Object
29 30 31 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 29 def self.platform_id :telegram end |
.set_env_data(data, config) ⇒ Object
47 48 49 50 51 52 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 47 def self.set_env_data(data, config) data["IM_TELEGRAM_BOT_TOKEN"] = config[:bot_token] data["IM_TELEGRAM_BASE_URL"] = config[:base_url] if config[:base_url] data["IM_TELEGRAM_PARSE_MODE"] = config[:parse_mode] if config[:parse_mode] data["IM_TELEGRAM_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",") end |
.test_connection(fields) ⇒ Hash
Verify credentials by calling getMe.
57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 57 def self.test_connection(fields) token = fields[:bot_token].to_s.strip return { ok: false, error: "bot_token is required" } if token.empty? base_url = fields[:base_url].to_s.strip base_url = ApiClient::DEFAULT_BASE_URL if base_url.empty? client = ApiClient.new(token: token, base_url: base_url) me = client.post("getMe", {}) { ok: true, message: "Connected — bot @#{me["username"]} (id #{me["id"]})" } rescue StandardError => e { ok: false, error: e. } end |
Instance Method Details
#collect_files(msg) ⇒ Object
Build file-attachment hashes for the agent’s vision / file pipeline.
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 301 def collect_files(msg) files = [] if msg["photo"].is_a?(Array) && !msg["photo"].empty? # `photo` is an array of size variants — pick the largest. largest = msg["photo"].max_by { |p| p["file_size"].to_i } begin raw = @api.download_file(largest["file_id"]) if raw.bytesize > MAX_IMAGE_BYTES Clacky::Logger.warn("[TelegramAdapter] image too large (#{raw.bytesize}B), dropping") else mime = detect_image_mime(raw) files << { type: :image, name: "image.jpg", mime_type: mime, data_url: "data:#{mime};base64,#{Base64.strict_encode64(raw)}" } end rescue => e Clacky::Logger.warn("[TelegramAdapter] image download failed: #{e.}") end end if (doc = msg["document"]) begin raw = @api.download_file(doc["file_id"]) filename = doc["file_name"].to_s filename = "attachment" if filename.empty? saved = Clacky::Utils::FileProcessor.save(body: raw, filename: filename) files << { type: :file, name: saved[:name], path: saved[:path] } rescue => e Clacky::Logger.warn("[TelegramAdapter] document download failed: #{e.}") end end files end |
#detect_image_mime(bytes) ⇒ Object
340 341 342 343 344 345 346 347 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 340 def detect_image_mime(bytes) return "image/jpeg" unless bytes && bytes.bytesize >= 4 head = bytes.byteslice(0, 8).bytes return "image/png" if head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47 return "image/gif" if head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46 return "image/webp" if head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46 "image/jpeg" end |
#ensure_bot_identity ⇒ Object
── Inbound ─────────────────────────────────────────────────────────
222 223 224 225 226 227 228 229 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 222 def ensure_bot_identity me = @api.post("getMe", {}) @bot_id = me["id"] @bot_username = me["username"] Clacky::Logger.info("[TelegramAdapter] bot identity: @#{@bot_username} (id=#{@bot_id})") rescue => e Clacky::Logger.warn("[TelegramAdapter] getMe failed: #{e.} — group @-mentions will be dropped") end |
#group_mention?(msg, text) ⇒ Boolean
The bot reacts to a group message only if:
1. text contains @<bot_username> as a mention entity, or
2. the message is a reply to a message authored by the bot
Fail closed when bot identity is unknown — drop the message rather than respond to every line and spam the group.
282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 282 def group_mention?(msg, text) return false unless @bot_id reply = msg["reply_to_message"] return true if reply && reply.dig("from", "id") == @bot_id entities = msg["entities"] || [] entities.any? do |e| e["type"] == "mention" && text[e["offset"], e["length"]].to_s.casecmp?("@#{@bot_username}") end end |
#process_update(update) ⇒ Object
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 231 def process_update(update) msg = update["message"] return unless msg chat = msg["chat"] || {} from = msg["from"] || {} chat_id = chat["id"] user_id = from["id"] return unless chat_id && user_id chat_type = chat["type"].to_s is_group = %w[group supergroup].include?(chat_type) text = msg["text"].to_s if is_group return unless group_mention?(msg, text) text = strip_bot_mention(text) end if @allowed_users.any? && !@allowed_users.include?(user_id.to_s) Clacky::Logger.debug("[TelegramAdapter] ignoring message from #{user_id} (not in allowed_users)") return end files = collect_files(msg) caption = msg["caption"].to_s text = caption if text.empty? && !caption.empty? return if text.strip.empty? && files.empty? event = { type: :message, platform: :telegram, chat_id: chat_id.to_s, user_id: user_id.to_s, text: text.strip, files: files, message_id: msg["message_id"].to_s, timestamp: msg["date"] ? Time.at(msg["date"]) : Time.now, chat_type: is_group ? :group : :direct, raw: msg } Clacky::Logger.info("[TelegramAdapter] msg from #{user_id} in #{chat_id} (#{chat_type}): #{text.slice(0, 80)}") @on_message&.call(event) end |
#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Object
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 173 def send_file(chat_id, path, name: nil, reply_to: nil) return { message_id: nil } unless File.exist?(path) is_image = path.to_s.downcase.match?(/\.(png|jpe?g|gif|webp)\z/) msg = if is_image @api.send_photo( chat_id: chat_id.to_s, photo_path: path, reply_to_message_id: reply_to&.to_i ) else @api.send_document( chat_id: chat_id.to_s, document_path: path, filename: name, reply_to_message_id: reply_to&.to_i ) end { message_id: msg["message_id"] } rescue => e Clacky::Logger.error("[TelegramAdapter] send_file failed for #{path}: #{e.}") { message_id: nil } end |
#send_text(chat_id, text, reply_to: nil) ⇒ Object
Send a text message. Splits content longer than Telegram’s 4096-char cap into multiple consecutive messages. Returns { message_id: } of the LAST chunk (matches the contract used by the other adapters).
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 135 def send_text(chat_id, text, reply_to: nil) chunks = (text.to_s) return { message_id: nil } if chunks.empty? = nil chunks.each_with_index do |chunk, i| params = { chat_id: chat_id.to_s, text: chunk, disable_web_page_preview: true } params[:parse_mode] = @parse_mode if @parse_mode params[:reply_to_message_id] = reply_to.to_i if reply_to && i == 0 msg = @api.post("sendMessage", params) = msg["message_id"] end { message_id: } rescue ApiClient::ApiError => e # Markdown parse failures fall back to plain text — most common cause # is unescaped Markdown reserved chars in the agent's output. if @parse_mode && e.description.to_s =~ /can't parse entities|markdown/i Clacky::Logger.warn("[TelegramAdapter] parse_mode failed, retrying as plain text: #{e.description}") fallback = { chat_id: chat_id.to_s, text: text.to_s, disable_web_page_preview: true } fallback[:reply_to_message_id] = reply_to.to_i if reply_to msg = @api.post("sendMessage", fallback) return { message_id: msg["message_id"] } end Clacky::Logger.error("[TelegramAdapter] send_text failed: #{e.}") { message_id: nil } rescue => e Clacky::Logger.error("[TelegramAdapter] send_text failed: #{e.}") { message_id: nil } end |
#split_message(text) ⇒ Object
Split text at Telegram’s 4096-char cap (we use 4000 as a margin). Prefers paragraph / line / space boundaries; hard-cuts as a last resort.
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 353 def (text) return [] if text.nil? || text.empty? return [text] if text.length <= MAX_MESSAGE_CHARS chunks = [] remaining = text.dup while remaining.length > MAX_MESSAGE_CHARS window = remaining[0, MAX_MESSAGE_CHARS] cut = window.rindex("\n\n") || window.rindex("\n") || window.rindex(" ") || MAX_MESSAGE_CHARS cut = MAX_MESSAGE_CHARS if cut.zero? chunks << remaining[0, cut].rstrip remaining = remaining[cut..].lstrip end chunks << remaining unless remaining.empty? chunks end |
#start(&on_message) ⇒ Object
── Lifecycle ──────────────────────────────────────────────────────
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 91 def start(&) @running = true @on_message = ensure_bot_identity Clacky::Logger.info("[TelegramAdapter] starting long-poll (base_url=#{@base_url})") consecutive_errors = 0 while @running begin updates = @api.get_updates(offset: @last_offset) consecutive_errors = 0 updates.each do |update| @last_offset = update["update_id"] + 1 process_update(update) rescue => e Clacky::Logger.warn("[TelegramAdapter] process_update error: #{e.}\n#{e.backtrace.first(3).join("\n")}") end rescue ApiClient::TimeoutError # Long-poll cycle ended with no updates — just loop. rescue ApiClient::ApiError => e consecutive_errors += 1 Clacky::Logger.warn("[TelegramAdapter] API #{e.code}: #{e.description}") sleep(consecutive_errors > 3 ? 30 : 5) rescue => e consecutive_errors += 1 Clacky::Logger.error("[TelegramAdapter] poll error: #{e.}") break unless @running sleep(consecutive_errors > 3 ? 30 : 5) end end end |
#stop ⇒ Object
126 127 128 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 126 def stop @running = false end |
#strip_bot_mention(text) ⇒ Object
295 296 297 298 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 295 def strip_bot_mention(text) return text unless @bot_username text.gsub(/@#{Regexp.escape(@bot_username)}\b/i, "").strip end |
#supports_message_updates? ⇒ Boolean
210 211 212 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 210 def true end |
#update_message(chat_id, message_id, text) ⇒ Object
197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 197 def (chat_id, , text) @api.( chat_id: chat_id.to_s, message_id: .to_i, text: text, parse_mode: @parse_mode ) true rescue => e Clacky::Logger.warn("[TelegramAdapter] update_message failed: #{e.}") false end |
#validate_config(config) ⇒ Object
214 215 216 217 218 |
# File 'lib/clacky/server/channel/adapters/telegram/adapter.rb', line 214 def validate_config(config) errors = [] errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].to_s.strip.empty? errors end |