Class: Clacky::Channel::Adapters::Feishu::Adapter

Inherits:
Base
  • Object
show all
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.

Returns:

  • (Hash, nil)

    enriched event or nil if permission error

3
MAX_IMAGE_BYTES =
Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#platform_id

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_keysObject



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_idObject



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).

Parameters:

  • fields (Hash)

    symbol-keyed credential fields

Returns:

  • (Hash)

    { ok: Boolean, message: String }



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.message }
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.

Parameters:

  • image_keys (Array<String>)
  • message_id (String)

Returns:

  • (Array<Hash>, Array<String>)
    file_hashes, error_messages


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, 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

#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.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

#handle_event(raw_event) ⇒ void

This method returns an undefined value.

Handle incoming WebSocket event

Parameters:

  • raw_event (Hash)

    Raw event data



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
    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

#handle_message_event(event) ⇒ void

This method returns an undefined value.

Handle message event

Parameters:

  • event (Hash)

    Parsed 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 handle_message_event(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.

Parameters:

  • attachments (Array<Hash>)
    name:
  • message_id (String)

Returns:

  • (Array<Hash>)

    { name:, path: }



291
292
293
294
295
296
297
298
299
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 291

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

#send_file(chat_id, path, name: nil, reply_to: nil) ⇒ Object

Send a file (or image) to a chat.

Parameters:

  • chat_id (String)

    Chat ID

  • path (String)

    Local file path

  • name (String, nil) (defaults to: nil)

    Display filename

  • reply_to (String, nil) (defaults to: nil)

    Message ID to reply to



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

Parameters:

  • chat_id (String)

    Chat ID

  • text (String)

    Message text

  • reply_to (String, nil) (defaults to: nil)

    Message ID to reply to

Returns:

  • (Hash)

    Result with :message_id



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

Yields:

  • (event)

    Yields standardized inbound messages



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(&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

#stopvoid

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

Returns:

  • (Boolean)


132
133
134
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 132

def supports_message_updates?
  true
end

#update_message(chat_id, message_id, text) ⇒ Boolean

Update existing message

Parameters:

  • chat_id (String)

    Chat ID (unused for Feishu)

  • message_id (String)

    Message ID to update

  • text (String)

    New text

Returns:

  • (Boolean)

    Success status



127
128
129
# File 'lib/clacky/server/channel/adapters/feishu/adapter.rb', line 127

def update_message(chat_id, message_id, text)
  @bot.update_message(message_id, text)
end

#validate_config(config) ⇒ Array<String>

Validate configuration

Parameters:

  • config (Hash)

    Configuration to validate

Returns:

  • (Array<String>)

    Error messages



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