openclacky 1.2.10 → 1.2.12
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/CHANGELOG.md +33 -1
- data/lib/clacky/agent/session_serializer.rb +1 -1
- data/lib/clacky/agent/tool_registry.rb +10 -0
- data/lib/clacky/agent.rb +59 -22
- data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
- data/lib/clacky/client.rb +25 -3
- data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
- data/lib/clacky/message_history.rb +57 -0
- data/lib/clacky/openai_stream_aggregator.rb +26 -2
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
- data/lib/clacky/server/channel/channel_manager.rb +65 -4
- data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
- data/lib/clacky/server/http_server.rb +73 -7
- data/lib/clacky/server/session_registry.rb +4 -6
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +21 -3
- data/lib/clacky/web/apple-touch-icon-180.png +0 -0
- data/lib/clacky/web/brand.js +22 -2
- data/lib/clacky/web/favicon.ico +0 -0
- data/lib/clacky/web/i18n.js +4 -0
- data/lib/clacky/web/index.html +4 -3
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/model-tester.js +8 -1
- data/lib/clacky/web/sessions.js +169 -41
- data/lib/clacky/web/theme.js +1 -0
- data/scripts/build/lib/gem.sh +9 -2
- data/scripts/build/src/install_full.sh.cc +2 -0
- data/scripts/build/src/uninstall.sh.cc +1 -1
- data/scripts/install.ps1 +19 -5
- data/scripts/install.sh +9 -2
- data/scripts/install_full.sh +11 -2
- data/scripts/install_rails_deps.sh +9 -2
- data/scripts/uninstall.sh +10 -3
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 451817565cffdf7b1efcdf5e741cea76af0451a8d9900804e2aa3c6a5384ba4a
|
|
4
|
+
data.tar.gz: 0232ede01332162004abc1638a8a03b41095c44c68198ce6327e5f5fc815f49a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a88a963b238a35fc25d5791b752981179290a88b27d176285647668711b91360a1d4c656677536fda84d18d0fa05fe67ad8364bdf0f7dbbba0a31a007156cbd
|
|
7
|
+
data.tar.gz: 124b77cbeec34494c8d35d58f1735459ba50da7c112a13a35c8038bbe89ee81412cf60107f4f926ad870efbbf65621b369f4bb5f1de67b477032b14dbad338d3
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.12] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Remove ruby_rich C extension dependency that caused installation failures
|
|
12
|
+
|
|
13
|
+
## [1.2.11] - 2026-06-05
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Logo branding in sidebar footer with link to official website
|
|
17
|
+
- Onboarding charge tips for new users to understand billing
|
|
18
|
+
- WebUI tool panel expand/collapse toggle for better space management
|
|
19
|
+
- Region parameter for CDN and install script switching (CN vs global)
|
|
20
|
+
- Automated Feishu app creation via OAuth device flow — no more manual App ID/Secret entry
|
|
21
|
+
- Feishu group chat history with sender name identification
|
|
22
|
+
- Cron job entry sorting by enabled status and running state indicator
|
|
23
|
+
- Quick switch model selector: show only active model name in card, rename sub-model
|
|
24
|
+
- API server now binds to non-local IP by default for LAN access
|
|
25
|
+
|
|
26
|
+
### Improved
|
|
27
|
+
- Sequential image generation now shows tips when generation is slow
|
|
28
|
+
- Startup time reduced significantly
|
|
29
|
+
- Clear GEM_HOME during Ruby 3 installation to avoid gem conflicts
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Uninstall crashes when brand.yml has no product_name configured
|
|
33
|
+
- Tool calls go stale after channel interrupt, causing silent failures
|
|
34
|
+
- Sanitize tool names to prevent invalid characters
|
|
35
|
+
- Completion summary now accumulates correctly across supplementary message relays
|
|
36
|
+
- WSL install exit code 2 (network unreachable) now propagates properly
|
|
37
|
+
- Recycled sessions now sorted by deletion time instead of creation time
|
|
38
|
+
- Idle status now updates correctly after server restart
|
|
39
|
+
|
|
8
40
|
## [1.2.10] - 2026-06-03
|
|
9
41
|
|
|
10
42
|
### Added
|
|
@@ -14,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
46
|
- WSL network connectivity pre-check before installation
|
|
15
47
|
- MiniMax M3 provider with vision support and pricing
|
|
16
48
|
- One-click exchange rate update in settings
|
|
17
|
-
- Rich TUI controller for terminal interaction
|
|
49
|
+
- Rich TUI controller (experimental, enable with `--ui rich`) for terminal interaction
|
|
18
50
|
|
|
19
51
|
### Improved
|
|
20
52
|
- WebUI working directory selector UX
|
|
@@ -266,7 +266,7 @@ module Clacky
|
|
|
266
266
|
|
|
267
267
|
page.each do |round|
|
|
268
268
|
msg = round[:user_msg]
|
|
269
|
-
raw_text = extract_text_from_content(msg[:content])
|
|
269
|
+
raw_text = msg[:display_text] || extract_text_from_content(msg[:content])
|
|
270
270
|
# Images: recovered from inline image_url blocks in content (carry data_url for <img> rendering)
|
|
271
271
|
image_files = extract_image_files_from_content(msg[:content])
|
|
272
272
|
# Disk files (PDF, doc, etc.): stored in display_files on the user message at send time
|
|
@@ -88,6 +88,9 @@ module Clacky
|
|
|
88
88
|
# 3. Alias lookup (e.g. "read_file" → "file_reader")
|
|
89
89
|
# Returns the canonical tool name, or nil if nothing matched.
|
|
90
90
|
def resolve(name)
|
|
91
|
+
return nil if name.nil?
|
|
92
|
+
|
|
93
|
+
name = sanitize_name(name)
|
|
91
94
|
return name if @tools.key?(name)
|
|
92
95
|
|
|
93
96
|
downcased = name.downcase
|
|
@@ -139,5 +142,12 @@ module Clacky
|
|
|
139
142
|
def by_category(category)
|
|
140
143
|
@tools.values.select { |tool| tool.category == category }
|
|
141
144
|
end
|
|
145
|
+
|
|
146
|
+
private def sanitize_name(name)
|
|
147
|
+
cleaned = name.to_s
|
|
148
|
+
cleaned = cleaned.split(/<\|/, 2).first.to_s
|
|
149
|
+
cleaned = cleaned.split(/[\s,;|]/, 2).first.to_s
|
|
150
|
+
cleaned.strip
|
|
151
|
+
end
|
|
142
152
|
end
|
|
143
153
|
end
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -104,6 +104,7 @@ module Clacky
|
|
|
104
104
|
@pending_injections = [] # Pending inline skill injections to flush after observe()
|
|
105
105
|
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs to shred when agent.run completes
|
|
106
106
|
@pending_error_rollback = false # Deferred rollback flag set by restore_session on error
|
|
107
|
+
@last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
|
|
107
108
|
|
|
108
109
|
# Compression tracking
|
|
109
110
|
@compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
|
|
@@ -251,7 +252,7 @@ module Clacky
|
|
|
251
252
|
@name = new_name.to_s.strip
|
|
252
253
|
end
|
|
253
254
|
|
|
254
|
-
def run(user_input, files: [])
|
|
255
|
+
def run(user_input, files: [], display_text: nil)
|
|
255
256
|
# Show the "thinking" indicator as early as possible so the user gets
|
|
256
257
|
# immediate feedback after sending a message. Without this the UI stays
|
|
257
258
|
# silent during synchronous setup work (system prompt assembly, file
|
|
@@ -263,24 +264,33 @@ module Clacky
|
|
|
263
264
|
# Start new task for Time Machine
|
|
264
265
|
task_id = start_new_task
|
|
265
266
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
267
|
+
# Continuation of a previously-interrupted task (e.g. user sent a
|
|
268
|
+
# supplementary message without stopping the running task) keeps the
|
|
269
|
+
# existing task-start snapshot so the completion summary accumulates
|
|
270
|
+
# iterations/cost/duration across the relay, instead of resetting and
|
|
271
|
+
# only counting the post-interrupt portion.
|
|
272
|
+
if @last_run_interrupted
|
|
273
|
+
@last_run_interrupted = false
|
|
274
|
+
else
|
|
275
|
+
@start_time = Time.now
|
|
276
|
+
@task_truncation_count = 0 # Reset truncation counter for each task
|
|
277
|
+
@task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
|
|
278
|
+
@task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
|
|
279
|
+
@task_cost_source = :estimated # Reset for new task
|
|
280
|
+
# Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
|
|
281
|
+
# across tasks to correctly calculate delta tokens in each iteration
|
|
282
|
+
@task_start_iterations = @iterations # Track starting iterations for this task
|
|
283
|
+
@task_start_cost = @total_cost # Track starting cost for this task
|
|
284
|
+
# Track cache stats for current task
|
|
285
|
+
@task_cache_stats = {
|
|
286
|
+
cache_creation_input_tokens: 0,
|
|
287
|
+
cache_read_input_tokens: 0,
|
|
288
|
+
prompt_tokens: 0,
|
|
289
|
+
completion_tokens: 0,
|
|
290
|
+
total_requests: 0,
|
|
291
|
+
cache_hit_requests: 0
|
|
292
|
+
}
|
|
293
|
+
end
|
|
284
294
|
|
|
285
295
|
# Deferred error rollback: if the previous session ended with an error,
|
|
286
296
|
# trim history back to just before that failed user message now — at the
|
|
@@ -348,6 +358,7 @@ module Clacky
|
|
|
348
358
|
end
|
|
349
359
|
|
|
350
360
|
@history.append({ role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f,
|
|
361
|
+
display_text: display_text,
|
|
351
362
|
display_files: display_files.empty? ? nil : display_files })
|
|
352
363
|
@total_tasks += 1
|
|
353
364
|
|
|
@@ -477,14 +488,34 @@ module Clacky
|
|
|
477
488
|
# truncation pattern, we still won't silently exit while the model
|
|
478
489
|
# is mid-tool_call.
|
|
479
490
|
if response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
480
|
-
|
|
491
|
+
content_str = response[:content].to_s
|
|
492
|
+
stripped = content_str.strip
|
|
493
|
+
ends_with_question = stripped.end_with?("?", "?")
|
|
494
|
+
finish_reason_str = response[:finish_reason].to_s
|
|
495
|
+
completion_tokens = response.dig(:token_usage, :completion_tokens)
|
|
496
|
+
|
|
481
497
|
Clacky::Logger.info("agent.loop_break_normal",
|
|
482
498
|
session_id: @session_id,
|
|
483
499
|
iteration: @iterations,
|
|
484
500
|
branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
|
|
485
|
-
finish_reason:
|
|
486
|
-
tool_calls_count: (response[:tool_calls] || []).size
|
|
501
|
+
finish_reason: finish_reason_str,
|
|
502
|
+
tool_calls_count: (response[:tool_calls] || []).size,
|
|
503
|
+
completion_tokens: completion_tokens,
|
|
504
|
+
max_tokens: @config.max_tokens,
|
|
505
|
+
content_len: content_str.length,
|
|
506
|
+
content_ends_with_question: ends_with_question
|
|
487
507
|
)
|
|
508
|
+
|
|
509
|
+
if finish_reason_str == "length"
|
|
510
|
+
Clacky::Logger.warn("agent.loop_break_on_length",
|
|
511
|
+
session_id: @session_id,
|
|
512
|
+
iteration: @iterations,
|
|
513
|
+
completion_tokens: completion_tokens,
|
|
514
|
+
max_tokens: @config.max_tokens,
|
|
515
|
+
content_len: content_str.length,
|
|
516
|
+
content_tail: content_str[-200, 200]
|
|
517
|
+
)
|
|
518
|
+
end
|
|
488
519
|
if response[:content] && !response[:content].empty?
|
|
489
520
|
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
|
|
490
521
|
end
|
|
@@ -595,6 +626,11 @@ module Clacky
|
|
|
595
626
|
@hooks.trigger(:on_complete, result)
|
|
596
627
|
result
|
|
597
628
|
rescue Clacky::AgentInterrupted
|
|
629
|
+
# Mark this run as interrupted so the next run() (e.g. user's
|
|
630
|
+
# supplementary message during a running task) keeps the existing
|
|
631
|
+
# task-start snapshot — the completion summary should reflect the
|
|
632
|
+
# entire task across the relay, not just the post-interrupt portion.
|
|
633
|
+
@last_run_interrupted = true
|
|
598
634
|
# Let CLI handle the interrupt message
|
|
599
635
|
raise
|
|
600
636
|
rescue StandardError => e
|
|
@@ -808,6 +844,7 @@ module Clacky
|
|
|
808
844
|
# reasoning_content so every outgoing payload satisfies the provider's
|
|
809
845
|
# "reasoning_content must be passed back" contract.
|
|
810
846
|
msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
|
|
847
|
+
check_stale!
|
|
811
848
|
@history.append(msg)
|
|
812
849
|
|
|
813
850
|
# Close the thinking spinner before returning. The caller (run loop)
|
|
@@ -19,9 +19,16 @@ module Clacky
|
|
|
19
19
|
@usage = {}
|
|
20
20
|
@last_input_tokens = 0
|
|
21
21
|
@last_output_tokens = 0
|
|
22
|
+
@parse_failures = 0
|
|
23
|
+
@frames_seen = 0
|
|
24
|
+
@bytes_seen = 0
|
|
22
25
|
end
|
|
23
26
|
|
|
27
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
28
|
+
|
|
24
29
|
def handle(event, data_str)
|
|
30
|
+
@bytes_seen += data_str.to_s.bytesize
|
|
31
|
+
@frames_seen += 1
|
|
25
32
|
data = parse_or_nil(data_str)
|
|
26
33
|
return unless data
|
|
27
34
|
|
|
@@ -99,7 +106,16 @@ module Clacky
|
|
|
99
106
|
|
|
100
107
|
private def parse_or_nil(s)
|
|
101
108
|
JSON.parse(s)
|
|
102
|
-
rescue JSON::ParserError
|
|
109
|
+
rescue JSON::ParserError => e
|
|
110
|
+
@parse_failures += 1
|
|
111
|
+
if @parse_failures == 1
|
|
112
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
113
|
+
provider: "anthropic",
|
|
114
|
+
error: "#{e.class}: #{e.message}",
|
|
115
|
+
frame_head: s.to_s[0, 200],
|
|
116
|
+
frame_bytes: s.to_s.bytesize
|
|
117
|
+
)
|
|
118
|
+
end
|
|
103
119
|
nil
|
|
104
120
|
end
|
|
105
121
|
|
|
@@ -29,9 +29,16 @@ module Clacky
|
|
|
29
29
|
@usage = {}
|
|
30
30
|
@last_input_tokens = 0
|
|
31
31
|
@last_output_tokens = 0
|
|
32
|
+
@parse_failures = 0
|
|
33
|
+
@frames_seen = 0
|
|
34
|
+
@bytes_seen = 0
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
38
|
+
|
|
34
39
|
def handle(event, data_str)
|
|
40
|
+
@bytes_seen += data_str.to_s.bytesize
|
|
41
|
+
@frames_seen += 1
|
|
35
42
|
data = parse_or_nil(data_str)
|
|
36
43
|
return unless data
|
|
37
44
|
|
|
@@ -101,7 +108,16 @@ module Clacky
|
|
|
101
108
|
|
|
102
109
|
private def parse_or_nil(s)
|
|
103
110
|
JSON.parse(s)
|
|
104
|
-
rescue JSON::ParserError
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
@parse_failures += 1
|
|
113
|
+
if @parse_failures == 1
|
|
114
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
115
|
+
provider: "bedrock",
|
|
116
|
+
error: "#{e.class}: #{e.message}",
|
|
117
|
+
frame_head: s.to_s[0, 200],
|
|
118
|
+
frame_bytes: s.to_s.bytesize
|
|
119
|
+
)
|
|
120
|
+
end
|
|
105
121
|
nil
|
|
106
122
|
end
|
|
107
123
|
|
data/lib/clacky/client.rb
CHANGED
|
@@ -260,6 +260,7 @@ module Clacky
|
|
|
260
260
|
end
|
|
261
261
|
|
|
262
262
|
result = aggregator.to_h
|
|
263
|
+
log_stream_summary("bedrock", aggregator, result["stopReason"])
|
|
263
264
|
# A complete Converse stream always emits stopReason in its messageStop
|
|
264
265
|
# frame. Its absence means the upstream cut the stream mid-response,
|
|
265
266
|
# leaving a half-written message; retry rather than accept the truncation.
|
|
@@ -318,6 +319,7 @@ module Clacky
|
|
|
318
319
|
end
|
|
319
320
|
|
|
320
321
|
result = aggregator.to_h
|
|
322
|
+
log_stream_summary("anthropic", aggregator, result["stop_reason"])
|
|
321
323
|
# A complete Messages stream always emits stop_reason in its message_delta
|
|
322
324
|
# frame. Its absence means the upstream cut the stream mid-response,
|
|
323
325
|
# leaving a half-written message; retry rather than accept the truncation.
|
|
@@ -380,6 +382,7 @@ module Clacky
|
|
|
380
382
|
end
|
|
381
383
|
|
|
382
384
|
result = aggregator.to_h
|
|
385
|
+
log_stream_summary("openai", aggregator, result.dig("choices", 0, "finish_reason"))
|
|
383
386
|
# A complete chat-completion stream always terminates with a frame
|
|
384
387
|
# carrying finish_reason. Its absence means the upstream cut the stream
|
|
385
388
|
# mid-response (e.g. proxy idle-timeout, connection reset that Faraday
|
|
@@ -462,6 +465,24 @@ module Clacky
|
|
|
462
465
|
"/model/#{model}/converse-stream"
|
|
463
466
|
end
|
|
464
467
|
|
|
468
|
+
# Emit a one-line summary of a streaming response when something looks
|
|
469
|
+
# off (parse failures, missing terminal frame). No-op on the happy path
|
|
470
|
+
# to keep logs quiet.
|
|
471
|
+
private def log_stream_summary(provider, aggregator, terminal_marker)
|
|
472
|
+
parse_failures = aggregator.respond_to?(:parse_failures) ? aggregator.parse_failures.to_i : 0
|
|
473
|
+
missing_terminal = terminal_marker.nil?
|
|
474
|
+
return if parse_failures.zero? && !missing_terminal
|
|
475
|
+
|
|
476
|
+
Clacky::Logger.warn("stream.summary",
|
|
477
|
+
provider: provider,
|
|
478
|
+
frames_seen: aggregator.respond_to?(:frames_seen) ? aggregator.frames_seen : nil,
|
|
479
|
+
bytes_seen: aggregator.respond_to?(:bytes_seen) ? aggregator.bytes_seen : nil,
|
|
480
|
+
parse_failures: parse_failures,
|
|
481
|
+
saw_done: aggregator.respond_to?(:saw_done?) ? aggregator.saw_done? : nil,
|
|
482
|
+
terminal_marker_present: !missing_terminal
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
465
486
|
# Pull complete SSE frames out of a buffer and yield them as (event, data).
|
|
466
487
|
# An SSE frame ends at a blank line ("\n\n"); incomplete trailing data
|
|
467
488
|
# stays in the buffer for the next chunk. Frames without an explicit
|
|
@@ -565,9 +586,10 @@ module Clacky
|
|
|
565
586
|
|
|
566
587
|
error_body = JSON.parse(response.body) rescue nil
|
|
567
588
|
{
|
|
568
|
-
success:
|
|
569
|
-
status:
|
|
570
|
-
error:
|
|
589
|
+
success: false,
|
|
590
|
+
status: response.status,
|
|
591
|
+
error: extract_error_message(error_body, response.body),
|
|
592
|
+
error_code: extract_error_code(error_body)
|
|
571
593
|
}
|
|
572
594
|
end
|
|
573
595
|
|
|
@@ -99,90 +99,95 @@ Ask:
|
|
|
99
99
|
|
|
100
100
|
### Feishu setup
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
no permission JSON, no event subscription, no version/release steps. Just create
|
|
105
|
-
the app and copy the credentials. The connection mode is unchanged (long
|
|
106
|
-
connection / WebSocket), handled entirely by the server.
|
|
102
|
+
Use the setup script to create the Feishu app automatically via OAuth 2.0 Device Authorization Grant.
|
|
103
|
+
The user only needs to scan a QR code once.
|
|
107
104
|
|
|
108
|
-
#### Step 1 —
|
|
105
|
+
#### Step 1 — Run setup script as a background session
|
|
109
106
|
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
```
|
|
108
|
+
terminal(command: "ruby SKILL_DIR/feishu_setup.rb", background: true)
|
|
109
|
+
```
|
|
112
110
|
|
|
113
|
-
|
|
111
|
+
Keep polling the session. The script will print:
|
|
112
|
+
- `SCAN_URL:<url>` — the QR code URL
|
|
113
|
+
- `EXPIRE_IN:<seconds>` — how long the URL is valid
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
Wait for "done".
|
|
115
|
+
Once you see these lines, tell the user immediately:
|
|
116
|
+
- zh: "请在飞书中打开以下链接(或扫码)完成授权,链接 <expire_in> 秒内有效:\n<url>"
|
|
117
|
+
- en: "Open this link in Feishu (or scan the QR code) to authorize. Valid for <expire_in>s:\n<url>"
|
|
119
118
|
|
|
120
|
-
|
|
119
|
+
Continue polling until the response contains an `exit_code`. When the session ends successfully, stdout will contain:
|
|
120
|
+
- `APP_ID:<app_id>`
|
|
121
|
+
- `APP_SECRET:<app_secret>`
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
The Secret is masked by default. Guide the user: "Click the eye icon next to **App Secret** to reveal it,
|
|
124
|
-
then copy both values and paste here. Reply with: App ID: xxx, App Secret: xxx"
|
|
125
|
-
Wait for the reply. Parse `app_id` (starts with `cli_`) and `app_secret`. Trim whitespace and
|
|
126
|
-
make sure the two values are not swapped.
|
|
123
|
+
Parse both values.
|
|
127
124
|
|
|
128
|
-
#### Step
|
|
125
|
+
#### Step 2 — Save credentials
|
|
129
126
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
127
|
+
```bash
|
|
128
|
+
curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
|
|
129
|
+
-H "Content-Type: application/json" \
|
|
130
|
+
-d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml`
|
|
134
|
+
or any file under `~/.clacky/channels/` directly. The server API handles persistence, hot-reload,
|
|
135
|
+
and establishing the long connection.**
|
|
136
|
+
|
|
137
|
+
On success: tell the user the following (zh), then **continue to Step 3 (Feishu CLI)**:
|
|
139
138
|
|
|
140
|
-
|
|
139
|
+
zh: "✅ 飞书通道已配置成功!现在你可以通过飞书与智能助手进行私聊和群聊,也支持阅读飞书文档。"
|
|
140
|
+
en: "✅ Feishu channel configured! You can now chat with the assistant via Feishu DMs or group chats, and read Feishu Docs."
|
|
141
141
|
|
|
142
142
|
---
|
|
143
143
|
|
|
144
|
-
#### Step
|
|
144
|
+
#### Step 3 — Optional: install Feishu CLI
|
|
145
145
|
|
|
146
|
-
Reach here after the channel is configured (Step
|
|
146
|
+
Reach here after the channel is configured (Step 2 succeeded). Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
|
|
147
147
|
|
|
148
148
|
Call `request_user_feedback`:
|
|
149
149
|
|
|
150
150
|
zh:
|
|
151
151
|
```json
|
|
152
152
|
{
|
|
153
|
-
\"question\": \"
|
|
154
|
-
"options": ["
|
|
153
|
+
\"question\": \"是否安装飞书 CLI?安装后将解锁更多飞书能力,例如创建、编辑、删除云文档。\",
|
|
154
|
+
"options": ["安装", "跳过"]
|
|
155
155
|
}
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
en:
|
|
159
159
|
```json
|
|
160
160
|
{
|
|
161
|
-
"question": "Install Feishu CLI?
|
|
162
|
-
"options": ["
|
|
161
|
+
"question": "Install Feishu CLI? It unlocks more Feishu capabilities, such as creating, editing, and deleting Docs.",
|
|
162
|
+
"options": ["Install", "Skip"]
|
|
163
163
|
}
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
If the user picks Skip, stop — setup is complete.
|
|
167
167
|
|
|
168
|
-
If the user picks Enable, run
|
|
168
|
+
If the user picks Enable, run the following **in order**:
|
|
169
169
|
|
|
170
|
+
**Step 3a** — Install and configure (single terminal call):
|
|
170
171
|
```bash
|
|
171
172
|
lark-cli --version > /dev/null 2>&1 || npm install -g @larksuite/cli
|
|
172
173
|
echo -n "<APP_SECRET>" | lark-cli config init --app-id <APP_ID> --app-secret-stdin --brand feishu
|
|
173
174
|
ruby "SKILL_DIR/install_feishu_skills.rb"
|
|
174
|
-
lark-cli auth login --recommend
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
**Step 3b** — Start authorization as a background session:
|
|
178
|
+
```
|
|
179
|
+
terminal(command: "lark-cli auth login --recommend", background: true)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
This returns a `session_id`. Keep polling with `terminal(session_id: <id>, input: "")` every few seconds.
|
|
178
183
|
|
|
179
|
-
Once you see the authorization URL in the
|
|
184
|
+
Once you see the authorization URL appear in the output, tell the user immediately (do **not** wait for their reply):
|
|
180
185
|
- zh: "请在浏览器中打开下方链接完成授权:\n<URL>"
|
|
181
186
|
- en: "Open this URL in your browser to authorize:\n<URL>"
|
|
182
187
|
|
|
183
|
-
|
|
188
|
+
Continue polling until the response contains an `exit_code` (meaning the session has ended). **Do not kill the session** — restarting invalidates the device code.
|
|
184
189
|
|
|
185
|
-
When
|
|
190
|
+
When the session ends with `exit_code: 0`, tell the user:
|
|
186
191
|
- zh: "✅ 飞书 CLI 已就绪。"
|
|
187
192
|
- en: "✅ Feishu CLI is ready."
|
|
188
193
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module FeishuSetup
|
|
8
|
+
ENDPOINT = "/oauth/v1/app/registration"
|
|
9
|
+
DEFAULT_DOMAIN = "https://accounts.feishu.cn"
|
|
10
|
+
DEFAULT_LARK_DOMAIN = "https://accounts.larksuite.com"
|
|
11
|
+
SDK_NAME = "ruby-sdk"
|
|
12
|
+
|
|
13
|
+
class SetupError < StandardError
|
|
14
|
+
attr_reader :code, :description
|
|
15
|
+
def initialize(code, description)
|
|
16
|
+
@code = code
|
|
17
|
+
@description = description
|
|
18
|
+
super("#{code}: #{description}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class AppAccessDeniedError < SetupError; end
|
|
23
|
+
class AppExpiredError < SetupError; end
|
|
24
|
+
|
|
25
|
+
def self.run(app_name: nil, app_desc: nil, on_qr_code:, on_status_change: nil,
|
|
26
|
+
domain: DEFAULT_DOMAIN, lark_domain: DEFAULT_LARK_DOMAIN)
|
|
27
|
+
base_url = domain
|
|
28
|
+
domain_switched = false
|
|
29
|
+
|
|
30
|
+
init_res = post(base_url, action: "init")
|
|
31
|
+
methods = init_res["supported_auth_methods"] || []
|
|
32
|
+
unless methods.include?("client_secret")
|
|
33
|
+
raise SetupError.new("unsupported_auth_method", "client_secret not supported")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
begin_res = post(base_url,
|
|
37
|
+
action: "begin",
|
|
38
|
+
archetype: "PersonalAgent",
|
|
39
|
+
auth_method: "client_secret",
|
|
40
|
+
request_user_info: "open_id"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
device_code = begin_res["device_code"]
|
|
44
|
+
interval = (begin_res["interval"] || 5).to_i
|
|
45
|
+
expire_in = (begin_res["expires_in"] || 600).to_i
|
|
46
|
+
qr_url = build_qr_url(begin_res["verification_uri_complete"], app_name: app_name, app_desc: app_desc)
|
|
47
|
+
|
|
48
|
+
on_qr_code.call(qr_url, expire_in)
|
|
49
|
+
|
|
50
|
+
deadline = Time.now + expire_in
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
raise AppExpiredError.new("expired_token", "polling timed out") if Time.now >= deadline
|
|
54
|
+
|
|
55
|
+
poll_res = post(base_url, action: "poll", device_code: device_code)
|
|
56
|
+
|
|
57
|
+
if poll_res["client_id"] && poll_res["client_secret"]
|
|
58
|
+
return { client_id: poll_res["client_id"], client_secret: poll_res["client_secret"] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
user_info = poll_res["user_info"] || {}
|
|
62
|
+
if user_info["tenant_brand"] == "lark" && !domain_switched
|
|
63
|
+
base_url = lark_domain
|
|
64
|
+
domain_switched = true
|
|
65
|
+
on_status_change&.call("domain_switched")
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
case poll_res["error"]
|
|
70
|
+
when "authorization_pending"
|
|
71
|
+
on_status_change&.call("polling")
|
|
72
|
+
sleep interval
|
|
73
|
+
when "slow_down"
|
|
74
|
+
interval += 5
|
|
75
|
+
on_status_change&.call("slow_down")
|
|
76
|
+
sleep interval
|
|
77
|
+
when "access_denied"
|
|
78
|
+
raise AppAccessDeniedError.new("access_denied", poll_res["error_description"].to_s)
|
|
79
|
+
when "expired_token"
|
|
80
|
+
raise AppExpiredError.new("expired_token", poll_res["error_description"].to_s)
|
|
81
|
+
else
|
|
82
|
+
err = poll_res["error"].to_s
|
|
83
|
+
raise SetupError.new(err, poll_res["error_description"].to_s) unless err.empty?
|
|
84
|
+
sleep interval
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method def self.post(base_url, params)
|
|
90
|
+
uri = URI("#{base_url}#{ENDPOINT}")
|
|
91
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
92
|
+
http.use_ssl = uri.scheme == "https"
|
|
93
|
+
http.open_timeout = 10
|
|
94
|
+
http.read_timeout = 30
|
|
95
|
+
req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/x-www-form-urlencoded")
|
|
96
|
+
req.body = URI.encode_www_form(params)
|
|
97
|
+
JSON.parse(http.request(req).body)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method def self.build_qr_url(uri_complete, app_name: nil, app_desc: nil)
|
|
101
|
+
uri = URI.parse(uri_complete)
|
|
102
|
+
params = URI.decode_www_form(uri.query.to_s).to_h
|
|
103
|
+
params["from"] = "sdk"
|
|
104
|
+
params["tp"] = "sdk"
|
|
105
|
+
params["source"] = SDK_NAME
|
|
106
|
+
params["name"] = app_name if app_name
|
|
107
|
+
params["desc"] = app_desc if app_desc
|
|
108
|
+
uri.query = URI.encode_www_form(params)
|
|
109
|
+
uri.to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
if __FILE__ == $PROGRAM_NAME
|
|
114
|
+
product_name = ENV.fetch("CLACKY_PRODUCT_NAME", "OpenClacky")
|
|
115
|
+
date_suffix = Time.now.strftime("%Y%m%d")
|
|
116
|
+
app_desc = "Your personal assistant powered by #{product_name}"
|
|
117
|
+
|
|
118
|
+
result = FeishuSetup.run(
|
|
119
|
+
app_name: "#{product_name} #{date_suffix}",
|
|
120
|
+
app_desc: app_desc,
|
|
121
|
+
on_qr_code: lambda { |url, expire_in|
|
|
122
|
+
puts "SCAN_URL:#{url}"
|
|
123
|
+
puts "EXPIRE_IN:#{expire_in}"
|
|
124
|
+
$stdout.flush
|
|
125
|
+
},
|
|
126
|
+
on_status_change: lambda { |status|
|
|
127
|
+
$stderr.puts "[feishu-setup] status=#{status}"
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
puts "APP_ID:#{result[:client_id]}"
|
|
132
|
+
puts "APP_SECRET:#{result[:client_secret]}"
|
|
133
|
+
$stdout.flush
|
|
134
|
+
end
|
|
@@ -32,6 +32,11 @@ Do NOT try to fall back to `terminal` + a hand-written `curl https://api.openai.
|
|
|
32
32
|
|
|
33
33
|
## Step 2 — Generate the image
|
|
34
34
|
|
|
35
|
+
### ⚠️ Important: generation speed & concurrency
|
|
36
|
+
|
|
37
|
+
- **Image generation can be slow — up to 2 minutes per image depending on the model.** Before calling the API, warn the user that it may take a minute or two. The curl request blocks until the image is ready; do NOT run it in the background.
|
|
38
|
+
- **One at a time only.** Never generate multiple images concurrently (e.g. by running several `curl` commands simultaneously or in a script loop). Each call consumes significant server-side resources, and parallel requests will almost certainly cause timeouts. If the user wants several images, generate them **sequentially**, one after another.
|
|
39
|
+
|
|
35
40
|
```bash
|
|
36
41
|
curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image \
|
|
37
42
|
-H "Content-Type: application/json" \
|