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

#bot_open_idString?

Get this bot’s own open_id (cached, fetched lazily on first use). Used to detect @bot mentions in group chats.

Returns:

  • (String, nil)

    bot open_id, or nil if the API call fails



232
233
234
235
236
237
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 232

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)


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

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.



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

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.



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

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)



378
379
380
381
382
383
384
385
386
387
388
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 378

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)


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

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”



428
429
430
431
432
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 428

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



261
262
263
264
265
266
267
268
269
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 261

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)


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

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



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

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



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

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



276
277
278
279
280
281
282
283
284
285
286
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 276

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



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

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



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 241

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



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 350

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



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 321

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