openclacky 1.2.17 → 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 +34 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +21 -31
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
- data/lib/clacky/media/base.rb +125 -0
- data/lib/clacky/media/dashscope.rb +243 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +75 -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 +180 -81
- data/lib/clacky/server/http_server.rb +348 -15
- 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/skill.rb +3 -1
- 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 +6 -6
- 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 +8 -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
|