Class: Clacky::Channel::Adapters::Feishu::Bot
- Inherits:
-
Object
- Object
- Clacky::Channel::Adapters::Feishu::Bot
- Defined in:
- lib/clacky/server/channel/adapters/feishu/bot.rb
Overview
Feishu Bot API client. Handles authentication, message sending, and API calls.
Constant Summary collapse
- API_TIMEOUT =
10- DOWNLOAD_TIMEOUT =
60- ERR_SCOPE_MISSING =
99991672- ERR_SCOPE_MISSING_2 =
230027- GROUP_HISTORY_LIMIT =
15- SCOPE_GROUP_MSG =
"im:message.group_msg"
Instance Method Summary collapse
-
#bot_open_id ⇒ String?
Used to detect @bot mentions in group chats.
-
#build_connection ⇒ Faraday::Connection
Build Faraday connection.
-
#build_message_payload(text) ⇒ Array<String, String>
Build message content and type based on text content.
-
#check_doc_error!(response, token) ⇒ Object
Check doc API response for known permission errors and raise accordingly.
-
#detect_mime(filename) ⇒ Object
Detect MIME type from filename extension.
-
#download_message_resource(message_id, file_key, type: "image") ⇒ Hash
Download a message resource (image or file) from Feishu.
-
#feishu_file_type(filename) ⇒ Object
Map file extension to Feishu file_type enum.
-
#fetch_chat_history(chat_id, limit: GROUP_HISTORY_LIMIT) ⇒ Array<Hash>
Fetch recent messages from a chat via the message list API.
-
#fetch_doc_content(url) ⇒ String
Fetch the plain-text content of a Feishu document (docx / docs / wiki).
-
#fetch_docx_raw_content(doc_token) ⇒ String
Fetch raw text content of a docx document.
-
#fetch_wiki_node(wiki_token) ⇒ Hash
Resolve wiki node to get real obj_token and obj_type.
-
#get(path, params: {}) ⇒ Hash
Make authenticated GET request.
- #has_code_block_or_table?(text) ⇒ Boolean
-
#initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN) ⇒ Bot
constructor
A new instance of Bot.
-
#parse_doc_url(url) ⇒ Array<String, Symbol>?
Parse Feishu doc URL and return [doc_token, type] type is :docx, :docs, or :wiki.
-
#parse_response(response) ⇒ Hash
Parse API response.
-
#patch(path, body) ⇒ Hash
Make authenticated PATCH request.
-
#post(path, body, params: {}) ⇒ Hash
Make authenticated POST request.
-
#post_without_auth(path, body) ⇒ Hash
Make POST request without authentication (for token endpoint).
-
#sanitize_images_for_card(text) ⇒ String
Convert Markdown image syntax  to plain links [alt](url) inside interactive card content.
-
#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Hash
Upload a local file to Feishu and send it to a chat.
-
#send_text(chat_id, text, reply_to: nil) ⇒ Hash
Send plain text message.
-
#tenant_access_token ⇒ String
Get tenant access token (cached).
-
#update_message(message_id, text) ⇒ Boolean
Update an existing message.
-
#upload_file(data, filename) ⇒ String
Upload a file to Feishu and return file_key.
-
#upload_image(data, filename) ⇒ String
Upload an image to Feishu and return image_key.
Constructor Details
#initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN) ⇒ Bot
Returns a new instance of Bot.
56 57 58 59 60 61 62 63 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 56 def initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN) @app_id = app_id @app_secret = app_secret @domain = domain @token_cache = nil @token_expires_at = nil end |
Instance Method Details
#bot_open_id ⇒ String?
Used to detect @bot mentions in group chats.
293 294 295 296 297 298 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 293 def bot_open_id @bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id") rescue => e Clacky::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.}") nil end |
#build_connection ⇒ Faraday::Connection
Build Faraday connection
518 519 520 521 522 523 524 525 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 518 def build_connection Faraday.new(url: @domain) do |f| f..timeout = API_TIMEOUT f..open_timeout = API_TIMEOUT f.ssl.verify = false f.adapter Faraday.default_adapter end end |
#build_message_payload(text) ⇒ Array<String, String>
Build message content and type based on text content. Uses interactive card (schema 2.0) for code blocks and tables, post/md for everything else.
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 198 def (text) if has_code_block_or_table?(text) safe_text = sanitize_images_for_card(text) content = JSON.generate({ schema: "2.0", config: { wide_screen_mode: true }, body: { elements: [{ tag: "markdown", content: safe_text }] } }) [content, "interactive"] else safe_text = sanitize_images_for_card(text) content = JSON.generate({ zh_cn: { content: [[{ tag: "md", text: safe_text }]] } }) [content, "post"] end end |
#check_doc_error!(response, token) ⇒ Object
Check doc API response for known permission errors and raise accordingly.
496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 496 def check_doc_error!(response, token) code = response["code"].to_i return if code == 0 if code == 91403 raise FeishuDocPermissionError, token elsif code == ERR_SCOPE_MISSING # Extract auth URL from the error message if present auth_url = response.dig("error", "permission_violations", 0, "attach_url") || extract_url(response["msg"].to_s) raise FeishuDocScopeError.new(auth_url) else raise "Failed to fetch doc: code=#{code} msg=#{response["msg"]}" end end |
#detect_mime(filename) ⇒ Object
Detect MIME type from filename extension.
452 453 454 455 456 457 458 459 460 461 462 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 452 def detect_mime(filename) case File.extname(filename).downcase when ".jpg", ".jpeg" then "image/jpeg" when ".png" then "image/png" when ".gif" then "image/gif" when ".webp" then "image/webp" when ".pdf" then "application/pdf" when ".mp4" then "video/mp4" else "application/octet-stream" end end |
#download_message_resource(message_id, file_key, type: "image") ⇒ Hash
Download a message resource (image or file) from Feishu. For message attachments, must use messageResource API — not im/v1/images.
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 150 def (, file_key, type: "image") conn = Faraday.new(url: @domain) do |f| f..timeout = DOWNLOAD_TIMEOUT f..open_timeout = API_TIMEOUT f.ssl.verify = false f.adapter Faraday.default_adapter end response = conn.get("/open-apis/im/v1/messages/#{}/resources/#{file_key}") do |req| req.headers["Authorization"] = "Bearer #{tenant_access_token}" req.params["type"] = type end unless response.success? raise "Failed to download message resource: HTTP #{response.status}" end { body: response.body, content_type: response.headers["content-type"].to_s.split(";").first.strip } end |
#feishu_file_type(filename) ⇒ Object
Map file extension to Feishu file_type enum. Feishu accepts: opus, mp4, pdf, doc, xls, ppt, stream (others)
439 440 441 442 443 444 445 446 447 448 449 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 439 def feishu_file_type(filename) case File.extname(filename).downcase when ".pdf" then "pdf" when ".doc", ".docx" then "doc" when ".xls", ".xlsx" then "xls" when ".ppt", ".pptx" then "ppt" when ".mp4" then "mp4" when ".opus" then "opus" else "stream" end end |
#fetch_chat_history(chat_id, limit: GROUP_HISTORY_LIMIT) ⇒ Array<Hash>
Fetch recent messages from a chat via the message list API. Returns an array of { user_id, text } hashes, oldest first.
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 252 def fetch_chat_history(chat_id, limit: GROUP_HISTORY_LIMIT) response = get("/open-apis/im/v1/messages", params: { container_id_type: "chat", container_id: chat_id, sort_type: "ByCreateTimeDesc", page_size: limit }) unless response["code"] == 0 code = response["code"].to_i Clacky::Logger.warn("[feishu] fetch_chat_history failed code=#{code} msg=#{response["msg"]}, ext=#{response.dig("error", "message") || response["msg"]}") if code == ERR_SCOPE_MISSING || code == ERR_SCOPE_MISSING_2 auth_url = response.dig("error", "permission_violations", 0, "attach_url") || extract_url(response["msg"].to_s) scopes = (response.dig("error", "permission_violations") || []).map { |v| v["subject"] }.compact raise FeishuScopeError.new(auth_url, required_scopes: scopes) end return [] end items = response.dig("data", "items") || [] Clacky::Logger.info("[feishu] fetch_chat_history chat=#{chat_id} api_items=#{items.size} first=#{items.first&.inspect}") items.reverse.filter_map do |item| content = begin body = JSON.parse(item.dig("body", "content").to_s) body["text"].to_s.gsub(/@_user_\S+\s?/, "").gsub(/@\S+\s?/, "").strip rescue JSON::ParserError nil end next if content.nil? || content.empty? sender_id = item.dig("sender", "id").to_s { user_id: sender_id, text: content } end rescue FeishuScopeError raise rescue => e Clacky::Logger.warn("[feishu] fetch_chat_history failed: #{e.}") [] end |
#fetch_doc_content(url) ⇒ String
Fetch the plain-text content of a Feishu document (docx / docs / wiki). Raises FeishuDocPermissionError (code 91403) when the app has no access.
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 176 def fetch_doc_content(url) doc_token, doc_type = parse_doc_url(url) raise ArgumentError, "Unsupported Feishu doc URL: #{url}" unless doc_token if doc_type == :wiki # Wiki: first resolve the real docToken via get_node node = fetch_wiki_node(doc_token) actual_token = node["obj_token"] actual_type = node["obj_type"] # "docx" / "doc" / etc. raise "Unsupported wiki node type: #{actual_type}" unless %w[docx doc].include?(actual_type) fetch_docx_raw_content(actual_token) else fetch_docx_raw_content(doc_token) end end |
#fetch_docx_raw_content(doc_token) ⇒ String
Fetch raw text content of a docx document. Raises FeishuDocPermissionError on 91403.
480 481 482 483 484 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 480 def fetch_docx_raw_content(doc_token) response = get("/open-apis/docx/v1/documents/#{doc_token}/raw_content") check_doc_error!(response, doc_token) response.dig("data", "content").to_s.strip end |
#fetch_wiki_node(wiki_token) ⇒ Hash
Resolve wiki node to get real obj_token and obj_type.
489 490 491 492 493 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 489 def fetch_wiki_node(wiki_token) response = get("/open-apis/wiki/v2/spaces/get_node", params: { token: wiki_token, obj_type: "wiki" }) check_doc_error!(response, wiki_token) response.dig("data", "node") or raise "No node in wiki response" end |
#get(path, params: {}) ⇒ Hash
Make authenticated GET request
322 323 324 325 326 327 328 329 330 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 322 def get(path, params: {}) conn = build_connection response = conn.get(path) do |req| req.headers["Authorization"] = "Bearer #{tenant_access_token}" req.params.update(params) end parse_response(response) end |
#has_code_block_or_table?(text) ⇒ Boolean
216 217 218 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 216 def has_code_block_or_table?(text) text.match?(/```[\s\S]*?```/) || text.match?(/\|.+\|[\r\n]+\|[-:| ]+\|/) end |
#parse_doc_url(url) ⇒ Array<String, Symbol>?
Parse Feishu doc URL and return [doc_token, type] type is :docx, :docs, or :wiki
468 469 470 471 472 473 474 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 468 def parse_doc_url(url) if (m = url.match(%r{/(?:docx|docs)/([A-Za-z0-9_-]+)})) [m[1], :docx] elsif (m = url.match(%r{/wiki/([A-Za-z0-9_-]+)})) [m[1], :wiki] end end |
#parse_response(response) ⇒ Hash
Parse API response
530 531 532 533 534 535 536 537 538 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 530 def parse_response(response) # Feishu returns JSON even on 4xx — parse it so callers can inspect error codes parsed = JSON.parse(response.body) return parsed if response.success? || parsed.key?("code") raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}" rescue JSON::ParserError raise "API request failed: HTTP #{response.status} body=#{response.body.to_s[0..300]}" end |
#patch(path, body) ⇒ Hash
Make authenticated PATCH request
353 354 355 356 357 358 359 360 361 362 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 353 def patch(path, body) conn = build_connection response = conn.patch(path) do |req| req.headers["Authorization"] = "Bearer #{tenant_access_token}" req.headers["Content-Type"] = "application/json" req.body = JSON.generate(body) end parse_response(response) end |
#post(path, body, params: {}) ⇒ Hash
Make authenticated POST request
337 338 339 340 341 342 343 344 345 346 347 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 337 def post(path, body, params: {}) conn = build_connection response = conn.post(path) do |req| req.headers["Authorization"] = "Bearer #{tenant_access_token}" req.headers["Content-Type"] = "application/json" req.params.update(params) req.body = JSON.generate(body) end parse_response(response) end |
#post_without_auth(path, body) ⇒ Hash
Make POST request without authentication (for token endpoint)
368 369 370 371 372 373 374 375 376 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 368 def post_without_auth(path, body) conn = build_connection response = conn.post(path) do |req| req.headers["Content-Type"] = "application/json" req.body = JSON.generate(body) end parse_response(response) end |
#sanitize_images_for_card(text) ⇒ String
Convert Markdown image syntax  to plain links [alt](url) inside interactive card content. Feishu interactive cards do NOT support image markdown — sending it triggers error 230099 and the entire message is silently dropped.
Code blocks are preserved untouched (images inside “‘ fences are left as-is since they are literal text, not rendered markdown).
This is a pure function with no side effects — thread-safe by design.
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 231 def sanitize_images_for_card(text) # Split on code fences to avoid transforming inside code blocks parts = text.split(/(```[\s\S]*?```)/) parts.map { |segment| if segment.start_with?("```") segment # code block — leave untouched else #  → [alt](url) (drop the leading !) segment.gsub(/!\[([^\]]*)\]\(([^)]+)\)/) do alt, url = Regexp.last_match(1), Regexp.last_match(2) alt.empty? ? url : "[#{alt}](#{url})" end end }.join end |
#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Hash
Upload a local file to Feishu and send it to a chat. Images use /im/v1/images + msg_type “image”. All other files use /im/v1/files + msg_type “file”.
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 117 def send_file(chat_id, path, name: nil, reply_to: nil) raise ArgumentError, "File not found: #{path}" unless File.exist?(path) # Always derive filename from the real path for type detection and upload. # The `name` param (often markdown alt text) may lack an extension, # causing images to be mis-detected as generic files. filename = File.basename(path) file_data = File.binread(path) ext = File.extname(filename).downcase if %w[.jpg .jpeg .png .gif .webp].include?(ext) image_key = upload_image(file_data, filename) content = JSON.generate({ image_key: image_key }) msg_type = "image" else file_key = upload_file(file_data, filename) content = JSON.generate({ file_key: file_key }) msg_type = "file" end payload = { receive_id: chat_id, msg_type: msg_type, content: content } payload[:reply_to_message_id] = reply_to if reply_to response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" }) { message_id: response.dig("data", "message_id") } end |
#send_text(chat_id, text, reply_to: nil) ⇒ Hash
Send plain text message
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 70 def send_text(chat_id, text, reply_to: nil) content, msg_type = (text) payload = { receive_id: chat_id, msg_type: msg_type, content: content } payload[:reply_to_message_id] = reply_to if reply_to response = post("/open-apis/im/v1/messages", payload, params: { receive_id_type: "chat_id" }) code = response["code"] if code != 0 Clacky::Logger.warn("[feishu] send_text failed", code: code, msg: response["msg"], chat_id: chat_id, msg_type: msg_type) end { message_id: response.dig("data", "message_id") } end |
#tenant_access_token ⇒ String
Get tenant access token (cached)
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 302 def tenant_access_token return @token_cache if @token_cache && @token_expires_at && Time.now < @token_expires_at response = post_without_auth("/open-apis/auth/v3/tenant_access_token/internal", { app_id: @app_id, app_secret: @app_secret }) raise "Failed to get tenant access token: #{response['msg']}" if response["code"] != 0 @token_cache = response["tenant_access_token"] # Token expires in 2 hours, refresh 5 minutes early @token_expires_at = Time.now + (2 * 60 * 60 - 5 * 60) @token_cache end |
#update_message(message_id, text) ⇒ Boolean
Update an existing message
95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 95 def (, text) content, msg_type = (text) payload = { msg_type: msg_type, content: content } response = patch("/open-apis/im/v1/messages/#{}", payload) response["code"] == 0 rescue => e Clacky::Logger.warn("[feishu] Failed to update message: #{e.}") false end |
#upload_file(data, filename) ⇒ String
Upload a file to Feishu and return file_key.
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 411 def upload_file(data, filename) conn = Faraday.new(url: @domain) do |f| f..timeout = DOWNLOAD_TIMEOUT f..open_timeout = API_TIMEOUT f.ssl.verify = false f.request :multipart f.adapter Faraday.default_adapter end response = conn.post("/open-apis/im/v1/files") do |req| req.headers["Authorization"] = "Bearer #{tenant_access_token}" req.body = { file_type: feishu_file_type(filename), file_name: filename, file: Faraday::Multipart::FilePart.new( StringIO.new(data), detect_mime(filename), filename ) } end result = JSON.parse(response.body) raise "Failed to upload file: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0 result.dig("data", "file_key") or raise "No file_key returned" end |
#upload_image(data, filename) ⇒ String
Upload an image to Feishu and return image_key.
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 382 def upload_image(data, filename) conn = Faraday.new(url: @domain) do |f| f..timeout = DOWNLOAD_TIMEOUT f..open_timeout = API_TIMEOUT f.ssl.verify = false f.request :multipart f.adapter Faraday.default_adapter end response = conn.post("/open-apis/im/v1/images") do |req| req.headers["Authorization"] = "Bearer #{tenant_access_token}" req.body = { image_type: "message", image: Faraday::Multipart::FilePart.new( StringIO.new(data), detect_mime(filename), filename ) } end result = JSON.parse(response.body) raise "Failed to upload image: code=#{result["code"]} msg=#{result["msg"]}" if result["code"] != 0 result.dig("data", "image_key") or raise "No image_key returned" end |