class Clacky::Channel::Adapters::Feishu::Bot
Handles authentication, message sending, and API calls.
Feishu Bot API client.
def build_connection
-
(Faraday::Connection)-
def build_connection Faraday.new(url: @domain) do |f| f.options.timeout = API_TIMEOUT f.options.open_timeout = API_TIMEOUT f.ssl.verify = false f.adapter Faraday.default_adapter end end
def build_message_payload(text)
-
(Array- [content_json, msg_type])
Parameters:
-
text(String) --
def build_message_payload(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
def check_doc_error!(response, token)
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
def detect_mime(filename)
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
def download_message_resource(message_id, file_key, type: "image")
-
(Hash)- { body: String, content_type: String }
Parameters:
-
type(String) -- "image" or "file" -
file_key(String) -- Resource key (image_key or file_key from message content) -
message_id(String) -- Message ID containing the resource
def download_message_resource(message_id, file_key, type: "image") conn = Faraday.new(url: @domain) do |f| f.options.timeout = DOWNLOAD_TIMEOUT f.options.open_timeout = API_TIMEOUT f.ssl.verify = false f.adapter Faraday.default_adapter end response = conn.get("/open-apis/im/v1/messages/#{message_id}/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
def feishu_file_type(filename)
Map file extension to Feishu file_type enum.
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
def fetch_doc_content(url)
-
(String)- Document plain text
Parameters:
-
url(String) -- Feishu document URL
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
def fetch_docx_raw_content(doc_token)
-
(String)-
Parameters:
-
doc_token(String) --
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
def fetch_wiki_node(wiki_token)
-
(Hash)- node data with "obj_token" and "obj_type"
Parameters:
-
wiki_token(String) --
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
def get(path, params: {})
-
(Hash)- Parsed response
Parameters:
-
params(Hash) -- Query parameters -
path(String) -- API path
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
def has_code_block_or_table?(text)
def has_code_block_or_table?(text) text.match?(/```[\s\S]*?```/) || text.match?(/\|.+\|[\r\n]+\|[-:| ]+\|/) end
def initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN)
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
def parse_doc_url(url)
-
(Array-, nil)
Parameters:
-
url(String) --
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
def parse_response(response)
-
(Hash)- Parsed JSON
Parameters:
-
response(Faraday::Response) --
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
def patch(path, body)
-
(Hash)- Parsed response
Parameters:
-
body(Hash) -- Request body -
path(String) -- API path
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
def post(path, body, params: {})
-
(Hash)- Parsed response
Parameters:
-
params(Hash) -- Query parameters -
body(Hash) -- Request body -
path(String) -- API path
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
def post_without_auth(path, body)
-
(Hash)- Parsed response
Parameters:
-
body(Hash) -- Request body -
path(String) -- API path
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
def send_file(chat_id, path, name: nil, reply_to: nil)
-
(Hash)- Response with :message_id
Parameters:
-
reply_to(String, nil) -- Message ID to reply to -
name(String, nil) -- Display filename -
path(String) -- Local file path -
chat_id(String) -- Chat ID
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
def send_text(chat_id, text, reply_to: nil)
-
(Hash)- Response with :message_id
Parameters:
-
reply_to(String, nil) -- Message ID to reply to -
text(String) -- Message text -
chat_id(String) -- Chat ID (open_chat_id)
def send_text(chat_id, text, reply_to: nil) content, msg_type = build_message_payload(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
def tenant_access_token
-
(String)- Access token
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
def update_message(message_id, text)
-
(Boolean)- Success status
Parameters:
-
text(String) -- New text content -
message_id(String) -- Message ID to update
def update_message(message_id, text) content, msg_type = build_message_payload(text) payload = { msg_type: msg_type, content: content } response = patch("/open-apis/im/v1/messages/#{message_id}", payload) response["code"] == 0 rescue => e Clacky::Logger.warn("[feishu] Failed to update message: #{e.message}") false end
def upload_file(data, filename)
-
(String)- file_key
Parameters:
-
filename(String) -- Display filename -
data(String) -- Binary file content
def upload_file(data, filename) conn = Faraday.new(url: @domain) do |f| f.options.timeout = DOWNLOAD_TIMEOUT f.options.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
def upload_image(data, filename)
-
(String)- image_key
Parameters:
-
filename(String) -- Display filename -
data(String) -- Binary file content
def upload_image(data, filename) conn = Faraday.new(url: @domain) do |f| f.options.timeout = DOWNLOAD_TIMEOUT f.options.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