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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +27 -31
- data/CHANGELOG.md +14 -0
- data/Dockerfile +28 -0
- data/docs/engineering-article.md +343 -0
- data/lib/clacky/agent/llm_caller.rb +1 -5
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/message_format/anthropic.rb +17 -1
- data/lib/clacky/providers.rb +34 -0
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
- data/lib/clacky/ui2/ui_controller.rb +14 -0
- data/lib/clacky/ui_interface.rb +14 -0
- data/lib/clacky/utils/model_pricing.rb +96 -25
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +8 -0
- data/lib/clacky/web/index.html +1 -1
- data/lib/clacky/web/onboard.js +6 -0
- data/lib/clacky/web/settings.js +17 -5
- data/scripts/build/lib/apt.sh +30 -10
- data/scripts/build/lib/network.sh +3 -2
- data/scripts/install.sh +30 -9
- metadata +3 -16
- data/docs/HOW-TO-USE-CN.md +0 -96
- data/docs/HOW-TO-USE.md +0 -94
- data/docs/browser-cdp-native-design.md +0 -195
- data/docs/c-end-user-positioning.md +0 -64
- data/docs/config.example.yml +0 -27
- data/docs/deploy-architecture.md +0 -619
- data/docs/deploy_subagent_design.md +0 -540
- data/docs/install-script-simplification.md +0 -89
- data/docs/memory-architecture.md +0 -343
- data/docs/openclacky_cloud_api_reference.md +0 -584
- data/docs/security-design.md +0 -109
- data/docs/session-management-redesign.md +0 -202
- data/docs/system-skill-authoring-guide.md +0 -47
- data/docs/why-developer.md +0 -371
- 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
|
|
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
|
-
|
|
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
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -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
|