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)
-
(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)
-
(Array- [file_hashes, error_messages], Array )
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)
-
(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)
-
(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)
-
(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)
-
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)
-
(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)
-
(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
-
(void)-
def stop @running = false @ws_client&.stop end
def supports_message_updates?
-
(Boolean)-
def supports_message_updates? true end
def update_message(chat_id, message_id, text)
-
(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)
-
(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