class Clacky::Channel::Adapters::Feishu::Bot

Handles authentication, message sending, and API calls.
Feishu Bot API client.

def build_connection

Returns:
  • (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)

Returns:
  • (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)

Check doc API response for known permission errors and raise accordingly.
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)

Detect MIME type from filename extension.
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")

Returns:
  • (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)

Feishu accepts: opus, mp4, pdf, doc, xls, ppt, stream (others)
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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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: {})

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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: {})

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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