openclacky 1.2.10 → 1.2.13

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +56 -1
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/session_serializer.rb +1 -1
  8. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  9. data/lib/clacky/agent/skill_evolution.rb +23 -5
  10. data/lib/clacky/agent/skill_manager.rb +86 -1
  11. data/lib/clacky/agent/skill_reflector.rb +18 -23
  12. data/lib/clacky/agent/tool_registry.rb +10 -0
  13. data/lib/clacky/agent.rb +68 -23
  14. data/lib/clacky/agent_config.rb +59 -15
  15. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  16. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  17. data/lib/clacky/cli.rb +55 -0
  18. data/lib/clacky/client.rb +25 -3
  19. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  20. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  21. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  22. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  23. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  24. data/lib/clacky/idle_compression_timer.rb +1 -1
  25. data/lib/clacky/message_format/open_ai.rb +7 -1
  26. data/lib/clacky/message_history.rb +57 -0
  27. data/lib/clacky/openai_stream_aggregator.rb +30 -3
  28. data/lib/clacky/providers.rb +40 -12
  29. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  30. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  31. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  32. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  33. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  34. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  35. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  36. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  37. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  38. data/lib/clacky/server/http_server.rb +190 -10
  39. data/lib/clacky/server/session_registry.rb +34 -14
  40. data/lib/clacky/server/web_ui_controller.rb +24 -1
  41. data/lib/clacky/session_manager.rb +120 -0
  42. data/lib/clacky/tools/trash_manager.rb +1 -1
  43. data/lib/clacky/tools/web_search.rb +59 -8
  44. data/lib/clacky/ui2/layout_manager.rb +15 -5
  45. data/lib/clacky/ui2/progress_handle.rb +7 -1
  46. data/lib/clacky/ui2/ui_controller.rb +27 -0
  47. data/lib/clacky/ui_interface.rb +22 -0
  48. data/lib/clacky/utils/model_pricing.rb +96 -0
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +230 -7
  51. data/lib/clacky/web/app.js +6 -5
  52. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  53. data/lib/clacky/web/brand.js +22 -2
  54. data/lib/clacky/web/favicon.ico +0 -0
  55. data/lib/clacky/web/i18n.js +22 -4
  56. data/lib/clacky/web/index.html +6 -4
  57. data/lib/clacky/web/logo_nav_dark.png +0 -0
  58. data/lib/clacky/web/model-tester.js +8 -1
  59. data/lib/clacky/web/sessions.js +576 -120
  60. data/lib/clacky/web/settings.js +213 -51
  61. data/lib/clacky/web/skills.js +5 -14
  62. data/lib/clacky/web/theme.js +1 -0
  63. data/lib/clacky/web/utils.js +57 -0
  64. data/lib/clacky/web/ws-dispatcher.js +136 -0
  65. data/scripts/build/lib/gem.sh +9 -2
  66. data/scripts/build/src/install_full.sh.cc +2 -0
  67. data/scripts/build/src/uninstall.sh.cc +1 -1
  68. data/scripts/install.ps1 +19 -5
  69. data/scripts/install.sh +9 -2
  70. data/scripts/install_full.sh +11 -2
  71. data/scripts/install_rails_deps.sh +9 -2
  72. data/scripts/uninstall.sh +10 -3
  73. metadata +9 -2
@@ -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" \
@@ -48,10 +48,11 @@ Scan the list above:
48
48
 
49
49
  Use the `write` tool. Always include the YAML frontmatter shown above.
50
50
 
51
- ## Hard constraints (CRITICAL)
51
+ ## Guidelines
52
52
 
53
- - Each file MUST stay under **4000 characters of content** (after the frontmatter).
54
- - If merging would exceed this limit, remove the least important information do NOT split into multiple files for the same topic.
53
+ - Aim for around 4000 characters of content (after the frontmatter). This is a soft target — moderate overshoot is fine, do NOT iterate writes just to shave characters.
54
+ - If a file grows much larger than that (say, well past 8000), trim the least important information rather than splitting one topic across multiple files.
55
+ - Prefer merging into an existing file over creating a new one. Only create a new file when no existing topic genuinely covers the area.
55
56
  - Write concise, factual Markdown — no fluff, no redundant headings.
56
57
  - One topic per file. Don't bundle unrelated facts together.
57
58
  - Do NOT use `terminal` or `file_reader` to list the memories directory — the list above is authoritative.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: search-skills
3
+ description: 'Search ALL installed skills (including ones not shown in AVAILABLE SKILLS) by keyword. Use this whenever you suspect a fitting skill might exist but is not listed in your system prompt — for example before building a new skill, when the user mentions a domain not covered by visible skills, or after seeing the (N more skills installed) hint. Triggers on phrases like search skills, find a skill for, is there a skill that, 查找skill, 有没有skill做.'
4
+ disable-model-invocation: false
5
+ user-invocable: true
6
+ fork_agent: true
7
+ auto_summarize: true
8
+ forbidden_tools:
9
+ - write
10
+ - edit
11
+ - terminal
12
+ - web_search
13
+ - web_fetch
14
+ - browser
15
+ ---
16
+
17
+ # Search Skills Subagent
18
+
19
+ You are a Skill Search Subagent. Given a keyword or topic from the parent agent, scan the complete list of installed skills below and return the best matches.
20
+
21
+ The AVAILABLE SKILLS section in the parent agent system prompt is capped (~30 entries). The list below is the FULL list — pre-loaded for you, no scanning required. Your job is to look beyond that cap so the parent does not redundantly create a new skill when one already exists.
22
+
23
+ ## Complete Skill Inventory
24
+
25
+ This list was pre-loaded — do NOT re-scan the filesystem or call any tools.
26
+
27
+ <%= all_skills_meta %>
28
+
29
+ ## Workflow
30
+
31
+ ### Step 1 — Extract keywords
32
+
33
+ Pull 2-4 keywords from the input task. Both English and Chinese terms are valid (skill descriptions are bilingual).
34
+
35
+ ### Step 2 — Match against the inventory above
36
+
37
+ For each skill in the inventory, judge relevance against the keywords:
38
+ - Strong match: keyword appears in the skill `name` or clearly in the `description`'s purpose statement
39
+ - Weak match: keyword appears only in the trigger examples or peripheral mentions
40
+
41
+ ### Step 3 — Return a ranked summary
42
+
43
+ Return at most 5 results, strongest matches first:
44
+
45
+ ```
46
+ Found N matching skill(s) for: <keywords>
47
+
48
+ 1. <name> (<source>)
49
+ <description trimmed to ~200 chars>
50
+
51
+ 2. ...
52
+ ```
53
+
54
+ If nothing genuinely matches, return exactly: `No installed skill matches: <task>`
55
+
56
+ ## Rules
57
+
58
+ - Do NOT invoke any tool. The inventory above is authoritative; just match and return.
59
+ - Do NOT recommend creating a new skill — that is the parent agent's call.
60
+ - If the task is vague, return what genuinely matched, do not invent relevance.
61
+ - Default skills (built-in) are part of the inventory but typically also visible to the parent — flagging them is still useful as a reminder.
@@ -17,7 +17,7 @@ module Clacky
17
17
  # Seconds of inactivity before idle compression is triggered.
18
18
  # Kept under the 5-minute prompt cache TTL so the compression call itself
19
19
  # still hits the existing prefix cache.
20
- IDLE_DELAY = 314
20
+ IDLE_DELAY = 266
21
21
 
22
22
  # @param agent [Clacky::Agent] the agent whose messages will be compressed
23
23
  # @param session_manager [Clacky::SessionManager, nil] used to persist session after compression
@@ -206,7 +206,13 @@ module Clacky
206
206
  # Skip malformed tool calls where name or arguments is nil (broken API response)
207
207
  next if name.nil? || arguments.nil?
208
208
 
209
- { id: call["id"], type: call["type"], name: name, arguments: arguments }
209
+ tc = { id: call["id"], type: call["type"], name: name, arguments: arguments }
210
+ # Vertex Gemini's OpenAI shim returns thought_signature inside
211
+ # tool_calls[i].extra_content.google and requires it echoed back on
212
+ # replay, otherwise the next turn 400s with "Function call is missing
213
+ # a thought_signature". Preserve it through the canonical layer.
214
+ tc[:extra_content] = call["extra_content"] if call["extra_content"]
215
+ tc
210
216
  end
211
217
  end
212
218
 
@@ -193,6 +193,7 @@ module Clacky
193
193
  # this bypass lets us recover on the retry without a server restart.
194
194
  def to_api(force_reasoning_content_pad: false)
195
195
  msgs = @messages.map { |m| strip_for_api(m) }
196
+ msgs = repair_tool_call_pairing(msgs)
196
197
  ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
197
198
  end
198
199
 
@@ -276,6 +277,62 @@ module Clacky
276
277
  message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
277
278
  end
278
279
 
280
+ # Defensive integrity pass before sending history to the LLM.
281
+ # OpenAI-compat protocols (incl. DeepSeek) require every assistant.tool_calls
282
+ # to be followed by a tool message for each tool_call_id. If a previous turn
283
+ # was interrupted mid-act() — e.g. a channel user sent a second message
284
+ # before the first finished — the tool segment may be missing entries,
285
+ # producing HTTP 400 "insufficient tool messages following tool_calls".
286
+ # This pass scans the whole history and inserts placeholder tool messages
287
+ # for any unmatched ids. It is deterministic (same input → same output)
288
+ # so prompt caching is not disturbed.
289
+ private def repair_tool_call_pairing(msgs)
290
+ result = []
291
+ i = 0
292
+ while i < msgs.size
293
+ msg = msgs[i]
294
+ result << msg
295
+
296
+ if msg[:role] == "assistant" && msg[:tool_calls].is_a?(Array) && !msg[:tool_calls].empty?
297
+ expected_ids = msg[:tool_calls].map { |tc| tc[:id] }.compact
298
+ seen_ids = []
299
+
300
+ j = i + 1
301
+ while j < msgs.size && tool_result_message?(msgs[j])
302
+ result << msgs[j]
303
+ seen_ids.concat(tool_result_ids(msgs[j]))
304
+ j += 1
305
+ end
306
+
307
+ (expected_ids - seen_ids).each do |id|
308
+ result << {
309
+ role: "tool",
310
+ tool_call_id: id,
311
+ content: '{"error":"Tool result missing (interrupted)"}'
312
+ }
313
+ end
314
+
315
+ i = j
316
+ else
317
+ i += 1
318
+ end
319
+ end
320
+ result
321
+ end
322
+
323
+ private def tool_result_message?(msg)
324
+ MessageFormat::OpenAI.tool_result_message?(msg) ||
325
+ MessageFormat::Anthropic.tool_result_message?(msg)
326
+ end
327
+
328
+ private def tool_result_ids(msg)
329
+ if MessageFormat::OpenAI.tool_result_message?(msg)
330
+ MessageFormat::OpenAI.tool_call_ids(msg)
331
+ else
332
+ MessageFormat::Anthropic.tool_use_ids(msg)
333
+ end
334
+ end
335
+
279
336
  # Detect thinking-mode providers purely from history content and pad
280
337
  # synthetic assistant messages with an empty reasoning_content when needed.
281
338
  #
@@ -28,10 +28,25 @@ module Clacky
28
28
  @usage = nil
29
29
  @last_input_tokens = 0
30
30
  @last_output_tokens = 0
31
+ @parse_failures = 0
32
+ @frames_seen = 0
33
+ @bytes_seen = 0
34
+ @saw_done = false
35
+ end
36
+
37
+ attr_reader :parse_failures, :frames_seen, :bytes_seen
38
+
39
+ def saw_done?
40
+ @saw_done
31
41
  end
32
42
 
33
43
  def handle(data_str)
34
- return if data_str == "[DONE]"
44
+ @bytes_seen += data_str.bytesize
45
+ if data_str == "[DONE]"
46
+ @saw_done = true
47
+ return
48
+ end
49
+ @frames_seen += 1
35
50
  data = parse_or_nil(data_str)
36
51
  return unless data
37
52
 
@@ -57,7 +72,7 @@ module Clacky
57
72
  def to_h
58
73
  tool_calls = @tool_calls.keys.sort.map do |idx|
59
74
  tc = @tool_calls[idx]
60
- {
75
+ out = {
61
76
  "id" => tc[:id],
62
77
  "type" => tc[:type] || "function",
63
78
  "function" => {
@@ -65,6 +80,8 @@ module Clacky
65
80
  "arguments" => tc[:arguments].to_s
66
81
  }
67
82
  }
83
+ out["extra_content"] = tc[:extra_content] if tc[:extra_content]
84
+ out
68
85
  end
69
86
 
70
87
  message = {
@@ -89,11 +106,21 @@ module Clacky
89
106
  slot[:name] ||= fn["name"] if fn["name"]
90
107
  slot[:arguments] << fn["arguments"].to_s if fn["arguments"]
91
108
  end
109
+ slot[:extra_content] = tc["extra_content"] if tc["extra_content"]
92
110
  end
93
111
 
94
112
  private def parse_or_nil(s)
95
113
  JSON.parse(s)
96
- rescue JSON::ParserError
114
+ rescue JSON::ParserError => e
115
+ @parse_failures += 1
116
+ if @parse_failures == 1
117
+ Clacky::Logger.warn("stream.parse_failure",
118
+ provider: "openai",
119
+ error: "#{e.class}: #{e.message}",
120
+ frame_head: s.to_s[0, 200],
121
+ frame_bytes: s.to_s.bytesize
122
+ )
123
+ end
97
124
  nil
98
125
  end
99
126
 
@@ -39,18 +39,25 @@ module Clacky
39
39
  "abs-claude-haiku-4-5",
40
40
  "dsk-deepseek-v4-pro",
41
41
  "dsk-deepseek-v4-flash",
42
- "or-gemini-3-1-pro"
42
+ "or-gemini-3-1-pro",
43
+ "or-gemini-3-5-flash"
43
44
  ],
44
45
  # Image generation models served by the openclacky platform
45
46
  # gateway. The gateway exposes a standard OpenAI-compatible
46
47
  # /v1/images/generations endpoint, so the same OpenAICompat
47
- # provider class handles them. `or-` prefix mirrors the chat
48
- # model naming these are routed through the OpenRouter
49
- # backend by the platform.
48
+ # provider class handles them. `or-` prefix is a routing alias
49
+ # onlythe platform may dispatch to OpenRouter or Vertex AI
50
+ # (Gemini Nano Banana family) depending on the model.
50
51
  "image_models" => [
51
52
  "or-gemini-3-pro-image",
53
+ "or-gemini-3-1-flash-image",
52
54
  "or-gpt-image-2"
53
55
  ],
56
+ "image_model_aliases" => {
57
+ "or-gemini-3-pro-image" => "Nano Banana Pro",
58
+ "or-gemini-3-1-flash-image" => "Nano Banana 2",
59
+ "or-gpt-image-2" => "GPT Image 2"
60
+ },
54
61
  "default_image_model" => "or-gpt-image-2",
55
62
  # Provider-level default: the Claude family served here is vision-capable.
56
63
  "capabilities" => { "vision" => true }.freeze,
@@ -65,20 +72,17 @@ module Clacky
65
72
  # Per-primary lite pairing: keys are "strong" primary models, values
66
73
  # are the lite sidekick to auto-inject when that primary is the
67
74
  # default. Lite is consumed by some subagents for cheap/fast work;
68
- # weak models (haiku / v4-flash) ARE the lite tier themselves, so
69
- # they're intentionally not listed here no injection happens when
70
- # the default model is already lite-class.
71
- #
72
- # or-gemini-3-1-pro is intentionally absent: Gemini has no lite
73
- # sibling wired up (yet) on this provider; subagents using the
74
- # Gemini default will just reuse it for lite work until we add one.
75
+ # weak models (haiku / v4-flash / 3-5-flash) ARE the lite tier
76
+ # themselves, so they're intentionally not listed here as keys
77
+ # no injection happens when the default model is already lite-class.
75
78
  "lite_models" => {
76
79
  "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
77
80
  "abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
78
81
  "abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
79
82
  "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
80
83
  "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
81
- "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash"
84
+ "dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
85
+ "or-gemini-3-1-pro" => "or-gemini-3-5-flash"
82
86
  },
83
87
  # Fallback chain: if a model is unavailable, try the next one in order.
84
88
  # Keys are primary model names; values are the fallback model to use instead.
@@ -487,6 +491,30 @@ module Clacky
487
491
  preset&.dig("image_models") || []
488
492
  end
489
493
 
494
+ def image_model_aliases(provider_id)
495
+ preset = PRESETS[provider_id]
496
+ preset&.dig("image_model_aliases") || {}
497
+ end
498
+
499
+ def video_model_aliases(provider_id)
500
+ preset = PRESETS[provider_id]
501
+ preset&.dig("video_model_aliases") || {}
502
+ end
503
+
504
+ def audio_model_aliases(provider_id)
505
+ preset = PRESETS[provider_id]
506
+ preset&.dig("audio_model_aliases") || {}
507
+ end
508
+
509
+ def media_model_aliases(provider_id, kind)
510
+ case kind.to_s
511
+ when "image" then image_model_aliases(provider_id)
512
+ when "video" then video_model_aliases(provider_id)
513
+ when "audio" then audio_model_aliases(provider_id)
514
+ else {}
515
+ end
516
+ end
517
+
490
518
  # Video generation models — placeholder. No provider supports video
491
519
  # via Clacky yet; once they do, declare "video_models" alongside
492
520
  # "image_models" in the relevant PRESETS entry and this returns it.
@@ -181,6 +181,10 @@ module Clacky
181
181
  at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
182
182
  bot_id = data.dig("chatbotUserId").to_s
183
183
  unless at_users.include?(bot_id) || content.include?("@")
184
+ observe_text = content.strip
185
+ unless observe_text.empty?
186
+ @on_message&.call({ platform: :dingtalk, chat_id: chat_id, user_id: sender_id, text: observe_text, observe_only: true })
187
+ end
184
188
  return
185
189
  end
186
190
  end
@@ -188,7 +192,11 @@ module Clacky
188
192
  allowed = @config[:allowed_users]
189
193
  return if allowed && !allowed.empty? && !allowed.include?(sender_id)
190
194
 
191
- text, files = extract_payload(data, robot_code)
195
+ text, files, unsupported = extract_payload(data, robot_code)
196
+ if unsupported
197
+ @on_message&.call({ platform: :dingtalk, chat_id: chat_id, unsupported: true })
198
+ return
199
+ end
192
200
  return if text.strip.empty? && files.empty?
193
201
 
194
202
  event = {
@@ -242,6 +250,7 @@ module Clacky
242
250
  end
243
251
  else
244
252
  Clacky::Logger.info("[dingtalk] unsupported msgtype=#{msgtype}, ignoring")
253
+ return ["", [], true]
245
254
  end
246
255
 
247
256
  [text, files]
@@ -142,7 +142,10 @@ module Clacky
142
142
  Clacky::Logger.warn("[DiscordAdapter] bot_user_id unavailable; dropping group message")
143
143
  return
144
144
  end
145
- return unless mentioned_ids.include?(@bot_user_id)
145
+ unless mentioned_ids.include?(@bot_user_id)
146
+ @on_message&.call(event.merge(observe_only: true))
147
+ return
148
+ end
146
149
  end
147
150
 
148
151
  allowed_users = @config[:allowed_users]
@@ -153,7 +156,10 @@ module Clacky
153
156
  text = strip_bot_mention(msg["content"].to_s, @bot_user_id)
154
157
  files = process_attachments(Array(msg["attachments"]), chat_id)
155
158
 
156
- return if text.strip.empty? && files.empty?
159
+ if text.strip.empty? && files.empty?
160
+ @on_message&.call({ type: :message, platform: :discord, chat_id: chat_id, unsupported: true })
161
+ return
162
+ end
157
163
 
158
164
  event = {
159
165
  type: :message,
@@ -177,7 +177,11 @@ module Clacky
177
177
  Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
178
178
  return
179
179
  end
180
- return unless Array(event[:mentioned_open_ids]).include?(bot_id)
180
+ unless Array(event[:mentioned_open_ids]).include?(bot_id)
181
+ user_name = @bot.fetch_user_name(event[:user_id])
182
+ @on_message&.call(event.merge(observe_only: true, user_name: user_name))
183
+ return
184
+ end
181
185
  end
182
186
 
183
187
  allowed_users = @config[:allowed_users]
@@ -185,6 +189,11 @@ module Clacky
185
189
  return unless allowed_users.include?(event[:user_id])
186
190
  end
187
191
 
192
+ if event[:unsupported]
193
+ @on_message&.call(event)
194
+ return
195
+ end
196
+
188
197
  # Download images and attach as file hashes
189
198
  image_files = []
190
199
  if event[:image_keys] && !event[:image_keys].empty?
@@ -42,6 +42,7 @@ module Clacky
42
42
  @domain = domain
43
43
  @token_cache = nil
44
44
  @token_expires_at = nil
45
+ @user_name_cache = {}
45
46
  end
46
47
 
47
48
  # Send plain text message
@@ -236,6 +237,17 @@ module Clacky
236
237
  nil
237
238
  end
238
239
 
240
+ def fetch_user_name(open_id)
241
+ return @user_name_cache[open_id] if @user_name_cache.key?(open_id)
242
+
243
+ name = get("/open-apis/contact/v3/users/#{open_id}", params: { user_id_type: "open_id" })
244
+ .dig("data", "user", "name")
245
+ @user_name_cache[open_id] = name.to_s.strip.then { |n| n.empty? ? open_id : n }
246
+ rescue => e
247
+ Clacky::Logger.warn("[feishu] Failed to fetch user name for #{open_id}: #{e.message}")
248
+ @user_name_cache[open_id] = open_id
249
+ end
250
+
239
251
  # Get tenant access token (cached)
240
252
  # @return [String] Access token
241
253
  def tenant_access_token
@@ -56,8 +56,16 @@ module Clacky
56
56
  msg_type = message["message_type"]
57
57
  Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
58
58
  unless %w[text image file post].include?(msg_type)
59
- Clacky::Logger.info("[feishu] dropping unsupported msg_type=#{msg_type}")
60
- return nil
59
+ Clacky::Logger.info("[feishu] unsupported msg_type=#{msg_type}")
60
+ chat_type = message["chat_type"] == "p2p" ? :direct : :group
61
+ return {
62
+ type: :message,
63
+ platform: :feishu,
64
+ chat_id: message["chat_id"],
65
+ chat_type: chat_type,
66
+ mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
67
+ unsupported: true
68
+ }
61
69
  end
62
70
 
63
71
  content_raw = message["content"]
@@ -70,7 +78,7 @@ module Clacky
70
78
 
71
79
  case msg_type
72
80
  when "text"
73
- text = strip_mentions(content["text"].to_s.strip)
81
+ text = resolve_mentions(content["text"].to_s.strip, message["mentions"])
74
82
  return nil if text.empty?
75
83
  when "image"
76
84
  image_keys = [content["image_key"]].compact
@@ -131,6 +139,18 @@ module Clacky
131
139
  text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
132
140
  end
133
141
 
142
+ # Replace @_user_N placeholders in text with real display names from mentions array.
143
+ # Falls back to stripping unresolved placeholders via strip_mentions.
144
+ def resolve_mentions(text, mentions)
145
+ mapping = Array(mentions).each_with_object({}) do |m, h|
146
+ key = m["key"].to_s
147
+ name = m.dig("name").to_s
148
+ h[key] = name unless key.empty? || name.empty?
149
+ end
150
+ result = text.gsub(/@_user_\d+/) { |k| mapping[k] ? "@#{mapping[k]}" : k }
151
+ strip_mentions(result).strip
152
+ end
153
+
134
154
  # Parse a Feishu post content body into text and image_keys.
135
155
  # post content structure from event payloads:
136
156
  # {"title": "", "content": [[{tag, text, ...}, ...], ...]}
@@ -243,7 +243,13 @@ module Clacky
243
243
  text = msg["text"].to_s
244
244
 
245
245
  if is_group
246
- return unless group_mention?(msg, text)
246
+ unless group_mention?(msg, text)
247
+ observe_text = text.strip
248
+ unless observe_text.empty?
249
+ @on_message&.call({ type: :message, platform: :telegram, chat_id: chat_id.to_s, user_id: user_id.to_s, text: observe_text, observe_only: true })
250
+ end
251
+ return
252
+ end
247
253
  text = strip_bot_mention(text)
248
254
  end
249
255
 
@@ -255,7 +261,11 @@ module Clacky
255
261
  files = collect_files(msg)
256
262
  caption = msg["caption"].to_s
257
263
  text = caption if text.empty? && !caption.empty?
258
- return if text.strip.empty? && files.empty?
264
+
265
+ if text.strip.empty? && files.empty?
266
+ @on_message&.call({ type: :message, platform: :telegram, chat_id: chat_id.to_s, unsupported: true })
267
+ return
268
+ end
259
269
 
260
270
  event = {
261
271
  type: :message,