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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +15 -20
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
- data/lib/clacky/media/base.rb +93 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +57 -0
- data/lib/clacky/media/openai_compat.rb +160 -0
- data/lib/clacky/message_history.rb +12 -7
- data/lib/clacky/providers.rb +28 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1
- data/lib/clacky/server/backup_manager.rb +200 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
- data/lib/clacky/server/channel/channel_manager.rb +65 -50
- data/lib/clacky/server/http_server.rb +345 -14
- data/lib/clacky/server/scheduler.rb +19 -0
- data/lib/clacky/server/session_registry.rb +8 -4
- data/lib/clacky/session_manager.rb +40 -2
- data/lib/clacky/tools/trash_manager.rb +14 -0
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +34 -7
- data/lib/clacky/ui2/ui_controller.rb +150 -19
- data/lib/clacky/utils/file_processor.rb +75 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2038 -1147
- data/lib/clacky/web/app.js +22 -1
- data/lib/clacky/web/backup.js +119 -0
- data/lib/clacky/web/billing.js +94 -7
- data/lib/clacky/web/channels.js +81 -11
- data/lib/clacky/web/design-sample.css +247 -0
- data/lib/clacky/web/design-sample.html +127 -0
- data/lib/clacky/web/favicon.svg +16 -0
- data/lib/clacky/web/i18n.js +159 -31
- data/lib/clacky/web/index.html +175 -55
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/onboard.js +114 -28
- data/lib/clacky/web/sessions.js +436 -192
- data/lib/clacky/web/settings.js +21 -1
- data/lib/clacky/web/skills.js +1 -1
- data/lib/clacky/web/tasks.js +129 -61
- data/lib/clacky/web/utils.js +72 -0
- data/lib/clacky/web/ws-dispatcher.js +6 -0
- data/lib/clacky.rb +1 -0
- metadata +7 -3
- 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
|
-
|
|
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
|
-
#
|
|
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 ==
|
|
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
|
|
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 [:
|
|
32
|
-
# :
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
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)
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
762
|
-
user_id:
|
|
763
|
-
|
|
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
|
-
|
|
809
|
-
|
|
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
|
|
827
|
-
#
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
end
|
|
850
|
+
history = event[:group_history]
|
|
851
|
+
if history.nil? || history.empty?
|
|
852
|
+
return "[Sender: #{sender}]\n#{text}"
|
|
853
|
+
end
|
|
841
854
|
|
|
842
|
-
|
|
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
|