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)


403
404
405
406
407
408
409
410
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 403

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


169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 169

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

#check_doc_error!(response, token) ⇒ Object

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



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

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.



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

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 }



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

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)



328
329
330
331
332
333
334
335
336
337
338
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 328

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)


147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 147

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)


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

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”



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

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



211
212
213
214
215
216
217
218
219
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 211

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)


185
186
187
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 185

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)


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

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



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

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



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

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



226
227
228
229
230
231
232
233
234
235
236
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 226

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



257
258
259
260
261
262
263
264
265
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 257

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

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


91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 91

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

  filename  = name || 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
# 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" })
  { message_id: response.dig("data", "message_id") }
end

#tenant_access_tokenString

Get tenant access token (cached)

Returns:

  • (String)

    Access token



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 191

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



69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 69

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



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 300

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



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/clacky/server/channel/adapters/feishu/bot.rb', line 271

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