openclacky 1.0.0 → 1.0.2

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +87 -53
  4. data/lib/clacky/agent/cost_tracker.rb +19 -2
  5. data/lib/clacky/agent/llm_caller.rb +218 -0
  6. data/lib/clacky/agent/message_compressor_helper.rb +32 -2
  7. data/lib/clacky/agent.rb +54 -22
  8. data/lib/clacky/client.rb +44 -5
  9. data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
  10. data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
  11. data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
  12. data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
  13. data/lib/clacky/default_skills/new/SKILL.md +3 -114
  14. data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
  15. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
  16. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  17. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  18. data/lib/clacky/message_format/anthropic.rb +72 -8
  19. data/lib/clacky/message_format/bedrock.rb +6 -3
  20. data/lib/clacky/providers.rb +146 -3
  21. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  22. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  23. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  24. data/lib/clacky/server/channel/channel_manager.rb +12 -4
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  26. data/lib/clacky/server/http_server.rb +746 -13
  27. data/lib/clacky/server/session_registry.rb +55 -24
  28. data/lib/clacky/skill.rb +10 -9
  29. data/lib/clacky/skill_loader.rb +23 -11
  30. data/lib/clacky/tools/file_reader.rb +232 -127
  31. data/lib/clacky/tools/security.rb +42 -64
  32. data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
  33. data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
  34. data/lib/clacky/tools/terminal/session_manager.rb +8 -3
  35. data/lib/clacky/tools/terminal.rb +263 -16
  36. data/lib/clacky/ui2/layout_manager.rb +8 -1
  37. data/lib/clacky/ui2/output_buffer.rb +83 -23
  38. data/lib/clacky/ui2/ui_controller.rb +74 -7
  39. data/lib/clacky/utils/file_processor.rb +14 -40
  40. data/lib/clacky/utils/model_pricing.rb +215 -0
  41. data/lib/clacky/utils/parser_manager.rb +70 -6
  42. data/lib/clacky/utils/string_matcher.rb +23 -1
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +673 -9
  45. data/lib/clacky/web/app.js +40 -1608
  46. data/lib/clacky/web/i18n.js +209 -0
  47. data/lib/clacky/web/index.html +166 -2
  48. data/lib/clacky/web/onboard.js +77 -1
  49. data/lib/clacky/web/profile.js +442 -0
  50. data/lib/clacky/web/sessions.js +1034 -2
  51. data/lib/clacky/web/settings.js +127 -6
  52. data/lib/clacky/web/sidebar.js +39 -0
  53. data/lib/clacky/web/skills.js +460 -0
  54. data/lib/clacky/web/trash.js +343 -0
  55. data/lib/clacky/web/ws-dispatcher.js +255 -0
  56. data/lib/clacky.rb +5 -3
  57. metadata +16 -17
  58. data/lib/clacky/clacky_auth_client.rb +0 -152
  59. data/lib/clacky/clacky_cloud_config.rb +0 -123
  60. data/lib/clacky/cloud_project_client.rb +0 -169
  61. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
  62. data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
  63. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
  64. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
  65. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
  66. data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
  67. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
  68. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
  69. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
  70. data/lib/clacky/deploy_api_client.rb +0 -484
@@ -91,13 +91,53 @@ module Clacky
91
91
  else data["stop_reason"]
92
92
  end
93
93
 
94
+ # Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
95
+ # input — cache_read_input_tokens and cache_creation_input_tokens are
96
+ # reported separately and are disjoint from input_tokens.
97
+ #
98
+ # Normalise to the codebase's canonical shape (OpenAI-style) so downstream
99
+ # (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
100
+ # provider-agnostic:
101
+ #
102
+ # prompt_tokens = non_cached + cache_read (OpenAI convention:
103
+ # includes cache_read
104
+ # but NOT cache_write;
105
+ # ModelPricing does
106
+ # `regular_input = prompt_tokens - cache_read`.)
107
+ # completion_tokens = output
108
+ # total_tokens = THIS TURN'S new compute volume
109
+ # = raw_input + cache_creation + output
110
+ # (cache_read is excluded because hits are ~free /
111
+ # already-paid-for; cache_creation IS new work this
112
+ # turn even though it's billed at write_rate.)
113
+ # cache_read_input_tokens / cache_creation_input_tokens → independent fields
114
+ #
115
+ # total_tokens is purely presentational. CostTracker treats it as the
116
+ # per-iteration delta directly (no subtraction of previous_total), which
117
+ # is the correct reading when total_tokens already means "new work this
118
+ # turn" rather than "cumulative".
119
+ raw_input_tokens = usage["input_tokens"].to_i
120
+ cache_read = usage["cache_read_input_tokens"].to_i
121
+ cache_creation = usage["cache_creation_input_tokens"].to_i
122
+ output_tokens = usage["output_tokens"].to_i
123
+
124
+ prompt_tokens = raw_input_tokens + cache_read
125
+
94
126
  usage_data = {
95
- prompt_tokens: usage["input_tokens"],
96
- completion_tokens: usage["output_tokens"],
97
- total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i
127
+ prompt_tokens: prompt_tokens,
128
+ completion_tokens: output_tokens,
129
+ # Per-turn new compute: what the server freshly processed this request.
130
+ # Excludes cache_read (nearly free, already-paid-for).
131
+ total_tokens: raw_input_tokens + cache_creation + output_tokens,
132
+ # Signal to CostTracker: total_tokens above is already the per-turn
133
+ # delta (not a running cumulative like OpenAI's). CostTracker should
134
+ # NOT subtract previous_total when this flag is truthy.
135
+ # OpenAI parse leaves this field unset; Bedrock may adopt the same
136
+ # convention in future if we normalise it there too.
137
+ total_is_per_turn: true
98
138
  }
99
- usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"] if usage["cache_read_input_tokens"]
100
- usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"] if usage["cache_creation_input_tokens"]
139
+ usage_data[:cache_read_input_tokens] = cache_read if cache_read > 0
140
+ usage_data[:cache_creation_input_tokens] = cache_creation if cache_creation > 0
101
141
 
102
142
  { content: content, tool_calls: tool_calls, finish_reason: finish_reason,
103
143
  usage: usage_data, raw_api_usage: usage }
@@ -151,15 +191,39 @@ module Clacky
151
191
 
152
192
  # canonical tool result (role: "tool") → Anthropic user message with tool_result block
153
193
  if role == "tool"
194
+ # Strip any cache_control that Client#apply_message_caching may have
195
+ # embedded INSIDE msg[:content] (it wraps string content as
196
+ # [{type:"text", text:..., cache_control:{...}}]). We hoist that
197
+ # marker up to the tool_result block itself below — that's where
198
+ # Anthropic expects the marker for a tool_result turn.
199
+ #
200
+ # CRITICAL: if we leave cache_control on the inner text block, the
201
+ # tool_result.content shape flips between "string" and
202
+ # "[{text,cache_control}]" depending on whether this message is the
203
+ # current cache breakpoint — which mutates the cached prefix every
204
+ # turn and destroys cache_read hit-rate (the classic "cache_read
205
+ # stuck at tiny number" symptom).
206
+ hoisted_cache_control = nil
207
+ raw_content = msg[:content]
208
+ if raw_content.is_a?(Array) &&
209
+ raw_content.length == 1 &&
210
+ raw_content.first.is_a?(Hash) &&
211
+ raw_content.first[:type] == "text" &&
212
+ raw_content.first[:cache_control]
213
+ hoisted_cache_control = raw_content.first[:cache_control]
214
+ raw_content = raw_content.first[:text]
215
+ end
216
+
154
217
  # If content is an Array of canonical blocks (e.g. image_url + text from file_reader),
155
218
  # convert each block to Anthropic format via content_to_blocks.
156
219
  # Plain strings pass through unchanged.
157
- tool_content = if msg[:content].is_a?(Array)
158
- content_to_blocks(msg[:content])
220
+ tool_content = if raw_content.is_a?(Array)
221
+ content_to_blocks(raw_content)
159
222
  else
160
- msg[:content]
223
+ raw_content
161
224
  end
162
225
  block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: tool_content }
226
+ block[:cache_control] = hoisted_cache_control if hoisted_cache_control
163
227
  return { role: "user", content: [block] }
164
228
  end
165
229
 
@@ -118,11 +118,14 @@ module Clacky
118
118
  cache_write = usage["cacheWriteInputTokens"].to_i
119
119
 
120
120
  # Bedrock `inputTokens` = non-cached input only.
121
- # Anthropic direct `input_tokens` = all input including cache_read.
122
- # Normalise to Anthropic semantics so ModelPricing.calculate_cost works correctly:
121
+ # Anthropic direct `input_tokens` = ALSO non-cached input only
122
+ # (cache_read_input_tokens and cache_creation_input_tokens are reported
123
+ # separately and are disjoint from input_tokens — NOT included in it).
124
+ # Normalise to the OpenAI/Bedrock convention so ModelPricing.calculate_cost
125
+ # works correctly:
123
126
  # prompt_tokens = inputTokens + cacheReadInputTokens
124
127
  # (calculate_cost subtracts cache_read_tokens from prompt_tokens to get
125
- # the billable non-cached portion; that arithmetic requires the Anthropic convention.)
128
+ # the billable non-cached portion; cache_write is priced on top.)
126
129
  prompt_tokens = usage["inputTokens"].to_i + cache_read
127
130
 
128
131
  usage_data = {
@@ -17,6 +17,13 @@ module Clacky
17
17
  # { "<model_name>" => { "<cap>" => bool, ... } }. Use this when a
18
18
  # single provider hosts models with different capabilities (e.g.
19
19
  # openclacky hosts both vision-capable Claude and text-only DeepSeek).
20
+ # - model_api_overrides (optional): per-model API-type override map,
21
+ # { <Regexp|String> => "anthropic-messages" | "openai-completions" | ... }.
22
+ # Keys can be a plain model name or a Regexp matched against the model.
23
+ # The first key that matches wins; if none match, the provider's top-level
24
+ # "api" is used. Used so e.g. OpenRouter can keep "openai-responses" as
25
+ # its default while routing Claude models through the native Anthropic
26
+ # endpoint (which preserves cache_control fidelity).
20
27
  PRESETS = {
21
28
  "openclacky" => {
22
29
  "name" => "OpenClacky",
@@ -74,6 +81,19 @@ module Clacky
74
81
  "api" => "openai-responses",
75
82
  "default_model" => "anthropic/claude-sonnet-4-6",
76
83
  "models" => [], # Dynamic - fetched from API
84
+ # Per-model API type overrides. Matched by Regexp against the model name.
85
+ # Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
86
+ # /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
87
+ # The OpenAI shim is lossy for Claude's cache_control semantics — prefix
88
+ # rewrites inside the proxy cause ~10% prompt-cache misses. Pinning
89
+ # "anthropic/*" (and any direct "claude-*" alias) to the native Anthropic
90
+ # endpoint preserves cache_control byte-for-byte and matches what Claude
91
+ # Code CLI does internally. Non-Claude models (Gemini, GPT, etc.) keep
92
+ # the OpenAI shim — that's what OpenRouter documents as their primary.
93
+ "model_api_overrides" => {
94
+ /\Aanthropic\// => "anthropic-messages",
95
+ /\Aclaude[-.]/ => "anthropic-messages"
96
+ }.freeze,
77
97
  "website_url" => "https://openrouter.ai/keys"
78
98
  }.freeze,
79
99
 
@@ -105,6 +125,15 @@ module Clacky
105
125
  "api" => "openai-completions",
106
126
  "default_model" => "MiniMax-M2.7",
107
127
  "models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
128
+ # MiniMax operates two regional endpoints with identical APIs & model
129
+ # lineup — mainland China (.com) and international (.io). Listing both
130
+ # lets find_by_base_url identify either one as provider "minimax",
131
+ # so capability checks (vision=false) fire correctly regardless of
132
+ # which endpoint the user configured.
133
+ "endpoint_variants" => [
134
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
135
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
136
+ ].freeze,
108
137
  # MiniMax M2.x does not support multimodal/vision input on this endpoint.
109
138
  "capabilities" => { "vision" => false }.freeze,
110
139
  "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
@@ -116,6 +145,17 @@ module Clacky
116
145
  "api" => "openai-completions",
117
146
  "default_model" => "kimi-k2.6",
118
147
  "models" => ["kimi-k2.6", "kimi-k2.5"],
148
+ # Moonshot operates two regional endpoints with identical APIs & model
149
+ # lineup — mainland China (.cn) and international (.ai). Kimi does not
150
+ # distinguish pay-as-you-go vs coding-plan at the base_url level, so
151
+ # only two variants are needed. Listing both here lets find_by_base_url
152
+ # identify either one as provider "kimi", so downstream capability
153
+ # checks, fallback chains, and provider-specific behaviours work
154
+ # regardless of which endpoint the user configured.
155
+ "endpoint_variants" => [
156
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
157
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
158
+ ].freeze,
119
159
  # k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
120
160
  "capabilities" => { "vision" => true }.freeze,
121
161
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
@@ -172,17 +212,56 @@ module Clacky
172
212
  }.freeze,
173
213
 
174
214
  "glm" => {
175
- "name" => "GLM (ZhipuAI)",
215
+ "name" => "GLM (Z.ai / Zhipu)",
176
216
  "base_url" => "https://open.bigmodel.cn/api/paas/v4",
177
217
  "api" => "openai-completions",
178
218
  "default_model" => "glm-5.1",
179
219
  "models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
220
+ # Zhipu / Z.ai expose four functionally-equivalent endpoints:
221
+ # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
222
+ # each with a general-billing and a Coding-Plan subpath. They share the
223
+ # same model lineup & identical capability profile, so a single preset
224
+ # with endpoint_variants is the right shape — one source of truth for
225
+ # vision/model_capabilities, four URLs recognised by find_by_base_url.
226
+ # Without this, users pointing at api.z.ai or the /coding/ path fell
227
+ # through to the conservative "assume vision=true" default and got
228
+ # hallucinated image descriptions on text-only GLM models (C-5563).
229
+ "endpoint_variants" => [
230
+ { "label" => "Mainland · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mainland_cn_payg", "base_url" => "https://open.bigmodel.cn/api/paas/v4", "region" => "cn" }.freeze,
231
+ { "label" => "Mainland · Coding Plan", "label_key" => "settings.models.baseurl.variant.mainland_cn_coding", "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn" }.freeze,
232
+ { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
233
+ { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
234
+ ].freeze,
180
235
  # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
181
236
  "capabilities" => { "vision" => false }.freeze,
182
237
  "model_capabilities" => {
183
238
  "glm-5v-turbo" => { "vision" => true }.freeze
184
239
  }.freeze,
185
240
  "website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
241
+ }.freeze,
242
+
243
+ "openai" => {
244
+ "name" => "OpenAI (GPT)",
245
+ "base_url" => "https://api.openai.com/v1",
246
+ "api" => "openai-completions",
247
+ "default_model" => "gpt-5.5",
248
+ "models" => [
249
+ "gpt-5.5",
250
+ "gpt-5.4",
251
+ "gpt-5.4-mini",
252
+ "gpt-5.4-nano",
253
+ "o4-mini",
254
+ "o3"
255
+ ],
256
+ # GPT-5.x and o-series models are multimodal (text + image input).
257
+ "capabilities" => { "vision" => true }.freeze,
258
+ # Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
259
+ # o4-mini and o3 are reasoning models without a lite-tier sibling here.
260
+ "lite_models" => {
261
+ "gpt-5.5" => "gpt-5.4-mini",
262
+ "gpt-5.4" => "gpt-5.4-mini"
263
+ },
264
+ "website_url" => "https://platform.openai.com/api-keys"
186
265
  }.freeze
187
266
 
188
267
  }.freeze
@@ -226,6 +305,51 @@ module Clacky
226
305
  preset&.dig("api")
227
306
  end
228
307
 
308
+ # Resolve the API type for a specific provider+model pair.
309
+ #
310
+ # Resolution order:
311
+ # 1. PRESETS[provider_id]["model_api_overrides"] — first key (String or
312
+ # Regexp) that matches the model name wins.
313
+ # 2. PRESETS[provider_id]["api"] — the provider-wide default.
314
+ # 3. nil — unknown provider.
315
+ #
316
+ # Use this instead of api_type when you need the precise transport for a
317
+ # given model (e.g. routing OpenRouter's Claude requests to the native
318
+ # /v1/messages endpoint to preserve prompt-cache fidelity).
319
+ #
320
+ # @param provider_id [String] The provider identifier
321
+ # @param model_name [String, nil] The specific model name
322
+ # @return [String, nil] The API type (e.g. "anthropic-messages")
323
+ def api_type_for_model(provider_id, model_name)
324
+ preset = PRESETS[provider_id]
325
+ return nil unless preset
326
+
327
+ overrides = preset["model_api_overrides"]
328
+ if overrides.is_a?(Hash) && model_name
329
+ name = model_name.to_s
330
+ matched = overrides.find do |pattern, _api|
331
+ case pattern
332
+ when Regexp then pattern.match?(name)
333
+ when String then pattern == name
334
+ else false
335
+ end
336
+ end
337
+ return matched[1] if matched
338
+ end
339
+
340
+ preset["api"]
341
+ end
342
+
343
+ # Returns true when the provider+model should be talked to using the
344
+ # native Anthropic /v1/messages format. This is the single source of
345
+ # truth for deciding anthropic_format at Client construction time.
346
+ # @param provider_id [String] The provider identifier
347
+ # @param model_name [String, nil] The specific model name
348
+ # @return [Boolean]
349
+ def anthropic_format_for_model?(provider_id, model_name)
350
+ api_type_for_model(provider_id, model_name) == "anthropic-messages"
351
+ end
352
+
229
353
  # List all available provider IDs
230
354
  # @return [Array<String>] List of provider identifiers
231
355
  def provider_ids
@@ -287,14 +411,33 @@ module Clacky
287
411
  # Find provider ID by base URL.
288
412
  # Matches if the given URL starts with the provider's base_url (after normalisation),
289
413
  # so both exact matches and sub-path variants (e.g. "/v1") are recognised.
414
+ #
415
+ # Also scans `endpoint_variants` (when present) so providers that operate
416
+ # multiple regional / billing-plan endpoints under the same identity
417
+ # (e.g. GLM on open.bigmodel.cn + api.z.ai, MiniMax on .com + .io) are
418
+ # all recognised as that single provider — one capability definition,
419
+ # N entry URLs. Without this, users configured with a non-default
420
+ # variant fall back to the "unknown provider" path and miss capability
421
+ # enforcement (see C-5563).
290
422
  # @param base_url [String] The base URL to look up
291
423
  # @return [String, nil] The provider ID or nil if not found
292
424
  def find_by_base_url(base_url)
293
425
  return nil if base_url.nil? || base_url.empty?
294
426
  normalized = base_url.to_s.chomp("/")
295
427
  PRESETS.find do |_id, preset|
296
- preset_base = preset["base_url"].to_s.chomp("/")
297
- normalized == preset_base || normalized.start_with?("#{preset_base}/")
428
+ # Collect every URL this preset claims: the canonical base_url plus
429
+ # any declared endpoint_variants. Dedup so the canonical one showing
430
+ # up in both lists doesn't change behaviour.
431
+ candidates = [preset["base_url"]]
432
+ variants = preset["endpoint_variants"]
433
+ if variants.is_a?(Array)
434
+ variants.each { |v| candidates << v["base_url"] if v.is_a?(Hash) }
435
+ end
436
+ candidates.compact.uniq.any? do |candidate|
437
+ preset_base = candidate.to_s.chomp("/")
438
+ next false if preset_base.empty?
439
+ normalized == preset_base || normalized.start_with?("#{preset_base}/")
440
+ end
298
441
  end&.first
299
442
  end
300
443
 
@@ -166,6 +166,20 @@ module Clacky
166
166
  # @param event [Hash] Parsed message event
167
167
  # @return [void]
168
168
  def handle_message_event(event)
169
+ # In group chats, only respond when the bot is explicitly @-mentioned.
170
+ # Private chats always respond.
171
+ # Fail closed: if the bot's own open_id cannot be fetched (API error,
172
+ # bad credentials, etc.), drop group messages instead of responding to
173
+ # every message and spamming the group.
174
+ if event[:chat_type] == :group
175
+ bot_id = @bot.bot_open_id
176
+ if bot_id.nil?
177
+ Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
178
+ return
179
+ end
180
+ return unless Array(event[:mentioned_open_ids]).include?(bot_id)
181
+ end
182
+
169
183
  allowed_users = @config[:allowed_users]
170
184
  if allowed_users && !allowed_users.empty?
171
185
  return unless allowed_users.include?(event[:user_id])
@@ -226,6 +226,16 @@ module Clacky
226
226
  }.join
227
227
  end
228
228
 
229
+ # Get this bot's own open_id (cached, fetched lazily on first use).
230
+ # Used to detect @bot mentions in group chats.
231
+ # @return [String, nil] bot open_id, or nil if the API call fails
232
+ def bot_open_id
233
+ @bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
234
+ rescue => e
235
+ Clacky::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
236
+ nil
237
+ end
238
+
229
239
  # Get tenant access token (cached)
230
240
  # @return [String] Access token
231
241
  def tenant_access_token
@@ -98,6 +98,7 @@ module Clacky
98
98
  message_id: message_id,
99
99
  timestamp: timestamp,
100
100
  chat_type: chat_type,
101
+ mentioned_open_ids: Array(message["mentions"]).filter_map { |m| m.dig("id", "open_id") },
101
102
  raw: @data
102
103
  }
103
104
  rescue JSON::ParserError
@@ -27,8 +27,14 @@ module Clacky
27
27
  # @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
28
28
  # @param interrupt_session [Proc] (session_id) — from HttpServer
29
29
  # @param channel_config [Clacky::ChannelConfig]
30
- # @param binding_mode [:user | :chat] how to map IM identities to sessions
31
- def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :user)
30
+ # @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
31
+ # :chat_user (default) one session per (chat, user) pair. Most natural:
32
+ # private chat = that user's session; in a group each
33
+ # user has their own session; the same user across
34
+ # different groups keeps those contexts separate.
35
+ # :chat — one session per chat (all users in a group share it).
36
+ # :user — one session per user (merges DMs and all groups).
37
+ def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
32
38
  @registry = session_registry
33
39
  @session_builder = session_builder
34
40
  @run_agent_task = run_agent_task
@@ -354,8 +360,10 @@ module Clacky
354
360
  def channel_key(event)
355
361
  platform = event[:platform].to_s
356
362
  case @binding_mode
357
- when :chat then "#{platform}:chat:#{event[:chat_id]}"
358
- else "#{platform}:user:#{event[:user_id]}"
363
+ when :chat then "#{platform}:chat:#{event[:chat_id]}"
364
+ when :user then "#{platform}:user:#{event[:user_id]}"
365
+ else # :chat_user (default)
366
+ "#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
359
367
  end
360
368
  end
361
369
 
@@ -33,9 +33,15 @@ module Clacky
33
33
 
34
34
  # Update the reply context for the current inbound message.
35
35
  # Called at the start of each route_message so replies are threaded correctly.
36
- # @param event [Hash] inbound event with :message_id
36
+ # Also updates chat_id a session may span multiple chats (e.g. same user
37
+ # in both a direct message and a group), and each inbound event dictates
38
+ # where outbound replies should be routed.
39
+ # @param event [Hash] inbound event with :message_id and :chat_id
37
40
  def update_message_context(event)
38
- @mutex.synchronize { @message_id = event[:message_id] }
41
+ @mutex.synchronize do
42
+ @message_id = event[:message_id]
43
+ @chat_id = event[:chat_id] if event[:chat_id]
44
+ end
39
45
  end
40
46
 
41
47
  # === Output display ===