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
-
#bot_open_id ⇒ String?
Get this bot’s own open_id (cached, fetched lazily on first use).
-
#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_user_name(open_id) ⇒ Object
-
#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 46 |
# 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 @user_name_cache = {} end |
Instance Method Details
#bot_open_id ⇒ String?
Get this bot’s own open_id (cached, fetched lazily on first use). Used to detect @bot mentions in group chats.
233 234 235 236 237 238 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 233 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
465 466 467 468 469 470 471 472 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 465 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.
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 181 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.
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 447 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.
403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 403 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.
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 133 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)
390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 390 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.
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 159 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.
431 432 433 434 435 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 431 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_user_name(open_id) ⇒ Object
240 241 242 243 244 245 246 247 248 249 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 240 def fetch_user_name(open_id) return @user_name_cache[open_id] if @user_name_cache.key?(open_id) name = get("/open-apis/contact/v3/users/#{open_id}", params: { user_id_type: "open_id" }) .dig("data", "user", "name") @user_name_cache[open_id] = name.to_s.strip.then { |n| n.empty? ? open_id : n } rescue => e Clacky::Logger.warn("[feishu] Failed to fetch user name for #{open_id}: #{e.}") @user_name_cache[open_id] = open_id end |
#fetch_wiki_node(wiki_token) ⇒ Hash
Resolve wiki node to get real obj_token and obj_type.
440 441 442 443 444 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 440 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
273 274 275 276 277 278 279 280 281 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 273 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
199 200 201 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 199 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
419 420 421 422 423 424 425 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 419 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
477 478 479 480 481 482 483 484 485 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 477 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
304 305 306 307 308 309 310 311 312 313 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 304 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
288 289 290 291 292 293 294 295 296 297 298 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 288 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)
319 320 321 322 323 324 325 326 327 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 319 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.
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 214 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”.
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 125 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 100 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
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 53 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)
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 253 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
78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 78 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.
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 362 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.
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 333 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 |