Class: Clacky::Channel::Adapters::Feishu::Adapter
- Defined in:
- lib/clacky/server/channel/adapters/feishu/adapter.rb
Overview
Feishu adapter implementation. Handles message receiving via WebSocket and sending via Bot API.
Constant Summary collapse
- DOC_RETRY_MAX =
Fetch Feishu document content and append to event. If the app lacks permission (91403), sends a guidance message and returns nil so the caller can skip forwarding the event to the agent.
3- MAX_IMAGE_BYTES =
Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
Class Method Summary collapse
- .env_keys ⇒ Object
- .platform_config(data) ⇒ Object
- .platform_id ⇒ Object
- .set_env_data(data, config) ⇒ Object
-
.test_connection(fields) ⇒ Hash
Test connectivity with provided credentials (does not persist).
Instance Method Summary collapse
-
#download_images(image_keys, message_id) ⇒ Array<Hash>, Array<String>
Download images from Feishu and return as file hashes.
- #enrich_with_doc_content(event) ⇒ Object
-
#handle_event(raw_event) ⇒ void
Handle incoming WebSocket event.
-
#handle_message_event(event) ⇒ void
Handle message event.
-
#initialize(config) ⇒ Adapter
constructor
A new instance of Adapter.
-
#process_files(attachments, message_id) ⇒ Array<Hash>
Download and save file attachments, returning file hashes for agent.
-
#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Object
Send a file (or image) to a chat.
-
#send_text(chat_id, text, reply_to: nil) ⇒ Hash
Send plain text message.
-
#start {|event| ... } ⇒ void
Start listening for messages via WebSocket.
-
#stop ⇒ void
Stop the adapter.
- #supports_message_updates? ⇒ Boolean
-
#update_message(chat_id, message_id, text) ⇒ Boolean
Update existing message.
-
#validate_config(config) ⇒ Array<String>
Validate configuration.
Methods inherited from Base
Constructor Details
#initialize(config) ⇒ Adapter
Returns a new instance of Adapter.
67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 67 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 |
Class Method Details
.env_keys ⇒ Object
23 24 25 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 23 def self.env_keys %w[IM_FEISHU_APP_ID IM_FEISHU_APP_SECRET IM_FEISHU_DOMAIN IM_FEISHU_ALLOWED_USERS] end |
.platform_config(data) ⇒ Object
27 28 29 30 31 32 33 34 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 27 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 |
.platform_id ⇒ Object
19 20 21 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 19 def self.platform_id :feishu end |
.set_env_data(data, config) ⇒ Object
36 37 38 39 40 41 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 36 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 |
.test_connection(fields) ⇒ Hash
Test connectivity with provided credentials (does not persist).
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 46 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. } end |
Instance Method Details
#download_images(image_keys, message_id) ⇒ Array<Hash>, Array<String>
Download images from Feishu and return as file hashes. Images within MAX_IMAGE_BYTES are returned with data_url for vision. Oversized images are rejected with an error message.
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 265 def download_images(image_keys, ) require "base64" file_hashes = [] errors = [] image_keys.each do |image_key| result = @bot.(, 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.}") errors << "Image download failed: #{e.}" end [file_hashes, errors] end |
#enrich_with_doc_content(event) ⇒ Object
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 216 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.}") doc_sections << "#{url}\n[System Notice] Cannot read the above Feishu doc: #{e.}. 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 |
#handle_event(raw_event) ⇒ void
This method returns an undefined value.
Handle incoming WebSocket event
150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 150 def handle_event(raw_event) parsed = MessageParser.parse(raw_event) return unless parsed case parsed[:type] when :message (parsed) when :challenge # Challenge is handled by MessageParser end rescue => e Clacky::Logger.warn("[feishu] Error handling event: #{e.}") Clacky::Logger.warn(e.backtrace.first(5).join("\n")) end |
#handle_message_event(event) ⇒ void
This method returns an undefined value.
Handle message event
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 168 def (event) 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 |
#process_files(attachments, message_id) ⇒ Array<Hash>
Download and save file attachments, returning file hashes for agent. Parsing happens inside agent.run, not here.
291 292 293 294 295 296 297 298 299 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 291 def process_files(, ) .filter_map do || result = @bot.(, [:key], type: "file") Clacky::Utils::FileProcessor.save(body: result[:body], filename: [:name]) rescue => e Clacky::Logger.warn("[feishu] Failed to download file #{[:name]}: #{e.}") nil end.compact end |
#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Object
Send a file (or image) to a chat.
118 119 120 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 118 def send_file(chat_id, path, name: nil, reply_to: nil) @bot.send_file(chat_id, path, name: name, reply_to: reply_to) end |
#send_text(chat_id, text, reply_to: nil) ⇒ Hash
Send plain text message
109 110 111 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 109 def send_text(chat_id, text, reply_to: nil) @bot.send_text(chat_id, text, reply_to: reply_to) end |
#start {|event| ... } ⇒ void
This method returns an undefined value.
Start listening for messages via WebSocket
82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 82 def start(&) @running = true @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 |
#stop ⇒ void
This method returns an undefined value.
Stop the adapter
99 100 101 102 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 99 def stop @running = false @ws_client&.stop end |
#supports_message_updates? ⇒ Boolean
132 133 134 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 132 def true end |
#update_message(chat_id, message_id, text) ⇒ Boolean
Update existing message
127 128 129 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 127 def (chat_id, , text) @bot.(, text) end |
#validate_config(config) ⇒ Array<String>
Validate configuration
139 140 141 142 143 144 |
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 139 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 |