class Clacky::Channel::Adapters::Feishu::Adapter

Handles message receiving via WebSocket and sending via Bot API.
Feishu adapter implementation.

def self.env_keys

def self.env_keys
  %w[IM_FEISHU_APP_ID IM_FEISHU_APP_SECRET IM_FEISHU_DOMAIN IM_FEISHU_ALLOWED_USERS]
end

def self.platform_config(data)

def self.platform_config(data)
  {
    app_id: data["IM_FEISHU_APP_ID"],
    app_secret: data["IM_FEISHU_APP_SECRET"],
    domain: data["IM_FEISHU_DOMAIN"] || DEFAULT_DOMAIN,
    allowed_users: data["IM_FEISHU_ALLOWED_USERS"]&.split(",")&.map(&:strip)&.reject(&:empty?)
  }
end

def self.platform_id

def self.platform_id
  :feishu
end

def self.set_env_data(data, config)

def self.set_env_data(data, config)
  data["IM_FEISHU_APP_ID"] = config[:app_id]
  data["IM_FEISHU_APP_SECRET"] = config[:app_secret]
  data["IM_FEISHU_DOMAIN"] = config[:domain] if config[:domain]
  data["IM_FEISHU_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
end

def self.test_connection(fields)

Returns:
  • (Hash) - { ok: Boolean, message: String }

Parameters:
  • fields (Hash) -- symbol-keyed credential fields
def self.test_connection(fields)
  app_id     = fields[:app_id].to_s.strip
  app_secret = fields[:app_secret].to_s.strip
  domain     = fields[:domain].to_s.strip
  domain     = DEFAULT_DOMAIN if domain.empty?
  return { ok: false, error: "app_id is required" }     if app_id.empty?
  return { ok: false, error: "app_secret is required" }  if app_secret.empty?
  bot = Bot.new(app_id: app_id, app_secret: app_secret, domain: domain)
  # Attempt to fetch a tenant access token — success means credentials are valid.
  token = bot.tenant_access_token
  if token && !token.empty?
    { ok: true, message: "Connected — tenant access token obtained" }
  else
    { ok: false, error: "Empty token returned — check app_id and app_secret" }
  end
rescue StandardError => e
  { ok: false, error: e.message }
end

def download_images(image_keys, message_id)

Returns:
  • (Array, Array) - [file_hashes, error_messages]

Parameters:
  • message_id (String) --
  • image_keys (Array) --
def download_images(image_keys, message_id)
  require "base64"
  file_hashes = []
  errors = []
  image_keys.each do |image_key|
    result = @bot.download_message_resource(message_id, image_key, type: "image")
    if result[:body].bytesize > MAX_IMAGE_BYTES
      errors << "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB"
      next
    end
    mime = result[:content_type]
    mime = "image/jpeg" if mime.nil? || mime.empty? || !mime.start_with?("image/")
    data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
    file_hashes << { name: "image.jpg", mime_type: mime, data_url: data_url }
  rescue => e
    Clacky::Logger.warn("[feishu] Failed to download image #{image_key}: #{e.message}")
    errors << "Image download failed: #{e.message}"
  end
  [file_hashes, errors]
end

def enrich_with_doc_content(event)

def enrich_with_doc_content(event)
  doc_sections = []
  failed_urls = []
  event[:doc_urls].each do |url|
    content = @bot.fetch_doc_content(url)
    doc_sections << "📄 [Doc content from #{url}]\n#{content}" unless content.empty?
  rescue Feishu::FeishuDocPermissionError
    failed_urls << url
    doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: the app has no access (error 91403). Tell user to: open the doc → top-right \"...\"\"Add Document App\" → add this bot → just send any message to retry."
  rescue Feishu::FeishuDocScopeError => e
    failed_urls << url
    scope_hint = e.auth_url ? "Admin can approve with one click: [点击授权](#{e.auth_url})" : "Admin needs to enable 'docx:document:readonly' scope in Feishu Open Platform."
    doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: app is missing docx API scope (error 99991672). #{scope_hint} Tell user to just send any message to retry after approval."
  rescue => e
    failed_urls << url
    Clacky::Logger.warn("[feishu] Failed to fetch doc #{url}: #{e.message}")
    doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: #{e.message}. Tell user to just send any message to retry."
  end
  # Update retry cache
  chat_id = event[:chat_id]
  if failed_urls.any?
    existing = @doc_retry_cache[chat_id]
    attempts = (existing&.dig(:attempts) || 0) + 1
    if attempts >= DOC_RETRY_MAX
      @doc_retry_cache.delete(chat_id)
    else
      @doc_retry_cache[chat_id] = { doc_urls: failed_urls, attempts: attempts }
    end
  else
    # All docs fetched successfully, clear cache
    @doc_retry_cache.delete(chat_id)
  end
  return event if doc_sections.empty?
  enriched_text = [event[:text], *doc_sections].reject(&:empty?).join("\n\n")
  event.merge(text: enriched_text)
end

def handle_event(raw_event)

Returns:
  • (void) -

Parameters:
  • raw_event (Hash) -- Raw event data
def handle_event(raw_event)
  parsed = MessageParser.parse(raw_event)
  return unless parsed
  case parsed[:type]
  when :message
    handle_message_event(parsed)
  when :challenge
    # Challenge is handled by MessageParser
  end
rescue => e
  Clacky::Logger.warn("[feishu] Error handling event: #{e.message}")
  Clacky::Logger.warn(e.backtrace.first(5).join("\n"))
end

def handle_message_event(event)

Returns:
  • (void) -

Parameters:
  • event (Hash) -- Parsed message event
def handle_message_event(event)
  # In group chats, only respond when the bot is explicitly @-mentioned.
  # Private chats always respond.
  # Fail closed: if the bot's own open_id cannot be fetched (API error,
  # bad credentials, etc.), drop group messages instead of responding to
  # every message and spamming the group.
  if event[:chat_type] == :group
    bot_id = @bot.bot_open_id
    if bot_id.nil?
      Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
      return
    end
    return unless Array(event[:mentioned_open_ids]).include?(bot_id)
  end
  allowed_users = @config[:allowed_users]
  if allowed_users && !allowed_users.empty?
    return unless allowed_users.include?(event[:user_id])
  end
  # Download images and attach as file hashes
  image_files = []
  if event[:image_keys] && !event[:image_keys].empty?
    image_files, errors = download_images(event[:image_keys], event[:message_id])
    if image_files.empty? && !errors.empty?
      @bot.send_text(event[:chat_id], "#{errors.first}", reply_to: event[:message_id])
      return
    end
  end
  # Download and process file attachments
  disk_files = []
  if event[:file_attachments] && !event[:file_attachments].empty?
    disk_files = process_files(event[:file_attachments], event[:message_id])
  end
  all_files = image_files + disk_files
  event = event.merge(files: all_files) unless all_files.empty?
  # Merge cached doc_urls (from previous failed attempts) into current event
  cached = @doc_retry_cache[event[:chat_id]]
  if cached
    merged_urls = ((event[:doc_urls] || []) + cached[:doc_urls]).uniq
    event = event.merge(doc_urls: merged_urls)
  end
  # Fetch Feishu document content for any doc URLs in the message
  if event[:doc_urls] && !event[:doc_urls].empty?
    event = enrich_with_doc_content(event)
    return if event.nil?
  end
  @on_message&.call(event)
end

def initialize(config)

def initialize(config)
  @config = config
  @bot = Bot.new(
    app_id: config[:app_id],
    app_secret: config[:app_secret],
    domain: config[:domain] || DEFAULT_DOMAIN
  )
  @ws_client = nil
  @running = false
  @doc_retry_cache = {} # { chat_id => { doc_urls: [...], attempts: N } }
end

def process_files(attachments, message_id)

Returns:
  • (Array) - { name:, path: }

Parameters:
  • message_id (String) --
  • attachments (Array) -- [{key:, name:}]
def process_files(attachments, message_id)
  attachments.filter_map do |attachment|
    result = @bot.download_message_resource(message_id, attachment[:key], type: "file")
    Clacky::Utils::FileProcessor.save(body: result[:body], filename: attachment[:name])
  rescue => e
    Clacky::Logger.warn("[feishu] Failed to download file #{attachment[:name]}: #{e.message}")
    nil
  end.compact
end

def send_file(chat_id, path, name: nil, reply_to: nil)

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)
  @bot.send_file(chat_id, path, name: name, reply_to: reply_to)
end

def send_text(chat_id, text, reply_to: nil)

Returns:
  • (Hash) - Result with :message_id

Parameters:
  • reply_to (String, nil) -- Message ID to reply to
  • text (String) -- Message text
  • chat_id (String) -- Chat ID
def send_text(chat_id, text, reply_to: nil)
  @bot.send_text(chat_id, text, reply_to: reply_to)
end

def start(&on_message)

Returns:
  • (void) -

Other tags:
    Yield: - Yields standardized inbound messages
def start(&on_message)
  @running = true
  @on_message = on_message
  @ws_client = WSClient.new(
    app_id: @config[:app_id],
    app_secret: @config[:app_secret],
    domain: @config[:domain] || DEFAULT_DOMAIN
  )
  @ws_client.start do |raw_event|
    handle_event(raw_event)
  end
end

def stop

Returns:
  • (void) -
def stop
  @running = false
  @ws_client&.stop
end

def supports_message_updates?

Returns:
  • (Boolean) -
def supports_message_updates?
  true
end

def update_message(chat_id, message_id, text)

Returns:
  • (Boolean) - Success status

Parameters:
  • text (String) -- New text
  • message_id (String) -- Message ID to update
  • chat_id (String) -- Chat ID (unused for Feishu)
def update_message(chat_id, message_id, text)
  @bot.update_message(message_id, text)
end

def validate_config(config)

Returns:
  • (Array) - Error messages

Parameters:
  • config (Hash) -- Configuration to validate
def validate_config(config)
  errors = []
  errors << "app_id is required" if config[:app_id].nil? || config[:app_id].empty?
  errors << "app_secret is required" if config[:app_secret].nil? || config[:app_secret].empty?
  errors
end