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,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "../../adapters/base"
5
+ require_relative "api_client"
6
+
7
+ module Clacky
8
+ module Channel
9
+ module Adapters
10
+ module Weixin
11
+ # Weixin (WeChat iLink) adapter.
12
+ #
13
+ # Protocol: HTTP long-poll via ilinkai.weixin.qq.com
14
+ # Auth: token obtained from QR login (stored in channels.yml as `token`)
15
+ #
16
+ # Config keys (channels.yml):
17
+ # token: [String] bot token from QR login
18
+ # base_url: [String] API base URL (default: https://ilinkai.weixin.qq.com)
19
+ # allowed_users: [Array<String>] optional whitelist of from_user_id values
20
+ #
21
+ # Event fields yielded to ChannelManager:
22
+ # platform: :weixin
23
+ # chat_id: String (from_user_id — used for replies)
24
+ # user_id: String (from_user_id)
25
+ # text: String
26
+ # files: Array<Hash>
27
+ # message_id: String
28
+ # timestamp: Time
29
+ # chat_type: :direct
30
+ # context_token: String (must be echoed in every reply)
31
+ class Adapter < Base
32
+ RECONNECT_DELAY = 5
33
+
34
+ def self.platform_id
35
+ :weixin
36
+ end
37
+
38
+ def self.env_keys
39
+ %w[IM_WEIXIN_TOKEN IM_WEIXIN_BASE_URL IM_WEIXIN_ALLOWED_USERS]
40
+ end
41
+
42
+ def self.platform_config(data)
43
+ {
44
+ token: data["IM_WEIXIN_TOKEN"] || data["token"],
45
+ base_url: data["IM_WEIXIN_BASE_URL"] || data["base_url"] || ApiClient::DEFAULT_BASE_URL,
46
+ allowed_users: (data["IM_WEIXIN_ALLOWED_USERS"] || data["allowed_users"] || "")
47
+ .then { |v| v.is_a?(Array) ? v : v.to_s.split(",").map(&:strip).reject(&:empty?) }
48
+ }.compact
49
+ end
50
+
51
+ def self.set_env_data(data, config)
52
+ data["IM_WEIXIN_TOKEN"] = config[:token]
53
+ data["IM_WEIXIN_BASE_URL"] = config[:base_url] if config[:base_url]
54
+ data["IM_WEIXIN_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
55
+ end
56
+
57
+ def self.test_connection(fields)
58
+ token = fields[:token].to_s.strip
59
+
60
+ return { ok: false, error: "token is required" } if token.empty?
61
+
62
+ # Weixin iLink token is obtained via the QR scan flow and is already
63
+ # confirmed valid by the iLink API before we store it. There is no
64
+ # lightweight ping endpoint, so we just verify the token is present.
65
+ { ok: true, message: "Connected to Weixin iLink" }
66
+ end
67
+
68
+ def initialize(config)
69
+ @config = config
70
+ @token = config[:token].to_s
71
+ @base_url = config[:base_url] || ApiClient::DEFAULT_BASE_URL
72
+ @allowed_users = Array(config[:allowed_users])
73
+ @running = false
74
+ @on_message = nil
75
+ # In-memory store: user_id → context_token (for reply threading)
76
+ @context_tokens = {}
77
+ @ctx_mutex = Mutex.new
78
+ @api_client = ApiClient.new(base_url: @base_url, token: @token)
79
+ end
80
+
81
+ def start(&on_message)
82
+ @running = true
83
+ @on_message = on_message
84
+
85
+ get_updates_buf = ""
86
+ consecutive_errors = 0
87
+
88
+ Clacky::Logger.info("[WeixinAdapter] starting long-poll (base_url=#{@base_url})")
89
+
90
+ while @running
91
+ begin
92
+ resp = @api_client.get_updates(get_updates_buf: get_updates_buf)
93
+
94
+ consecutive_errors = 0
95
+ new_buf = resp["get_updates_buf"].to_s
96
+ get_updates_buf = new_buf unless new_buf.empty?
97
+
98
+ (resp["msgs"] || []).each do |msg|
99
+ process_message(msg)
100
+ rescue => e
101
+ Clacky::Logger.warn("[WeixinAdapter] process_message error: #{e.message}")
102
+ end
103
+
104
+ rescue ApiClient::TimeoutError
105
+ # Long-poll server-side timeout is expected — just retry
106
+ rescue ApiClient::ApiError => e
107
+ if e.code == ApiClient::SESSION_EXPIRED_ERRCODE
108
+ Clacky::Logger.warn("[WeixinAdapter] Session expired (token may need refresh), backing off 60s")
109
+ sleep 60
110
+ else
111
+ consecutive_errors += 1
112
+ Clacky::Logger.warn("[WeixinAdapter] API error #{e.code}: #{e.message}")
113
+ sleep(consecutive_errors > 3 ? 30 : RECONNECT_DELAY)
114
+ end
115
+ rescue => e
116
+ consecutive_errors += 1
117
+ Clacky::Logger.error("[WeixinAdapter] poll error: #{e.message}")
118
+ break unless @running
119
+ sleep(consecutive_errors > 3 ? 30 : RECONNECT_DELAY)
120
+ end
121
+ end
122
+ end
123
+
124
+ def stop
125
+ @running = false
126
+ end
127
+
128
+ # Send a plain text reply to a user.
129
+ # The context_token from the inbound message is required by the Weixin protocol.
130
+ def send_text(chat_id, text, reply_to: nil)
131
+ ctoken = lookup_context_token(chat_id)
132
+ unless ctoken
133
+ Clacky::Logger.warn("[WeixinAdapter] send_text: no context_token for #{chat_id}, dropping message")
134
+ return { message_id: nil }
135
+ end
136
+
137
+ plain = markdown_to_plain(text)
138
+ split_message(plain).each do |chunk|
139
+ @api_client.send_text(to_user_id: chat_id, text: chunk, context_token: ctoken)
140
+ end
141
+
142
+ { message_id: nil }
143
+ rescue => e
144
+ Clacky::Logger.error("[WeixinAdapter] send_text failed for #{chat_id}: #{e.message}")
145
+ { message_id: nil }
146
+ end
147
+
148
+ # Send a file to a user.
149
+ # file_path: local path to the file to send
150
+ # file_name: optional display name (defaults to basename)
151
+ def send_file(chat_id, file_path, name: nil, reply_to: nil)
152
+ ctoken = lookup_context_token(chat_id)
153
+ unless ctoken
154
+ Clacky::Logger.warn("[WeixinAdapter] send_file: no context_token for #{chat_id}, dropping")
155
+ return { message_id: nil }
156
+ end
157
+
158
+ @api_client.send_file(
159
+ to_user_id: chat_id,
160
+ file_path: file_path,
161
+ file_name: name || File.basename(file_path),
162
+ context_token: ctoken
163
+ )
164
+ { message_id: nil }
165
+ rescue => e
166
+ Clacky::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.message}")
167
+ { message_id: nil }
168
+ end
169
+
170
+ def validate_config(config)
171
+ errors = []
172
+ errors << "token is required" if config[:token].nil? || config[:token].to_s.strip.empty?
173
+ errors
174
+ end
175
+
176
+ def supports_message_updates?
177
+ false
178
+ end
179
+
180
+ private
181
+
182
+ def process_message(msg)
183
+ # Only process inbound USER messages (message_type 1 = USER)
184
+ return unless msg["message_type"] == 1
185
+
186
+ from_user_id = msg["from_user_id"].to_s
187
+ context_token = msg["context_token"].to_s
188
+ return if from_user_id.empty? || context_token.empty?
189
+
190
+ if @allowed_users.any? && !@allowed_users.include?(from_user_id)
191
+ Clacky::Logger.debug("[WeixinAdapter] ignoring message from #{from_user_id} (not in allowed_users)")
192
+ return
193
+ end
194
+
195
+ # Cache context_token — needed when sending replies
196
+ store_context_token(from_user_id, context_token)
197
+
198
+ item_list = msg["item_list"] || []
199
+ Clacky::Logger.debug("[WeixinAdapter] item_list raw: #{item_list.to_json}")
200
+ text = extract_text(item_list)
201
+ files = extract_files(item_list)
202
+
203
+ # Require at least some content (text or files)
204
+ return if text.strip.empty? && files.empty?
205
+
206
+ event = {
207
+ type: :message,
208
+ platform: :weixin,
209
+ chat_id: from_user_id,
210
+ user_id: from_user_id,
211
+ text: text.strip,
212
+ files: files,
213
+ message_id: msg["message_id"]&.to_s,
214
+ timestamp: msg["create_time_ms"] ? Time.at(msg["create_time_ms"] / 1000.0) : Time.now,
215
+ chat_type: :direct,
216
+ context_token: context_token,
217
+ raw: msg
218
+ }
219
+
220
+ log_parts = []
221
+ log_parts << text.slice(0, 80) unless text.strip.empty?
222
+ log_parts << "#{files.size} file(s)" unless files.empty?
223
+ Clacky::Logger.info("[WeixinAdapter] message from #{from_user_id}: #{log_parts.join(" + ")}")
224
+ @on_message&.call(event)
225
+ end
226
+
227
+ def extract_text(item_list)
228
+ parts = []
229
+ item_list.each do |item|
230
+ case item["type"]
231
+ when 1 # TEXT
232
+ raw_text = item.dig("text_item", "text").to_s.strip
233
+ ref = item["ref_msg"]
234
+ if ref && !ref.empty?
235
+ ref_parts = []
236
+ ref_parts << ref["title"] if ref["title"] && !ref["title"].empty?
237
+ if (ri = ref["message_item"]) && ri["type"] == 1
238
+ rt = ri.dig("text_item", "text").to_s.strip
239
+ ref_parts << rt unless rt.empty?
240
+ end
241
+ parts << "[引用: #{ref_parts.join(" | ")}]" unless ref_parts.empty?
242
+ end
243
+ parts << raw_text unless raw_text.empty?
244
+ when 3 # VOICE — use transcription if available
245
+ vt = item.dig("voice_item", "text").to_s.strip
246
+ parts << vt unless vt.empty?
247
+ end
248
+ end
249
+ parts.join("\n")
250
+ end
251
+
252
+ # Extract file attachments from item_list for inbound messages.
253
+ # Returns array of hashes: { type:, name:, cdn_media: }
254
+ # cdn_media contains { encrypt_query_param:, aes_key: } for potential download.
255
+ # Extract and materialize file attachments from an inbound item_list.
256
+ #
257
+ # Images are downloaded from CDN and converted to data_url so the agent's
258
+ # vision pipeline (partition_files → resolve_vision_images) picks them up
259
+ # correctly. Other file types are returned with cdn_media metadata only
260
+ # (download-on-demand is not yet implemented for non-image types).
261
+ #
262
+ # Returns Array of Hashes. Image entries include:
263
+ # { type: :image, name: String, mime_type: String, data_url: String }
264
+ # Other entries include:
265
+ # { type: :file/:voice/:video, name: String, cdn_media: Hash }
266
+ def extract_files(item_list)
267
+ files = []
268
+ item_list.each do |item|
269
+ case item["type"]
270
+ when 2 # IMAGE — download + convert to data_url for agent vision
271
+ img = item["image_item"]
272
+ next unless img
273
+ cdn_media = img["media"]
274
+ next unless cdn_media
275
+
276
+ # Protocol: image_item may have a top-level aeskey field that overrides
277
+ # the one inside media. Use image_item.aeskey first, fall back to media.aes_key.
278
+ top_level_aeskey = img["aeskey"]
279
+ effective_cdn_media = if top_level_aeskey && !top_level_aeskey.empty?
280
+ cdn_media.merge("aes_key" => top_level_aeskey)
281
+ else
282
+ cdn_media
283
+ end
284
+
285
+ Clacky::Logger.debug("[WeixinAdapter] image cdn_media: #{effective_cdn_media.to_json}")
286
+
287
+ begin
288
+ raw_bytes = @api_client.download_media(effective_cdn_media, ApiClient::MEDIA_TYPE_IMAGE)
289
+ mime_type = detect_image_mime(raw_bytes)
290
+ data_url = "data:#{mime_type};base64,#{Base64.strict_encode64(raw_bytes)}"
291
+ files << {
292
+ type: :image,
293
+ name: "image.jpg",
294
+ mime_type: mime_type,
295
+ data_url: data_url
296
+ }
297
+ rescue => e
298
+ Clacky::Logger.warn("[WeixinAdapter] Failed to download image: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
299
+ end
300
+
301
+ when 3 # VOICE
302
+ v = item["voice_item"]
303
+ next unless v
304
+ files << {
305
+ type: :voice,
306
+ name: "voice.amr",
307
+ cdn_media: v["media"]
308
+ }
309
+ when 4 # FILE
310
+ fi = item["file_item"]
311
+ next unless fi
312
+ files << {
313
+ type: :file,
314
+ name: fi["file_name"],
315
+ md5: fi["md5"],
316
+ len: fi["len"],
317
+ cdn_media: fi["media"]
318
+ }
319
+ when 5 # VIDEO
320
+ vi = item["video_item"]
321
+ next unless vi
322
+ files << {
323
+ type: :video,
324
+ name: "video.mp4",
325
+ cdn_media: vi["media"]
326
+ }
327
+ end
328
+ end
329
+ files
330
+ end
331
+
332
+ # Detect image MIME type from magic bytes.
333
+ # Falls back to image/jpeg if unknown.
334
+ def detect_image_mime(bytes)
335
+ return "image/jpeg" unless bytes && bytes.bytesize >= 4
336
+ head = bytes.byteslice(0, 8).bytes
337
+ if head[0] == 0xFF && head[1] == 0xD8
338
+ "image/jpeg"
339
+ elsif head[0] == 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47
340
+ "image/png"
341
+ elsif head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46
342
+ "image/gif"
343
+ elsif head[0] == 0x52 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x46
344
+ "image/webp"
345
+ else
346
+ "image/jpeg"
347
+ end
348
+ end
349
+
350
+ def store_context_token(user_id, token)
351
+ @ctx_mutex.synchronize { @context_tokens[user_id] = token }
352
+ end
353
+
354
+ def lookup_context_token(user_id)
355
+ @ctx_mutex.synchronize { @context_tokens[user_id] }
356
+ end
357
+
358
+ # Split text into ≤4000-char chunks, preferring newline boundaries.
359
+ def split_message(text, limit: 4000)
360
+ return [text] if text.length <= limit
361
+ chunks = []
362
+ while text.length > limit
363
+ cut = text.rindex("\n", limit) || limit
364
+ chunks << text[0, cut].rstrip
365
+ text = text[cut..].lstrip
366
+ end
367
+ chunks << text unless text.empty?
368
+ chunks
369
+ end
370
+
371
+ # Strip markdown syntax for WeChat (no markdown rendering).
372
+ def markdown_to_plain(text)
373
+ r = text.dup
374
+ r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
375
+ r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
376
+ r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\1')
377
+ r.gsub!(/\*\*([^*]+)\*\*/, '\1')
378
+ r.gsub!(/\*([^*]+)\*/, '\1')
379
+ r.gsub!(/__([^_]+)__/, '\1')
380
+ r.gsub!(/_([^_]+)_/, '\1')
381
+ r.gsub!(/^#+\s+/, "")
382
+ r.gsub!(/^[-*_]{3,}\s*$/, "")
383
+ r.strip
384
+ end
385
+ end
386
+
387
+ Adapters.register(:weixin, Adapter)
388
+ end
389
+ end
390
+ end
391
+ end