openclacky 0.9.7 → 0.9.9
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 +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "digest"
|
|
9
|
+
require "tempfile"
|
|
10
|
+
|
|
11
|
+
module Clacky
|
|
12
|
+
module Channel
|
|
13
|
+
module Adapters
|
|
14
|
+
module Weixin
|
|
15
|
+
# HTTP API client for Weixin iLink bot protocol.
|
|
16
|
+
#
|
|
17
|
+
# All requests POST JSON to <base_url>/<endpoint>.
|
|
18
|
+
# Required headers per request:
|
|
19
|
+
# Content-Type: application/json
|
|
20
|
+
# AuthorizationType: ilink_bot_token
|
|
21
|
+
# Authorization: Bearer <token>
|
|
22
|
+
# X-WECHAT-UIN: base64(random uint32 as decimal string)
|
|
23
|
+
class ApiClient
|
|
24
|
+
DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
25
|
+
CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
|
26
|
+
API_PATH_PREFIX = "ilink/bot"
|
|
27
|
+
CHANNEL_VERSION = "1.0.2"
|
|
28
|
+
LONG_POLL_TIMEOUT_S = 40 # slightly above the server's 35s
|
|
29
|
+
API_TIMEOUT_S = 15
|
|
30
|
+
|
|
31
|
+
# media_type values for getuploadurl
|
|
32
|
+
MEDIA_TYPE_IMAGE = 1
|
|
33
|
+
MEDIA_TYPE_VIDEO = 2
|
|
34
|
+
MEDIA_TYPE_FILE = 3
|
|
35
|
+
MEDIA_TYPE_VOICE = 4
|
|
36
|
+
|
|
37
|
+
# Raised for non-zero API return codes or HTTP errors.
|
|
38
|
+
class ApiError < StandardError
|
|
39
|
+
attr_reader :code
|
|
40
|
+
def initialize(code, msg) = (@code = code; super("WeixinApiError(#{code}): #{msg.to_s.slice(0, 200)}"))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised on network/read timeouts.
|
|
44
|
+
class TimeoutError < StandardError; end
|
|
45
|
+
|
|
46
|
+
# Server errcode for expired sessions.
|
|
47
|
+
SESSION_EXPIRED_ERRCODE = -14
|
|
48
|
+
|
|
49
|
+
def initialize(base_url:, token:)
|
|
50
|
+
@base_url = base_url.to_s.chomp("/")
|
|
51
|
+
@token = token.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Long-poll for new messages.
|
|
55
|
+
# @param get_updates_buf [String] cursor from last response ("" for first call)
|
|
56
|
+
# @return [Hash] { ret:, msgs: [], get_updates_buf:, longpolling_timeout_ms: }
|
|
57
|
+
def get_updates(get_updates_buf:)
|
|
58
|
+
post("getupdates", { get_updates_buf: get_updates_buf }, timeout: LONG_POLL_TIMEOUT_S)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Send a plain text message.
|
|
62
|
+
# context_token is required by the Weixin protocol for conversation association.
|
|
63
|
+
def send_text(to_user_id:, text:, context_token:)
|
|
64
|
+
body = {
|
|
65
|
+
msg: {
|
|
66
|
+
from_user_id: "",
|
|
67
|
+
to_user_id: to_user_id,
|
|
68
|
+
client_id: "clacky-#{SecureRandom.hex(8)}",
|
|
69
|
+
message_type: 2, # BOT
|
|
70
|
+
message_state: 2, # FINISH
|
|
71
|
+
item_list: [{ type: 1, text_item: { text: text } }],
|
|
72
|
+
context_token: context_token
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
post("sendmessage", body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Send a file (any type) to a user.
|
|
79
|
+
#
|
|
80
|
+
# @param to_user_id [String]
|
|
81
|
+
# @param file_path [String] local path to the file
|
|
82
|
+
# @param file_name [String] display name (defaults to basename)
|
|
83
|
+
# @param context_token [String]
|
|
84
|
+
# @param media_type [Integer] MEDIA_TYPE_* constant (default: auto-detect)
|
|
85
|
+
# @return [Hash] API response
|
|
86
|
+
def send_file(to_user_id:, file_path:, context_token:, file_name: nil, media_type: nil)
|
|
87
|
+
file_name ||= File.basename(file_path)
|
|
88
|
+
media_type ||= detect_media_type(file_name)
|
|
89
|
+
raw_bytes = File.binread(file_path)
|
|
90
|
+
|
|
91
|
+
cdn_media = upload_media(
|
|
92
|
+
raw_bytes: raw_bytes,
|
|
93
|
+
file_name: file_name,
|
|
94
|
+
media_type: media_type,
|
|
95
|
+
to_user_id: to_user_id
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
item = build_media_item(media_type, cdn_media, raw_bytes, file_name)
|
|
99
|
+
body = {
|
|
100
|
+
msg: {
|
|
101
|
+
from_user_id: "",
|
|
102
|
+
to_user_id: to_user_id,
|
|
103
|
+
client_id: "clacky-#{SecureRandom.hex(8)}",
|
|
104
|
+
message_type: 2, # BOT
|
|
105
|
+
message_state: 2, # FINISH
|
|
106
|
+
item_list: [item],
|
|
107
|
+
context_token: context_token
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
Clacky::Logger.debug("[WeixinApiClient] send_file item: #{item.to_json}")
|
|
111
|
+
post("sendmessage", body)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Download and decrypt a media file from the Weixin CDN.
|
|
115
|
+
#
|
|
116
|
+
# @param cdn_media [Hash] { "encrypt_query_param" => String, "aes_key" => String }
|
|
117
|
+
# Keys may be Symbol or String.
|
|
118
|
+
# @param media_type [Integer] MEDIA_TYPE_* constant — controls aeskey decoding.
|
|
119
|
+
# @return [String] raw (decrypted) file bytes.
|
|
120
|
+
def download_media(cdn_media, media_type)
|
|
121
|
+
encrypted_param = cdn_media[:encrypt_query_param] || cdn_media["encrypt_query_param"]
|
|
122
|
+
aeskey_b64 = cdn_media[:aes_key] || cdn_media["aes_key"]
|
|
123
|
+
|
|
124
|
+
raise ApiError.new(0, "download_media: missing encrypt_query_param") unless encrypted_param
|
|
125
|
+
raise ApiError.new(0, "download_media: missing aes_key") unless aeskey_b64
|
|
126
|
+
|
|
127
|
+
# Decode aes_key. The encoding depends on who generated the key:
|
|
128
|
+
#
|
|
129
|
+
# Outbound (we upload): image → base64(raw 16 bytes), others → base64(hex 32 chars)
|
|
130
|
+
# Inbound (WeChat client uploaded): aes_key is a plain hex string (32 hex chars, no base64)
|
|
131
|
+
#
|
|
132
|
+
# Detection strategy — try to figure out the actual key by checking decoded size:
|
|
133
|
+
# decoded 16 bytes → raw AES key (our outbound image encoding)
|
|
134
|
+
# decoded 24 bytes → aes_key was a plain hex string (32 chars) passed as-is,
|
|
135
|
+
# meaning aeskey_b64 IS the hex string, not base64 at all.
|
|
136
|
+
# Use the original string directly: [aeskey_b64].pack("H*")
|
|
137
|
+
# decoded 32 bytes → base64(hex 32 chars) → [decoded].pack("H*") → 16 bytes
|
|
138
|
+
raw_aes_key = begin
|
|
139
|
+
decoded = Base64.strict_decode64(aeskey_b64)
|
|
140
|
+
case decoded.bytesize
|
|
141
|
+
when 16
|
|
142
|
+
# Our outbound image encoding: base64(raw 16 bytes)
|
|
143
|
+
decoded
|
|
144
|
+
when 32
|
|
145
|
+
# Our outbound non-image encoding: base64(hex 32 chars)
|
|
146
|
+
[decoded].pack("H*")
|
|
147
|
+
else
|
|
148
|
+
# Unexpected — fall through to hex-string path
|
|
149
|
+
raise ArgumentError, "unexpected decoded size #{decoded.bytesize}"
|
|
150
|
+
end
|
|
151
|
+
rescue ArgumentError
|
|
152
|
+
# aes_key is a plain hex string (32 hex chars), not base64.
|
|
153
|
+
# This is the inbound format used by WeChat clients.
|
|
154
|
+
if aeskey_b64.match?(/\A[0-9a-fA-F]{32}\z/)
|
|
155
|
+
[aeskey_b64].pack("H*")
|
|
156
|
+
else
|
|
157
|
+
Clacky::Logger.warn("[WeixinApiClient] unknown aeskey format: len=#{aeskey_b64.bytesize}")
|
|
158
|
+
aeskey_b64[0, 16] # last-resort: first 16 bytes
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
Clacky::Logger.debug("[WeixinApiClient] download_media key_bytes=#{raw_aes_key.bytesize} media_type=#{media_type}")
|
|
163
|
+
|
|
164
|
+
# GET encrypted bytes from CDN.
|
|
165
|
+
cdn_url = "#{CDN_BASE_URL}/download" \
|
|
166
|
+
"?encrypted_query_param=#{URI.encode_uri_component(encrypted_param)}"
|
|
167
|
+
encrypted_bytes = cdn_get(cdn_url)
|
|
168
|
+
|
|
169
|
+
# Decrypt with AES-128-ECB.
|
|
170
|
+
aes_ecb_decrypt(encrypted_bytes, raw_aes_key)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# Full upload pipeline: encrypt → getuploadurl → CDN PUT → return CDNMedia hash.
|
|
176
|
+
def upload_media(raw_bytes:, file_name:, media_type:, to_user_id:)
|
|
177
|
+
# Generate a random 16-byte AES key.
|
|
178
|
+
aes_key_raw = SecureRandom.bytes(16)
|
|
179
|
+
|
|
180
|
+
# Encrypt file bytes with AES-128-ECB + PKCS7.
|
|
181
|
+
encrypted_bytes = aes_ecb_encrypt(raw_bytes, aes_key_raw)
|
|
182
|
+
|
|
183
|
+
# filekey: arbitrary unique string (use hex of random bytes).
|
|
184
|
+
filekey = SecureRandom.hex(16)
|
|
185
|
+
|
|
186
|
+
# aeskey for getuploadurl: hex string of raw 16 bytes (32 hex chars), NOT base64.
|
|
187
|
+
# Confirmed from @tencent-weixin/openclaw-weixin source: aeskey.toString("hex")
|
|
188
|
+
aeskey_hex = aes_key_raw.unpack1("H*")
|
|
189
|
+
|
|
190
|
+
# aes_key for CDNMedia: base64 of the hex string as UTF-8 bytes.
|
|
191
|
+
# Confirmed: Buffer.from(aeskey_hex).toString("base64") in Node.js = base64 of hex string bytes
|
|
192
|
+
aeskey_b64 = Base64.strict_encode64(aeskey_hex)
|
|
193
|
+
|
|
194
|
+
raw_md5 = Digest::MD5.hexdigest(raw_bytes)
|
|
195
|
+
|
|
196
|
+
# Step 1: get CDN upload URL from iLink API.
|
|
197
|
+
upload_resp = post("getuploadurl", {
|
|
198
|
+
filekey: filekey,
|
|
199
|
+
media_type: media_type,
|
|
200
|
+
to_user_id: to_user_id,
|
|
201
|
+
rawsize: raw_bytes.bytesize,
|
|
202
|
+
rawfilemd5: raw_md5,
|
|
203
|
+
filesize: encrypted_bytes.bytesize,
|
|
204
|
+
aeskey: aeskey_hex,
|
|
205
|
+
no_need_thumb: true
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
upload_param = upload_resp["upload_param"]
|
|
209
|
+
Clacky::Logger.debug("[WeixinApiClient] getuploadurl resp: #{upload_resp.to_json}")
|
|
210
|
+
raise ApiError.new(0, "getuploadurl: missing upload_param") unless upload_param
|
|
211
|
+
|
|
212
|
+
# Step 2: upload encrypted bytes to CDN.
|
|
213
|
+
download_param = cdn_upload(
|
|
214
|
+
upload_param: upload_param,
|
|
215
|
+
filekey: filekey,
|
|
216
|
+
encrypted_bytes: encrypted_bytes
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Return CDNMedia structure for use in sendmessage item_list.
|
|
220
|
+
# encrypt_type: 1 confirmed from @tencent-weixin/openclaw-weixin source.
|
|
221
|
+
{
|
|
222
|
+
encrypt_query_param: download_param,
|
|
223
|
+
aes_key: aeskey_b64,
|
|
224
|
+
encrypt_type: 1
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# POST encrypted bytes to CDN. Returns the x-encrypted-param header value.
|
|
229
|
+
def cdn_upload(upload_param:, filekey:, encrypted_bytes:)
|
|
230
|
+
cdn_url = "#{CDN_BASE_URL}/upload" \
|
|
231
|
+
"?encrypted_query_param=#{URI.encode_uri_component(upload_param)}" \
|
|
232
|
+
"&filekey=#{URI.encode_uri_component(filekey)}"
|
|
233
|
+
uri = URI(cdn_url)
|
|
234
|
+
|
|
235
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
236
|
+
http.use_ssl = true
|
|
237
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
238
|
+
http.read_timeout = API_TIMEOUT_S
|
|
239
|
+
http.open_timeout = 10
|
|
240
|
+
|
|
241
|
+
req = Net::HTTP::Post.new("#{uri.path}?#{uri.query}")
|
|
242
|
+
req["Content-Type"] = "application/octet-stream"
|
|
243
|
+
req["Content-Length"] = encrypted_bytes.bytesize.to_s
|
|
244
|
+
req.body = encrypted_bytes
|
|
245
|
+
|
|
246
|
+
Clacky::Logger.debug("[WeixinApiClient] CDN upload #{encrypted_bytes.bytesize} bytes")
|
|
247
|
+
|
|
248
|
+
res = http.request(req)
|
|
249
|
+
raise ApiError.new(res.code.to_i, res.body.to_s.slice(0, 200)), "CDN upload HTTP #{res.code}" \
|
|
250
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
251
|
+
|
|
252
|
+
download_param = res["x-encrypted-param"]
|
|
253
|
+
raise ApiError.new(0, "CDN upload: missing x-encrypted-param header") unless download_param
|
|
254
|
+
|
|
255
|
+
download_param
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# GET raw bytes from a CDN URL (no iLink auth headers needed for download).
|
|
259
|
+
def cdn_get(url)
|
|
260
|
+
uri = URI(url)
|
|
261
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
262
|
+
http.use_ssl = true
|
|
263
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
264
|
+
http.read_timeout = API_TIMEOUT_S
|
|
265
|
+
http.open_timeout = 10
|
|
266
|
+
|
|
267
|
+
req = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
|
|
268
|
+
Clacky::Logger.debug("[WeixinApiClient] CDN GET #{uri.host}#{uri.path}")
|
|
269
|
+
|
|
270
|
+
res = http.request(req)
|
|
271
|
+
raise ApiError.new(res.code.to_i, "CDN download HTTP #{res.code}") \
|
|
272
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
273
|
+
|
|
274
|
+
res.body.force_encoding("BINARY")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Decrypt bytes with AES-128-ECB + PKCS7 unpadding using OpenSSL.
|
|
278
|
+
def aes_ecb_decrypt(data, key)
|
|
279
|
+
cipher = OpenSSL::Cipher.new("AES-128-ECB")
|
|
280
|
+
cipher.decrypt
|
|
281
|
+
cipher.key = key
|
|
282
|
+
cipher.update(data) + cipher.final
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Encrypt bytes with AES-128-ECB + PKCS7 padding using OpenSSL.
|
|
286
|
+
def aes_ecb_encrypt(data, key)
|
|
287
|
+
cipher = OpenSSL::Cipher.new("AES-128-ECB")
|
|
288
|
+
cipher.encrypt
|
|
289
|
+
cipher.key = key
|
|
290
|
+
cipher.update(data) + cipher.final
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Guess media_type from file extension.
|
|
294
|
+
def detect_media_type(file_name)
|
|
295
|
+
ext = File.extname(file_name).downcase
|
|
296
|
+
case ext
|
|
297
|
+
when ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"
|
|
298
|
+
MEDIA_TYPE_IMAGE
|
|
299
|
+
when ".mp4", ".mov", ".avi", ".mkv", ".flv"
|
|
300
|
+
MEDIA_TYPE_VIDEO
|
|
301
|
+
when ".mp3", ".m4a", ".amr", ".wav", ".ogg"
|
|
302
|
+
MEDIA_TYPE_VOICE
|
|
303
|
+
else
|
|
304
|
+
MEDIA_TYPE_FILE
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Build the item_list entry for sendmessage based on media type.
|
|
309
|
+
def build_media_item(media_type, cdn_media, raw_bytes, file_name)
|
|
310
|
+
case media_type
|
|
311
|
+
when MEDIA_TYPE_IMAGE
|
|
312
|
+
{ type: 2, image_item: { media: cdn_media } }
|
|
313
|
+
when MEDIA_TYPE_VIDEO
|
|
314
|
+
{ type: 5, video_item: { media: cdn_media } }
|
|
315
|
+
when MEDIA_TYPE_VOICE
|
|
316
|
+
{ type: 3, voice_item: { media: cdn_media } }
|
|
317
|
+
else
|
|
318
|
+
{
|
|
319
|
+
type: 4,
|
|
320
|
+
file_item: {
|
|
321
|
+
media: cdn_media,
|
|
322
|
+
file_name: file_name,
|
|
323
|
+
md5: Digest::MD5.hexdigest(raw_bytes),
|
|
324
|
+
len: raw_bytes.bytesize.to_s
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def post(endpoint, body_hash, timeout: API_TIMEOUT_S)
|
|
331
|
+
uri = URI("#{@base_url}/#{API_PATH_PREFIX}/#{endpoint}")
|
|
332
|
+
# All POST bodies must include base_info per iLink protocol spec.
|
|
333
|
+
body = JSON.generate(body_hash.merge(base_info: { channel_version: CHANNEL_VERSION }))
|
|
334
|
+
|
|
335
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
336
|
+
http.use_ssl = uri.scheme == "https"
|
|
337
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
338
|
+
http.read_timeout = timeout
|
|
339
|
+
http.open_timeout = 10
|
|
340
|
+
|
|
341
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
342
|
+
req["Content-Type"] = "application/json"
|
|
343
|
+
req["AuthorizationType"] = "ilink_bot_token"
|
|
344
|
+
req["Content-Length"] = body.bytesize.to_s
|
|
345
|
+
req["X-WECHAT-UIN"] = random_wechat_uin
|
|
346
|
+
req["Authorization"] = "Bearer #{@token}" unless @token.empty?
|
|
347
|
+
req.body = body
|
|
348
|
+
|
|
349
|
+
Clacky::Logger.debug("[WeixinApiClient] POST #{endpoint}")
|
|
350
|
+
|
|
351
|
+
res = http.request(req)
|
|
352
|
+
raise ApiError.new(res.code.to_i, res.body), "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
353
|
+
|
|
354
|
+
data = JSON.parse(res.body)
|
|
355
|
+
ret = data["ret"] || data["errcode"]
|
|
356
|
+
raise ApiError.new(ret, data["errmsg"]) if ret && ret != 0
|
|
357
|
+
|
|
358
|
+
data
|
|
359
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
360
|
+
raise TimeoutError, "#{endpoint} timed out"
|
|
361
|
+
rescue JSON::ParserError => e
|
|
362
|
+
raise ApiError.new(0, "Invalid JSON: #{e.message}")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# X-WECHAT-UIN: random uint32 → decimal string → base64
|
|
366
|
+
def random_wechat_uin
|
|
367
|
+
uint32 = SecureRandom.random_bytes(4).unpack1("N")
|
|
368
|
+
Base64.strict_encode64(uint32.to_s)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -103,6 +103,12 @@ module Clacky
|
|
|
103
103
|
bot_id: raw["bot_id"],
|
|
104
104
|
secret: raw["secret"]
|
|
105
105
|
}.compact
|
|
106
|
+
when :weixin
|
|
107
|
+
{
|
|
108
|
+
token: raw["token"],
|
|
109
|
+
base_url: raw["base_url"],
|
|
110
|
+
allowed_users: raw["allowed_users"]
|
|
111
|
+
}.compact
|
|
106
112
|
else
|
|
107
113
|
# Unknown platform — pass all non-meta keys as symbol-keyed hash
|
|
108
114
|
raw.reject { |k, _| k == "enabled" }
|
|
@@ -39,6 +39,7 @@ module Clacky
|
|
|
39
39
|
@adapter_threads = []
|
|
40
40
|
@running = false
|
|
41
41
|
@mutex = Mutex.new
|
|
42
|
+
@session_counters = Hash.new(0) # platform => count, for short session names
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
# Start all enabled adapters in background threads. Non-blocking.
|
|
@@ -157,7 +158,7 @@ module Clacky
|
|
|
157
158
|
end
|
|
158
159
|
|
|
159
160
|
session_id = resolve_session(event)
|
|
160
|
-
session_id = auto_create_session(event) unless session_id
|
|
161
|
+
session_id = auto_create_session(adapter, event) unless session_id
|
|
161
162
|
|
|
162
163
|
session = @registry.get(session_id)
|
|
163
164
|
unless session
|
|
@@ -177,21 +178,20 @@ module Clacky
|
|
|
177
178
|
agent = session[:agent]
|
|
178
179
|
web_ui = session[:ui]
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
# Update reply context so responses thread under the current message.
|
|
182
|
+
# channel_ui is bound to the session for its full lifetime (created in auto_create_session).
|
|
183
|
+
channel_ui_for_session(session_id)&.update_message_context(event)
|
|
181
184
|
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
# Sync the inbound message to WebUI so it shows up in the browser session.
|
|
186
|
+
# source: :channel prevents the message from being echoed back to the IM channel.
|
|
187
|
+
web_ui&.show_user_message(text, source: :channel) unless text.nil? || text.empty?
|
|
188
|
+
|
|
189
|
+
# Acknowledge to the IM channel only — WebUI doesn't need a "Thinking..." noise.
|
|
190
|
+
adapter.send_text(event[:chat_id], "Thinking...")
|
|
184
191
|
|
|
185
192
|
@run_agent_task.call(session_id, agent) do
|
|
186
193
|
agent.run(text, files: files)
|
|
187
194
|
end
|
|
188
|
-
|
|
189
|
-
# Unsubscribe channel_ui once the task thread finishes
|
|
190
|
-
Thread.new do
|
|
191
|
-
Thread.current.name = "channel-cleanup-#{session_id[0, 8]}"
|
|
192
|
-
sleep 0.1 until @registry.get(session_id)&.dig(:status) != :running
|
|
193
|
-
web_ui&.unsubscribe_channel(channel_ui)
|
|
194
|
-
end
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
def handle_command(adapter, event, text)
|
|
@@ -213,7 +213,23 @@ module Clacky
|
|
|
213
213
|
adapter.send_text(chat_id, "Session not found. Use /list to see available sessions.")
|
|
214
214
|
return
|
|
215
215
|
end
|
|
216
|
+
|
|
217
|
+
# Detach channel_ui from the old session's web_ui, reattach to the new one.
|
|
218
|
+
old_session_id = resolve_session(event)
|
|
219
|
+
channel_ui = old_session_id ? channel_ui_for_session(old_session_id) : nil
|
|
220
|
+
|
|
221
|
+
if channel_ui
|
|
222
|
+
@registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
|
|
223
|
+
else
|
|
224
|
+
channel_ui = ChannelUIController.new(event, adapter)
|
|
225
|
+
end
|
|
226
|
+
|
|
216
227
|
bind_key_to_session(key, session_id)
|
|
228
|
+
@registry.with_session(session_id) do |s|
|
|
229
|
+
s[:ui]&.subscribe_channel(channel_ui)
|
|
230
|
+
s[:channel_ui] = channel_ui
|
|
231
|
+
end
|
|
232
|
+
|
|
217
233
|
Clacky::Logger.info("[ChannelManager] Bound #{key} -> session #{session_id[0, 8]}")
|
|
218
234
|
adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}`.")
|
|
219
235
|
|
|
@@ -271,15 +287,34 @@ module Clacky
|
|
|
271
287
|
nil
|
|
272
288
|
end
|
|
273
289
|
|
|
274
|
-
def auto_create_session(event)
|
|
275
|
-
key
|
|
276
|
-
|
|
277
|
-
|
|
290
|
+
def auto_create_session(adapter, event)
|
|
291
|
+
key = channel_key(event)
|
|
292
|
+
platform = event[:platform].to_s
|
|
293
|
+
count = @mutex.synchronize { @session_counters[platform] += 1 }
|
|
294
|
+
name = "#{platform}-#{count}"
|
|
295
|
+
session_id = @session_builder.call(name: name, working_dir: Dir.home, source: :channel)
|
|
278
296
|
bind_key_to_session(key, session_id)
|
|
297
|
+
|
|
298
|
+
# Create a long-lived ChannelUIController for this session and subscribe it
|
|
299
|
+
# to the session's WebUIController. It stays for the session's full lifetime
|
|
300
|
+
# so all events (agent output, errors, status) flow through web_ui → channel_ui.
|
|
301
|
+
channel_ui = ChannelUIController.new(event, adapter)
|
|
302
|
+
@registry.with_session(session_id) do |s|
|
|
303
|
+
s[:ui]&.subscribe_channel(channel_ui)
|
|
304
|
+
s[:channel_ui] = channel_ui
|
|
305
|
+
end
|
|
306
|
+
|
|
279
307
|
Clacky::Logger.info("[ChannelManager] Auto-created session #{session_id[0, 8]} for #{key}")
|
|
280
308
|
session_id
|
|
281
309
|
end
|
|
282
310
|
|
|
311
|
+
# Retrieve the ChannelUIController bound to a session (if any).
|
|
312
|
+
def channel_ui_for_session(session_id)
|
|
313
|
+
result = nil
|
|
314
|
+
@registry.with_session(session_id) { |s| result = s[:channel_ui] }
|
|
315
|
+
result
|
|
316
|
+
end
|
|
317
|
+
|
|
283
318
|
def bind_key_to_session(key, session_id)
|
|
284
319
|
@registry.list.each do |summary|
|
|
285
320
|
@registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }
|
|
@@ -31,8 +31,23 @@ module Clacky
|
|
|
31
31
|
@mutex = Mutex.new
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Update the reply context for the current inbound message.
|
|
35
|
+
# Called at the start of each route_message so replies are threaded correctly.
|
|
36
|
+
# @param event [Hash] inbound event with :message_id
|
|
37
|
+
def update_message_context(event)
|
|
38
|
+
@mutex.synchronize { @message_id = event[:message_id] }
|
|
39
|
+
end
|
|
40
|
+
|
|
34
41
|
# === Output display ===
|
|
35
42
|
|
|
43
|
+
# Forward WebUI user messages to the IM channel so both sides stay in sync.
|
|
44
|
+
# Prefixed with the product/user context so it's clear who sent it.
|
|
45
|
+
def show_user_message(content)
|
|
46
|
+
return if content.nil? || content.to_s.strip.empty?
|
|
47
|
+
|
|
48
|
+
send_text("[USER] #{content}")
|
|
49
|
+
end
|
|
50
|
+
|
|
36
51
|
def show_assistant_message(content, files:)
|
|
37
52
|
flush_buffer
|
|
38
53
|
Clacky::Logger.info("[ChannelUI] show_assistant_message files=#{files.size} content_len=#{content.to_s.length}")
|
|
@@ -154,6 +169,9 @@ module Clacky
|
|
|
154
169
|
private
|
|
155
170
|
|
|
156
171
|
def send_text(text)
|
|
172
|
+
text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip
|
|
173
|
+
return if text.empty?
|
|
174
|
+
|
|
157
175
|
@adapter.send_text(@chat_id, text, reply_to: @message_id)
|
|
158
176
|
rescue StandardError => e
|
|
159
177
|
warn "[ChannelUI] send_text failed (#{@platform}/#{@chat_id}): #{e.message}"
|
|
@@ -23,6 +23,7 @@ require_relative "channel/adapters/base"
|
|
|
23
23
|
# Load platform adapters (each registers itself)
|
|
24
24
|
require_relative "channel/adapters/feishu/adapter"
|
|
25
25
|
require_relative "channel/adapters/wecom/adapter"
|
|
26
|
+
require_relative "channel/adapters/weixin/adapter"
|
|
26
27
|
|
|
27
28
|
require_relative "channel/channel_config"
|
|
28
29
|
require_relative "channel/channel_ui_controller"
|