openclacky 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +27 -31
  3. data/CHANGELOG.md +14 -0
  4. data/Dockerfile +28 -0
  5. data/docs/engineering-article.md +343 -0
  6. data/lib/clacky/agent/llm_caller.rb +1 -5
  7. data/lib/clacky/cli.rb +1 -1
  8. data/lib/clacky/message_format/anthropic.rb +17 -1
  9. data/lib/clacky/providers.rb +34 -0
  10. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
  11. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
  12. data/lib/clacky/ui2/ui_controller.rb +14 -0
  13. data/lib/clacky/ui_interface.rb +14 -0
  14. data/lib/clacky/utils/model_pricing.rb +96 -25
  15. data/lib/clacky/version.rb +1 -1
  16. data/lib/clacky/web/app.css +8 -0
  17. data/lib/clacky/web/index.html +1 -1
  18. data/lib/clacky/web/onboard.js +6 -0
  19. data/lib/clacky/web/settings.js +17 -5
  20. data/scripts/build/lib/apt.sh +30 -10
  21. data/scripts/build/lib/network.sh +3 -2
  22. data/scripts/install.sh +30 -9
  23. metadata +3 -16
  24. data/docs/HOW-TO-USE-CN.md +0 -96
  25. data/docs/HOW-TO-USE.md +0 -94
  26. data/docs/browser-cdp-native-design.md +0 -195
  27. data/docs/c-end-user-positioning.md +0 -64
  28. data/docs/config.example.yml +0 -27
  29. data/docs/deploy-architecture.md +0 -619
  30. data/docs/deploy_subagent_design.md +0 -540
  31. data/docs/install-script-simplification.md +0 -89
  32. data/docs/memory-architecture.md +0 -343
  33. data/docs/openclacky_cloud_api_reference.md +0 -584
  34. data/docs/security-design.md +0 -109
  35. data/docs/session-management-redesign.md +0 -202
  36. data/docs/system-skill-authoring-guide.md +0 -47
  37. data/docs/why-developer.md +0 -371
  38. data/docs/why-openclacky.md +0 -266
@@ -53,6 +53,10 @@ module Clacky
53
53
  # and expires (~2h). We cache it from inbound events and validate on send.
54
54
  @webhook_urls = {}
55
55
  @webhook_mutex = Mutex.new
56
+ # chat_id => { robot_code:, conv_id:, user_id:, conv_type: } — needed
57
+ # to route OAPI calls (e.g. send_file) which can't go through webhook.
58
+ @routes = {}
59
+ @routes_mutex = Mutex.new
56
60
  end
57
61
 
58
62
  WEBHOOK_SAFETY_MARGIN_MS = 5 * 60 * 1000
@@ -74,13 +78,69 @@ module Clacky
74
78
  end
75
79
 
76
80
  # @param chat_id [String] — for DingTalk Stream Mode, chat_id == webhook URL
81
+ # Always sent as markdown so AI replies render rich text (headings,
82
+ # bold, lists, links). DingTalk's markdown msgtype renders plain text
83
+ # unchanged, so no detection branch is needed.
77
84
  def send_text(chat_id, text, reply_to: nil)
78
85
  webhook_url = resolve_webhook(chat_id)
79
86
  unless webhook_url
80
87
  Clacky::Logger.warn("[dingtalk] no valid sessionWebhook for chat #{chat_id} (expired or never received)")
81
88
  return { ok: false, error: "session_webhook_expired" }
82
89
  end
83
- @api_client.send_via_webhook(webhook_url, text)
90
+ @api_client.send_via_webhook(webhook_url, text, msg_type: :markdown)
91
+ end
92
+
93
+ # Send a local file (image or generic file) as a native attachment.
94
+ # Webhook can't deliver attachments — use OAPI sendMessage with mediaId.
95
+ # @param chat_id [String]
96
+ # @param path [String] local file path
97
+ # @param name [String, nil] display filename (not used by image msg)
98
+ def send_file(chat_id, path, name: nil, reply_to: nil)
99
+ unless File.exist?(path)
100
+ Clacky::Logger.warn("[dingtalk] send_file: file not found #{path}")
101
+ return { ok: false, error: "file_not_found" }
102
+ end
103
+
104
+ route = resolve_route(chat_id)
105
+ unless route
106
+ Clacky::Logger.warn("[dingtalk] send_file: no routing info for chat #{chat_id}")
107
+ return { ok: false, error: "no_route" }
108
+ end
109
+
110
+ kind = image_file?(path) ? :image : :file
111
+
112
+ # Non-image files outside DingTalk's accepted extension list
113
+ # (sampleFile rejects anything not in SUPPORTED_FILE_EXTS).
114
+ # Surface the failure directly to the user in the chat,
115
+ # disguised as a DingTalk system message so it's clear the
116
+ # restriction comes from the IM platform, not us.
117
+ if kind == :file && !supported_file?(path)
118
+ ext = File.extname(path).delete_prefix(".").downcase
119
+ display_name = name || File.basename(path)
120
+ Clacky::Logger.info("[dingtalk] send_file: unsupported extension .#{ext} (#{display_name})")
121
+ supported_list = ApiClient::SUPPORTED_FILE_EXTS.map { |e| ".#{e}" }.join(", ")
122
+ send_text(
123
+ chat_id,
124
+ %([DingTalk System] ⚠️ Failed to deliver file "#{display_name}": file type ".#{ext}" is not supported. Supported types: #{supported_list}.)
125
+ )
126
+ return { ok: false, error: :unsupported_extension }
127
+ end
128
+
129
+ media_id = @api_client.upload_media(path, kind: kind)
130
+ unless media_id
131
+ Clacky::Logger.warn("[dingtalk] send_file: upload failed for #{path}")
132
+ return { ok: false, error: "upload_failed" }
133
+ end
134
+
135
+ @api_client.send_media(
136
+ robot_code: route[:robot_code],
137
+ conv_type: route[:conv_type],
138
+ conv_id: route[:conv_id],
139
+ user_id: route[:user_id],
140
+ media_id: media_id,
141
+ kind: kind,
142
+ file_name: name || File.basename(path)
143
+ )
84
144
  end
85
145
 
86
146
  def validate_config(config)
@@ -106,16 +166,18 @@ module Clacky
106
166
  chat_id = data.dig("conversationId") || sender_id
107
167
  webhook_url = data.dig("sessionWebhook") || ""
108
168
  expired_ms = (data.dig("sessionWebhookExpiredTime") || 0).to_i
109
- text = extract_text(data)
110
169
  conv_type = data.dig("conversationType").to_s # "1"=DM, "2"=group
170
+ robot_code = data["robotCode"].to_s
111
171
 
112
172
  cache_webhook(chat_id, webhook_url, expired_ms) unless webhook_url.empty?
173
+ cache_route(chat_id, robot_code: robot_code, conv_id: data["conversationId"].to_s,
174
+ user_id: sender_id, conv_type: conv_type)
113
175
 
114
176
  return if sender_id.empty?
115
177
 
116
178
  # Group chats: only respond when @-mentioned
117
179
  if conv_type == "2"
118
- content = data.dig("text", "content").to_s
180
+ content = data.dig("text", "content").to_s
119
181
  at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
120
182
  bot_id = data.dig("chatbotUserId").to_s
121
183
  unless at_users.include?(bot_id) || content.include?("@")
@@ -126,22 +188,72 @@ module Clacky
126
188
  allowed = @config[:allowed_users]
127
189
  return if allowed && !allowed.empty? && !allowed.include?(sender_id)
128
190
 
191
+ text, files = extract_payload(data, robot_code)
192
+ return if text.strip.empty? && files.empty?
193
+
129
194
  event = {
130
195
  platform: :dingtalk,
131
196
  user_id: sender_id,
132
197
  chat_id: chat_id,
133
198
  message_id: data.dig("msgId") || "",
134
199
  text: text,
135
- files: [],
200
+ files: files,
136
201
  chat_type: conv_type == "2" ? :group : :direct
137
202
  }
138
203
 
139
- Clacky::Logger.info("[dingtalk] message from #{sender_id}: #{text.to_s[0, 80]}")
204
+ log_parts = []
205
+ log_parts << text.slice(0, 80) unless text.strip.empty?
206
+ log_parts << "#{files.size} file(s)" unless files.empty?
207
+ Clacky::Logger.info("[dingtalk] message from #{sender_id}: #{log_parts.join(' | ')}")
140
208
  @on_message&.call(event)
141
209
  rescue => e
142
210
  Clacky::Logger.warn("[dingtalk] handle_frame error: #{e.message}")
143
211
  end
144
212
 
213
+ # Parse text + attachments from inbound event by msgtype.
214
+ # Returns [text, files] where files is an array of { path:, mime:, name: }.
215
+ private def extract_payload(data, robot_code)
216
+ msgtype = data["msgtype"].to_s
217
+ text = ""
218
+ files = []
219
+
220
+ case msgtype
221
+ when "text"
222
+ text = extract_text(data)
223
+ when "picture"
224
+ code = data.dig("content", "downloadCode")
225
+ file = download_one(code, robot_code)
226
+ files << file if file
227
+ when "file"
228
+ # Inbound file message — DingTalk's downloadCode → downloadUrl path
229
+ # works for any file type (no whitelist on the inbound side).
230
+ code = data.dig("content", "downloadCode")
231
+ name = data.dig("content", "fileName")
232
+ file = download_one(code, robot_code, prefer_name: name)
233
+ files << file if file
234
+ when "richText"
235
+ Array(data.dig("content", "richText")).each do |part|
236
+ if part["text"]
237
+ text += part["text"].to_s
238
+ elsif part["downloadCode"] && part["type"] == "picture"
239
+ file = download_one(part["downloadCode"], robot_code)
240
+ files << file if file
241
+ end
242
+ end
243
+ else
244
+ Clacky::Logger.info("[dingtalk] unsupported msgtype=#{msgtype}, ignoring")
245
+ end
246
+
247
+ [text, files]
248
+ end
249
+
250
+ private def download_one(download_code, robot_code, prefer_name: nil)
251
+ res = @api_client.download_message_file(download_code, robot_code, prefer_name: prefer_name)
252
+ return nil unless res
253
+ name = (prefer_name && !prefer_name.to_s.empty?) ? prefer_name.to_s : File.basename(res[:path])
254
+ { path: res[:path], mime: res[:mime], name: name }
255
+ end
256
+
145
257
  private def extract_text(data)
146
258
  content = data.dig("text", "content").to_s.strip
147
259
  # Strip leading @bot mention if present
@@ -168,6 +280,31 @@ module Clacky
168
280
  end
169
281
  entry[:url]
170
282
  end
283
+
284
+ private def cache_route(chat_id, robot_code:, conv_id:, user_id:, conv_type:)
285
+ return if robot_code.empty?
286
+ @routes_mutex.synchronize do
287
+ @routes[chat_id] = {
288
+ robot_code: robot_code,
289
+ conv_id: conv_id,
290
+ user_id: user_id,
291
+ conv_type: conv_type
292
+ }
293
+ end
294
+ end
295
+
296
+ private def resolve_route(chat_id)
297
+ @routes_mutex.synchronize { @routes[chat_id] }
298
+ end
299
+
300
+ private def image_file?(path)
301
+ %w[.jpg .jpeg .png .gif .webp].include?(File.extname(path).downcase)
302
+ end
303
+
304
+ private def supported_file?(path)
305
+ ext = File.extname(path).delete_prefix(".").downcase
306
+ ApiClient::SUPPORTED_FILE_EXTS.include?(ext)
307
+ end
171
308
  end
172
309
 
173
310
  Adapters.register(:dingtalk, Adapter)
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require "securerandom"
6
7
 
7
8
  module Clacky
8
9
  module Channel
@@ -11,12 +12,31 @@ module Clacky
11
12
  # DingTalk Bot API client — sends messages via session webhook (Stream Mode).
12
13
  class ApiClient
13
14
  OPENAPI_BASE = "https://api.dingtalk.com"
15
+ OAPI_BASE = "https://oapi.dingtalk.com"
16
+
17
+ # File extensions accepted by DingTalk sampleFile message (fileType field).
18
+ # Anything outside this list is rejected by DingTalk's API — we surface
19
+ # a friendly text notice to the user instead of attempting upload.
20
+ #
21
+ # Why 9 entries instead of the 6 the public doc lists? The official
22
+ # doc (open.dingtalk.com / sampleFile) explicitly names only:
23
+ # xlsx, pdf, zip, rar, doc, docx
24
+ # However old-format Office types (xls / ppt / pptx) are accepted in
25
+ # practice and were verified by hand during the C-5597 rollout.
26
+ # We deliberately keep the empirical 9-entry list because
27
+ # downgrading to the doc's 6 would silently reject files users
28
+ # routinely send. If DingTalk ever tightens enforcement and the
29
+ # extra 3 start failing, prefer adding a converter (e.g. xls→xlsx)
30
+ # over shrinking the list — the goal is "things users send arrive".
31
+ SUPPORTED_FILE_EXTS = %w[doc docx xls xlsx ppt pptx pdf zip rar].freeze
14
32
 
15
33
  def initialize(client_id:, client_secret:)
16
34
  @client_id = client_id
17
35
  @client_secret = client_secret
18
36
  @token = nil
19
37
  @token_expires_at = 0
38
+ @oapi_token = nil
39
+ @oapi_token_expires_at = 0
20
40
  end
21
41
 
22
42
  # Send a text (or Markdown) message via the session webhook URL.
@@ -67,6 +87,26 @@ module Clacky
67
87
  @token
68
88
  end
69
89
 
90
+ # OAPI access token — required by legacy /media/upload endpoint.
91
+ # Independent token system from /v1.0/oauth2/accessToken.
92
+ def oapi_access_token
93
+ return @oapi_token if @oapi_token && Time.now.to_i < @oapi_token_expires_at - 60
94
+
95
+ uri = URI.parse("#{OAPI_BASE}/gettoken?appkey=#{@client_id}&appsecret=#{@client_secret}")
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.use_ssl = true
98
+ resp = http.request(Net::HTTP::Get.new(uri.request_uri))
99
+ data = JSON.parse(resp.body)
100
+
101
+ unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["access_token"]
102
+ raise "DingTalk OAPI token error (#{resp.code}): #{data["errmsg"] || resp.body}"
103
+ end
104
+
105
+ @oapi_token = data["access_token"]
106
+ @oapi_token_expires_at = Time.now.to_i + (data["expires_in"] || 7200).to_i
107
+ @oapi_token
108
+ end
109
+
70
110
  # Validate credentials by fetching a token.
71
111
  # @return [Hash] { ok: Boolean, error: String? }
72
112
  def test_connection
@@ -75,6 +115,275 @@ module Clacky
75
115
  rescue => e
76
116
  { ok: false, error: e.message }
77
117
  end
118
+
119
+ # Download a file the bot received, given its downloadCode + robotCode
120
+ # from the inbound event. Two-step: exchange downloadCode for a temporary
121
+ # downloadUrl, then persist bytes to UPLOAD_DIR via FileProcessor.save.
122
+ # @param download_code [String]
123
+ # @param robot_code [String]
124
+ # @param prefer_name [String, nil] original filename from inbound event
125
+ # (DingTalk's content.fileName) — used to pick the file extension so
126
+ # downstream consumers (parsers, vision models) route by suffix correctly.
127
+ # @return [Hash, nil] { name:, path:, mime: } or nil on failure
128
+ def download_message_file(download_code, robot_code, prefer_name: nil)
129
+ return nil if download_code.to_s.empty? || robot_code.to_s.empty?
130
+
131
+ url = fetch_download_url(download_code, robot_code)
132
+ return nil unless url
133
+
134
+ download_to_disk(url, prefer_name: prefer_name)
135
+ rescue => e
136
+ Clacky::Logger.warn("[dingtalk] download_message_file failed: #{e.message}")
137
+ nil
138
+ end
139
+
140
+ private def fetch_download_url(download_code, robot_code)
141
+ uri = URI.parse("#{OPENAPI_BASE}/v1.0/robot/messageFiles/download")
142
+ http = Net::HTTP.new(uri.host, uri.port)
143
+ http.use_ssl = true
144
+ req = Net::HTTP::Post.new(uri.path,
145
+ "Content-Type" => "application/json",
146
+ "x-acs-dingtalk-access-token" => access_token)
147
+ req.body = JSON.generate(downloadCode: download_code, robotCode: robot_code)
148
+ resp = http.request(req)
149
+ data = JSON.parse(resp.body) rescue {}
150
+ unless resp.code.to_i == 200 && data["downloadUrl"]
151
+ Clacky::Logger.warn("[dingtalk] fetch_download_url rejected (#{resp.code}): #{resp.body}")
152
+ return nil
153
+ end
154
+ data["downloadUrl"]
155
+ end
156
+
157
+ private def download_to_disk(url, prefer_name: nil)
158
+ uri = URI.parse(url)
159
+ http = Net::HTTP.new(uri.host, uri.port)
160
+ http.use_ssl = uri.scheme == "https"
161
+ resp = http.get(uri.request_uri)
162
+ return nil unless resp.code.to_i == 200
163
+
164
+ mime = resp["content-type"].to_s
165
+ # Prefer extension from the original fileName supplied by DingTalk
166
+ # (e.g. report.pdf, notes.txt). Fall back to MIME mapping, then .bin.
167
+ ext = ext_from_name(prefer_name) || guess_ext(mime) || ".bin"
168
+
169
+ base = prefer_name && !prefer_name.to_s.empty? ?
170
+ File.basename(prefer_name.to_s, ".*") : "dingtalk"
171
+ base = sanitize_basename(base)
172
+ filename = "#{base}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(3)}#{ext}"
173
+ saved = Clacky::Utils::FileProcessor.save(body: resp.body, filename: filename)
174
+ saved.merge(mime: mime)
175
+ end
176
+
177
+ private def ext_from_name(name)
178
+ return nil if name.to_s.empty?
179
+ ext = File.extname(name.to_s).downcase
180
+ ext.empty? ? nil : ext
181
+ end
182
+
183
+ private def sanitize_basename(name)
184
+ # Keep ASCII letters/digits/dash/underscore; drop everything else
185
+ # (CJK, spaces, slashes) so the final filename stays filesystem-safe.
186
+ cleaned = name.to_s.gsub(/[^A-Za-z0-9_\-]/, "_").gsub(/_+/, "_").gsub(/^_+|_+$/, "")
187
+ cleaned.empty? ? "dingtalk" : cleaned
188
+ end
189
+
190
+ private def guess_ext(mime)
191
+ case mime.to_s.split(";").first.to_s.strip.downcase
192
+ # Images
193
+ when "image/jpeg", "image/jpg" then ".jpg"
194
+ when "image/png" then ".png"
195
+ when "image/gif" then ".gif"
196
+ when "image/webp" then ".webp"
197
+ when "image/bmp" then ".bmp"
198
+ when "image/svg+xml" then ".svg"
199
+ # Documents
200
+ when "application/pdf" then ".pdf"
201
+ when "application/msword" then ".doc"
202
+ when "application/vnd.openxmlformats-officedocument.wordprocessingml.document" then ".docx"
203
+ when "application/vnd.ms-excel" then ".xls"
204
+ when "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" then ".xlsx"
205
+ when "application/vnd.ms-powerpoint" then ".ppt"
206
+ when "application/vnd.openxmlformats-officedocument.presentationml.presentation" then ".pptx"
207
+ # Archives
208
+ when "application/zip" then ".zip"
209
+ when "application/x-rar-compressed", "application/vnd.rar" then ".rar"
210
+ when "application/x-7z-compressed" then ".7z"
211
+ when "application/x-tar" then ".tar"
212
+ when "application/gzip" then ".gz"
213
+ # Text
214
+ when "text/plain" then ".txt"
215
+ when "text/markdown" then ".md"
216
+ when "text/csv" then ".csv"
217
+ when "text/html" then ".html"
218
+ when "application/json" then ".json"
219
+ when "application/xml", "text/xml" then ".xml"
220
+ when "application/x-yaml", "text/yaml" then ".yml"
221
+ # Audio / video
222
+ when "audio/mpeg" then ".mp3"
223
+ when "audio/wav", "audio/x-wav" then ".wav"
224
+ when "audio/aac" then ".aac"
225
+ when "audio/ogg" then ".ogg"
226
+ when "video/mp4" then ".mp4"
227
+ when "video/quicktime" then ".mov"
228
+ end
229
+ end
230
+
231
+ # Upload a local file to DingTalk and return its media_id.
232
+ # Webhook delivery doesn't support image/file attachments — uploaded
233
+ # mediaId is used by the OAPI sendMessage path below.
234
+ # @param path [String]
235
+ # @param kind [Symbol] :image | :file
236
+ # @return [String, nil] media_id
237
+ def upload_media(path, kind:)
238
+ type_str = kind == :image ? "image" : "file"
239
+ # NB: legacy OAPI /media/upload — the new /v1.0/robot/messageFiles/*
240
+ # path returns 404, this is the only working endpoint as of 2026-05.
241
+ token = oapi_access_token
242
+ uri = URI.parse("#{OAPI_BASE}/media/upload?access_token=#{token}&type=#{type_str}")
243
+ boundary = "----DingTalkBoundary#{rand(1 << 64).to_s(16)}"
244
+ body = build_multipart(path, boundary, type_str)
245
+
246
+ http = Net::HTTP.new(uri.host, uri.port)
247
+ http.use_ssl = true
248
+ req = Net::HTTP::Post.new(uri.request_uri,
249
+ "Content-Type" => "multipart/form-data; boundary=#{boundary}")
250
+ req.body = body
251
+ resp = http.request(req)
252
+ data = JSON.parse(resp.body) rescue {}
253
+ unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["media_id"]
254
+ Clacky::Logger.warn("[dingtalk] upload_media rejected (#{resp.code}): #{resp.body}")
255
+ return nil
256
+ end
257
+ # Operational log: confirm upload succeeded and surface the
258
+ # media_id shape (length + first 4 chars). We keep this at info
259
+ # level because outbound failures correlate strongly with
260
+ # media_id format drift (e.g. DingTalk silently changing the
261
+ # `@` prefix policy). Avoid logging the full body to keep the
262
+ # token / id from leaking into shared log channels.
263
+ mid = data["media_id"].to_s
264
+ Clacky::Logger.info("[dingtalk] upload_media ok type=#{type_str} media_id_len=#{mid.length} media_id_prefix=#{mid[0, 4].inspect}")
265
+ mid
266
+ rescue => e
267
+ Clacky::Logger.warn("[dingtalk] upload_media failed: #{e.message}")
268
+ nil
269
+ end
270
+
271
+ # Send a media message via OAPI (not webhook).
272
+ # DM → /v1.0/robot/oToMessages/batchSend (needs userIds)
273
+ # Group → /v1.0/robot/groupMessages/send (needs openConversationId)
274
+ # @param conv_type [String] "1"=DM, "2"=group
275
+ # @param kind [Symbol] :image | :file
276
+ def send_media(robot_code:, conv_type:, conv_id:, user_id:, media_id:, kind:, file_name: nil)
277
+ msg_key, msg_param = build_media_message(media_id, kind, file_name)
278
+
279
+ if conv_type == "2"
280
+ path = "/v1.0/robot/groupMessages/send"
281
+ body = {
282
+ msgKey: msg_key,
283
+ msgParam: JSON.generate(msg_param),
284
+ openConversationId: conv_id,
285
+ robotCode: robot_code
286
+ }
287
+ else
288
+ path = "/v1.0/robot/oToMessages/batchSend"
289
+ body = {
290
+ msgKey: msg_key,
291
+ msgParam: JSON.generate(msg_param),
292
+ userIds: [user_id],
293
+ robotCode: robot_code
294
+ }
295
+ end
296
+
297
+ uri = URI.parse("#{OPENAPI_BASE}#{path}")
298
+ http = Net::HTTP.new(uri.host, uri.port)
299
+ http.use_ssl = true
300
+ req = Net::HTTP::Post.new(uri.request_uri,
301
+ "Content-Type" => "application/json",
302
+ "x-acs-dingtalk-access-token" => access_token)
303
+ req.body = JSON.generate(body)
304
+ resp = http.request(req)
305
+ data = JSON.parse(resp.body) rescue {}
306
+ if resp.code.to_i != 200
307
+ Clacky::Logger.warn("[dingtalk] send_media rejected (#{resp.code}): #{resp.body}")
308
+ return { ok: false, error: data["message"] || resp.body }
309
+ end
310
+ # Operational log: success path. We log msgKey (image vs file)
311
+ # so the operator can correlate "sampleImageMsg" with image
312
+ # delivery and "sampleFile" with file delivery without parsing
313
+ # the full request body.
314
+ Clacky::Logger.info("[dingtalk] send_media ok kind=#{kind} msgKey=#{msg_key}")
315
+ { ok: true, data: data }
316
+ rescue => e
317
+ Clacky::Logger.warn("[dingtalk] send_media failed: #{e.message}")
318
+ { ok: false, error: e.message }
319
+ end
320
+
321
+ private def build_media_message(media_id, kind, file_name)
322
+ # Images: `sampleImageMsg` with the OAPI-uploaded mediaId (type=image)
323
+ # renders an inline original-resolution image in the chat. The doc
324
+ # field is named `photoURL`, but DingTalk explicitly documents that
325
+ # photoURL accepts either a public URL OR a mediaId — and the
326
+ # mediaId form is what we use (the only way to send local files
327
+ # without standing up a public file server).
328
+ #
329
+ # NOTE — implementation pitfalls hard-won (2026-05-19):
330
+ # 1. Pass the raw mediaId returned by /media/upload AS-IS.
331
+ # Despite the sampleLink doc example showing `picUrl: "@lADO..."`
332
+ # with an `@` prefix, sampleImageMsg.photoURL must NOT be
333
+ # prefixed — the upload response already includes whatever
334
+ # shape DingTalk wants. Adding `@` produces "原图加载失败".
335
+ # 2. msgKey is `sampleImageMsg`, NOT `msgtype: "image"`.
336
+ # `msgtype:image` belongs to the corpconversation/asyncsend_v2
337
+ # work-notification protocol and does NOT work on the group
338
+ # robot endpoint we use here.
339
+ # 3. msgParam must be a JSON-stringified object (handled by the
340
+ # caller), not a nested hash.
341
+ return ["sampleImageMsg", { photoURL: media_id }] if kind == :image
342
+
343
+ # Generic files: sampleFile. fileType must be one of
344
+ # SUPPORTED_FILE_EXTS (see top of file for why we keep the
345
+ # empirically-verified 9-entry list rather than the 6-entry
346
+ # doc list). Upstream `Adapter#send_file` already screens out
347
+ # unsupported extensions before we reach here, so we just pass
348
+ # `ext` through.
349
+ ext = File.extname(file_name.to_s).delete(".")
350
+ ["sampleFile", { mediaId: media_id, fileName: file_name.to_s, fileType: ext }]
351
+ end
352
+
353
+ private def build_multipart(path, boundary, type_str)
354
+ filename = File.basename(path)
355
+ mime = mime_for(filename)
356
+ content = File.binread(path)
357
+
358
+ # All parts must be ASCII-8BIT before joining; mixing UTF-8 (e.g. a
359
+ # filename with CJK chars) with binary file content raises
360
+ # "incompatible character encodings: UTF-8 and BINARY".
361
+ parts = []
362
+ parts << "--#{boundary}\r\n".b
363
+ parts << "Content-Disposition: form-data; name=\"type\"\r\n\r\n#{type_str}\r\n".b
364
+ parts << "--#{boundary}\r\n".b
365
+ parts << "Content-Disposition: form-data; name=\"media\"; filename=\"#{filename}\"\r\n".b
366
+ parts << "Content-Type: #{mime}\r\n\r\n".b
367
+ parts << content.b
368
+ parts << "\r\n--#{boundary}--\r\n".b
369
+ parts.join
370
+ end
371
+
372
+ private def mime_for(filename)
373
+ case File.extname(filename).downcase
374
+ when ".jpg", ".jpeg" then "image/jpeg"
375
+ when ".png" then "image/png"
376
+ when ".gif" then "image/gif"
377
+ when ".webp" then "image/webp"
378
+ when ".txt", ".log" then "text/plain"
379
+ when ".md" then "text/markdown"
380
+ when ".pdf" then "application/pdf"
381
+ when ".json" then "application/json"
382
+ when ".csv" then "text/csv"
383
+ when ".zip" then "application/zip"
384
+ else "application/octet-stream"
385
+ end
386
+ end
78
387
  end
79
388
  end
80
389
  end
@@ -795,6 +795,20 @@ module Clacky
795
795
  @legacy_progress_handles[type] = start_progress(message: display, style: style)
796
796
  end
797
797
 
798
+ # Stream-only update for the live thinking progress. Unlike
799
+ # +show_progress(progress_type: "thinking", phase: "active")+, this
800
+ # NEVER creates a new handle — if no thinking handle is currently
801
+ # alive (e.g. we're inside an idle-compression call_llm where only
802
+ # the quiet "Compressing..." handle is on the stack), the streamed
803
+ # token counts are silently dropped instead of spawning a primary
804
+ # spinner that would push the compression progress off-screen.
805
+ def stream_thinking_progress(input_tokens:, output_tokens:)
806
+ @legacy_progress_handles ||= {}
807
+ existing = @legacy_progress_handles["thinking"]
808
+ return unless existing&.running?
809
+ existing.update(metadata: { input_tokens: input_tokens, output_tokens: output_tokens })
810
+ end
811
+
798
812
  # ---------------------------------------------------------------------
799
813
  # (Legacy dead-code removed: the old imperative show_progress body
800
814
  # used to live here and is now superseded by the shim + owner
@@ -36,6 +36,20 @@ module Clacky
36
36
  # metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
37
37
  def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
38
38
 
39
+ # Update the live "thinking" progress with streamed token counts.
40
+ # This is *purely decorative*: it must NEVER start a new progress
41
+ # indicator. If no thinking progress is currently active (e.g. during
42
+ # idle compression, where only a quiet "Compressing..." progress is
43
+ # live), the call is a no-op. UI2 overrides this; other UIs delegate
44
+ # to show_progress.
45
+ def stream_thinking_progress(input_tokens:, output_tokens:)
46
+ show_progress(
47
+ progress_type: "thinking",
48
+ phase: "active",
49
+ metadata: { input_tokens: input_tokens, output_tokens: output_tokens }
50
+ )
51
+ end
52
+
39
53
  # === Progress (v2: owned handles) ===
40
54
  #
41
55
  # Start a new progress indicator and return an owned handle. The caller