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
ERR_INVALID_TOKEN =
99991663
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.



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

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



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

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)


540
541
542
543
544
545
546
547
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 540

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]



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

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.



518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 518

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.



474
475
476
477
478
479
480
481
482
483
484
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 474

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 }



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

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)



461
462
463
464
465
466
467
468
469
470
471
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 461

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


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
291
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 253

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)


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

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)


502
503
504
505
506
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 502

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"



511
512
513
514
515
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 511

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



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

def get(path, params: {})
  with_token_retry do
    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
end

#has_code_block_or_table?(text) ⇒ Boolean

Returns:

  • (Boolean)


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

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)


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

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



552
553
554
555
556
557
558
559
560
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 552

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



358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 358

def patch(path, body)
  with_token_retry do
    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
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



340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 340

def post(path, body, params: {})
  with_token_retry do
    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
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



390
391
392
393
394
395
396
397
398
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 390

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 to plain links alt 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



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

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)


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
143
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 118

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



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

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



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

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



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

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



433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 433

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



404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 404

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

#with_token_retryHash

Wrap an authenticated API call. If Feishu reports an invalid or revoked access token (99991663) while it is still inside our cache window, force a token refresh and retry the request once.

Returns:

  • (Hash)

    Parsed response



375
376
377
378
379
380
381
382
383
384
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 375

def with_token_retry
  response = yield
  if response.is_a?(Hash) && response["code"] == ERR_INVALID_TOKEN
    Clacky::Logger.warn("[feishu] token invalid (99991663), refreshing and retrying once")
    @token_cache = nil
    @token_expires_at = nil
    response = yield
  end
  response
end