Class: Clacky::Channel::Adapters::Feishu::Bot

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/server/channel/adapters/feishu/bot.rb

Overview

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

Constant Summary collapse

API_TIMEOUT =
10
DOWNLOAD_TIMEOUT =
60
ERR_SCOPE_MISSING =
99991672
ERR_SCOPE_MISSING_2 =
230027
GROUP_HISTORY_LIMIT =
15
SCOPE_GROUP_MSG =
"im:message.group_msg"

Instance Method Summary collapse

Constructor Details

#initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN) ⇒ Bot

Returns a new instance of Bot.



56
57
58
59
60
61
62
63
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 56

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

Instance Method Details

#bot_open_idString?

Used to detect @bot mentions in group chats.

Returns:

  • (String, nil)

    bot open_id, or nil if the API call fails



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

def bot_open_id
  @bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
rescue => e
  Clacky::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
  nil
end

#build_connectionFaraday::Connection

Build Faraday connection

Returns:

  • (Faraday::Connection)


518
519
520
521
522
523
524
525
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 518

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

#build_message_payload(text) ⇒ Array<String, String>

Build message content and type based on text content. Uses interactive card (schema 2.0) for code blocks and tables, post/md for everything else.

Parameters:

  • text (String)

Returns:

  • (Array<String, String>)
    content_json, msg_type


198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 198

def build_message_payload(text)
  if has_code_block_or_table?(text)
    safe_text = sanitize_images_for_card(text)
    content = JSON.generate({
      schema: "2.0",
      config: { wide_screen_mode: true },
      body: { elements: [{ tag: "markdown", content: safe_text }] }
    })
    [content, "interactive"]
  else
    safe_text = sanitize_images_for_card(text)
    content = JSON.generate({
      zh_cn: { content: [[{ tag: "md", text: safe_text }]] }
    })
    [content, "post"]
  end
end

#check_doc_error!(response, token) ⇒ Object

Check doc API response for known permission errors and raise accordingly.



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 496

def check_doc_error!(response, token)
  code = response["code"].to_i
  return if code == 0

  if code == 91403
    raise FeishuDocPermissionError, token
  elsif code == ERR_SCOPE_MISSING
    # Extract auth URL from the error message if present
    auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
               extract_url(response["msg"].to_s)
    raise FeishuDocScopeError.new(auth_url)
  else
    raise "Failed to fetch doc: code=#{code} msg=#{response["msg"]}"
  end
end

#detect_mime(filename) ⇒ Object

Detect MIME type from filename extension.



452
453
454
455
456
457
458
459
460
461
462
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 452

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

#download_message_resource(message_id, file_key, type: "image") ⇒ Hash

Download a message resource (image or file) from Feishu. For message attachments, must use messageResource API — not im/v1/images.

Parameters:

  • message_id (String)

    Message ID containing the resource

  • file_key (String)

    Resource key (image_key or file_key from message content)

  • type (String) (defaults to: "image")

    “image” or “file”

Returns:

  • (Hash)

    { body: String, content_type: String }



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 150

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

#feishu_file_type(filename) ⇒ Object

Map file extension to Feishu file_type enum. Feishu accepts: opus, mp4, pdf, doc, xls, ppt, stream (others)



439
440
441
442
443
444
445
446
447
448
449
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 439

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

#fetch_chat_history(chat_id, limit: GROUP_HISTORY_LIMIT) ⇒ Array<Hash>

Fetch recent messages from a chat via the message list API. Returns an array of { user_id, text } hashes, oldest first.

Parameters:

  • chat_id (String)
  • limit (Integer) (defaults to: GROUP_HISTORY_LIMIT)

Returns:

  • (Array<Hash>)


252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 252

def fetch_chat_history(chat_id, limit: GROUP_HISTORY_LIMIT)
  response = get("/open-apis/im/v1/messages", params: {
    container_id_type: "chat",
    container_id:      chat_id,
    sort_type:         "ByCreateTimeDesc",
    page_size:         limit
  })
  unless response["code"] == 0
    code = response["code"].to_i
    Clacky::Logger.warn("[feishu] fetch_chat_history failed code=#{code} msg=#{response["msg"]}, ext=#{response.dig("error", "message") || response["msg"]}")
    if code == ERR_SCOPE_MISSING || code == ERR_SCOPE_MISSING_2
      auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
                 extract_url(response["msg"].to_s)
      scopes = (response.dig("error", "permission_violations") || []).map { |v| v["subject"] }.compact
      raise FeishuScopeError.new(auth_url, required_scopes: scopes)
    end
    return []
  end

  items = response.dig("data", "items") || []
  Clacky::Logger.info("[feishu] fetch_chat_history chat=#{chat_id} api_items=#{items.size} first=#{items.first&.inspect}")
  items.reverse.filter_map do |item|
    content = begin
      body = JSON.parse(item.dig("body", "content").to_s)
      body["text"].to_s.gsub(/@_user_\S+\s?/, "").gsub(/@\S+\s?/, "").strip
    rescue JSON::ParserError
      nil
    end
    next if content.nil? || content.empty?

    sender_id = item.dig("sender", "id").to_s
    { user_id: sender_id, text: content }
  end
rescue FeishuScopeError
  raise
rescue => e
  Clacky::Logger.warn("[feishu] fetch_chat_history failed: #{e.message}")
  []
end

#fetch_doc_content(url) ⇒ String

Fetch the plain-text content of a Feishu document (docx / docs / wiki). Raises FeishuDocPermissionError (code 91403) when the app has no access.

Parameters:

  • url (String)

    Feishu document URL

Returns:

  • (String)

    Document plain text

Raises:

  • (ArgumentError)


176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 176

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

#fetch_docx_raw_content(doc_token) ⇒ String

Fetch raw text content of a docx document. Raises FeishuDocPermissionError on 91403.

Parameters:

  • doc_token (String)

Returns:

  • (String)


480
481
482
483
484
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 480

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

#fetch_wiki_node(wiki_token) ⇒ Hash

Resolve wiki node to get real obj_token and obj_type.

Parameters:

  • wiki_token (String)

Returns:

  • (Hash)

    node data with “obj_token” and “obj_type”



489
490
491
492
493
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 489

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

#get(path, params: {}) ⇒ Hash

Make authenticated GET request

Parameters:

  • path (String)

    API path

  • params (Hash) (defaults to: {})

    Query parameters

Returns:

  • (Hash)

    Parsed response



322
323
324
325
326
327
328
329
330
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 322

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

#has_code_block_or_table?(text) ⇒ Boolean

Returns:

  • (Boolean)


216
217
218
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 216

def has_code_block_or_table?(text)
  text.match?(/```[\s\S]*?```/) || text.match?(/\|.+\|[\r\n]+\|[-:| ]+\|/)
end

#parse_doc_url(url) ⇒ Array<String, Symbol>?

Parse Feishu doc URL and return [doc_token, type] type is :docx, :docs, or :wiki

Parameters:

  • url (String)

Returns:

  • (Array<String, Symbol>, nil)


468
469
470
471
472
473
474
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 468

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

#parse_response(response) ⇒ Hash

Parse API response

Parameters:

  • response (Faraday::Response)

Returns:

  • (Hash)

    Parsed JSON



530
531
532
533
534
535
536
537
538
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 530

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

#patch(path, body) ⇒ Hash

Make authenticated PATCH request

Parameters:

  • path (String)

    API path

  • body (Hash)

    Request body

Returns:

  • (Hash)

    Parsed response



353
354
355
356
357
358
359
360
361
362
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 353

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

#post(path, body, params: {}) ⇒ Hash

Make authenticated POST request

Parameters:

  • path (String)

    API path

  • body (Hash)

    Request body

  • params (Hash) (defaults to: {})

    Query parameters

Returns:

  • (Hash)

    Parsed response



337
338
339
340
341
342
343
344
345
346
347
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 337

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

#post_without_auth(path, body) ⇒ Hash

Make POST request without authentication (for token endpoint)

Parameters:

  • path (String)

    API path

  • body (Hash)

    Request body

Returns:

  • (Hash)

    Parsed response



368
369
370
371
372
373
374
375
376
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 368

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

#sanitize_images_for_card(text) ⇒ String

Convert Markdown image syntax ![alt](url) to plain links [alt](url) inside interactive card content. Feishu interactive cards do NOT support image markdown — sending it triggers error 230099 and the entire message is silently dropped.

Code blocks are preserved untouched (images inside “‘ fences are left as-is since they are literal text, not rendered markdown).

This is a pure function with no side effects — thread-safe by design.

Parameters:

  • text (String)

    raw markdown text

Returns:

  • (String)

    sanitised text safe for interactive cards



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 231

def sanitize_images_for_card(text)
  # Split on code fences to avoid transforming inside code blocks
  parts = text.split(/(```[\s\S]*?```)/)
  parts.map { |segment|
    if segment.start_with?("```")
      segment  # code block — leave untouched
    else
      # ![alt](url) → [alt](url)   (drop the leading !)
      segment.gsub(/!\[([^\]]*)\]\(([^)]+)\)/) do
        alt, url = Regexp.last_match(1), Regexp.last_match(2)
        alt.empty? ? url : "[#{alt}](#{url})"
      end
    end
  }.join
end

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

Upload a local file to Feishu and send it to a chat. Images use /im/v1/images + msg_type “image”. All other files use /im/v1/files + msg_type “file”.

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

Returns:

  • (Hash)

    Response with :message_id

Raises:

  • (ArgumentError)


117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 117

def send_file(chat_id, path, name: nil, reply_to: nil)
  raise ArgumentError, "File not found: #{path}" unless File.exist?(path)

  # Always derive filename from the real path for type detection and upload.
  # The `name` param (often markdown alt text) may lack an extension,
  # causing images to be mis-detected as generic files.
  filename  = 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

#send_text(chat_id, text, reply_to: nil) ⇒ Hash

Send plain text message

Parameters:

  • chat_id (String)

    Chat ID (open_chat_id)

  • text (String)

    Message text

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

    Message ID to reply to

Returns:

  • (Hash)

    Response with :message_id



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 70

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" })

  code = response["code"]
  if code != 0
    Clacky::Logger.warn("[feishu] send_text failed",
      code: code, msg: response["msg"],
      chat_id: chat_id, msg_type: msg_type)
  end

  { message_id: response.dig("data", "message_id") }
end

#tenant_access_tokenString

Get tenant access token (cached)

Returns:

  • (String)

    Access token



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 302

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

#update_message(message_id, text) ⇒ Boolean

Update an existing message

Parameters:

  • message_id (String)

    Message ID to update

  • text (String)

    New text content

Returns:

  • (Boolean)

    Success status



95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 95

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

#upload_file(data, filename) ⇒ String

Upload a file to Feishu and return file_key.

Parameters:

  • data (String)

    Binary file content

  • filename (String)

    Display filename

Returns:

  • (String)

    file_key



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 411

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

#upload_image(data, filename) ⇒ String

Upload an image to Feishu and return image_key.

Parameters:

  • data (String)

    Binary file content

  • filename (String)

    Display filename

Returns:

  • (String)

    image_key



382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 382

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