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 +4 -4
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/CHANGELOG.md +18 -0
- data/lib/clacky/agent/message_compressor_helper.rb +8 -0
- data/lib/clacky/default_parsers/docx_parser.rb +43 -27
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +112 -13
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +28 -2
- data/lib/clacky/server/channel/channel_manager.rb +15 -4
- data/lib/clacky/server/http_server.rb +129 -28
- data/lib/clacky/tools/web_search.rb +206 -63
- data/lib/clacky/ui2/layout_manager.rb +26 -11
- data/lib/clacky/ui2/ui_controller.rb +4 -2
- data/lib/clacky/utils/file_processor.rb +6 -4
- data/lib/clacky/utils/parser_manager.rb +6 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +10 -0
- data/scripts/install.ps1 +395 -136
- data/scripts/install_simple.sh +37 -19
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b959a76ace0015ec6279b1572afa3e961d862d9c856c9fdecc98f72d9e190e59
|
|
4
|
+
data.tar.gz: 53cc27fce3b47e83cb428955eb6591d53acc148cdab55721c7c32330b351fed0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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(
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 ≤
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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!(/\[([^\]]+)\]\([^)]*\)/, '
|
|
376
|
-
r.gsub!(/\*\*([^*]+)\*\*/, '
|
|
377
|
-
r.gsub!(/\*([^*]+)\*/, '
|
|
378
|
-
r.gsub!(/__([^_]+)__/, '
|
|
379
|
-
r.gsub!(/_([^_]+)_/, '
|
|
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
|
-
|
|
376
|
+
raw_body = res.body
|
|
377
|
+
data = JSON.parse(raw_body)
|
|
357
378
|
ret = data["ret"] || data["errcode"]
|
|
358
|
-
|
|
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(
|
|
196
|
+
adapter.send_text(chat_id, "Thinking...")
|
|
190
197
|
|
|
191
198
|
@run_agent_task.call(session_id, agent) do
|
|
192
|
-
|
|
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
|
|
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
|
-
#
|
|
766
|
-
#
|
|
767
|
-
#
|
|
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
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
|