openclacky 1.2.18 → 1.3.0

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/lib/clacky/agent/time_machine.rb +256 -74
  4. data/lib/clacky/agent/tool_executor.rb +12 -0
  5. data/lib/clacky/agent.rb +15 -20
  6. data/lib/clacky/agent_config.rb +18 -0
  7. data/lib/clacky/cli.rb +55 -3
  8. data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
  9. data/lib/clacky/media/base.rb +93 -0
  10. data/lib/clacky/media/gemini.rb +10 -0
  11. data/lib/clacky/media/generator.rb +57 -0
  12. data/lib/clacky/media/openai_compat.rb +160 -0
  13. data/lib/clacky/message_history.rb +12 -7
  14. data/lib/clacky/providers.rb +28 -0
  15. data/lib/clacky/rich_ui_controller.rb +3 -1
  16. data/lib/clacky/server/backup_manager.rb +200 -0
  17. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  18. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  19. data/lib/clacky/server/channel/channel_manager.rb +65 -50
  20. data/lib/clacky/server/http_server.rb +345 -14
  21. data/lib/clacky/server/scheduler.rb +19 -0
  22. data/lib/clacky/server/session_registry.rb +8 -4
  23. data/lib/clacky/session_manager.rb +40 -2
  24. data/lib/clacky/tools/trash_manager.rb +14 -0
  25. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  26. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  27. data/lib/clacky/ui2/ui_controller.rb +150 -19
  28. data/lib/clacky/utils/file_processor.rb +75 -4
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +2038 -1147
  31. data/lib/clacky/web/app.js +22 -1
  32. data/lib/clacky/web/backup.js +119 -0
  33. data/lib/clacky/web/billing.js +94 -7
  34. data/lib/clacky/web/channels.js +81 -11
  35. data/lib/clacky/web/design-sample.css +247 -0
  36. data/lib/clacky/web/design-sample.html +127 -0
  37. data/lib/clacky/web/favicon.svg +16 -0
  38. data/lib/clacky/web/i18n.js +159 -31
  39. data/lib/clacky/web/index.html +175 -55
  40. data/lib/clacky/web/logo_nav_dark.png +0 -0
  41. data/lib/clacky/web/onboard.js +114 -28
  42. data/lib/clacky/web/sessions.js +436 -192
  43. data/lib/clacky/web/settings.js +21 -1
  44. data/lib/clacky/web/skills.js +1 -1
  45. data/lib/clacky/web/tasks.js +129 -61
  46. data/lib/clacky/web/utils.js +72 -0
  47. data/lib/clacky/web/ws-dispatcher.js +6 -0
  48. data/lib/clacky.rb +1 -0
  49. metadata +7 -3
  50. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "yaml"
6
+ require "time"
7
+
8
+ module Clacky
9
+ module Server
10
+ # Backs up the user's ~/.clacky directory to a safe location.
11
+ #
12
+ # Design notes:
13
+ # * Regenerable caches/logs are always excluded to keep archives small.
14
+ # * On WSL, the default destination is a Windows drive (/mnt/c|d|e) so
15
+ # backups survive a WSL distro reset.
16
+ # * Session history (sessions/ + snapshots/) is optional — it is the
17
+ # bulk of the data and the user may not want it in every archive.
18
+ # * Config lives in ~/.clacky/backup.yml, separate from config.yml so
19
+ # it never mixes with API keys.
20
+ class BackupManager
21
+ CLACKY_DIR = File.expand_path("~/.clacky")
22
+ CONFIG_FILE = File.join(CLACKY_DIR, "backup.yml")
23
+
24
+ # Always excluded — regenerable or disposable.
25
+ ALWAYS_EXCLUDE = %w[
26
+ ocr_cache parsers parsers-1 logger safety_logs trash .DS_Store backup.yml
27
+ ].freeze
28
+
29
+ # Excluded unless the user opts into a full backup.
30
+ HEAVY_EXCLUDE = %w[sessions snapshots].freeze
31
+
32
+ DEFAULT_CONFIG = {
33
+ "enabled" => false,
34
+ "cron" => "0 3 * * *",
35
+ "dest_dir" => nil,
36
+ "keep" => 7,
37
+ "include_sessions" => true,
38
+ "last_run_at" => nil,
39
+ "last_status" => nil,
40
+ "last_error" => nil,
41
+ "last_archive" => nil
42
+ }.freeze
43
+
44
+ class << self
45
+ def config
46
+ DEFAULT_CONFIG.merge(load_raw)
47
+ end
48
+
49
+ def update_config(enabled: nil, cron: nil, dest_dir: nil, keep: nil, include_sessions: nil)
50
+ cfg = config
51
+ cfg["enabled"] = !!enabled unless enabled.nil?
52
+ cfg["cron"] = cron.to_s unless cron.nil?
53
+ cfg["dest_dir"] = normalize_dest(dest_dir) unless dest_dir.nil?
54
+ cfg["keep"] = [keep.to_i, 1].max unless keep.nil?
55
+ cfg["include_sessions"] = !!include_sessions unless include_sessions.nil?
56
+ save_raw(cfg)
57
+ cfg
58
+ end
59
+
60
+ # Run a backup now. Returns a hash describing the result.
61
+ def run!
62
+ cfg = config
63
+ dest = resolve_dest(cfg["dest_dir"])
64
+ FileUtils.mkdir_p(dest)
65
+
66
+ stamp = Time.now.strftime("%Y%m%d-%H%M%S")
67
+ archive = File.join(dest, "clacky-backup-#{stamp}.tar.gz")
68
+ excludes = ALWAYS_EXCLUDE.dup
69
+ excludes.concat(HEAVY_EXCLUDE) unless cfg["include_sessions"]
70
+
71
+ ok = build_archive(archive, excludes)
72
+ unless ok && File.exist?(archive)
73
+ record_result(cfg, status: "error", error: "tar failed", archive: nil)
74
+ raise "Backup failed: tar did not produce an archive"
75
+ end
76
+
77
+ prune(dest, cfg["keep"])
78
+ record_result(cfg, status: "success", error: nil, archive: archive)
79
+
80
+ { archive: archive, size: File.size(archive), dest_dir: dest }
81
+ rescue => e
82
+ Clacky::Logger.error("backup_run_error", error: e) if defined?(Clacky::Logger)
83
+ record_result(config, status: "error", error: e.message, archive: nil)
84
+ raise
85
+ end
86
+
87
+ # Build a one-off archive for direct download (not written to dest_dir,
88
+ # not pruned, not recorded). Always includes session history so the
89
+ # downloaded file is a complete snapshot. Caller is responsible for
90
+ # deleting the returned temp file after streaming it.
91
+ def build_download!
92
+ stamp = Time.now.strftime("%Y%m%d-%H%M%S")
93
+ filename = "clacky-backup-#{stamp}.tar.gz"
94
+ archive = File.join(Dir.tmpdir, filename)
95
+
96
+ ok = build_archive(archive, ALWAYS_EXCLUDE.dup)
97
+ unless ok && File.exist?(archive)
98
+ FileUtils.rm_f(archive)
99
+ raise "Backup failed: tar did not produce an archive"
100
+ end
101
+
102
+ { path: archive, filename: filename, size: File.size(archive) }
103
+ end
104
+
105
+ # List existing backup archives at the resolved destination.
106
+ def list
107
+ dest = resolve_dest(config["dest_dir"])
108
+ return [] unless Dir.exist?(dest)
109
+
110
+ Dir.glob(File.join(dest, "clacky-backup-*.tar.gz")).map do |path|
111
+ {
112
+ "name" => File.basename(path),
113
+ "path" => path,
114
+ "size" => File.size(path),
115
+ "created_at" => File.mtime(path).iso8601
116
+ }
117
+ end.sort_by { |b| b["created_at"] }.reverse
118
+ end
119
+
120
+ # Resolved destination + whether we're on WSL (for UI display).
121
+ def status
122
+ dest = resolve_dest(config["dest_dir"])
123
+ {
124
+ "config" => config,
125
+ "dest_dir" => dest,
126
+ "is_wsl" => wsl?,
127
+ "backups" => list
128
+ }
129
+ end
130
+
131
+ def wsl?
132
+ @wsl ||= begin
133
+ File.exist?("/proc/version") &&
134
+ File.read("/proc/version").match?(/microsoft|wsl/i)
135
+ rescue StandardError
136
+ false
137
+ end
138
+ end
139
+
140
+ # ── internals ──────────────────────────────────────────────────────
141
+
142
+ # Where archives go when the user hasn't set an explicit dest_dir.
143
+ def default_dest
144
+ if wsl?
145
+ %w[d c e].each do |drive|
146
+ mount = "/mnt/#{drive}"
147
+ return File.join(mount, "clacky_backups") if Dir.exist?(mount) && File.writable?(mount)
148
+ end
149
+ end
150
+ File.expand_path("~/clacky_backups")
151
+ end
152
+
153
+ private def resolve_dest(dir)
154
+ d = dir.to_s.strip
155
+ d.empty? ? default_dest : File.expand_path(d)
156
+ end
157
+
158
+ private def normalize_dest(dir)
159
+ d = dir.to_s.strip
160
+ d.empty? ? nil : d
161
+ end
162
+
163
+ private def build_archive(archive, excludes)
164
+ args = ["tar", "-czf", archive, "-C", CLACKY_DIR]
165
+ excludes.each { |e| args << "--exclude=./#{e}" }
166
+ args << "."
167
+ system(*args)
168
+ end
169
+
170
+ private def prune(dest, keep)
171
+ keep = [keep.to_i, 1].max
172
+ all = Dir.glob(File.join(dest, "clacky-backup-*.tar.gz")).sort_by { |f| File.mtime(f) }.reverse
173
+ all.drop(keep).each { |f| FileUtils.rm_f(f) }
174
+ end
175
+
176
+ private def record_result(cfg, status:, error:, archive:)
177
+ cfg = cfg.dup
178
+ cfg["last_run_at"] = Time.now.iso8601
179
+ cfg["last_status"] = status
180
+ cfg["last_error"] = error
181
+ cfg["last_archive"] = archive ? File.basename(archive) : cfg["last_archive"]
182
+ save_raw(cfg)
183
+ end
184
+
185
+ private def load_raw
186
+ return {} unless File.exist?(CONFIG_FILE)
187
+
188
+ YAMLCompat.load_file(CONFIG_FILE) || {}
189
+ rescue StandardError
190
+ {}
191
+ end
192
+
193
+ private def save_raw(cfg)
194
+ FileUtils.mkdir_p(CLACKY_DIR)
195
+ File.write(CONFIG_FILE, YAML.dump(cfg))
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -178,10 +178,18 @@ module Clacky
178
178
  return
179
179
  end
180
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
181
  return
184
182
  end
183
+
184
+ begin
185
+ history = @bot.fetch_chat_history(event[:chat_id], limit: Bot::GROUP_HISTORY_LIMIT)
186
+ Clacky::Logger.info("[feishu] fetch_chat_history chat=#{event[:chat_id]} got #{history.size} messages")
187
+ event = event.merge(group_history: history) unless history.empty?
188
+ rescue FeishuScopeError => e
189
+ scopes_text = e.required_scopes.any? ? e.required_scopes.join(", ") : Bot::SCOPE_GROUP_MSG
190
+ scope_hint = e.auth_url ? "The developer can grant access here: #{e.auth_url}" : "Please enable the following scopes for this app in Feishu Open Platform: #{scopes_text}"
191
+ event = event.merge(text: "#{event[:text]}\n\n[System Notice] This app is missing the scope to read group message history. #{scope_hint} Do NOT mention this proactively — only inform the user if they ask about chat history or context.")
192
+ end
185
193
  end
186
194
 
187
195
  allowed_users = @config[:allowed_users]
@@ -3,6 +3,7 @@
3
3
  require "faraday"
4
4
  require "faraday/multipart"
5
5
  require "json"
6
+ require "uri"
6
7
 
7
8
  module Clacky
8
9
  module Channel
@@ -30,11 +31,27 @@ module Clacky
30
31
  end
31
32
  end
32
33
 
34
+ # Raised when any API call fails with 99991672 (missing app scope).
35
+ # The admin needs to open auth_url to grant the required permissions.
36
+ class FeishuScopeError < StandardError
37
+ attr_reader :auth_url, :required_scopes
38
+
39
+ def initialize(auth_url, required_scopes: [])
40
+ @auth_url = auth_url
41
+ @required_scopes = required_scopes
42
+ super("App is missing required API scope")
43
+ end
44
+ end
45
+
33
46
  # Feishu Bot API client.
34
47
  # Handles authentication, message sending, and API calls.
35
48
  class Bot
36
49
  API_TIMEOUT = 10
37
50
  DOWNLOAD_TIMEOUT = 60
51
+ ERR_SCOPE_MISSING = 99991672
52
+ ERR_SCOPE_MISSING_2 = 230027
53
+ GROUP_HISTORY_LIMIT = 15
54
+ SCOPE_GROUP_MSG = "im:message.group_msg"
38
55
 
39
56
  def initialize(app_id:, app_secret:, domain: DEFAULT_DOMAIN)
40
57
  @app_id = app_id
@@ -42,7 +59,7 @@ module Clacky
42
59
  @domain = domain
43
60
  @token_cache = nil
44
61
  @token_expires_at = nil
45
- @user_name_cache = {}
62
+
46
63
  end
47
64
 
48
65
  # Send plain text message
@@ -227,7 +244,50 @@ module Clacky
227
244
  }.join
228
245
  end
229
246
 
230
- # Get this bot's own open_id (cached, fetched lazily on first use).
247
+ # Fetch recent messages from a chat via the message list API.
248
+ # Returns an array of { user_id, text } hashes, oldest first.
249
+ # @param chat_id [String]
250
+ # @param limit [Integer]
251
+ # @return [Array<Hash>]
252
+ def fetch_chat_history(chat_id, limit: GROUP_HISTORY_LIMIT)
253
+ response = get("/open-apis/im/v1/messages", params: {
254
+ container_id_type: "chat",
255
+ container_id: chat_id,
256
+ sort_type: "ByCreateTimeDesc",
257
+ page_size: limit
258
+ })
259
+ unless response["code"] == 0
260
+ code = response["code"].to_i
261
+ Clacky::Logger.warn("[feishu] fetch_chat_history failed code=#{code} msg=#{response["msg"]}, ext=#{response.dig("error", "message") || response["msg"]}")
262
+ if code == ERR_SCOPE_MISSING || code == ERR_SCOPE_MISSING_2
263
+ auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
264
+ extract_url(response["msg"].to_s)
265
+ scopes = (response.dig("error", "permission_violations") || []).map { |v| v["subject"] }.compact
266
+ raise FeishuScopeError.new(auth_url, required_scopes: scopes)
267
+ end
268
+ return []
269
+ end
270
+
271
+ items = response.dig("data", "items") || []
272
+ Clacky::Logger.info("[feishu] fetch_chat_history chat=#{chat_id} api_items=#{items.size} first=#{items.first&.inspect}")
273
+ items.reverse.filter_map do |item|
274
+ content = begin
275
+ body = JSON.parse(item.dig("body", "content").to_s)
276
+ body["text"].to_s.gsub(/@_user_\S+\s?/, "").gsub(/@\S+\s?/, "").strip
277
+ rescue JSON::ParserError
278
+ nil
279
+ end
280
+ next if content.nil? || content.empty?
281
+
282
+ sender_id = item.dig("sender", "id").to_s
283
+ { user_id: sender_id, text: content }
284
+ end
285
+ rescue FeishuScopeError
286
+ raise
287
+ rescue => e
288
+ Clacky::Logger.warn("[feishu] fetch_chat_history failed: #{e.message}")
289
+ []
290
+ end
231
291
  # Used to detect @bot mentions in group chats.
232
292
  # @return [String, nil] bot open_id, or nil if the API call fails
233
293
  def bot_open_id
@@ -237,17 +297,6 @@ module Clacky
237
297
  nil
238
298
  end
239
299
 
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
-
251
300
  # Get tenant access token (cached)
252
301
  # @return [String] Access token
253
302
  def tenant_access_token
@@ -450,16 +499,20 @@ module Clacky
450
499
 
451
500
  if code == 91403
452
501
  raise FeishuDocPermissionError, token
453
- elsif code == 99991672
502
+ elsif code == ERR_SCOPE_MISSING
454
503
  # Extract auth URL from the error message if present
455
504
  auth_url = response.dig("error", "permission_violations", 0, "attach_url") ||
456
- response["msg"].to_s[/https:\/\/open\.feishu\.cn\/app\/[^\s"]+/]
505
+ extract_url(response["msg"].to_s)
457
506
  raise FeishuDocScopeError.new(auth_url)
458
507
  else
459
508
  raise "Failed to fetch doc: code=#{code} msg=#{response["msg"]}"
460
509
  end
461
510
  end
462
511
 
512
+ private def extract_url(text)
513
+ text[URI::DEFAULT_PARSER.make_regexp(%w[https])]
514
+ end
515
+
463
516
  # Build Faraday connection
464
517
  # @return [Faraday::Connection]
465
518
  def build_connection
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "channel_ui_controller"
4
- require_relative "group_message_buffer"
5
4
 
6
5
  module Clacky
7
6
  module Channel
@@ -28,14 +27,11 @@ module Clacky
28
27
  # @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
29
28
  # @param interrupt_session [Proc] (session_id) — from HttpServer
30
29
  # @param channel_config [Clacky::ChannelConfig]
31
- # @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
32
- # :chat_user (default) — one session per (chat, user) pair. Most natural:
33
- # private chat = that user's session; in a group each
34
- # user has their own session; the same user across
35
- # different groups keeps those contexts separate.
36
- # :chat — one session per chat (all users in a group share it).
37
- # :user — one session per user (merges DMs and all groups).
38
- def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
30
+ # @param binding_mode [:chat | :chat_user | :user] how to map IM identities to sessions.
31
+ # :chat (default) — one session per chat (all users in a chat share it).
32
+ # :chat_user — one session per (chat, user) pair.
33
+ # :user one session per user (merges all chats).
34
+ def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat)
39
35
  @registry = session_registry
40
36
  @session_builder = session_builder
41
37
  @run_agent_task = run_agent_task
@@ -46,8 +42,7 @@ module Clacky
46
42
  @adapter_threads = []
47
43
  @running = false
48
44
  @mutex = Mutex.new
49
- @session_counters = Hash.new(0) # platform => count, for short session names
50
- @group_buffer = GroupMessageBuffer.new
45
+ @session_counters = Hash.new(0)
51
46
  end
52
47
 
53
48
  # Start all enabled adapters in background threads. Non-blocking.
@@ -82,15 +77,6 @@ module Clacky
82
77
  @mutex.synchronize { @adapters.map(&:platform_id) }
83
78
  end
84
79
 
85
- # Return all buffered group chat messages for a given chat.
86
- # @param chat_id [String]
87
- # @return [Array<Hash>] each entry has :user_id, :user_name, :text
88
- def group_history(chat_id)
89
- @group_buffer.peek(chat_id).map do |e|
90
- { user_id: e.user_id, user_name: e.user_name, text: e.text }
91
- end
92
- end
93
-
94
80
  # Proactively send a message to a user on the given platform.
95
81
  #
96
82
  # For Weixin (iLink protocol) a context_token is required for every outbound
@@ -244,7 +230,6 @@ module Clacky
244
230
 
245
231
  def route_message(adapter, event)
246
232
  if event[:observe_only]
247
- @group_buffer.push(event[:chat_id], user_id: event[:user_id], user_name: event[:user_name], text: event[:text].to_s)
248
233
  return
249
234
  end
250
235
 
@@ -320,7 +305,7 @@ module Clacky
320
305
  # Prepend buffered group history so the agent knows what was discussed
321
306
  # before it was @-mentioned. Buffer is cleared atomically on take.
322
307
  # WebUI always receives the raw user text — context is agent-only.
323
- prompt = build_prompt_with_context(event[:chat_id], text)
308
+ prompt = build_prompt_with_context(event, text)
324
309
 
325
310
  # Start typing keepalive BEFORE sending any message.
326
311
  # sendmessage cancels the typing indicator in WeChat protocol,
@@ -375,17 +360,28 @@ module Clacky
375
360
  else
376
361
  arg
377
362
  end
378
- unless session_id && @registry.get(session_id)
363
+ unless session_id && @registry.ensure(session_id)
379
364
  adapter.send_text(chat_id, "Session not found. Use /list to see available sessions.")
380
365
  return
381
366
  end
382
367
 
383
368
  # Detach channel_ui from the old session's web_ui, reattach to the new one.
369
+ # Also clear the old session's persisted agent.channel_info if it still
370
+ # matches this key — keeping channel_keys and channel_info strictly in sync
371
+ # so resolve_session never sees two sessions claim the same key via different
372
+ # sources (see comment in resolve_session).
384
373
  old_session_id = resolve_session(event)
385
374
  channel_ui = old_session_id ? channel_ui_for_session(old_session_id) : nil
386
375
 
387
376
  if channel_ui
388
- @registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
377
+ @registry.with_session(old_session_id) do |s|
378
+ s[:ui]&.unsubscribe_channel(channel_ui)
379
+ s.delete(:channel_ui)
380
+ if s[:agent]&.respond_to?(:channel_info=) && s[:agent].respond_to?(:channel_info) &&
381
+ s[:agent].channel_info && channel_key_from_info(s[:agent].channel_info) == key
382
+ s[:agent].channel_info = nil
383
+ end
384
+ end
389
385
  else
390
386
  channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
391
387
  end
@@ -412,7 +408,17 @@ module Clacky
412
408
  unbound = false
413
409
  @registry.list.each do |summary|
414
410
  @registry.with_session(summary[:id]) do |s|
415
- unbound = true if s[:channel_keys]&.delete(key)
411
+ if s[:channel_keys]&.delete(key)
412
+ unbound = true
413
+ # Keep agent.channel_info in sync with channel_keys (see resolve_session).
414
+ # Without this, after process restart + eviction, the fallback path would
415
+ # silently re-bind this key back to the unbinded session via stale
416
+ # channel_info, defeating /unbind.
417
+ if s[:agent]&.respond_to?(:channel_info=) && s[:agent].respond_to?(:channel_info) &&
418
+ s[:agent].channel_info && channel_key_from_info(s[:agent].channel_info) == key
419
+ s[:agent].channel_info = nil
420
+ end
421
+ end
416
422
  end
417
423
  end
418
424
  adapter.send_text(chat_id, unbound ? "Unbound." : "No binding found.")
@@ -630,12 +636,20 @@ module Clacky
630
636
 
631
637
  def resolve_session(event)
632
638
  key = channel_key(event)
639
+
640
+ # Resolve order per session:
641
+ # 1. explicit in-memory channel_keys (set by /bind or auto_create_session)
642
+ # 2. fallback to persisted agent.channel_info for evicted channel sessions
643
+ # (process restart with in-memory channel_keys lost)
644
+ #
645
+ # /bind and /unbind keep agent.channel_info strictly in sync with channel_keys
646
+ # (see handle_bind / handle_unbind), so the two sources never disagree on the
647
+ # same key for two different sessions — a single pass is sufficient.
633
648
  @registry.list.each do |summary|
634
649
  found = nil
635
650
  @registry.with_session(summary[:id]) { |s| found = s[:channel_keys]&.include?(key) }
636
651
  return summary[:id] if found
637
652
 
638
- # Check evicted channel sessions via persisted channel_info
639
653
  next unless summary[:source] == "channel"
640
654
  next unless @registry.ensure(summary[:id])
641
655
  agent = nil
@@ -645,6 +659,7 @@ module Clacky
645
659
  bind_key_to_session(key, summary[:id])
646
660
  return summary[:id]
647
661
  end
662
+
648
663
  nil
649
664
  rescue StandardError => e
650
665
  Clacky::Logger.error("[ChannelManager] Session resolve failed: #{e.message}")
@@ -739,7 +754,7 @@ module Clacky
739
754
  case @binding_mode
740
755
  when :chat then "#{platform}:chat:#{event[:chat_id]}"
741
756
  when :user then "#{platform}:user:#{event[:user_id]}"
742
- else # :chat_user (default)
757
+ else # :chat_user
743
758
  "#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
744
759
  end
745
760
  end
@@ -751,17 +766,16 @@ module Clacky
751
766
  case @binding_mode
752
767
  when :chat then "#{platform}:chat:#{chat_id}"
753
768
  when :user then "#{platform}:user:#{user_id}"
754
- else # :chat_user (default)
769
+ else # :chat_user
755
770
  "#{platform}:chat:#{chat_id}:user:#{user_id}"
756
771
  end
757
772
  end
758
773
 
759
774
  private def extract_channel_info(event)
760
775
  {
761
- platform: event[:platform],
762
- user_id: event[:user_id],
763
- user_name: event[:user_name],
764
- chat_id: event[:chat_id]
776
+ platform: event[:platform],
777
+ user_id: event[:user_id],
778
+ chat_id: event[:chat_id]
765
779
  }
766
780
  end
767
781
 
@@ -805,12 +819,16 @@ module Clacky
805
819
 
806
820
  key = channel_key_from_info(info)
807
821
 
808
- event = { platform: info[:platform], chat_id: info[:chat_id] }
809
- ensure_channel_ui_subscribed(summary[:id], event)
810
-
822
+ # Arbitrate first: skip duplicate keys before attaching any channel_ui.
823
+ # Attaching channel_ui to a loser session would leave an orphan in its
824
+ # web_ui subscriber list (it cannot be detached later), which a subsequent
825
+ # /bind onto that session would then double up — causing duplicate broadcasts.
811
826
  next unless bound_keys.add?(key)
812
827
  bind_key_to_session(key, summary[:id])
813
828
 
829
+ event = { platform: info[:platform], chat_id: info[:chat_id] }
830
+ ensure_channel_ui_subscribed(summary[:id], event)
831
+
814
832
  Clacky::Logger.info("[ChannelManager] Restored channel binding #{key} -> session #{summary[:id][0, 8]}")
815
833
  restored_count += 1
816
834
  end
@@ -823,23 +841,20 @@ module Clacky
823
841
  Clacky::Logger.warn("[ChannelManager] Error stopping #{adapter.platform_id}: #{e.message}")
824
842
  end
825
843
 
826
- # Prepend recent group chat history to the user's message so the agent
827
- # has context for questions like "what were we just discussing?".
828
- # Returns the original text unchanged when there is no buffered history.
829
- private def build_prompt_with_context(chat_id, text)
830
- entries = @group_buffer.peek(chat_id, limit: GroupMessageBuffer::PROMPT_LIMIT)
831
- return text if entries.empty?
832
-
833
- total = @group_buffer.peek(chat_id).size
834
- lines = entries.map { |e| "#{e.user_name || e.user_id}: #{e.text}" }.join("\n")
844
+ # Prepend sender identity and optional group chat history to the user's message.
845
+ # Returns the original text unchanged when there is no extra context.
846
+ private def build_prompt_with_context(event, text)
847
+ user_id = event[:user_id].to_s
848
+ sender = user_id
835
849
 
836
- partial = total > entries.size
837
- header = partial ? "[Group chat partial history (latest #{entries.size} of #{total}, max buffer #{GroupMessageBuffer::MAX_MESSAGES} messages)]" : "[Group chat history (#{total} messages, max buffer #{GroupMessageBuffer::MAX_MESSAGES})]"
838
- footer = if partial
839
- "[INSTRUCTION: If the above partial history is insufficient to answer, MUST web_fetch http://#{ENV["CLACKY_SERVER_HOST"]}:#{ENV["CLACKY_SERVER_PORT"]}/api/channels/group_history/#{chat_id} for full context.]"
840
- end
850
+ history = event[:group_history]
851
+ if history.nil? || history.empty?
852
+ return "[Sender: #{sender}]\n#{text}"
853
+ end
841
854
 
842
- [header, lines, footer, "---", text].compact.join("\n")
855
+ lines = history.map { |e| "#{e[:user_id]}: #{e[:text]}" }.join("\n")
856
+ header = "[Group chat history (#{history.size} messages)]"
857
+ [header, lines, "---", "[Sender: #{sender}]", text].join("\n")
843
858
  end
844
859
  end
845
860
  end