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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/lib/clacky/agent/skill_manager.rb +1 -1
  4. data/lib/clacky/agent/time_machine.rb +256 -74
  5. data/lib/clacky/agent/tool_executor.rb +12 -0
  6. data/lib/clacky/agent.rb +21 -31
  7. data/lib/clacky/agent_config.rb +18 -0
  8. data/lib/clacky/cli.rb +55 -3
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
  10. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
  11. data/lib/clacky/media/base.rb +125 -0
  12. data/lib/clacky/media/dashscope.rb +243 -0
  13. data/lib/clacky/media/gemini.rb +10 -0
  14. data/lib/clacky/media/generator.rb +75 -0
  15. data/lib/clacky/media/openai_compat.rb +160 -0
  16. data/lib/clacky/message_history.rb +12 -7
  17. data/lib/clacky/providers.rb +28 -0
  18. data/lib/clacky/rich_ui_controller.rb +3 -1
  19. data/lib/clacky/server/backup_manager.rb +200 -0
  20. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  21. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  22. data/lib/clacky/server/channel/channel_manager.rb +180 -81
  23. data/lib/clacky/server/http_server.rb +348 -15
  24. data/lib/clacky/server/scheduler.rb +19 -0
  25. data/lib/clacky/server/session_registry.rb +8 -4
  26. data/lib/clacky/session_manager.rb +40 -2
  27. data/lib/clacky/skill.rb +3 -1
  28. data/lib/clacky/tools/trash_manager.rb +14 -0
  29. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  30. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  31. data/lib/clacky/ui2/ui_controller.rb +150 -19
  32. data/lib/clacky/utils/file_processor.rb +75 -4
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +2038 -1147
  35. data/lib/clacky/web/app.js +22 -1
  36. data/lib/clacky/web/backup.js +119 -0
  37. data/lib/clacky/web/billing.js +94 -7
  38. data/lib/clacky/web/channels.js +81 -11
  39. data/lib/clacky/web/design-sample.css +247 -0
  40. data/lib/clacky/web/design-sample.html +127 -0
  41. data/lib/clacky/web/favicon.svg +16 -0
  42. data/lib/clacky/web/i18n.js +159 -31
  43. data/lib/clacky/web/index.html +175 -55
  44. data/lib/clacky/web/logo_nav_dark.png +0 -0
  45. data/lib/clacky/web/onboard.js +114 -28
  46. data/lib/clacky/web/sessions.js +436 -192
  47. data/lib/clacky/web/settings.js +21 -1
  48. data/lib/clacky/web/skills.js +6 -6
  49. data/lib/clacky/web/tasks.js +129 -61
  50. data/lib/clacky/web/utils.js +72 -0
  51. data/lib/clacky/web/ws-dispatcher.js +6 -0
  52. data/lib/clacky.rb +1 -0
  53. metadata +8 -3
  54. 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