Class: Clacky::Channel::Adapters::DingTalk::ApiClient
- Inherits:
-
Object
- Object
- Clacky::Channel::Adapters::DingTalk::ApiClient
- 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, docxHowever 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
-
#access_token ⇒ Object
Fetch a short-lived access token (cached for its lifetime).
-
#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.
-
#initialize(client_id:, client_secret:) ⇒ ApiClient
constructor
A new instance of ApiClient.
-
#oapi_access_token ⇒ Object
OAPI access token — required by legacy /media/upload endpoint.
-
#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).
-
#send_via_webhook(webhook_url, text, msg_type: :text) ⇒ Object
Send a text (or Markdown) message via the session webhook URL.
-
#test_connection ⇒ Hash
Validate credentials by fetching a token.
-
#upload_media(path, kind:) ⇒ String?
Upload a local file to DingTalk and return its media_id.
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_token ⇒ Object
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.
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_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.}") nil end |
#oapi_access_token ⇒ Object
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)
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 = (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.}") { ok: false, error: e. } 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.
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.}") {} end |
#test_connection ⇒ Hash
Validate credentials by fetching a token.
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. } 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.
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.}") nil end |