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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. 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
- adapter.send_text(event[:chat_id], "Working...")
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
- channel_ui = ChannelUIController.new(event, adapter)
183
- web_ui&.subscribe_channel(channel_ui)
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 = channel_key(event)
276
- name = "channel-#{event[:platform]}-#{event[:user_id]}"
277
- session_id = @session_builder.call(name: name, working_dir: Dir.home)
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"