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- ERR_INVALID_TOKEN =
99991663- 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 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.
-
#with_token_retry ⇒ Hash
Wrap an authenticated API call.
Constructor Details
#initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN) ⇒ Bot
Returns a new instance of Bot.
57 58 59 60 61 62 63 64 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 57 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.
294 295 296 297 298 299 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 294 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
540 541 542 543 544 545 546 547 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 540 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.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 199 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.
518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 518 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.
474 475 476 477 478 479 480 481 482 483 484 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 474 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.
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/feishu/bot.rb', line 151 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)
461 462 463 464 465 466 467 468 469 470 471 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 461 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.
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 291 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 253 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.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 177 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.
502 503 504 505 506 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 502 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.
511 512 513 514 515 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 511 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
323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 323 def get(path, params: {}) with_token_retry do 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 end |
#has_code_block_or_table?(text) ⇒ Boolean
217 218 219 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 217 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
490 491 492 493 494 495 496 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 490 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
552 553 554 555 556 557 558 559 560 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 552 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
358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 358 def patch(path, body) with_token_retry do 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 end |
#post(path, body, params: {}) ⇒ Hash
Make authenticated POST request
340 341 342 343 344 345 346 347 348 349 350 351 352 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 340 def post(path, body, params: {}) with_token_retry do 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 end |
#post_without_auth(path, body) ⇒ Hash
Make POST request without authentication (for token endpoint)
390 391 392 393 394 395 396 397 398 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 390 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
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.
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 232 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".
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 143 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 118 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
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 71 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)
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 303 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
96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 96 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.
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 433 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.
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 404 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 |
#with_token_retry ⇒ Hash
Wrap an authenticated API call. If Feishu reports an invalid or revoked access token (99991663) while it is still inside our cache window, force a token refresh and retry the request once.
375 376 377 378 379 380 381 382 383 384 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 375 def with_token_retry response = yield if response.is_a?(Hash) && response["code"] == ERR_INVALID_TOKEN Clacky::Logger.warn("[feishu] token invalid (99991663), refreshing and retrying once") @token_cache = nil @token_expires_at = nil response = yield end response end |