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.
- checksums.yaml +4 -4
- data/.clacky/skills/oss-upload/SKILL.md +47 -0
- data/CHANGELOG.md +27 -0
- data/lib/clacky/agent/cost_tracker.rb +0 -1
- data/lib/clacky/agent/hook_manager.rb +0 -1
- data/lib/clacky/agent/message_compressor.rb +0 -1
- data/lib/clacky/agent/message_compressor_helper.rb +8 -1
- data/lib/clacky/agent/session_serializer.rb +0 -1
- data/lib/clacky/agent/skill_manager.rb +0 -1
- data/lib/clacky/brand_config.rb +0 -1
- data/lib/clacky/client.rb +0 -1
- data/lib/clacky/default_parsers/docx_parser.rb +43 -27
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +0 -2
- data/lib/clacky/plain_ui_controller.rb +0 -1
- data/lib/clacky/server/browser_manager.rb +0 -1
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +0 -1
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +0 -1
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +0 -1
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +0 -1
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +0 -1
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +0 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +112 -14
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +28 -3
- data/lib/clacky/server/channel/channel_manager.rb +15 -5
- data/lib/clacky/server/channel/channel_ui_controller.rb +0 -1
- data/lib/clacky/server/http_server.rb +129 -29
- data/lib/clacky/server/server_master.rb +0 -1
- data/lib/clacky/server/session_registry.rb +0 -1
- data/lib/clacky/server/web_ui_controller.rb +0 -1
- data/lib/clacky/session_manager.rb +0 -1
- data/lib/clacky/skill.rb +0 -1
- data/lib/clacky/skill_loader.rb +4 -1
- data/lib/clacky/tools/browser.rb +0 -1
- data/lib/clacky/tools/file_reader.rb +0 -1
- data/lib/clacky/tools/grep.rb +0 -1
- data/lib/clacky/tools/run_project.rb +0 -1
- data/lib/clacky/tools/todo_manager.rb +0 -1
- data/lib/clacky/tools/web_search.rb +206 -63
- data/lib/clacky/ui2/components/command_suggestions.rb +0 -1
- data/lib/clacky/ui2/components/inline_input.rb +0 -1
- data/lib/clacky/ui2/components/input_area.rb +0 -1
- data/lib/clacky/ui2/components/message_component.rb +0 -1
- data/lib/clacky/ui2/components/modal_component.rb +0 -1
- data/lib/clacky/ui2/components/todo_area.rb +0 -1
- data/lib/clacky/ui2/components/tool_component.rb +0 -1
- data/lib/clacky/ui2/components/welcome_banner.rb +0 -1
- data/lib/clacky/ui2/layout_manager.rb +26 -11
- data/lib/clacky/ui2/markdown_renderer.rb +0 -1
- data/lib/clacky/ui2/progress_indicator.rb +0 -1
- data/lib/clacky/ui2/screen_buffer.rb +0 -1
- data/lib/clacky/ui2/theme_manager.rb +0 -1
- data/lib/clacky/ui2/themes/base_theme.rb +0 -1
- data/lib/clacky/ui2/ui_controller.rb +4 -3
- data/lib/clacky/utils/arguments_parser.rb +0 -1
- data/lib/clacky/utils/file_processor.rb +6 -4
- data/lib/clacky/utils/gitignore_parser.rb +0 -1
- data/lib/clacky/utils/limit_stack.rb +0 -1
- data/lib/clacky/utils/model_pricing.rb +0 -1
- 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.sh +215 -494
- data/scripts/install_full.sh +891 -0
- data/scripts/install_simple.sh +37 -19
- metadata +3 -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,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
|
|
@@ -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
|
data/lib/clacky/brand_config.rb
CHANGED
data/lib/clacky/client.rb
CHANGED
|
@@ -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
|
|
@@ -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.
|
|
@@ -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)
|
|
@@ -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 ≤
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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!(/\[([^\]]+)\]\([^)]*\)/, '
|
|
377
|
-
r.gsub!(/\*\*([^*]+)\*\*/, '
|
|
378
|
-
r.gsub!(/\*([^*]+)\*/, '
|
|
379
|
-
r.gsub!(/__([^_]+)__/, '
|
|
380
|
-
r.gsub!(/_([^_]+)_/, '
|
|
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
|
-
|
|
376
|
+
raw_body = res.body
|
|
377
|
+
data = JSON.parse(raw_body)
|
|
358
378
|
ret = data["ret"] || data["errcode"]
|
|
359
|
-
|
|
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(
|
|
196
|
+
adapter.send_text(chat_id, "Thinking...")
|
|
191
197
|
|
|
192
198
|
@run_agent_task.call(session_id, agent) do
|
|
193
|
-
|
|
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
|
|
363
|
+
Clacky::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
|
|
354
364
|
end
|
|
355
365
|
end
|
|
356
366
|
end
|