Class: Clacky::Channel::Adapters::DingTalk::ApiClient

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

Overview

DingTalk Bot API client — sends messages via session webhook (Stream Mode).

Constant Summary collapse

OPENAPI_BASE =
"https://api.dingtalk.com"
OAPI_BASE =
"https://oapi.dingtalk.com"
SUPPORTED_FILE_EXTS =

File extensions accepted by DingTalk sampleFile message (fileType field). Anything outside this list is rejected by DingTalk’s API — we surface a friendly text notice to the user instead of attempting upload.

Why 9 entries instead of the 6 the public doc lists? The official doc (open.dingtalk.com / sampleFile) explicitly names only:

xlsx, pdf, zip, rar, doc, docx

However old-format Office types (xls / ppt / pptx) are accepted in practice and were verified by hand during the C-5597 rollout. We deliberately keep the empirical 9-entry list because downgrading to the doc’s 6 would silently reject files users routinely send. If DingTalk ever tightens enforcement and the extra 3 start failing, prefer adding a converter (e.g. xls→xlsx) over shrinking the list — the goal is “things users send arrive”.

%w[doc docx xls xlsx ppt pptx pdf zip rar].freeze

Instance Method Summary collapse

Constructor Details

#initialize(client_id:, client_secret:) ⇒ ApiClient

Returns a new instance of ApiClient.



33
34
35
36
37
38
39
40
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 33

def initialize(client_id:, client_secret:)
  @client_id     = client_id
  @client_secret = client_secret
  @token         = nil
  @token_expires_at = 0
  @oapi_token    = nil
  @oapi_token_expires_at = 0
end

Instance Method Details

#access_tokenObject

Fetch a short-lived access token (cached for its lifetime).



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

def access_token
  return @token if @token && Time.now.to_i < @token_expires_at - 60

  uri  = URI.parse("#{OPENAPI_BASE}/v1.0/oauth2/accessToken")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
  req.body = JSON.generate({ appKey: @client_id, appSecret: @client_secret })

  resp = http.request(req)
  data = JSON.parse(resp.body)

  raise "DingTalk token error (#{resp.code}): #{data["message"] || resp.body}" unless resp.code.to_i == 200

  @token = data["accessToken"] || raise("Missing accessToken in response")
  @token_expires_at = Time.now.to_i + (data["expireIn"] || 7200).to_i
  @token
end

#download_message_file(download_code, robot_code, prefer_name: nil) ⇒ Hash?

Download a file the bot received, given its downloadCode + robotCode from the inbound event. Two-step: exchange downloadCode for a temporary downloadUrl, then persist bytes to UPLOAD_DIR via FileProcessor.save.

Parameters:

  • download_code (String)
  • robot_code (String)
  • prefer_name (String, nil) (defaults to: nil)

    original filename from inbound event (DingTalk’s content.fileName) — used to pick the file extension so downstream consumers (parsers, vision models) route by suffix correctly.

Returns:

  • (Hash, nil)

    { name:, path:, mime: } or nil on failure



128
129
130
131
132
133
134
135
136
137
138
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 128

def download_message_file(download_code, robot_code, prefer_name: nil)
  return nil if download_code.to_s.empty? || robot_code.to_s.empty?

  url = fetch_download_url(download_code, robot_code)
  return nil unless url

  download_to_disk(url, prefer_name: prefer_name)
rescue => e
  Clacky::Logger.warn("[dingtalk] download_message_file failed: #{e.message}")
  nil
end

#oapi_access_tokenObject

OAPI access token — required by legacy /media/upload endpoint. Independent token system from /v1.0/oauth2/accessToken.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 92

def oapi_access_token
  return @oapi_token if @oapi_token && Time.now.to_i < @oapi_token_expires_at - 60

  uri = URI.parse("#{OAPI_BASE}/gettoken?appkey=#{@client_id}&appsecret=#{@client_secret}")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  resp = http.request(Net::HTTP::Get.new(uri.request_uri))
  data = JSON.parse(resp.body)

  unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["access_token"]
    raise "DingTalk OAPI token error (#{resp.code}): #{data["errmsg"] || resp.body}"
  end

  @oapi_token = data["access_token"]
  @oapi_token_expires_at = Time.now.to_i + (data["expires_in"] || 7200).to_i
  @oapi_token
end

#send_media(robot_code:, conv_type:, conv_id:, user_id:, media_id:, kind:, file_name: nil) ⇒ Object

Send a media message via OAPI (not webhook). DM → /v1.0/robot/oToMessages/batchSend (needs userIds) Group → /v1.0/robot/groupMessages/send (needs openConversationId)

Parameters:

  • conv_type (String)

    “1”=DM, “2”=group

  • kind (Symbol)

    :image | :file



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 276

def send_media(robot_code:, conv_type:, conv_id:, user_id:, media_id:, kind:, file_name: nil)
  msg_key, msg_param = build_media_message(media_id, kind, file_name)

  if conv_type == "2"
    path = "/v1.0/robot/groupMessages/send"
    body = {
      msgKey:             msg_key,
      msgParam:           JSON.generate(msg_param),
      openConversationId: conv_id,
      robotCode:          robot_code
    }
  else
    path = "/v1.0/robot/oToMessages/batchSend"
    body = {
      msgKey:    msg_key,
      msgParam:  JSON.generate(msg_param),
      userIds:   [user_id],
      robotCode: robot_code
    }
  end

  uri  = URI.parse("#{OPENAPI_BASE}#{path}")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  req = Net::HTTP::Post.new(uri.request_uri,
    "Content-Type"                => "application/json",
    "x-acs-dingtalk-access-token" => access_token)
  req.body = JSON.generate(body)
  resp = http.request(req)
  data = JSON.parse(resp.body) rescue {}
  if resp.code.to_i != 200
    Clacky::Logger.warn("[dingtalk] send_media rejected (#{resp.code}): #{resp.body}")
    return { ok: false, error: data["message"] || resp.body }
  end
  # Operational log: success path. We log msgKey (image vs file)
  # so the operator can correlate "sampleImageMsg" with image
  # delivery and "sampleFile" with file delivery without parsing
  # the full request body.
  Clacky::Logger.info("[dingtalk] send_media ok kind=#{kind} msgKey=#{msg_key}")
  { ok: true, data: data }
rescue => e
  Clacky::Logger.warn("[dingtalk] send_media failed: #{e.message}")
  { ok: false, error: e.message }
end

#send_via_webhook(webhook_url, text, msg_type: :text) ⇒ Object

Send a text (or Markdown) message via the session webhook URL. In Stream Mode, inbound events carry a ‘sessionWebhook` — use that directly.

Parameters:

  • webhook_url (String)
  • text (String)
  • msg_type (:text, :markdown) (defaults to: :text)

    (default :text)



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 47

def send_via_webhook(webhook_url, text, msg_type: :text)
  body = if msg_type == :markdown
    { msgtype: "markdown", markdown: { title: "Reply", text: text } }
  else
    { msgtype: "text", text: { content: text } }
  end

  uri = URI.parse(webhook_url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
  req.body = JSON.generate(body)
  resp = http.request(req)
  data = JSON.parse(resp.body) rescue {}
  if resp.code.to_i != 200 || (data["errcode"] && data["errcode"] != 0)
    Clacky::Logger.warn("[dingtalk] webhook send rejected (#{resp.code}): #{resp.body}")
  end
  data
rescue => e
  Clacky::Logger.warn("[dingtalk] webhook send failed: #{e.message}")
  {}
end

#test_connectionHash

Validate credentials by fetching a token.

Returns:

  • (Hash)

    { ok: Boolean, error: String? }



112
113
114
115
116
117
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 112

def test_connection
  access_token
  { ok: true, message: "DingTalk access token obtained" }
rescue => e
  { ok: false, error: e.message }
end

#upload_media(path, kind:) ⇒ String?

Upload a local file to DingTalk and return its media_id. Webhook delivery doesn’t support image/file attachments — uploaded mediaId is used by the OAPI sendMessage path below.

Parameters:

  • path (String)
  • kind (Symbol)

    :image | :file

Returns:

  • (String, nil)

    media_id



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/clacky/server/channel/adapters/dingtalk/api_client.rb', line 237

def upload_media(path, kind:)
  type_str = kind == :image ? "image" : "file"
  # NB: legacy OAPI /media/upload — the new /v1.0/robot/messageFiles/*
  # path returns 404, this is the only working endpoint as of 2026-05.
  token    = oapi_access_token
  uri      = URI.parse("#{OAPI_BASE}/media/upload?access_token=#{token}&type=#{type_str}")
  boundary = "----DingTalkBoundary#{rand(1 << 64).to_s(16)}"
  body     = build_multipart(path, boundary, type_str)

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  req = Net::HTTP::Post.new(uri.request_uri,
    "Content-Type" => "multipart/form-data; boundary=#{boundary}")
  req.body = body
  resp = http.request(req)
  data = JSON.parse(resp.body) rescue {}
  unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["media_id"]
    Clacky::Logger.warn("[dingtalk] upload_media rejected (#{resp.code}): #{resp.body}")
    return nil
  end
  # Operational log: confirm upload succeeded and surface the
  # media_id shape (length + first 4 chars). We keep this at info
  # level because outbound failures correlate strongly with
  # media_id format drift (e.g. DingTalk silently changing the
  # `@` prefix policy). Avoid logging the full body to keep the
  # token / id from leaking into shared log channels.
  mid = data["media_id"].to_s
  Clacky::Logger.info("[dingtalk] upload_media ok type=#{type_str} media_id_len=#{mid.length} media_id_prefix=#{mid[0, 4].inspect}")
  mid
rescue => e
  Clacky::Logger.warn("[dingtalk] upload_media failed: #{e.message}")
  nil
end