openclacky 0.9.18 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 538f6014a8386fcddf69dbf69efd162a00ad594d776b812eba5ac04f98953995
4
- data.tar.gz: 4d9952dbbf2e20d1a598b136a0f01e821a1e53391240c197afe60e3570d4bfce
3
+ metadata.gz: b959a76ace0015ec6279b1572afa3e961d862d9c856c9fdecc98f72d9e190e59
4
+ data.tar.gz: 53cc27fce3b47e83cb428955eb6591d53acc148cdab55721c7c32330b351fed0
5
5
  SHA512:
6
- metadata.gz: 8ed52fb94f805bc83c8ab996c9a1f01635665b054750900623002edc29a0197c9ab99be08d192e2f1f1040c8665451e155d9878fec5a71c213d7c3130fbbb956
7
- data.tar.gz: 248569c522a59c37c3bda8342141be750609ca4fca996a8d563fcb953e6dcb1446fbc379082691b5503679d26d9168aa8e2503f7b169b3b4daeafa9428b7cf5b
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,24 @@ 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
+
10
28
  ## [0.9.18] - 2026-03-28
11
29
 
12
30
  ### Fixed
@@ -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
 
@@ -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
@@ -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
 
@@ -354,14 +360,20 @@ module Clacky
354
360
  @ctx_mutex.synchronize { @context_tokens[user_id] }
355
361
  end
356
362
 
357
- # Split text into ≤4000-char chunks, preferring newline boundaries.
358
- def split_message(text, limit: 4000)
359
- 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
360
367
  chunks = []
361
- while text.length > limit
362
- cut = text.rindex("\n", limit) || limit
363
- chunks << text[0, cut].rstrip
364
- 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
365
377
  end
366
378
  chunks << text unless text.empty?
367
379
  chunks
@@ -372,15 +384,102 @@ module Clacky
372
384
  r = text.dup
373
385
  r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
374
386
  r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
375
- r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\1')
376
- r.gsub!(/\*\*([^*]+)\*\*/, '\1')
377
- r.gsub!(/\*([^*]+)\*/, '\1')
378
- r.gsub!(/__([^_]+)__/, '\1')
379
- r.gsub!(/_([^_]+)_/, '\1')
387
+ r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '')
388
+ r.gsub!(/\*\*([^*]+)\*\*/, '')
389
+ r.gsub!(/\*([^*]+)\*/, '')
390
+ r.gsub!(/__([^_]+)__/, '')
391
+ r.gsub!(/_([^_]+)_/, '')
380
392
  r.gsub!(/^#+\s+/, "")
381
393
  r.gsub!(/^[-*_]{3,}\s*$/, "")
382
394
  r.strip
383
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
384
483
  end
385
484
 
386
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:)
@@ -353,9 +373,15 @@ module Clacky
353
373
  res = http.request(req)
354
374
  raise ApiError.new(res.code.to_i, res.body), "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
355
375
 
356
- data = JSON.parse(res.body)
376
+ raw_body = res.body
377
+ data = JSON.parse(raw_body)
357
378
  ret = data["ret"] || data["errcode"]
358
- 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
359
385
 
360
386
  data
361
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.
@@ -185,11 +184,23 @@ module Clacky
185
184
  # source: :channel prevents the message from being echoed back to the IM channel.
186
185
  web_ui&.show_user_message(text, source: :channel) unless text.nil? || text.empty?
187
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
+
188
195
  # Acknowledge to the IM channel only — WebUI doesn't need a "Thinking..." noise.
189
- adapter.send_text(event[:chat_id], "Thinking...")
196
+ adapter.send_text(chat_id, "Thinking...")
190
197
 
191
198
  @run_agent_task.call(session_id, agent) do
192
- 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
193
204
  end
194
205
  end
195
206
 
@@ -349,7 +360,7 @@ module Clacky
349
360
  def safe_stop_adapter(adapter)
350
361
  adapter.stop
351
362
  rescue StandardError => e
352
- warn "[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}"
363
+ Clacky::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
353
364
  end
354
365
  end
355
366
  end
@@ -762,40 +762,18 @@ module Clacky
762
762
  end
763
763
 
764
764
  # POST /api/version/upgrade
765
- # Runs `gem update openclacky --no-document` via Clacky::Tools::Shell (login shell)
766
- # in a background thread, streaming output via WebSocket broadcast.
767
- # On success, re-execs the process so the new gem version is loaded.
765
+ # Upgrades openclacky in a background thread, streaming output via WebSocket broadcast.
766
+ # If the user's gem source is the official RubyGems, use `gem update`.
767
+ # Otherwise (e.g. Aliyun mirror) download the .gem from OSS CDN to bypass mirror lag.
768
768
  def api_upgrade_version(req, res)
769
769
  json_response(res, 202, { ok: true, message: "Upgrade started" })
770
770
 
771
771
  Thread.new do
772
772
  begin
773
- Clacky::Logger.info("[Upgrade] Starting: gem update openclacky --no-document")
774
- broadcast_all(type: "upgrade_log", line: "Starting upgrade: gem update openclacky --no-document\n")
775
-
776
- shell = Clacky::Tools::Shell.new
777
- Clacky::Logger.info("[Upgrade] Calling shell.execute...")
778
- result = shell.execute(command: "gem update openclacky --no-document",
779
- soft_timeout: 30, hard_timeout: 300)
780
- Clacky::Logger.info("[Upgrade] shell.execute returned: exit_code=#{result[:exit_code]}")
781
- Clacky::Logger.info("[Upgrade] stdout=#{result[:stdout].to_s.slice(0, 500)}")
782
- Clacky::Logger.info("[Upgrade] stderr=#{result[:stderr].to_s.slice(0, 500)}")
783
-
784
- output = [result[:stdout], result[:stderr]].join
785
- success = result[:exit_code] == 0
786
-
787
- clients_count = @ws_mutex.synchronize { @all_ws_conns.size }
788
- Clacky::Logger.info("[Upgrade] Broadcasting output to #{clients_count} WS client(s)")
789
- broadcast_all(type: "upgrade_log", line: output)
790
-
791
- if success
792
- Clacky::Logger.info("[Upgrade] Success!")
793
- broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
794
- broadcast_all(type: "upgrade_complete", success: true)
773
+ if official_gem_source?
774
+ upgrade_via_gem_update
795
775
  else
796
- Clacky::Logger.warn("[Upgrade] Failed. exit_code=#{result[:exit_code]}")
797
- broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: gem update openclacky\n")
798
- broadcast_all(type: "upgrade_complete", success: false)
776
+ upgrade_via_oss_cdn
799
777
  end
800
778
  rescue StandardError => e
801
779
  Clacky::Logger.error("[Upgrade] Exception: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
@@ -805,6 +783,128 @@ module Clacky
805
783
  end
806
784
  end
807
785
 
786
+ # Returns true when the configured gem source is the official RubyGems.org.
787
+ # Raises on error — caller's rescue will handle it.
788
+ private def official_gem_source?
789
+ shell = Clacky::Tools::Shell.new
790
+ result = shell.execute(command: "gem sources -l", soft_timeout: 10, hard_timeout: 15)
791
+ raise "gem sources -l failed (exit #{result[:exit_code]}): #{result[:stderr]}" unless result[:exit_code] == 0
792
+
793
+ sources = result[:stdout].to_s
794
+ Clacky::Logger.info("[Upgrade] gem sources: #{sources.strip}")
795
+ sources.include?("https://rubygems.org") &&
796
+ !sources.match?(%r{mirrors\.|aliyun|tuna|ustc|ruby-china})
797
+ end
798
+
799
+ # Upgrade via `gem update openclacky --no-document` (official RubyGems source).
800
+ private def upgrade_via_gem_update
801
+ cmd = "gem update openclacky --no-document"
802
+ Clacky::Logger.info("[Upgrade] Official source — running: #{cmd}")
803
+ broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
804
+
805
+ shell = Clacky::Tools::Shell.new
806
+ result = shell.execute(command: cmd, soft_timeout: 30, hard_timeout: 300)
807
+
808
+ Clacky::Logger.info("[Upgrade] exit_code=#{result[:exit_code]}")
809
+ Clacky::Logger.info("[Upgrade] stdout=#{result[:stdout].to_s.slice(0, 500)}")
810
+ Clacky::Logger.info("[Upgrade] stderr=#{result[:stderr].to_s.slice(0, 500)}")
811
+
812
+ output = [result[:stdout], result[:stderr]].join
813
+ success = result[:exit_code] == 0
814
+
815
+ broadcast_all(type: "upgrade_log", line: output)
816
+ finish_upgrade(success, fallback_hint: "gem update openclacky")
817
+ end
818
+
819
+ # Upgrade via OSS CDN: fetch latest.txt → download .gem → gem install (bypasses mirror lag).
820
+ private def upgrade_via_oss_cdn
821
+ require "net/http"
822
+ require "uri"
823
+
824
+ oss_base = "https://oss.1024code.com/openclacky"
825
+ latest_url = "#{oss_base}/latest.txt"
826
+
827
+ Clacky::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
828
+ broadcast_all(type: "upgrade_log", line: "Non-official gem source detected — fetching latest version from OSS CDN...\n")
829
+
830
+ # Step 1: fetch latest version from OSS
831
+ latest_version = fetch_oss_latest_version(latest_url)
832
+ unless latest_version
833
+ broadcast_all(type: "upgrade_log", line: "✗ Failed to fetch latest version from OSS CDN\n")
834
+ broadcast_all(type: "upgrade_complete", success: false)
835
+ return
836
+ end
837
+
838
+ broadcast_all(type: "upgrade_log", line: "Latest version: #{latest_version}\n")
839
+
840
+ # Already up to date?
841
+ unless version_older?(Clacky::VERSION, latest_version)
842
+ broadcast_all(type: "upgrade_log", line: "✓ Already at latest version (#{Clacky::VERSION})\n")
843
+ broadcast_all(type: "upgrade_complete", success: true)
844
+ return
845
+ end
846
+
847
+ # Step 2: download .gem file from OSS
848
+ gem_url = "#{oss_base}/openclacky-#{latest_version}.gem"
849
+ gem_file = "/tmp/openclacky-#{latest_version}.gem"
850
+ broadcast_all(type: "upgrade_log", line: "Downloading openclacky-#{latest_version}.gem from OSS...\n")
851
+ Clacky::Logger.info("[Upgrade] Downloading #{gem_url}")
852
+
853
+ shell = Clacky::Tools::Shell.new
854
+ dl = shell.execute(command: "curl -fsSL '#{gem_url}' -o '#{gem_file}'",
855
+ soft_timeout: 60, hard_timeout: 120)
856
+ unless dl[:exit_code] == 0
857
+ broadcast_all(type: "upgrade_log", line: "✗ Download failed: #{dl[:stderr]}\n")
858
+ broadcast_all(type: "upgrade_complete", success: false)
859
+ return
860
+ end
861
+
862
+ # Step 3: install the downloaded .gem (dependencies resolved via configured gem source)
863
+ cmd = "gem install '#{gem_file}' --no-document"
864
+ broadcast_all(type: "upgrade_log", line: "Installing...\n")
865
+ Clacky::Logger.info("[Upgrade] Running: #{cmd}")
866
+
867
+ result = shell.execute(command: cmd, soft_timeout: 30, hard_timeout: 300)
868
+ output = [result[:stdout], result[:stderr]].join
869
+ success = result[:exit_code] == 0
870
+
871
+ broadcast_all(type: "upgrade_log", line: output)
872
+ finish_upgrade(success, fallback_hint: "gem install #{gem_url}")
873
+ ensure
874
+ File.delete(gem_file) if gem_file && File.exist?(gem_file) rescue nil
875
+ end
876
+
877
+ # Fetch the latest version string from OSS latest.txt.
878
+ private def fetch_oss_latest_version(url)
879
+ require "net/http"
880
+ uri = URI(url)
881
+ http = Net::HTTP.new(uri.host, uri.port)
882
+ http.use_ssl = uri.scheme == "https"
883
+ http.open_timeout = 10
884
+ http.read_timeout = 10
885
+ res = http.get(uri.request_uri)
886
+ return nil unless res.is_a?(Net::HTTPSuccess)
887
+
888
+ version = res.body.to_s.strip
889
+ version.empty? ? nil : version
890
+ rescue StandardError => e
891
+ Clacky::Logger.warn("[Upgrade] fetch_oss_latest_version error: #{e.message}")
892
+ nil
893
+ end
894
+
895
+ # Broadcast final upgrade result with appropriate log message.
896
+ private def finish_upgrade(success, fallback_hint: "gem update openclacky")
897
+ if success
898
+ Clacky::Logger.info("[Upgrade] Success!")
899
+ broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
900
+ broadcast_all(type: "upgrade_complete", success: true)
901
+ else
902
+ Clacky::Logger.warn("[Upgrade] Failed.")
903
+ broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: #{fallback_hint}\n")
904
+ broadcast_all(type: "upgrade_complete", success: false)
905
+ end
906
+ end
907
+
808
908
  # POST /api/restart
809
909
  # Re-execs the current process so the newly installed gem version is loaded.
810
910
  # Uses the absolute script path captured at startup to avoid relative-path issues.
@@ -1738,6 +1838,7 @@ module Clacky
1738
1838
  rescue JSON::ParserError => e
1739
1839
  conn.send_json(type: "error", message: "Invalid JSON: #{e.message}")
1740
1840
  rescue => e
1841
+ Clacky::Logger.error("[on_ws_message] #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
1741
1842
  conn.send_json(type: "error", message: e.message)
1742
1843
  end
1743
1844