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).
-
#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
403 404 405 406 407 408 409 410 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 403 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.
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 169 def (text) if has_code_block_or_table?(text) content = JSON.generate({ schema: "2.0", config: { wide_screen_mode: true }, body: { elements: [{ tag: "markdown", content: text }] } }) [content, "interactive"] else content = JSON.generate({ zh_cn: { content: [[{ tag: "md", text: text }]] } }) [content, "post"] end end |
#check_doc_error!(response, token) ⇒ Object
Check doc API response for known permission errors and raise accordingly.
385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 385 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.
341 342 343 344 345 346 347 348 349 350 351 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 341 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.
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 121 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)
328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 328 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.
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 147 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.
369 370 371 372 373 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 369 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.
378 379 380 381 382 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 378 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
211 212 213 214 215 216 217 218 219 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 211 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
185 186 187 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 185 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
357 358 359 360 361 362 363 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 357 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
415 416 417 418 419 420 421 422 423 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 415 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
242 243 244 245 246 247 248 249 250 251 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 242 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
226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 226 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)
257 258 259 260 261 262 263 264 265 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 257 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 |
#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”.
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 91 def send_file(chat_id, path, name: nil, reply_to: nil) raise ArgumentError, "File not found: #{path}" unless File.exist?(path) filename = name || 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 |
# 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" }) { message_id: response.dig("data", "message_id") } end |
#tenant_access_token ⇒ String
Get tenant access token (cached)
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 191 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
69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 69 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.
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 300 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.
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 |
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 271 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 |