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

Instance Method Summary collapse

Constructor Details

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

Returns a new instance of Bot.



39
40
41
42
43
44
45
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 39

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

#build_connectionFaraday::Connection

Build Faraday connection

Returns:

  • (Faraday::Connection)


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

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


180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 180

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.



425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 425

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

  if code == 91403
    raise FeishuDocPermissionError, token
  elsif code == 99991672
    # Extract auth URL from the error message if present
    auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
               response["msg"].to_s[/https:\/\/open\.feishu\.cn\/app\/[^\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.



381
382
383
384
385
386
387
388
389
390
391
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 381

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 }



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 132

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)



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

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


158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 158

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)


409
410
411
412
413
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 409

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”



418
419
420
421
422
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 418

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



251
252
253
254
255
256
257
258
259
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 251

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)


198
199
200
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 198

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)


397
398
399
400
401
402
403
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 397

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



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

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



282
283
284
285
286
287
288
289
290
291
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 282

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



266
267
268
269
270
271
272
273
274
275
276
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 266

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



297
298
299
300
301
302
303
304
305
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 297

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



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 213

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)


99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 99

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



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 52

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



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



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

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



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 340

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



311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 311

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