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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/lib/clacky/agent/session_serializer.rb +1 -1
  4. data/lib/clacky/agent/tool_registry.rb +10 -0
  5. data/lib/clacky/agent.rb +59 -22
  6. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  7. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  8. data/lib/clacky/client.rb +25 -3
  9. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  10. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  11. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  12. data/lib/clacky/message_history.rb +57 -0
  13. data/lib/clacky/openai_stream_aggregator.rb +26 -2
  14. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  15. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  16. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  17. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  18. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  19. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  20. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  21. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  22. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  23. data/lib/clacky/server/http_server.rb +73 -7
  24. data/lib/clacky/server/session_registry.rb +4 -6
  25. data/lib/clacky/tools/trash_manager.rb +1 -1
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +21 -3
  28. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  29. data/lib/clacky/web/brand.js +22 -2
  30. data/lib/clacky/web/favicon.ico +0 -0
  31. data/lib/clacky/web/i18n.js +4 -0
  32. data/lib/clacky/web/index.html +4 -3
  33. data/lib/clacky/web/logo_nav_dark.png +0 -0
  34. data/lib/clacky/web/model-tester.js +8 -1
  35. data/lib/clacky/web/sessions.js +169 -41
  36. data/lib/clacky/web/theme.js +1 -0
  37. data/scripts/build/lib/gem.sh +9 -2
  38. data/scripts/build/src/install_full.sh.cc +2 -0
  39. data/scripts/build/src/uninstall.sh.cc +1 -1
  40. data/scripts/install.ps1 +19 -5
  41. data/scripts/install.sh +9 -2
  42. data/scripts/install_full.sh +11 -2
  43. data/scripts/install_rails_deps.sh +9 -2
  44. data/scripts/uninstall.sh +10 -3
  45. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b32940868f1d61791afd615ff73dbaf72dc80c111f9f9435ef939ef39ae5dec
4
- data.tar.gz: be8efa7ee318c3f174ddbbdf1f5b2754705eb6a5d3f263aa11cbe5539b198e8f
3
+ metadata.gz: 451817565cffdf7b1efcdf5e741cea76af0451a8d9900804e2aa3c6a5384ba4a
4
+ data.tar.gz: 0232ede01332162004abc1638a8a03b41095c44c68198ce6327e5f5fc815f49a
5
5
  SHA512:
6
- metadata.gz: abcbed799ca8feed1a41e39a72bd8e6a5e9184c8e76a67bac79f9bee07f88ebf6b639102d6cfcd6527bf80b7b3c9bda5665f131bf81a164b9a14b963cad1ea47
7
- data.tar.gz: 829bc77c06483853c1d568d04a528a9611c6e7f775b38b6c6fdebe04c5a5ae6974328bb1ac9c0f8cb1afe0765a60795061157b65860eaf26e8bb7368dadb2b8e
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
- @start_time = Time.now
267
- @task_truncation_count = 0 # Reset truncation counter for each task
268
- @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
269
- @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
270
- @task_cost_source = :estimated # Reset for new task
271
- # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
272
- # across tasks to correctly calculate delta tokens in each iteration
273
- @task_start_iterations = @iterations # Track starting iterations for this task
274
- @task_start_cost = @total_cost # Track starting cost for this task
275
- # Track cache stats for current task
276
- @task_cache_stats = {
277
- cache_creation_input_tokens: 0,
278
- cache_read_input_tokens: 0,
279
- prompt_tokens: 0,
280
- completion_tokens: 0,
281
- total_requests: 0,
282
- cache_hit_requests: 0
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
- # [DIAG] Pin down exactly which sub-condition triggered the task exit.
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: response[:finish_reason].to_s,
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: false,
569
- status: response.status,
570
- error: extract_error_message(error_body, response.body)
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
- Feishu now offers a one-click **Agent App** (智能体应用) that auto-configures all
103
- required permissions, events, and publishing for you no Bot capability toggle,
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 — Open the Agent App creation page
105
+ #### Step 1 — Run setup script as a background session
109
106
 
110
- 1. Navigate: `open https://open.feishu.cn/page/launcher?from=backend_oneclick`. Pass `isolated: true`. If the browser is not configured (the `open` call fails), just give the user the URL and ask them to open it manually in any browser — the rest of the flow is fully manual and does not need browser automation.
111
- 2. If a login page or QR code is shown, tell the user to scan/log in and wait for "done".
107
+ ```
108
+ terminal(command: "ruby SKILL_DIR/feishu_setup.rb", background: true)
109
+ ```
112
110
 
113
- #### Step 2 Create the Agent App
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
- 3. After login, the page lands on **创建飞书智能体应用 (Create Feishu Agent App)**.
116
- Guide the user: "Enter an app name (e.g. Open Clacky), then click **立即创建 (Create Now)**. Reply done."
117
- (The avatar is auto-assigned at random and can be changed anytime it does not affect setup.)
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
- #### Step 3 Copy credentials
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
- 4. The page jumps to **创建成功 (Created Successfully)**, showing `App ID` and `App Secret`.
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 4 — Save credentials
125
+ #### Step 2 — Save credentials
129
126
 
130
- 5. Run:
131
- ```bash
132
- curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
133
- -H "Content-Type: application/json" \
134
- -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
135
- ```
136
- **CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml`
137
- or any file under `~/.clacky/channels/` directly. The server API handles persistence, hot-reload,
138
- and establishing the long connection.**
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
- On success: tell the user "✅ Feishu channel configured!" and **continue to Step 5 (Feishu CLI)**.
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 5 — Optional: install Feishu CLI
144
+ #### Step 3 — Optional: install Feishu CLI
145
145
 
146
- Reach here after the channel is configured (Step 4 succeeded). Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
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\": \"是否要安装「飞书 CLI」?装好之后 AI 可以帮你操作飞书云文档等能力。不装也 OK。\",
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? With it, the AI can help you work with Feishu Docs and more. Skipping is fine.",
162
- "options": ["Enable", "Skip"]
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
- The last command blocks up to 10 minutes waiting for browser authorization make sure the runner's timeout is ≥ 600s.
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 command's stdout, tell the user (do **not** wait for a reply — the CLI's blocking poll will return on its own when authorization completes):
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
- **Do not kill and restart this command** restarting invalidates the device code and breaks the link the user already opened. The "hang" is just polling; wait it out.
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 `lark-cli auth login` returns successfully, tell the user:
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" \