openclacky 1.5.17 → 1.5.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/docs/session-observation-hooks.md +86 -0
  4. data/lib/clacky/agent/chunk_index.rb +4 -1
  5. data/lib/clacky/agent/history_navigation.rb +6 -1
  6. data/lib/clacky/agent/hook_manager.rb +11 -4
  7. data/lib/clacky/agent.rb +39 -3
  8. data/lib/clacky/billing/platform_billing.rb +9 -0
  9. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +2 -1
  10. data/lib/clacky/default_extensions/advisor/hooks/advisor_complete.rb +3 -2
  11. data/lib/clacky/default_skills/channel-manager/SKILL.md +70 -9
  12. data/lib/clacky/default_skills/channel-manager/qq_setup.rb +79 -0
  13. data/lib/clacky/default_skills/media-gen/SKILL.md +45 -14
  14. data/lib/clacky/locales/en.rb +2 -2
  15. data/lib/clacky/locales/zh.rb +2 -2
  16. data/lib/clacky/media/openai_compat.rb +89 -2
  17. data/lib/clacky/message_format/bedrock.rb +5 -2
  18. data/lib/clacky/providers.rb +46 -27
  19. data/lib/clacky/server/channel/adapters/qq/adapter.rb +662 -0
  20. data/lib/clacky/server/channel/adapters/qq/api_client.rb +245 -0
  21. data/lib/clacky/server/channel/adapters/qq/gateway_client.rb +340 -0
  22. data/lib/clacky/server/channel/adapters/qq/qr_binder.rb +140 -0
  23. data/lib/clacky/server/channel/channel_config.rb +9 -0
  24. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  25. data/lib/clacky/server/channel.rb +1 -0
  26. data/lib/clacky/server/http_server.rb +116 -11
  27. data/lib/clacky/server/model_prices.rb +11 -3
  28. data/lib/clacky/shell_hook_loader.rb +3 -1
  29. data/lib/clacky/utils/model_pricing.rb +160 -20
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +357 -180
  32. data/lib/clacky/web/components/chat-navigator.js +154 -171
  33. data/lib/clacky/web/components/composer.js +11 -2
  34. data/lib/clacky/web/components/model-picker.js +133 -23
  35. data/lib/clacky/web/core/aside.js +72 -0
  36. data/lib/clacky/web/features/billing/store.js +1 -1
  37. data/lib/clacky/web/features/billing/view.js +126 -34
  38. data/lib/clacky/web/features/channels/store.js +25 -0
  39. data/lib/clacky/web/features/channels/view.js +122 -5
  40. data/lib/clacky/web/features/skills/view.js +12 -0
  41. data/lib/clacky/web/features/workspace/view.js +88 -12
  42. data/lib/clacky/web/i18n.js +64 -22
  43. data/lib/clacky/web/index.html +15 -1
  44. data/lib/clacky/web/projects.js +131 -3
  45. data/lib/clacky/web/sessions.js +65 -9
  46. data/lib/clacky/web/settings.js +19 -0
  47. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23761f95ba9b1fa52c2acc37020fd4e903a92d8f49c0c97668d5cfbb6e572329
4
- data.tar.gz: 16838f20ba7d650e15952c738c35b93ca418a8df81baaeb6f9fd0263c5754e65
3
+ metadata.gz: bdaf1f7be658187d9515e3fcd1ca37a1ec8bdf04267b434a4d5722c3cbee3970
4
+ data.tar.gz: a7a62edc84b707407fd06b186627b1a705a8e9977b60b88453e23759dc43f4b4
5
5
  SHA512:
6
- metadata.gz: d0a29e5ab8a0e6b21e1f2a2f36657bcb8043cfc9d904671acdead8157f082ac032c2d1f7f0a0cee16c0f72a7fdbb939020b3abfe52145a9eea722f4f4856bac2
7
- data.tar.gz: '050389221f4f71ab6e54a6038606f0d1f48f1a6571116c5b052493362158a850fe0f1a90a4d520b35fff875315fd1292610bab104366251626d5d9fdcb5b7f54'
6
+ metadata.gz: 574f85b7317b21b2d231f009405275a6aa77df9758bd2bb9d861a948d1bf35f32d876c1c154fb87f8248786490734aab84a6568ba522e37e840913216510f504
7
+ data.tar.gz: 6509de425a59612b5fad807c172affad49bee4da8b83d59ba9a204c3355814c1ef8f1b8ee233bee43755c624c3bbf4b9c7278378bf3b68e7faebcfc7096a6530
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.19] - 2026-10-10
8
+
9
+ ### Added
10
+ - Add the built-in QQ official-bot channel (#615 - @chengoak)
11
+ - Drag a session onto a project to move it (#610)
12
+ - Add bookmarks to the chat navigator and tighten its layout (#617)
13
+ - Scroll an overflowing session title on hover so it can be read in full (#618)
14
+ - Add session lifecycle hooks (#607)
15
+ - Rework the billing charts with a year heatmap and a 365-day range
16
+ - Add an "Add Model" button to the model picker
17
+ - Show an API Key hint in the model dialog and restyle the key link
18
+ - Add oc-glm-5.3 and oc-kimi-k3 aliases with pricing and capability caps
19
+ - Add abs-claude-sonnet-5-5 and abs-gpt-6.1-sol with corrected pricing
20
+ - Add Xiaomi MiMo V2.6 models (#614)
21
+ - Add the oc-minimax-m2.7 alias with its launch discount
22
+ - Show a GLM/Kimi series promotion with a badge in the model picker
23
+ - Forward Seedance reference images over the OpenAI-compatible video gateway (#616 - @codevoyager1984)
24
+ - Document the input image limits and the content_filter retry in the media skill (#593 - @codevoyager1984)
25
+
26
+ ### Improved
27
+ - Make the model picker visually distinct from the variant switcher
28
+ - Explain model price discounts with a tooltip
29
+ - Soften the chat navigator tick color
30
+ - Hide the skills tab strip when only one tab is available
31
+ - Tune the billing chart axis labels
32
+ - Clarify ambient audio generation in the media skill (#606 - @YeEmrick)
33
+
34
+ ### Fixed
35
+ - Stop the workspace file tree from flickering on reload
36
+ - Deliver web-originated replies to IM after a finished progress card (#603, #605 - @chengoak)
37
+ - Fall back to gem update when the OSS CDN fails (#583, #608 - @theluckystrike)
38
+ - Keep the chat position when toggling preview fullscreen (#613)
39
+ - Stop opening an empty variant panel for custom models
40
+ - Use a white background for the context bar popovers
41
+ - Point a rate limit error at switching models
42
+ - Skip reasoning fields for non-Anthropic Converse models
43
+
44
+ ### More
45
+ - Rename the billing panel title to Usage
46
+ - Use 服务商 instead of 供应商 in model settings
47
+
48
+ ## [1.5.18] - 2026-10-02
49
+
50
+ ### Fixed
51
+ - Tell users to switch to another model when a model request fails
52
+
7
53
  ## [1.5.17] - 2026-09-30
8
54
 
9
55
  ### Added
@@ -0,0 +1,86 @@
1
+ # Agent task lifecycle hooks
2
+
3
+ ## Task lifecycle: on_start / on_complete
4
+
5
+ > Availability: the additional context and abnormal completion paths require
6
+ > the runtime change on branch `session-lifecycle-hooks`. They are not
7
+ > available in 1.5.16. Upgrade the runtime before relying on these fields.
8
+
9
+ Use the existing events without extra configuration. `on_start` runs after task
10
+ and history initialization, before execution, and retains `deny` / `handled`.
11
+ `on_complete` observes the outcome of each Agent turn, including normal completion,
12
+ waiting, handled starts, denial, exceptions and interruption. Its return value is
13
+ ignored, including `deny` and `handled`; all completion callbacks can run.
14
+
15
+ Ruby callbacks receive `(input, agent, context)` or `(result, agent, context)`.
16
+ The first two positions are unchanged; ordinary two-argument Ruby blocks ignore
17
+ the extra argument. Existing completion callbacks must check `result[:status] ==
18
+ :success` before doing success-only work. A handled result has `context[:handled]`
19
+ set to true; skip follow-up work already performed by the handler.
20
+
21
+ These are Agent-turn events for server, CLI and channel execution, not server
22
+ persistence notifications. Queued input emits start only when consumed; steering
23
+ inside an active turn does not create an extra start or completion. Automatic goal
24
+ continuations have their own turn events. Normal completion fires before goal
25
+ continuation, and exceptions still propagate after notification. A superseded
26
+ turn's exception cleanup cannot settle its successor.
27
+
28
+ Context contains `session_id`, `run_id` (process-local task ID, not durable),
29
+ `source` (execution source, not authenticated identity), `input_created_at` (the
30
+ optional timestamp passed to `run`), `observed_at` (UTC ISO8601), and `status`.
31
+ Start status is `running`. No input text is added to context; input and result
32
+ arguments can contain sensitive data.
33
+
34
+ | Completion context status | Meaning |
35
+ |---------------------------|---------|
36
+ | `completed` | Result explicitly has `status: :success`, without a feedback request. This does not certify App generation or validation. |
37
+ | `awaiting_user` | Successful result requests `awaiting_user_feedback`. |
38
+ | `failed` | Exception or non-success result. Structured exceptions can supply `error_code`. |
39
+ | `cancelled` | Interruption or denial. `reason` may be `user`, `replacement`, `hook_denied` or `tool_denied`. |
40
+
41
+ Cancelled completion notifications carry `result[:status] == :cancelled`, even
42
+ when the original denial return value remains unchanged for queue compatibility.
43
+ An execution start alone does not establish recovery from a previous failure.
44
+
45
+ ```yaml
46
+ id: session-observer
47
+ name: Session Observer
48
+ version: "0.1.0"
49
+ contributes:
50
+ hooks:
51
+ - event: on_start
52
+ file: hooks/start.rb
53
+ - event: on_complete
54
+ file: hooks/complete.rb
55
+ ```
56
+
57
+ ```ruby
58
+ # hooks/start.rb
59
+ Clacky::ExtensionHookRegistry.add do |_input, _agent, context|
60
+ next unless context
61
+ Clacky::Logger.debug("[session-observer] started", session_id: context[:session_id],
62
+ run_id: context[:run_id])
63
+ nil
64
+ end
65
+ ```
66
+
67
+ ```ruby
68
+ # hooks/complete.rb
69
+ Clacky::ExtensionHookRegistry.add do |_result, _agent, context|
70
+ next unless context
71
+ Clacky::Logger.debug("[session-observer] settled", session_id: context[:session_id],
72
+ status: context[:status], run_id: context[:run_id])
73
+ nil
74
+ end
75
+ ```
76
+
77
+ Use separate files for separate registrations: each Ruby file is required only
78
+ once. Programmatic registration uses `agent.add_hook(:on_complete) { |result,
79
+ agent, context| ... }`. Shell hooks keep their JSON payload shape; neither the
80
+ Agent nor context is serialized, while abnormal completion results are now reported.
81
+
82
+ Callbacks execute synchronously; keep them short and dispatch network work
83
+ asynchronously. `StandardError` is logged without stopping later callbacks.
84
+ The host does not persist, retry, replay or acknowledge hook delivery. Consumers
85
+ must tolerate missing events and cannot infer inactivity solely from missing data.
86
+ `agent.emit_event(persist: true)` is a separate frontend-history feature.
@@ -41,6 +41,7 @@ module Clacky
41
41
  nested = []
42
42
  section_count = 0
43
43
  user_start = nil
44
+ user_task_id = nil
44
45
  archived_at = nil
45
46
  front_matter = false
46
47
  directory = File.dirname(path)
@@ -61,6 +62,7 @@ module Clacky
61
62
  section_count += 1
62
63
  nested << header[:nested_chunk] if header[:nested_chunk]
63
64
  user_start = header[:role] == "user" ? start : nil
65
+ user_task_id = header[:role] == "user" ? header[:task_id] : nil
64
66
  elsif user_start && !text.strip.empty?
65
67
  # Match replay's empty/metadata-only user handling without
66
68
  # retaining the rest of the user message or any assistant text.
@@ -69,8 +71,9 @@ module Clacky
69
71
  next if visible.empty? && events.empty? && files.empty?
70
72
 
71
73
  rounds.last[:length] = user_start - rounds.last[:start] if rounds.any?
72
- rounds << { start: user_start }
74
+ rounds << { start: user_start, task_id: user_task_id }
73
75
  user_start = nil
76
+ user_task_id = nil
74
77
  end
75
78
  end
76
79
  rounds.last[:length] = position - rounds.last[:start] if rounds.any?
@@ -12,7 +12,12 @@ module Clacky
12
12
  manifest = sources.map do |source|
13
13
  entry = { key: source[:key], version: source[:version], count: navigation_source_count(source),
14
14
  volatile: !!source[:rounds] || source[:continuation].to_a.any? }
15
- entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) } if source[:rounds]
15
+ if source[:rounds]
16
+ entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) }
17
+ else
18
+ task_ids = source[:index][:rounds].map { |round| round[:task_id] }
19
+ entry[:task_ids] = task_ids if task_ids.any?
20
+ end
16
21
  entry
17
22
  end
18
23
  { sources: manifest, total: manifest.sum { |source| source[:count] } }
@@ -26,20 +26,27 @@ module Clacky
26
26
 
27
27
  # @return [Hash] `{action: :allow}`, `{action: :deny, reason:}`, or
28
28
  # `{action: :handled, result:}` when a hook fulfilled the event itself.
29
- # on_start receives (user_input, agent) after task/history initialization.
29
+ # on_start receives (user_input, agent, context) after task/history initialization.
30
30
  # Its handled result is returned unchanged from Agent#run; the extension
31
31
  # supplies the run-result status and any queue/feedback flags it needs.
32
32
  # on_start is not fired for steering input inside an already running turn.
33
- # Extra trailing arg: the agent that owns this hook chain, so ext hooks can
33
+ # The agent follows the event payload, so ext hooks can
34
34
  # call `agent.emit_event(...)`. Blocks are procs — those declaring fewer
35
- # params (`|call|`, `|call, result|`) silently ignore it.
35
+ # params (`|call|`, `|call, result|`) silently ignore extra arguments.
36
+ # Start/complete append context after the agent. Completion is observation-only.
36
37
  def trigger(event, *args)
37
38
  validate_event!(event)
39
+ # Keep payload hashes positional: Ruby 2.6 otherwise promotes their keys
40
+ # into keyword arguments, breaking existing tool hooks.
41
+ context = args.pop if [:on_start, :on_complete].include?(event) && args.length == 2
38
42
  result = { action: :allow }
39
43
 
40
44
  @hooks[event].each do |hook|
41
45
  begin
42
- hook_result = hook.call(*args, @agent)
46
+ callback_args = [*args, @agent]
47
+ callback_args << context.dup if context
48
+ hook_result = hook.call(*callback_args)
49
+ next if event == :on_complete
43
50
  next unless hook_result.is_a?(Hash)
44
51
  # First deny wins and stops the chain: a weaker later verdict must
45
52
  # never clobber a stronger earlier one, and the first deny's reason
data/lib/clacky/agent.rb CHANGED
@@ -490,6 +490,8 @@ module Clacky
490
490
  # method-level and must be able to reference them on any exit path.
491
491
  result = nil
492
492
  run_turn_started = false
493
+ completion_sent = false
494
+ run_context = nil
493
495
 
494
496
  # Intercept /goal ... commands before any task/LLM work. Control-plane
495
497
  # commands (status/pause/resume/clear) return immediately without a turn;
@@ -520,6 +522,9 @@ module Clacky
520
522
 
521
523
  # Start new task for Time Machine
522
524
  task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
525
+ run_context = { session_id: @session_id, run_id: task_id.to_s,
526
+ source: @source.to_s, input_created_at: created_at,
527
+ observed_at: Time.now.utc.iso8601(6), status: "running" }
523
528
 
524
529
  # Continuation of a previously-interrupted task (e.g. user sent a
525
530
  # supplementary message without stopping the running task) keeps the
@@ -589,13 +594,15 @@ module Clacky
589
594
  @input_mutex.synchronize { @accepting_steering = true }
590
595
  notify_input_queue
591
596
  # The task and user history already exist. A terminal verdict skips the
592
- # loop and completion hooks; ensure still cleans up this started turn.
593
- hook_result = @hooks.trigger(:on_start, user_input)
597
+ # loop; ensure still reports completion and cleans up this started turn.
598
+ hook_result = @hooks.trigger(:on_start, user_input, run_context)
594
599
  case hook_result[:action]
595
600
  when :deny
601
+ run_context[:reason] = "hook_denied"
596
602
  @ui&.show_warning(hook_result[:reason] || "Task denied by hook")
597
603
  return result = build_result.merge(queue_paused: true)
598
604
  when :handled
605
+ run_context[:handled] = true
599
606
  return result = hook_result[:result]
600
607
  end
601
608
 
@@ -793,6 +800,7 @@ module Clacky
793
800
  notify_input_queue
794
801
  result = build_result(awaiting_user_feedback: awaiting_user_feedback)
795
802
  result[:queue_paused] = true if awaiting_user_feedback || task_interrupted
803
+ run_context[:reason] = "tool_denied" if task_interrupted
796
804
 
797
805
  # Run skill evolution hooks after main loop completes
798
806
  # Skip if task was interrupted by user (denied tool) or awaiting user feedback
@@ -825,7 +833,8 @@ module Clacky
825
833
  awaiting_user_feedback: awaiting_user_feedback
826
834
  )
827
835
  end
828
- @hooks.trigger(:on_complete, result)
836
+ completion_sent = true
837
+ complete_run_hook(result, run_context)
829
838
 
830
839
  # Standing-goal loop: after a completed turn, ask the judge whether the
831
840
  # goal is met. If not (and budget/health allow), auto-run the next turn
@@ -840,6 +849,8 @@ module Clacky
840
849
  result[:queue_paused] = true if @goal_manager&.state&.paused?
841
850
  result
842
851
  rescue Clacky::AgentInterrupted
852
+ result = { status: :cancelled }
853
+ run_context[:reason] = (Thread.current[:interrupt_reason] || :user).to_s if run_context
843
854
  # A cancelled fan-out captured its subagents' progress but never reached
844
855
  # observe() to persist it — anchor those trails now so a page reload
845
856
  # after the interrupt still shows what the subagents did.
@@ -852,6 +863,7 @@ module Clacky
852
863
  # Let CLI handle the interrupt message
853
864
  raise
854
865
  rescue StandardError => e
866
+ run_context[:error_code] = e.error_code if run_context && e.respond_to?(:error_code)
855
867
  # Log complete error information to debug_logs for troubleshooting
856
868
  @debug_logs << {
857
869
  timestamp: Time.now.iso8601,
@@ -871,6 +883,11 @@ module Clacky
871
883
  result = build_result(:error, error: e.message)
872
884
  raise
873
885
  ensure
886
+ # A recursive goal continuation owns its own completion. Do not let an
887
+ # unwinding predecessor settle a newer turn or report the same turn twice.
888
+ if run_context && !completion_sent && task_id == @current_task_id
889
+ complete_run_hook(result, run_context)
890
+ end
874
891
  if run_turn_started && task_id == @current_task_id
875
892
  @input_mutex.synchronize do
876
893
  @accepting_steering = false
@@ -888,6 +905,25 @@ module Clacky
888
905
  Clacky::Telemetry.task!(result: result) if run_turn_started
889
906
  end
890
907
 
908
+ private def complete_run_hook(result, context)
909
+ result = { status: :error } unless result.is_a?(Hash)
910
+ cancelled = context[:reason] || [:cancelled, :interrupted].include?(result[:status])
911
+ status = if cancelled
912
+ "cancelled"
913
+ elsif result[:status] != :success
914
+ "failed"
915
+ elsif result[:awaiting_user_feedback]
916
+ "awaiting_user"
917
+ else
918
+ "completed"
919
+ end
920
+ # Denied work can retain its historical return value for queue control,
921
+ # but completion observers must never interpret it as a successful run.
922
+ notification = cancelled ? result.merge(status: :cancelled) : result
923
+ @hooks.trigger(:on_complete, notification,
924
+ context.merge(status: status, observed_at: Time.now.utc.iso8601(6)))
925
+ end
926
+
891
927
  # Shared by initial input and steering; only the execution thread writes history.
892
928
  private def append_user_input(user_input, files: nil, reference_contexts: nil,
893
929
  display_text: nil, created_at: nil, references_display: nil,
@@ -24,6 +24,7 @@ module Clacky
24
24
  "dsk-deepseek-v4-flash-vision-exp" => "deepseek-v4-flash-vision-exp",
25
25
  # gpt via bedrock (abs-, OpenAI-compatible endpoint)
26
26
  "abs-gpt-6-astra" => "global.openai.gpt-6-astra",
27
+ "abs-gpt-6.1-sol" => "global.openai.gpt-6.1-sol",
27
28
  "abs-gpt-6-sol" => "global.openai.gpt-6-sol",
28
29
  "abs-gpt-6-luna" => "global.openai.gpt-6-luna",
29
30
  "abs-gpt-5.6-sol" => "global.openai.gpt-5.6-sol",
@@ -37,10 +38,18 @@ module Clacky
37
38
  "abs-claude-opus-4-8" => "global.anthropic.claude-opus-4-8",
38
39
  "abs-claude-opus-4-7" => "global.anthropic.claude-opus-4-7",
39
40
  "abs-claude-opus-4-6" => "global.anthropic.claude-opus-4-6",
41
+ "abs-claude-sonnet-5-5" => "global.anthropic.claude-sonnet-5-5",
40
42
  "abs-claude-sonnet-5" => "global.anthropic.claude-sonnet-5",
41
43
  "abs-claude-sonnet-4-6" => "global.anthropic.claude-sonnet-4-6",
42
44
  "abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
43
45
  "abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
46
+ # GLM / Kimi / MiniMax via TokHub (oc-). The gateway books these under
47
+ # the alias itself, so the mapping is an identity — it exists so the
48
+ # billing UI can resolve the alias instead of dropping the record as
49
+ # unknown.
50
+ "oc-glm-5.3" => "oc-glm-5.3",
51
+ "oc-kimi-k3" => "oc-kimi-k3",
52
+ "oc-minimax-m2.7" => "oc-minimax-m2.7",
44
53
  # gemini chat (or-)
45
54
  "or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
46
55
  "or-gemini-3-8-flash" => "gemini-3.8-flash",
@@ -127,7 +127,7 @@ module Clacky
127
127
  # rounds with no tool calls at all (e.g. the very first "hi"). The
128
128
  # pending event is emitted synchronously so the UI can show a "working"
129
129
  # state immediately instead of the card popping in out of nowhere.
130
- def finish_run
130
+ def finish_run(analyze: true)
131
131
  snapshot = @mutex.synchronize do
132
132
  snap = {
133
133
  tools: @tools_this_run,
@@ -139,6 +139,7 @@ module Clacky
139
139
  @tools_this_run = 0
140
140
  snap
141
141
  end
142
+ return unless analyze
142
143
  Clacky::Logger.info("[Advisor] finish_run",
143
144
  session: @agent.session_id.to_s,
144
145
  tools: snapshot[:tools],
@@ -4,8 +4,9 @@ require_relative "advisor"
4
4
 
5
5
  # on_complete: a run has ended — reset the per-run analysis budget so the
6
6
  # next run starts fresh.
7
- Clacky::ExtensionHookRegistry.add do |_result, agent|
7
+ Clacky::ExtensionHookRegistry.add do |result, agent, context|
8
8
  next unless Clacky::Advisor.enabled_for?(agent)
9
9
 
10
- Clacky::Advisor.worker_for(agent).finish_run
10
+ successful = result.is_a?(Hash) && result[:status] == :success && !context&.dig(:handled)
11
+ Clacky::Advisor.worker_for(agent).finish_run(analyze: successful)
11
12
  end
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: channel-manager
3
3
  name_zh: 渠道管理
4
- description_zh: 接入飞书、企业微信、微信、钉钉、Discord、Telegram 等 IM 渠道,支持配置、启停、诊断和发消息。
4
+ description_zh: 接入飞书、企业微信、微信、钉钉、Discord、Telegram、QQ 等 IM 渠道,支持配置、启停、诊断和发消息。
5
5
  description: |
6
- Configure IM platform channels (Feishu, WeCom, Weixin, Discord, Telegram, DingTalk) for openclacky.
6
+ Configure IM platform channels (Feishu, WeCom, Weixin, Discord, Telegram, DingTalk, QQ) for openclacky.
7
7
  Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
8
- Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "setup discord", "setup telegram", "setup dingtalk",
8
+ Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "setup discord", "setup telegram", "setup dingtalk", "setup qq",
9
9
  "channel config", "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
10
- "send message to weixin", "send message to feishu", "send message to wecom", "send message to discord", "send message to telegram", "send message to dingtalk".
10
+ "send message to weixin", "send message to feishu", "send message to wecom", "send message to discord", "send message to telegram", "send message to dingtalk", "send message to qq".
11
11
  Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
12
12
  argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
13
13
  allowed-tools:
@@ -30,13 +30,13 @@ Configure IM platform channels for openclacky.
30
30
 
31
31
  | User says | Subcommand |
32
32
  |---|---|
33
- | `channel setup`, `setup feishu`, `setup wecom`, `setup weixin`, `setup wechat`, `setup discord`, `setup telegram`, `setup dingtalk` | setup |
33
+ | `channel setup`, `setup feishu`, `setup wecom`, `setup weixin`, `setup wechat`, `setup discord`, `setup telegram`, `setup dingtalk`, `setup qq` | setup |
34
34
  | `channel status` | status |
35
- | `channel enable feishu/wecom/weixin/discord/telegram/dingtalk` | enable |
36
- | `channel disable feishu/wecom/weixin/discord/telegram/dingtalk` | disable |
35
+ | `channel enable feishu/wecom/weixin/discord/telegram/dingtalk/qq` | enable |
36
+ | `channel disable feishu/wecom/weixin/discord/telegram/dingtalk/qq` | disable |
37
37
  | `channel reconfigure` | reconfigure |
38
38
  | `channel doctor` | doctor |
39
- | `send <message> to weixin/feishu/wecom/discord/telegram/dingtalk` | send |
39
+ | `send <message> to weixin/feishu/wecom/discord/telegram/dingtalk/qq` | send |
40
40
 
41
41
  ---
42
42
 
@@ -80,6 +80,7 @@ dingtalk ✅ yes ✅ yes client_id: ding_xxx...
80
80
  - Discord: show `has_token: true/false` (token value is never displayed)
81
81
  - Telegram: show `has_token: true/false` (bot token is never displayed)
82
82
  - DingTalk: show `client_id` (truncated to 12 chars)
83
+ - QQ: show `app_id` (truncated to 12 chars) and `sandbox: true/false`
83
84
 
84
85
  If the API is unreachable or returns an empty list: "No channels configured yet. Run `/channel-manager setup` to get started."
85
86
 
@@ -96,6 +97,7 @@ Ask:
96
97
  > 4. Discord
97
98
  > 5. Telegram (Bot API)
98
99
  > 6. DingTalk
100
+ > 7. QQ (official bot, C2C & group @)
99
101
 
100
102
  ---
101
103
 
@@ -391,6 +393,56 @@ On success:
391
393
 
392
394
  ---
393
395
 
396
+ ### QQ setup (official QQ bot)
397
+
398
+ QQ bots are created on the QQ Open Platform (q.qq.com). The user registers a bot, enables the C2C / group-message scopes, and pastes the App ID / App Secret here. The adapter connects over the QQ Gateway WebSocket; no browser automation is required from this skill.
399
+
400
+ #### Step 1 — Create the bot on the QQ Open Platform
401
+
402
+ Tell the user (localize if they are chatting in Chinese):
403
+
404
+ > 1. Open the QQ Open Platform: https://q.qq.com (sandbox testing: use the sandbox toggle / sandbox AppID in the bot admin console).
405
+ > 2. Register / log in and create a robot (机器人). After creation you will find **AppID** and **AppSecret** in the bot's "开发设置 / Development Settings" page.
406
+ > 3. In "功能配置 / 配置" (or the sandbox panel), make sure the bot has the private-chat (C2C) and group @ message permissions enabled; add yourself as a sandbox tester if you are testing before release.
407
+ > 4. Paste the credentials back here in one line:
408
+ >
409
+ > `app_id=YOUR_APPID app_secret=YOUR_APPSECRET sandbox=true_or_false`
410
+
411
+ Wait for the reply. Parse with tolerant regex (`app_id=\S+`, `app_secret=\S+`, `sandbox=(true|false)`; default `sandbox=false` when omitted).
412
+
413
+ Optionally validate the credentials before saving (best-effort; requires outbound access to api.bot.qq.com). The token endpoint is shared by production and sandbox — only the openapi host differs — so validation takes no `--sandbox` flag:
414
+
415
+ ```bash
416
+ ruby "SKILL_DIR/qq_setup.rb" --validate "<APP_ID> <APP_SECRET>"
417
+ ```
418
+
419
+ #### Step 2 — Save credentials
420
+
421
+ The server persists the credentials and immediately opens the gateway WebSocket:
422
+
423
+ ```bash
424
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/qq \
425
+ -H "Content-Type: application/json" \
426
+ -d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","sandbox":<TRUE_OR_FALSE>}'
427
+ ```
428
+
429
+ - `200` — credentials saved; the QQ gateway session starts.
430
+ - `4xx` — show the returned error (bad AppID/Secret, or permission not granted) and offer to retry.
431
+
432
+ #### Step 3 — Verify
433
+
434
+ Tell the user:
435
+
436
+ > ✅ QQ channel configured. Send your bot a private message, or @ it in a group.
437
+ >
438
+ > Notes:
439
+ > - Before release, only accounts added as sandbox testers can reach the bot.
440
+ > - QQ bots reply in the passive window (5 minutes after the user's message); very long tasks may reply after the window closes.
441
+ > - Channel (频道 / guild) messages are parsed but outbound replies are not supported yet — use C2C or groups.
442
+ > - Optional: restrict who can use the bot via the `allowed_users` list (QQ openid values).
443
+
444
+ ---
445
+
394
446
  ## `enable`
395
447
 
396
448
  Call the server API to re-enable the platform (this reads from disk, sets enabled, saves, and hot-reloads):
@@ -466,6 +518,7 @@ Check each item, report ✅ / ❌ with remediation:
466
518
  - Weixin: `token` present and non-empty in `channels.yml`
467
519
  - Discord: `bot_token` present and non-empty in `channels.yml`
468
520
  - Telegram: `bot_token` present and non-empty
521
+ - QQ: `app_id`, `app_secret` present and non-empty
469
522
  3. **Feishu credentials** (if enabled) — run the token API call, check `code=0`.
470
523
  4. **Weixin token** (if enabled) — call `GET /api/channels` and check `has_token: true` for the weixin entry.
471
524
  5. **Telegram credentials** (if enabled) — call `getMe` against the Bot API:
@@ -495,6 +548,13 @@ Check each item, report ✅ / ❌ with remediation:
495
548
  ```
496
549
  - `WebSocket connected` → ✅
497
550
  - `Stream endpoint error` or `token error` → ❌ "DingTalk credentials invalid — re-run setup"
551
+ 8. **QQ credentials** (if enabled) — search today's log:
552
+ ```bash
553
+ grep -iE "QQ gateway|qq gateway|gateway ready|AppID|app_id.*invalid" \
554
+ ~/.clacky/logger/clacky-$(date +%Y-%m-%d).log
555
+ ```
556
+ - gateway ready / identified → ✅
557
+ - invalid app_id / app_secret or identify failed → ❌ "QQ credentials invalid — re-run setup"
498
558
 
499
559
  ---
500
560
 
@@ -505,7 +565,7 @@ Proactively send a message to a user via an IM channel adapter.
505
565
  ### Parse the request
506
566
 
507
567
  Extract two things from the user's instruction:
508
- - **platform** — one of `weixin`, `feishu`, `wecom`, `discord`, `telegram`, `dingtalk`
568
+ - **platform** — one of `weixin`, `feishu`, `wecom`, `discord`, `telegram`, `dingtalk`, `qq`
509
569
  - **message** — the text content to send
510
570
 
511
571
  If the platform cannot be inferred, ask the user to clarify.
@@ -545,6 +605,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/
545
605
  ### Constraints & notes
546
606
 
547
607
  - **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
608
+ - **QQ**: C2C/group replies reuse the recent inbound `msg_id` (passive reply window, ~5 minutes); proactive sends outside that window may be rejected.
548
609
  - **Feishu / WeCom / Discord / Telegram**: No per-message token required. As long as the adapter is running and the `user_id` / `chat_id` (or Discord channel/user id) is valid, the message will be delivered. For Telegram specifically, the `user_id` must be a Telegram chat_id that the bot can write to — the user must have sent at least one message to the bot first.
549
610
  - This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
550
611
 
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # QQ channel setup helper for the channel-manager skill.
5
+ #
6
+ # Usage:
7
+ # qq_setup.rb --validate "<APP_ID> <APP_SECRET>" [--sandbox]
8
+ #
9
+ # Exchanges the AppID/AppSecret for a bot access_token via the QQ Open
10
+ # Platform, so the skill can validate credentials BEFORE posting them to the
11
+ # server API. Prints a small JSON document on success; exits non-zero with an
12
+ # error message on failure. Uses only the Ruby standard library.
13
+
14
+ require "json"
15
+ require "net/http"
16
+ require "uri"
17
+
18
+ PORTAL_URL = "https://q.qq.com"
19
+ # The token endpoint is shared by production and sandbox ("不区分正式环境、沙箱环境");
20
+ # only the openapi host differs.
21
+ TOKEN_URL = "https://api.bot.qq.com/app/getAppAccessToken"
22
+
23
+ def fail!(message)
24
+ warn({ "ok" => false, "error" => message }.to_json)
25
+ exit 1
26
+ end
27
+
28
+ def print_usage
29
+ warn "Usage: #{$PROGRAM_NAME} --validate \"<APP_ID> <APP_SECRET>\" [--sandbox]"
30
+ warn " #{$PROGRAM_NAME} --portal-url"
31
+ exit 2
32
+ end
33
+
34
+ def parse_pair(pair)
35
+ app_id, app_secret = pair.to_s.split(/\s+/, 2)
36
+ fail!("missing app_id or app_secret") if app_id.nil? || app_id.empty? || app_secret.nil? || app_secret.empty?
37
+ [app_id, app_secret]
38
+ end
39
+
40
+ def request_token(app_id, app_secret)
41
+ uri = URI(TOKEN_URL)
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = true
44
+ http.open_timeout = 10
45
+ http.read_timeout = 10
46
+
47
+ req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
48
+ req.body = { appId: app_id, clientSecret: app_secret }.to_json
49
+ http.request(req)
50
+ end
51
+
52
+ def parse_body(res)
53
+ JSON.parse(res.body)
54
+ rescue JSON::ParserError
55
+ fail!("non-JSON response (HTTP #{res.code})")
56
+ end
57
+
58
+ def validate!(pair, sandbox: false)
59
+ app_id, app_secret = parse_pair(pair)
60
+ res = request_token(app_id, app_secret)
61
+ data = parse_body(res)
62
+ if res.is_a?(Net::HTTPSuccess) && data["access_token"]
63
+ puts({ "ok" => true, "app_id" => app_id, "sandbox" => sandbox, "expires_in" => data["expires_in"] }.to_json)
64
+ else
65
+ fail!(data["message"] || data["msg"] || "token request failed (HTTP #{res.code})")
66
+ end
67
+ end
68
+
69
+ mode = ARGV.first
70
+ print_usage if mode.nil?
71
+
72
+ case mode
73
+ when "--portal-url"
74
+ puts PORTAL_URL
75
+ when "--validate"
76
+ validate!(ARGV[1], sandbox: ARGV.include?("--sandbox"))
77
+ else
78
+ print_usage
79
+ end