openclacky 0.9.17 → 0.9.19

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/oss-upload/SKILL.md +47 -0
  3. data/CHANGELOG.md +27 -0
  4. data/lib/clacky/agent/cost_tracker.rb +0 -1
  5. data/lib/clacky/agent/hook_manager.rb +0 -1
  6. data/lib/clacky/agent/message_compressor.rb +0 -1
  7. data/lib/clacky/agent/message_compressor_helper.rb +8 -1
  8. data/lib/clacky/agent/session_serializer.rb +0 -1
  9. data/lib/clacky/agent/skill_manager.rb +0 -1
  10. data/lib/clacky/brand_config.rb +0 -1
  11. data/lib/clacky/client.rb +0 -1
  12. data/lib/clacky/default_parsers/docx_parser.rb +43 -27
  13. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +0 -2
  14. data/lib/clacky/plain_ui_controller.rb +0 -1
  15. data/lib/clacky/server/browser_manager.rb +0 -1
  16. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +0 -1
  17. data/lib/clacky/server/channel/adapters/feishu/bot.rb +0 -1
  18. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +0 -1
  19. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +0 -1
  20. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +0 -1
  21. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +0 -1
  22. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +112 -14
  23. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +28 -3
  24. data/lib/clacky/server/channel/channel_manager.rb +15 -5
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +0 -1
  26. data/lib/clacky/server/http_server.rb +129 -29
  27. data/lib/clacky/server/server_master.rb +0 -1
  28. data/lib/clacky/server/session_registry.rb +0 -1
  29. data/lib/clacky/server/web_ui_controller.rb +0 -1
  30. data/lib/clacky/session_manager.rb +0 -1
  31. data/lib/clacky/skill.rb +0 -1
  32. data/lib/clacky/skill_loader.rb +4 -1
  33. data/lib/clacky/tools/browser.rb +0 -1
  34. data/lib/clacky/tools/file_reader.rb +0 -1
  35. data/lib/clacky/tools/grep.rb +0 -1
  36. data/lib/clacky/tools/run_project.rb +0 -1
  37. data/lib/clacky/tools/todo_manager.rb +0 -1
  38. data/lib/clacky/tools/web_search.rb +206 -63
  39. data/lib/clacky/ui2/components/command_suggestions.rb +0 -1
  40. data/lib/clacky/ui2/components/inline_input.rb +0 -1
  41. data/lib/clacky/ui2/components/input_area.rb +0 -1
  42. data/lib/clacky/ui2/components/message_component.rb +0 -1
  43. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  44. data/lib/clacky/ui2/components/todo_area.rb +0 -1
  45. data/lib/clacky/ui2/components/tool_component.rb +0 -1
  46. data/lib/clacky/ui2/components/welcome_banner.rb +0 -1
  47. data/lib/clacky/ui2/layout_manager.rb +26 -11
  48. data/lib/clacky/ui2/markdown_renderer.rb +0 -1
  49. data/lib/clacky/ui2/progress_indicator.rb +0 -1
  50. data/lib/clacky/ui2/screen_buffer.rb +0 -1
  51. data/lib/clacky/ui2/theme_manager.rb +0 -1
  52. data/lib/clacky/ui2/themes/base_theme.rb +0 -1
  53. data/lib/clacky/ui2/ui_controller.rb +4 -3
  54. data/lib/clacky/utils/arguments_parser.rb +0 -1
  55. data/lib/clacky/utils/file_processor.rb +6 -4
  56. data/lib/clacky/utils/gitignore_parser.rb +0 -1
  57. data/lib/clacky/utils/limit_stack.rb +0 -1
  58. data/lib/clacky/utils/model_pricing.rb +0 -1
  59. data/lib/clacky/utils/parser_manager.rb +6 -1
  60. data/lib/clacky/version.rb +1 -1
  61. data/lib/clacky.rb +10 -0
  62. data/scripts/install.ps1 +395 -136
  63. data/scripts/install.sh +215 -494
  64. data/scripts/install_full.sh +891 -0
  65. data/scripts/install_simple.sh +37 -19
  66. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1dd996048847d73d0a71f8d4833f338e4c5ae2a58c6228122b817039f2d9fda4
4
- data.tar.gz: 2003df86f0c4446d6f3d6b7d0c14eef19cc05246735acdd1f79910a0cfb0a324
3
+ metadata.gz: b959a76ace0015ec6279b1572afa3e961d862d9c856c9fdecc98f72d9e190e59
4
+ data.tar.gz: 53cc27fce3b47e83cb428955eb6591d53acc148cdab55721c7c32330b351fed0
5
5
  SHA512:
6
- metadata.gz: ab9f7d2b510bdf5dc4736723dbfa602968a584f2d097d4747af4f5c74247f494e66d32a0420c7f7aa4b5e87662d5662671ab3c56e12bdb627bd7b4766504fda9
7
- data.tar.gz: 4e5543ac5c01d46b5be821769c1505de6797746991fa9605788a1036ed334fa03d6a3c2d7546706dcd0bde5595d94812196acbd51df8e56008c7ba493c1058ec
6
+ metadata.gz: 5b0d27875497a290060cabfc409c5b8a18b90b95c8bec82157f8d1f8e63a25fc78e3d52bff8bcbf3bd9969f8e65090137dc7ce7b394ac35779412a51534a166e
7
+ data.tar.gz: 2b08d76c821e84c7196b4bc6364323879669259895b30cb803e01e70c49a59f44a40f5f607f8a4d3e10a96bfaa47167c03ed991fcebe6a5dcb44d099b18bdccd
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: oss-upload
3
+ description: Upload local files to Tencent COS (oss.1024code.com CDN) using coscli. Use when user wants to upload a file to CDN/OSS, or deploy static assets.
4
+ ---
5
+
6
+ # OSS Upload Skill
7
+
8
+ Upload files to Tencent COS bucket `clackyai-1258723534`, served via `https://oss.1024code.com`.
9
+
10
+ ## Tool
11
+ `coscli` — config at `~/.cos.yaml`
12
+
13
+ ## Bucket Info
14
+ - Bucket: `clackyai-1258723534`
15
+ - Region: `ap-guangzhou`
16
+ - Endpoint: `cos.ap-guangzhou.myqcloud.com`
17
+ - Public CDN: `https://oss.1024code.com/<path>`
18
+
19
+ ## Upload Command
20
+
21
+ ```bash
22
+ coscli cp <local-file> cos://clackyai-1258723534/<remote-path>
23
+ ```
24
+
25
+ ### Examples
26
+
27
+ ```bash
28
+ # Upload a single file to bucket root
29
+ coscli cp /tmp/wsl.2.6.3.0.arm64.msi cos://clackyai-1258723534/wsl.2.6.3.0.arm64.msi
30
+
31
+ # Upload to a subdirectory
32
+ coscli cp /tmp/install.ps1 cos://clackyai-1258723534/clacky-ai/openclacky/main/scripts/install.ps1
33
+
34
+ # Upload entire directory recursively
35
+ coscli cp /tmp/dist/ cos://clackyai-1258723534/dist/ -r
36
+ ```
37
+
38
+ ## Public URL
39
+ After upload, the file is accessible at:
40
+ ```
41
+ https://oss.1024code.com/<remote-path>
42
+ ```
43
+
44
+ ## Steps
45
+ 1. Confirm local file exists
46
+ 2. Run `coscli cp <local> cos://clackyai-1258723534/<path>`
47
+ 3. Return the public URL: `https://oss.1024code.com/<path>`
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.19] - 2026-03-29
11
+
12
+ ### Added
13
+ - **Bing search engine support**: the web search tool now supports Bing in addition to DuckDuckGo and Baidu — improves search coverage and fallback reliability
14
+ - **WSL1 fallback for Windows installer**: the PowerShell installer now automatically falls back to WSL1 when WSL2/Hyper-V is unavailable, ensuring installation succeeds on older or constrained Windows machines
15
+ - **Upgrade via OSS (CN mirror)**: the upgrade flow now downloads new gem versions from Tencent OSS, making upgrades faster and more reliable for users in China
16
+
17
+ ### Fixed
18
+ - **WeChat (Weixin) context token refresh**: the WeChat channel adapter now correctly refreshes the access token when it expires, preventing message delivery failures
19
+ - **DOCX parser UTF-8 encoding bug**: parsing `.docx` files with non-ASCII content no longer causes encoding errors
20
+ - **WSL version detection broadened**: installer now correctly handles old inbox `wsl.exe` (exit code -1) in addition to "feature not enabled" (exit code 1)
21
+ - **Ctrl+C handling in UI**: Ctrl+C now correctly interrupts the current operation without leaving the UI in a broken state
22
+ - **Layout scrollback double-render**: fixed a UI rendering issue that caused the scrollback buffer to render twice
23
+
24
+ ### More
25
+ - Support custom brand name in Windows PowerShell installer
26
+ - Redesigned Windows registration flow; removed Win10 MSI dependency
27
+
28
+ ## [0.9.18] - 2026-03-28
29
+
30
+ ### Fixed
31
+ - **Brand skill config now reloads from disk on every `load_all`**: brand skills installed or activated after the initial startup were previously invisible until restart — the skill loader now refreshes `BrandConfig` each time it loads skills, so newly installed brand skills take effect immediately
32
+
33
+ ### More
34
+ - Remove `private` keyword from all internal classes to improve Ruby 2.6 compatibility
35
+ - Rename `install.sh` → `install_full.sh`; promote `install_simple.sh` → `install.sh` as the default entry point
36
+
10
37
  ## [0.9.17] - 2026-03-27
11
38
 
12
39
  ### Added
@@ -84,7 +84,6 @@ module Clacky
84
84
  # Simple approximation: characters / 4 (English text)
85
85
 
86
86
 
87
- private
88
87
 
89
88
  # Collect token usage data for current iteration and return it.
90
89
  # Does NOT call @ui directly — the caller is responsible for displaying
@@ -51,7 +51,6 @@ module Clacky
51
51
  end
52
52
  end
53
53
 
54
- private
55
54
 
56
55
  def validate_event!(event)
57
56
  return if HOOK_EVENTS.include?(event)
@@ -108,7 +108,6 @@ module Clacky
108
108
  [system_msg, *parsed_messages, *safe_recent].compact
109
109
  end
110
110
 
111
- private
112
111
 
113
112
  def parse_compressed_result(result, chunk_path: nil)
114
113
  # Return the compressed result as a single assistant message
@@ -20,6 +20,14 @@ module Clacky
20
20
  @ui&.show_idle_status(phase: :start, message: "Idle detected. Compressing conversation to optimize costs...")
21
21
  if compression_context.nil?
22
22
  @ui&.show_idle_status(phase: :end, message: "Idle skipped.")
23
+ Clacky::Logger.info(
24
+ "Idle compression skipped",
25
+ enable_compression: @config.enable_compression,
26
+ previous_total_tokens: @previous_total_tokens,
27
+ history_size: @history.size,
28
+ idle_threshold: IDLE_COMPRESSION_THRESHOLD,
29
+ max_recent_messages: MAX_RECENT_MESSAGES
30
+ )
23
31
  return false
24
32
  end
25
33
 
@@ -214,7 +222,6 @@ module Clacky
214
222
  end
215
223
  end
216
224
 
217
- private
218
225
 
219
226
  # Returns true if msg is a tool result, regardless of storage format.
220
227
  # Canonical: role:"tool" | Legacy Anthropic-native: role:"user" + tool_result blocks
@@ -193,7 +193,6 @@ module Clacky
193
193
  { has_more: has_more }
194
194
  end
195
195
 
196
- private
197
196
 
198
197
  # Render a single non-user message into the UI.
199
198
  # Used by both the normal round-based replay and the compressed-session fallback.
@@ -258,7 +258,6 @@ module Clacky
258
258
  @ui&.show_info("Injected skill content for /#{skill.identifier}")
259
259
  end
260
260
 
261
- private
262
261
 
263
262
  # Find skills whose identifiers are similar to the given name.
264
263
  # Uses substring matching first, then character overlap as a fallback.
@@ -862,7 +862,6 @@ module Clacky
862
862
  }
863
863
  end
864
864
 
865
- private
866
865
 
867
866
  def to_yaml
868
867
  data = {}
data/lib/clacky/client.rb CHANGED
@@ -121,7 +121,6 @@ module Clacky
121
121
  model_str.match?(/claude(?:-3[-.]?[5-9]|-[4-9]|-sonnet-[34])/)
122
122
  end
123
123
 
124
- private
125
124
 
126
125
  # ── Bedrock Converse request / response ───────────────────────────────────
127
126
 
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
+ # encoding: utf-8
4
+
5
+ Encoding.default_external = Encoding::UTF_8
6
+ Encoding.default_internal = Encoding::UTF_8
7
+
3
8
  #
4
9
  # Clacky DOCX Parser — CLI interface
5
10
  #
@@ -22,30 +27,43 @@ require "zip"
22
27
  require "rexml/document"
23
28
  require "stringio"
24
29
 
25
- def read_document_xml(body)
30
+ def safe_utf8(str)
31
+ # First try force_encoding (lossless, for content that IS valid UTF-8)
32
+ utf8 = str.dup.force_encoding("UTF-8")
33
+ return utf8 if utf8.valid_encoding?
34
+ # Fallback: transcode with replacement for genuinely invalid bytes
35
+ str.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "")
36
+ end
37
+
38
+ def read_zip_entry(body, name)
39
+ xml = nil
26
40
  Zip::File.open_buffer(StringIO.new(body)) do |zip|
27
- entry = zip.find_entry("word/document.xml")
28
- raise "Could not extract content — possibly encrypted or invalid format" unless entry
29
- entry.get_input_stream.read
41
+ entry = zip.find_entry(name)
42
+ xml = safe_utf8(entry.get_input_stream.read) if entry
30
43
  end
44
+ xml
45
+ end
46
+
47
+ def read_document_xml(body)
48
+ xml = read_zip_entry(body, "word/document.xml")
49
+ raise "Could not extract content — possibly encrypted or invalid format" unless xml
50
+ xml
31
51
  end
32
52
 
33
53
  def read_numbering(body)
34
54
  result = {}
35
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
36
- entry = zip.find_entry("word/numbering.xml")
37
- break unless entry
38
- doc = REXML::Document.new(entry.get_input_stream.read)
39
- REXML::XPath.each(doc, "//w:abstractNum") do |an|
40
- id = an.attributes["w:abstractNumId"]
41
- levels = {}
42
- REXML::XPath.each(an, "w:lvl") do |lvl|
43
- ilvl = lvl.attributes["w:ilvl"].to_i
44
- fmt = REXML::XPath.first(lvl, "w:numFmt")&.attributes&.[]("w:val")
45
- levels[ilvl] = { fmt: fmt || "bullet" }
46
- end
47
- result[id] = levels
55
+ xml = read_zip_entry(body, "word/numbering.xml")
56
+ return result unless xml
57
+ doc = REXML::Document.new(xml)
58
+ REXML::XPath.each(doc, "//w:abstractNum") do |an|
59
+ id = an.attributes["w:abstractNumId"]
60
+ levels = {}
61
+ REXML::XPath.each(an, "w:lvl") do |lvl|
62
+ ilvl = lvl.attributes["w:ilvl"].to_i
63
+ fmt = REXML::XPath.first(lvl, "w:numFmt")&.attributes&.[]("w:val")
64
+ levels[ilvl] = { fmt: fmt || "bullet" }
48
65
  end
66
+ result[id] = levels
49
67
  end
50
68
  result
51
69
  rescue
@@ -54,16 +72,14 @@ end
54
72
 
55
73
  def read_styles(body)
56
74
  result = {}
57
- Zip::File.open_buffer(StringIO.new(body)) do |zip|
58
- entry = zip.find_entry("word/styles.xml")
59
- break unless entry
60
- doc = REXML::Document.new(entry.get_input_stream.read)
61
- REXML::XPath.each(doc, "//w:style") do |s|
62
- sid = s.attributes["w:styleId"]
63
- name = REXML::XPath.first(s, "w:name")&.attributes&.[]("w:val").to_s
64
- if name =~ /^heading (\d)/i
65
- result[sid] = { heading: $1.to_i }
66
- end
75
+ xml = read_zip_entry(body, "word/styles.xml")
76
+ return result unless xml
77
+ doc = REXML::Document.new(xml)
78
+ REXML::XPath.each(doc, "//w:style") do |s|
79
+ sid = s.attributes["w:styleId"]
80
+ name = REXML::XPath.first(s, "w:name")&.attributes&.[]("w:val").to_s
81
+ if name =~ /^heading (\d)/i
82
+ result[sid] = { heading: $1.to_i }
67
83
  end
68
84
  end
69
85
  result
@@ -104,7 +104,6 @@ class ToolClient
104
104
  raise "ToolClient connection failed: #{e.message}"
105
105
  end
106
106
 
107
- private
108
107
 
109
108
  def http
110
109
  return @http if @http
@@ -286,7 +285,6 @@ class FeishuApiClient
286
285
  get_json("#{FEISHU_API_BASE}/app/#{app_id}")
287
286
  end
288
287
 
289
- private
290
288
 
291
289
  # Execute a GET fetch in the browser page context.
292
290
  # Uses window.csrfToken — required by all /developers/v1/ endpoints.
@@ -135,7 +135,6 @@ module Clacky
135
135
  def set_input_tips(message, type: :info); end
136
136
  def stop; end
137
137
 
138
- private
139
138
 
140
139
  def puts_line(text)
141
140
  @mutex.synchronize do
@@ -190,7 +190,6 @@ module Clacky
190
190
  # ---------------------------------------------------------------------------
191
191
  # Private
192
192
  # ---------------------------------------------------------------------------
193
- private
194
193
 
195
194
  def load_config
196
195
  return {} unless File.exist?(BROWSER_CONFIG_PATH)
@@ -143,7 +143,6 @@ module Clacky
143
143
  errors
144
144
  end
145
145
 
146
- private
147
146
 
148
147
  # Handle incoming WebSocket event
149
148
  # @param raw_event [Hash] Raw event data
@@ -160,7 +160,6 @@ module Clacky
160
160
  end
161
161
  end
162
162
 
163
- private
164
163
 
165
164
  # Build message content and type based on text content.
166
165
  # Uses interactive card (schema 2.0) for code blocks and tables,
@@ -42,7 +42,6 @@ module Clacky
42
42
  end
43
43
  end
44
44
 
45
- private
46
45
 
47
46
  # Parse message.receive event
48
47
  # @return [Hash, nil]
@@ -51,7 +51,6 @@ module Clacky
51
51
  @ws_socket&.close rescue nil
52
52
  end
53
53
 
54
- private
55
54
 
56
55
  # Timeout for IO.select on the read loop. Feishu server sends pings every
57
56
  # @ping_interval seconds (default 90s). Allow two missed pings before
@@ -74,7 +74,6 @@ module Clacky
74
74
  errors
75
75
  end
76
76
 
77
- private
78
77
 
79
78
  def handle_raw_message(raw)
80
79
  msgtype = raw["msgtype"]
@@ -115,7 +115,6 @@ module Clacky
115
115
  raise
116
116
  end
117
117
 
118
- private
119
118
 
120
119
  # Timeout for IO.select on the read loop. If no data arrives within this
121
120
  # window we treat the connection as dead and reconnect. This catches the
@@ -76,6 +76,12 @@ module Clacky
76
76
  @context_tokens = {}
77
77
  @ctx_mutex = Mutex.new
78
78
  @api_client = ApiClient.new(base_url: @base_url, token: @token)
79
+ # Typing keepalive: user_id → { ticket:, thread:, cached_at: }
80
+ @typing_tickets = {}
81
+ @typing_mutex = Mutex.new
82
+ # Active keepalive threads: user_id → Thread
83
+ @keepalive_threads = {}
84
+ @keepalive_mutex = Mutex.new
79
85
  end
80
86
 
81
87
  def start(&on_message)
@@ -141,7 +147,7 @@ module Clacky
141
147
 
142
148
  { message_id: nil }
143
149
  rescue => e
144
- Clacky::Logger.error("[WeixinAdapter] send_text failed for #{chat_id}: #{e.message}")
150
+ Clacky::Logger.error("[WeixinAdapter] send_text failed for #{chat_id} (context_token=#{lookup_context_token(chat_id).to_s.slice(0, 20)}...): #{e.message}")
145
151
  { message_id: nil }
146
152
  end
147
153
 
@@ -177,7 +183,6 @@ module Clacky
177
183
  false
178
184
  end
179
185
 
180
- private
181
186
 
182
187
  def process_message(msg)
183
188
  # Only process inbound USER messages (message_type 1 = USER)
@@ -355,14 +360,20 @@ module Clacky
355
360
  @ctx_mutex.synchronize { @context_tokens[user_id] }
356
361
  end
357
362
 
358
- # Split text into ≤4000-char chunks, preferring newline boundaries.
359
- def split_message(text, limit: 4000)
360
- return [text] if text.length <= limit
363
+ # Split text into ≤2000 Unicode character chunks per iLink protocol recommendation.
364
+ # Priority: split at \n\n, then \n, then space, then hard cut.
365
+ def split_message(text, limit: 2000)
366
+ return [text] if text.chars.length <= limit
361
367
  chunks = []
362
- while text.length > limit
363
- cut = text.rindex("\n", limit) || limit
364
- chunks << text[0, cut].rstrip
365
- text = text[cut..].lstrip
368
+ while text.chars.length > limit
369
+ window = text.chars.first(limit).join
370
+ # Prefer double-newline boundary
371
+ cut = window.rindex("\n\n")
372
+ cut = window.rindex("\n") if cut.nil?
373
+ cut = window.rindex(" ") if cut.nil?
374
+ cut = limit if cut.nil? || cut.zero?
375
+ chunks << text.chars.first(cut).join.rstrip
376
+ text = text.chars.drop(cut).join.lstrip
366
377
  end
367
378
  chunks << text unless text.empty?
368
379
  chunks
@@ -373,15 +384,102 @@ module Clacky
373
384
  r = text.dup
374
385
  r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
375
386
  r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
376
- r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\1')
377
- r.gsub!(/\*\*([^*]+)\*\*/, '\1')
378
- r.gsub!(/\*([^*]+)\*/, '\1')
379
- r.gsub!(/__([^_]+)__/, '\1')
380
- r.gsub!(/_([^_]+)_/, '\1')
387
+ r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '')
388
+ r.gsub!(/\*\*([^*]+)\*\*/, '')
389
+ r.gsub!(/\*([^*]+)\*/, '')
390
+ r.gsub!(/__([^_]+)__/, '')
391
+ r.gsub!(/_([^_]+)_/, '')
381
392
  r.gsub!(/^#+\s+/, "")
382
393
  r.gsub!(/^[-*_]{3,}\s*$/, "")
383
394
  r.strip
384
395
  end
396
+
397
+ # ── Typing keepalive ─────────────────────────────────────────────────
398
+ # sendtyping(status=1) serves dual purpose: maintains typing indicator AND
399
+ # renews the context_token TTL. Official @tencent-weixin/openclaw-weixin
400
+ # npm package uses keepaliveIntervalMs: 5000. We match that exactly.
401
+ TYPING_KEEPALIVE_INTERVAL = 5
402
+ # typing_ticket is valid for ~24h; cache and reuse it.
403
+ TYPING_TICKET_TTL = 86_400
404
+
405
+ # Fetch (or return cached) typing_ticket for user_id.
406
+ # Returns nil on failure — keepalive will just skip without crashing.
407
+ def fetch_typing_ticket(user_id, context_token)
408
+ @typing_mutex.synchronize do
409
+ entry = @typing_tickets[user_id]
410
+ if entry && (Time.now.to_i - entry[:cached_at]) < TYPING_TICKET_TTL
411
+ return entry[:ticket]
412
+ end
413
+ end
414
+
415
+ ticket = @api_client.get_typing_ticket(
416
+ ilink_user_id: user_id,
417
+ context_token: context_token
418
+ )
419
+ return nil if ticket.empty?
420
+
421
+ @typing_mutex.synchronize do
422
+ @typing_tickets[user_id] = { ticket: ticket, cached_at: Time.now.to_i }
423
+ end
424
+ ticket
425
+ rescue => e
426
+ Clacky::Logger.warn("[WeixinAdapter] getconfig failed for #{user_id}: #{e.message}")
427
+ nil
428
+ end
429
+
430
+ # Start a background thread that sends sendtyping(1) every TYPING_KEEPALIVE_INTERVAL.
431
+ # Any existing keepalive for this user is stopped first.
432
+ def start_typing_keepalive(user_id, context_token)
433
+ stop_typing_keepalive(user_id)
434
+
435
+ ticket = fetch_typing_ticket(user_id, context_token)
436
+ unless ticket
437
+ Clacky::Logger.debug("[WeixinAdapter] no typing_ticket for #{user_id}, skipping keepalive")
438
+ return
439
+ end
440
+
441
+ thread = Thread.new do
442
+ loop do
443
+ begin
444
+ @api_client.send_typing(
445
+ ilink_user_id: user_id,
446
+ typing_ticket: ticket,
447
+ status: 1
448
+ )
449
+ Clacky::Logger.debug("[WeixinAdapter] typing keepalive sent for #{user_id}")
450
+ rescue => e
451
+ Clacky::Logger.debug("[WeixinAdapter] typing keepalive error: #{e.message}")
452
+ end
453
+ sleep TYPING_KEEPALIVE_INTERVAL
454
+ end
455
+ end
456
+
457
+ @keepalive_mutex.synchronize { @keepalive_threads[user_id] = thread }
458
+ Clacky::Logger.debug("[WeixinAdapter] typing keepalive started for #{user_id}")
459
+ end
460
+
461
+ # Stop keepalive thread and send sendtyping(status=2) to cancel "typing" indicator.
462
+ def stop_typing_keepalive(user_id)
463
+ thread = @keepalive_mutex.synchronize { @keepalive_threads.delete(user_id) }
464
+ return unless thread
465
+
466
+ thread.kill
467
+ thread.join(1)
468
+
469
+ ticket = @typing_mutex.synchronize { @typing_tickets.dig(user_id, :ticket) }
470
+ if ticket
471
+ begin
472
+ @api_client.send_typing(
473
+ ilink_user_id: user_id,
474
+ typing_ticket: ticket,
475
+ status: 2
476
+ )
477
+ rescue => e
478
+ Clacky::Logger.debug("[WeixinAdapter] stop typing error: #{e.message}")
479
+ end
480
+ end
481
+ Clacky::Logger.debug("[WeixinAdapter] typing keepalive stopped for #{user_id}")
482
+ end
385
483
  end
386
484
 
387
485
  Adapters.register(:weixin, Adapter)
@@ -61,6 +61,26 @@ module Clacky
61
61
  post("getupdates", { get_updates_buf: get_updates_buf }, timeout: LONG_POLL_TIMEOUT_S)
62
62
  end
63
63
 
64
+ # Retrieve a typing_ticket for the given user.
65
+ # context_token is optional but recommended per protocol spec.
66
+ # @return [String] typing_ticket
67
+ def get_typing_ticket(ilink_user_id:, context_token: nil)
68
+ body = { ilink_user_id: ilink_user_id }
69
+ body[:context_token] = context_token if context_token
70
+ resp = post("getconfig", body)
71
+ resp["typing_ticket"].to_s
72
+ end
73
+
74
+ # Send/keep/cancel typing indicator.
75
+ # @param status [Integer] 1 = typing, 2 = cancel
76
+ def send_typing(ilink_user_id:, typing_ticket:, status:)
77
+ post("sendtyping", {
78
+ ilink_user_id: ilink_user_id,
79
+ typing_ticket: typing_ticket,
80
+ status: status
81
+ })
82
+ end
83
+
64
84
  # Send a plain text message.
65
85
  # context_token is required by the Weixin protocol for conversation association.
66
86
  def send_text(to_user_id:, text:, context_token:)
@@ -173,7 +193,6 @@ module Clacky
173
193
  aes_ecb_decrypt(encrypted_bytes, raw_aes_key)
174
194
  end
175
195
 
176
- private
177
196
 
178
197
  # Full upload pipeline: encrypt → getuploadurl → CDN PUT → return CDNMedia hash.
179
198
  def upload_media(raw_bytes:, file_name:, media_type:, to_user_id:)
@@ -354,9 +373,15 @@ module Clacky
354
373
  res = http.request(req)
355
374
  raise ApiError.new(res.code.to_i, res.body), "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
356
375
 
357
- data = JSON.parse(res.body)
376
+ raw_body = res.body
377
+ data = JSON.parse(raw_body)
358
378
  ret = data["ret"] || data["errcode"]
359
- raise ApiError.new(ret, data["errmsg"]) if ret && ret != 0
379
+ if ret && ret != 0
380
+ # Include full response body for easier debugging (errmsg is often empty)
381
+ detail = data["errmsg"].to_s.strip
382
+ detail = raw_body.slice(0, 300) if detail.empty?
383
+ raise ApiError.new(ret, detail)
384
+ end
360
385
 
361
386
  data
362
387
  rescue Net::ReadTimeout, Net::OpenTimeout
@@ -53,7 +53,6 @@ module Clacky
53
53
  Clacky::Logger.info("[ChannelManager] Starting channels: #{enabled_platforms.join(", ")}")
54
54
  @running = true
55
55
  enabled_platforms.each { |platform| start_adapter(platform) }
56
- puts " 📱 Channels started: #{enabled_platforms.join(", ")}"
57
56
  end
58
57
 
59
58
  # Stop all adapters gracefully.
@@ -96,7 +95,6 @@ module Clacky
96
95
  end
97
96
  end
98
97
 
99
- private
100
98
 
101
99
  def start_adapter(platform)
102
100
  klass = Adapters.find(platform)
@@ -186,11 +184,23 @@ module Clacky
186
184
  # source: :channel prevents the message from being echoed back to the IM channel.
187
185
  web_ui&.show_user_message(text, source: :channel) unless text.nil? || text.empty?
188
186
 
187
+ # Start typing keepalive BEFORE sending any message.
188
+ # sendmessage cancels the typing indicator in WeChat protocol,
189
+ # so keepalive must be running when "Thinking..." is sent so it
190
+ # immediately re-asserts the typing state after that message.
191
+ chat_id = event[:chat_id]
192
+ context_token = event[:context_token]
193
+ adapter.start_typing_keepalive(chat_id, context_token) if adapter.respond_to?(:start_typing_keepalive)
194
+
189
195
  # Acknowledge to the IM channel only — WebUI doesn't need a "Thinking..." noise.
190
- adapter.send_text(event[:chat_id], "Thinking...")
196
+ adapter.send_text(chat_id, "Thinking...")
191
197
 
192
198
  @run_agent_task.call(session_id, agent) do
193
- agent.run(text, files: files)
199
+ begin
200
+ agent.run(text, files: files)
201
+ ensure
202
+ adapter.stop_typing_keepalive(chat_id) if adapter.respond_to?(:stop_typing_keepalive)
203
+ end
194
204
  end
195
205
  end
196
206
 
@@ -350,7 +360,7 @@ module Clacky
350
360
  def safe_stop_adapter(adapter)
351
361
  adapter.stop
352
362
  rescue StandardError => e
353
- warn "[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}"
363
+ Clacky::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
354
364
  end
355
365
  end
356
366
  end
@@ -166,7 +166,6 @@ module Clacky
166
166
  def set_input_tips(message, type: :info); end
167
167
  def stop; end
168
168
 
169
- private
170
169
 
171
170
  def send_text(text)
172
171
  text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip