openclacky 1.1.2 → 1.1.4
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 +30 -0
- data/Dockerfile +28 -0
- data/README.md +4 -0
- data/README_CN.md +198 -0
- data/docs/engineering-article.md +343 -0
- data/lib/clacky/agent/llm_caller.rb +2 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +22 -1
- data/lib/clacky/brand_config.rb +87 -5
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/client.rb +15 -11
- data/lib/clacky/message_format/anthropic.rb +30 -2
- data/lib/clacky/message_format/bedrock.rb +13 -1
- data/lib/clacky/message_format/open_ai.rb +5 -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/server/http_server.rb +130 -15
- data/lib/clacky/server/session_registry.rb +9 -6
- 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 +1286 -1116
- data/lib/clacky/web/brand.js +20 -5
- data/lib/clacky/web/i18n.js +42 -0
- data/lib/clacky/web/index.html +26 -7
- data/lib/clacky/web/onboard.js +6 -0
- data/lib/clacky/web/sessions.js +194 -11
- data/lib/clacky/web/settings.js +51 -10
- data/lib/clacky/web/skills.js +53 -31
- data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
- data/scripts/build/lib/apt.sh +30 -10
- data/scripts/build/lib/network.sh +3 -2
- data/scripts/install.sh +30 -9
- data/scripts/install_browser.sh +2 -1
- data/scripts/install_full.sh +2 -1
- data/scripts/install_rails_deps.sh +30 -9
- data/scripts/install_system_deps.sh +30 -9
- metadata +7 -17
- 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
|
@@ -48,7 +48,7 @@ module Clacky
|
|
|
48
48
|
# @param max_tokens [Integer]
|
|
49
49
|
# @param caching_enabled [Boolean]
|
|
50
50
|
# @return [Hash] ready to serialize as JSON body
|
|
51
|
-
def build_request_body(messages, model, tools, max_tokens, caching_enabled)
|
|
51
|
+
def build_request_body(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil)
|
|
52
52
|
system_messages = messages.select { |m| m[:role] == "system" }
|
|
53
53
|
regular_messages = messages.reject { |m| m[:role] == "system" }
|
|
54
54
|
|
|
@@ -64,9 +64,21 @@ module Clacky
|
|
|
64
64
|
body = { model: model, max_tokens: max_tokens, messages: api_messages }
|
|
65
65
|
body[:system] = system_text unless system_text.empty?
|
|
66
66
|
body[:tools] = api_tools if api_tools&.any?
|
|
67
|
+
|
|
68
|
+
if (effort = normalized_effort(reasoning_effort))
|
|
69
|
+
body[:thinking] = { type: "adaptive" }
|
|
70
|
+
body[:output_config] = { effort: effort }
|
|
71
|
+
end
|
|
72
|
+
|
|
67
73
|
body
|
|
68
74
|
end
|
|
69
75
|
|
|
76
|
+
private_class_method def self.normalized_effort(effort)
|
|
77
|
+
return nil if effort.nil? || effort.to_s.empty?
|
|
78
|
+
s = effort.to_s
|
|
79
|
+
%w[low medium high].include?(s) ? s : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
70
82
|
# ── Response parsing ──────────────────────────────────────────────────────
|
|
71
83
|
|
|
72
84
|
# Parse Anthropic API response into canonical internal format.
|
|
@@ -182,7 +194,23 @@ module Clacky
|
|
|
182
194
|
func = tc[:function] || tc
|
|
183
195
|
name = func[:name] || tc[:name]
|
|
184
196
|
raw_args = func[:arguments] || tc[:arguments]
|
|
185
|
-
input =
|
|
197
|
+
input =
|
|
198
|
+
if raw_args.is_a?(String)
|
|
199
|
+
begin
|
|
200
|
+
JSON.parse(raw_args)
|
|
201
|
+
rescue JSON::ParserError => e
|
|
202
|
+
Clacky::Logger.warn("message_format.anthropic.tool_args_parse_failed",
|
|
203
|
+
tool_name: name.to_s,
|
|
204
|
+
tool_call_id: tc[:id].to_s,
|
|
205
|
+
args_len: raw_args.length,
|
|
206
|
+
args_head: raw_args[0, 120],
|
|
207
|
+
error: e.message
|
|
208
|
+
) if defined?(Clacky::Logger)
|
|
209
|
+
{}
|
|
210
|
+
end
|
|
211
|
+
else
|
|
212
|
+
raw_args
|
|
213
|
+
end
|
|
186
214
|
blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
|
|
187
215
|
end
|
|
188
216
|
|
|
@@ -52,7 +52,7 @@ module Clacky
|
|
|
52
52
|
# @param max_tokens [Integer]
|
|
53
53
|
# @param caching_enabled [Boolean] (currently unused for Bedrock)
|
|
54
54
|
# @return [Hash] ready to serialize as JSON body
|
|
55
|
-
def build_request_body(messages, model, tools, max_tokens, caching_enabled = false)
|
|
55
|
+
def build_request_body(messages, model, tools, max_tokens, caching_enabled = false, reasoning_effort: nil)
|
|
56
56
|
system_messages = messages.select { |m| m[:role] == "system" }
|
|
57
57
|
regular_messages = messages.reject { |m| m[:role] == "system" }
|
|
58
58
|
|
|
@@ -83,9 +83,21 @@ module Clacky
|
|
|
83
83
|
body[:toolConfig] = { tools: tools.map { |t| to_api_tool(t) } }
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
extra = additional_fields_for_effort(reasoning_effort)
|
|
87
|
+
body[:additionalModelRequestFields] = extra if extra
|
|
88
|
+
|
|
86
89
|
body
|
|
87
90
|
end
|
|
88
91
|
|
|
92
|
+
private_class_method def self.additional_fields_for_effort(effort)
|
|
93
|
+
return nil if effort.nil? || effort.to_s.empty?
|
|
94
|
+
return nil unless %w[low medium high].include?(effort.to_s)
|
|
95
|
+
{
|
|
96
|
+
thinking: { type: "adaptive" },
|
|
97
|
+
output_config: { effort: effort.to_s }
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
89
101
|
# ── Response parsing ──────────────────────────────────────────────────────
|
|
90
102
|
|
|
91
103
|
# Parse Bedrock Converse API response into canonical internal format.
|
|
@@ -44,7 +44,7 @@ module Clacky
|
|
|
44
44
|
# @param vision_supported [Boolean] whether the target model accepts
|
|
45
45
|
# image_url content blocks (default true, conservative)
|
|
46
46
|
# @return [Hash]
|
|
47
|
-
def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true)
|
|
47
|
+
def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true, reasoning_effort: nil)
|
|
48
48
|
api_messages = messages.map { |msg| normalize_message_content(msg, vision_supported: vision_supported) }
|
|
49
49
|
|
|
50
50
|
body = { model: model, max_tokens: max_tokens, messages: api_messages }
|
|
@@ -59,6 +59,10 @@ module Clacky
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
if reasoning_effort && !reasoning_effort.to_s.empty?
|
|
63
|
+
body[:reasoning_effort] = reasoning_effort.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
62
66
|
body
|
|
63
67
|
end
|
|
64
68
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -329,6 +329,40 @@ module Clacky
|
|
|
329
329
|
"gpt-5.4" => "gpt-5.4-mini"
|
|
330
330
|
},
|
|
331
331
|
"website_url" => "https://platform.openai.com/api-keys"
|
|
332
|
+
}.freeze,
|
|
333
|
+
|
|
334
|
+
"qwen" => {
|
|
335
|
+
"name" => "Qwen (Alibaba)",
|
|
336
|
+
"base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
337
|
+
"api" => "openai-completions",
|
|
338
|
+
"default_model" => "qwen3.6-plus",
|
|
339
|
+
"models" => [
|
|
340
|
+
"qwen3.6-plus",
|
|
341
|
+
"qwen3.6-max",
|
|
342
|
+
"qwen3.6-27b",
|
|
343
|
+
"qwen3.6-flash",
|
|
344
|
+
"qwen-plus-latest",
|
|
345
|
+
"qwen-vl-plus",
|
|
346
|
+
"qwen-vl-max"
|
|
347
|
+
],
|
|
348
|
+
"endpoint_variants" => [
|
|
349
|
+
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
|
|
350
|
+
{ "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
|
|
351
|
+
{ "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
|
|
352
|
+
].freeze,
|
|
353
|
+
"capabilities" => { "vision" => false }.freeze,
|
|
354
|
+
"model_capabilities" => {
|
|
355
|
+
"qwen3.6-27b" => { "vision" => true }.freeze,
|
|
356
|
+
"qwen-vl-plus" => { "vision" => true }.freeze,
|
|
357
|
+
"qwen-vl-max" => { "vision" => true }.freeze
|
|
358
|
+
}.freeze,
|
|
359
|
+
"lite_models" => {
|
|
360
|
+
"qwen3.6-plus" => "qwen3.6-flash",
|
|
361
|
+
"qwen3.6-max" => "qwen3.6-flash",
|
|
362
|
+
"qwen3.6-27b" => "qwen3.6-flash",
|
|
363
|
+
"qwen-plus-latest" => "qwen3.6-flash"
|
|
364
|
+
},
|
|
365
|
+
"website_url" => "https://bailian.console.aliyun.com/?apiKey=1"
|
|
332
366
|
}.freeze
|
|
333
367
|
|
|
334
368
|
}.freeze
|
|
@@ -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)
|