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
Instance Method Summary collapse
-
#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_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.
39 40 41 42 43 44 45 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 39 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
#build_connection ⇒ Faraday::Connection
Build Faraday connection
443 444 445 446 447 448 449 450 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 443 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.
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 180 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.
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 425 def check_doc_error!(response, token) code = response["code"].to_i return if code == 0 if code == 91403 raise FeishuDocPermissionError, token elsif code == 99991672 # Extract auth URL from the error message if present auth_url = response.dig("error", "permission_violations", 0, "attach_url") || response["msg"].to_s[/https:\/\/open\.feishu\.cn\/app\/[^\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.
381 382 383 384 385 386 387 388 389 390 391 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 381 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.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 132 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)
368 369 370 371 372 373 374 375 376 377 378 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 368 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_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.
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 158 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.
409 410 411 412 413 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 409 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.
418 419 420 421 422 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 418 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
251 252 253 254 255 256 257 258 259 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 251 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
198 199 200 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 198 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
397 398 399 400 401 402 403 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 397 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
455 456 457 458 459 460 461 462 463 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 455 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
282 283 284 285 286 287 288 289 290 291 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 282 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
266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 266 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)
297 298 299 300 301 302 303 304 305 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 297 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.
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 213 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”.
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/feishu/bot.rb', line 99 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
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 52 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)
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 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
77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 77 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.
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 340 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.
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 311 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 |