openclacky 1.2.7 → 1.2.9
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 +26 -0
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/agent_config.rb +91 -7
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/cli.rb +105 -0
- data/lib/clacky/client.rb +38 -5
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/patch_loader.rb +282 -0
- data/lib/clacky/providers.rb +82 -0
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +1 -1
- data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
- data/lib/clacky/server/channel.rb +5 -0
- data/lib/clacky/server/http_server.rb +236 -25
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/telemetry.rb +11 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +326 -24
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +84 -6
- data/lib/clacky/web/index.html +14 -2
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/settings.js +322 -97
- data/lib/clacky.rb +9 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +12 -3
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "prism"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
# Prism is a stdlib on Ruby 3.3+. On older Rubies we fall back to
|
|
11
|
+
# RubyVM::AbstractSyntaxTree (available since 2.6).
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Clacky
|
|
15
|
+
# Runtime patch layer. Loads user/AI-authored patches from ~/.clacky/patches/
|
|
16
|
+
# that override existing methods via Module#prepend, WITHOUT touching the
|
|
17
|
+
# installed gem source (so `gem update` never loses them).
|
|
18
|
+
#
|
|
19
|
+
# Each patch lives in its own directory:
|
|
20
|
+
# ~/.clacky/patches/<id>/
|
|
21
|
+
# meta.yml declares target + a fingerprint of the original method source
|
|
22
|
+
# patch.rb a prepend module that overrides the target method
|
|
23
|
+
#
|
|
24
|
+
# Safety — fingerprint drift:
|
|
25
|
+
# meta.yml records a SHA256 of the targeted method's source at authoring time.
|
|
26
|
+
# Before applying, the loader recomputes the fingerprint of the method as it
|
|
27
|
+
# exists in the CURRENTLY installed gem. If they differ, the upstream code has
|
|
28
|
+
# changed and the patch may no longer be valid, so by default the patch is
|
|
29
|
+
# DISABLED (moved to _disabled/) rather than applied — a stale patch must never
|
|
30
|
+
# silently corrupt behavior.
|
|
31
|
+
#
|
|
32
|
+
# meta.yml:
|
|
33
|
+
# id: fix-web-search-timeout
|
|
34
|
+
# description: bump default timeout to 30s
|
|
35
|
+
# target: "Clacky::Tools::WebSearch#execute" # '#' = instance, '.' = class method
|
|
36
|
+
# fingerprint: "a3f8c…"
|
|
37
|
+
# gem_version: "0.7.0"
|
|
38
|
+
# on_mismatch: disable # disable | warn (default disable)
|
|
39
|
+
module PatchLoader
|
|
40
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/patches")
|
|
41
|
+
DISABLED_DIR = "_disabled"
|
|
42
|
+
|
|
43
|
+
Result = Struct.new(:applied, :disabled, :skipped, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def load_all(dir: DEFAULT_DIR)
|
|
47
|
+
result = Result.new(applied: [], disabled: [], skipped: [])
|
|
48
|
+
if Dir.exist?(dir)
|
|
49
|
+
Dir.glob(File.join(dir, "*", "meta.yml")).sort.each do |meta_path|
|
|
50
|
+
patch_dir = File.dirname(meta_path)
|
|
51
|
+
next if File.basename(File.dirname(patch_dir)) == DISABLED_DIR
|
|
52
|
+
|
|
53
|
+
apply_one(patch_dir, meta_path, result)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
@last_result = result
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def last_result
|
|
61
|
+
@last_result || load_all
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate a ready-to-edit patch (meta.yml + patch.rb) for a target method.
|
|
65
|
+
# Computes the current fingerprint automatically so the author never does it
|
|
66
|
+
# by hand. The patch.rb skeleton prepends a module that overrides the method
|
|
67
|
+
# and calls super by default.
|
|
68
|
+
# @param target [String] "Const::Path#method" or "Const::Path.method"
|
|
69
|
+
# @return [String] path to the new patch directory
|
|
70
|
+
def scaffold(id, target, description: "", dir: DEFAULT_DIR)
|
|
71
|
+
slug = id.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
72
|
+
raise ArgumentError, "invalid patch id: #{id.inspect}" if slug.empty?
|
|
73
|
+
|
|
74
|
+
fp = fingerprint(target) # also validates the target resolves
|
|
75
|
+
|
|
76
|
+
patch_dir = File.join(dir, slug)
|
|
77
|
+
raise ArgumentError, "patch already exists: #{patch_dir}" if Dir.exist?(patch_dir)
|
|
78
|
+
|
|
79
|
+
FileUtils.mkdir_p(patch_dir)
|
|
80
|
+
File.write(File.join(patch_dir, "meta.yml"), <<~YAML)
|
|
81
|
+
id: #{slug}
|
|
82
|
+
description: #{description.to_s.empty? ? "(describe what this fixes)" : description}
|
|
83
|
+
target: "#{target}"
|
|
84
|
+
fingerprint: "#{fp}"
|
|
85
|
+
gem_version: "#{Clacky::VERSION}"
|
|
86
|
+
on_mismatch: disable
|
|
87
|
+
YAML
|
|
88
|
+
File.write(File.join(patch_dir, "patch.rb"), patch_skeleton(slug, target))
|
|
89
|
+
patch_dir
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def patch_skeleton(slug, target)
|
|
93
|
+
const_name, sep, method_name = target.partition(/[#.]/)
|
|
94
|
+
mod_const = "Patch_#{slug.gsub(/[^a-zA-Z0-9_]/, "_")}"
|
|
95
|
+
prepend_target = sep == "#" ? const_name : "#{const_name}.singleton_class"
|
|
96
|
+
|
|
97
|
+
<<~RUBY
|
|
98
|
+
# frozen_string_literal: true
|
|
99
|
+
|
|
100
|
+
# Patch for #{target}
|
|
101
|
+
# Only edit the method body below. Call `super` to keep the original behavior.
|
|
102
|
+
module #{mod_const}
|
|
103
|
+
def #{method_name}(*args, **kwargs, &blk)
|
|
104
|
+
# TODO: your fix here. Examples:
|
|
105
|
+
# result = super
|
|
106
|
+
# result
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#{prepend_target}.prepend(#{mod_const})
|
|
112
|
+
RUBY
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Recompute the fingerprint of a target's method as currently installed.
|
|
116
|
+
# @param target [String] "Const::Path#instance_method" or "Const::Path.class_method"
|
|
117
|
+
# @return [String] SHA256 hex of the method's source
|
|
118
|
+
# @raise [RuntimeError] if the target can't be resolved
|
|
119
|
+
def fingerprint(target)
|
|
120
|
+
meth = original_method(resolve_method(target))
|
|
121
|
+
file, lineno = meth.source_location
|
|
122
|
+
raise "no source location for #{target} (defined in C or eval?)" unless file && lineno
|
|
123
|
+
|
|
124
|
+
first, last = method_line_range(file, lineno, meth.name, meth)
|
|
125
|
+
raise "cannot locate source for #{target} in #{file}:#{lineno}" unless first && last
|
|
126
|
+
|
|
127
|
+
lines = File.readlines(file)[(first - 1)...last]
|
|
128
|
+
Digest::SHA256.hexdigest(lines.join)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def method_line_range(file, lineno, name, meth)
|
|
132
|
+
if defined?(Prism)
|
|
133
|
+
range = prism_line_range(file, lineno, name)
|
|
134
|
+
return range if range
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
ast_line_range(meth)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def prism_line_range(file, lineno, name)
|
|
141
|
+
result = Prism.parse_file(file)
|
|
142
|
+
return nil unless result.success?
|
|
143
|
+
|
|
144
|
+
node = find_def_at(result.value, lineno, name.to_sym)
|
|
145
|
+
return nil unless node
|
|
146
|
+
|
|
147
|
+
loc = node.location
|
|
148
|
+
[loc.start_line, loc.end_line]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def find_def_at(node, lineno, name)
|
|
152
|
+
return nil unless node
|
|
153
|
+
|
|
154
|
+
if node.is_a?(Prism::DefNode) && node.name == name && node.location.start_line == lineno
|
|
155
|
+
return node
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
node.compact_child_nodes.each do |child|
|
|
159
|
+
found = find_def_at(child, lineno, name)
|
|
160
|
+
return found if found
|
|
161
|
+
end
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ast_line_range(meth)
|
|
166
|
+
return nil unless defined?(RubyVM::AbstractSyntaxTree)
|
|
167
|
+
|
|
168
|
+
node = RubyVM::AbstractSyntaxTree.of(meth)
|
|
169
|
+
return nil unless node
|
|
170
|
+
|
|
171
|
+
[node.first_lineno, node.last_lineno]
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Walk past any methods introduced by our own patches (files under the
|
|
177
|
+
# patches dir) so the fingerprint always reflects the original upstream
|
|
178
|
+
# definition, even after a prepend has already been applied.
|
|
179
|
+
def original_method(meth)
|
|
180
|
+
current = meth
|
|
181
|
+
while current
|
|
182
|
+
file, = current.source_location
|
|
183
|
+
break if file.nil? || !file.start_with?(DEFAULT_DIR)
|
|
184
|
+
|
|
185
|
+
nxt = current.super_method
|
|
186
|
+
break if nxt.nil?
|
|
187
|
+
|
|
188
|
+
current = nxt
|
|
189
|
+
end
|
|
190
|
+
current
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def resolve_method(target)
|
|
194
|
+
if target.include?("#")
|
|
195
|
+
const_name, method_name = target.split("#", 2)
|
|
196
|
+
const = resolve_const(const_name)
|
|
197
|
+
const.instance_method(method_name.to_sym)
|
|
198
|
+
elsif target.include?(".")
|
|
199
|
+
const_name, method_name = target.split(".", 2)
|
|
200
|
+
const = resolve_const(const_name)
|
|
201
|
+
const.method(method_name.to_sym)
|
|
202
|
+
else
|
|
203
|
+
raise "invalid target (need '#' or '.'): #{target}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_one(patch_dir, meta_path, result)
|
|
208
|
+
id = File.basename(patch_dir)
|
|
209
|
+
meta = YAMLCompat.load_file(meta_path) || {}
|
|
210
|
+
target = meta["target"].to_s
|
|
211
|
+
recorded = meta["fingerprint"].to_s
|
|
212
|
+
|
|
213
|
+
if target.empty? || recorded.empty?
|
|
214
|
+
result.skipped << [id, "meta.yml missing target or fingerprint"]
|
|
215
|
+
log(:warn, id, result.skipped.last[1])
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
current = begin
|
|
220
|
+
fingerprint(target)
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
result.skipped << [id, "cannot fingerprint #{target}: #{e.message}"]
|
|
223
|
+
log(:warn, id, result.skipped.last[1])
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if current != recorded
|
|
228
|
+
handle_mismatch(patch_dir, id, meta, result)
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
patch_rb = File.join(patch_dir, "patch.rb")
|
|
233
|
+
unless File.exist?(patch_rb)
|
|
234
|
+
result.skipped << [id, "patch.rb not found"]
|
|
235
|
+
log(:warn, id, result.skipped.last[1])
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
require patch_rb
|
|
240
|
+
result.applied << id
|
|
241
|
+
log(:info, id, "applied → #{target}")
|
|
242
|
+
rescue StandardError, ScriptError => e
|
|
243
|
+
result.skipped << [id, e.message]
|
|
244
|
+
log(:warn, id, e.message)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def handle_mismatch(patch_dir, id, meta, result)
|
|
248
|
+
reason = "fingerprint mismatch — upstream code for #{meta["target"]} changed"
|
|
249
|
+
if meta["on_mismatch"].to_s == "warn"
|
|
250
|
+
result.skipped << [id, "#{reason} (kept, not applied)"]
|
|
251
|
+
log(:warn, id, result.skipped.last[1])
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
disable!(patch_dir, id)
|
|
256
|
+
result.disabled << [id, reason]
|
|
257
|
+
log(:warn, id, "#{reason} — disabled")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def disable!(patch_dir, id)
|
|
261
|
+
base = File.dirname(patch_dir)
|
|
262
|
+
dest_root = File.join(base, DISABLED_DIR)
|
|
263
|
+
FileUtils.mkdir_p(dest_root)
|
|
264
|
+
dest = File.join(dest_root, id)
|
|
265
|
+
FileUtils.rm_rf(dest)
|
|
266
|
+
FileUtils.mv(patch_dir, dest)
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
log(:error, id, "failed to disable: #{e.message}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def resolve_const(name)
|
|
272
|
+
name.split("::").reject(&:empty?).inject(Object) do |mod, part|
|
|
273
|
+
mod.const_get(part)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def log(level, id, msg)
|
|
278
|
+
Clacky::Logger.public_send(level, "[PatchLoader] #{id}: #{msg}")
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -41,6 +41,17 @@ module Clacky
|
|
|
41
41
|
"dsk-deepseek-v4-flash",
|
|
42
42
|
"or-gemini-3-1-pro"
|
|
43
43
|
],
|
|
44
|
+
# Image generation models served by the openclacky platform
|
|
45
|
+
# gateway. The gateway exposes a standard OpenAI-compatible
|
|
46
|
+
# /v1/images/generations endpoint, so the same OpenAICompat
|
|
47
|
+
# provider class handles them. `or-` prefix mirrors the chat
|
|
48
|
+
# model naming — these are routed through the OpenRouter
|
|
49
|
+
# backend by the platform.
|
|
50
|
+
"image_models" => [
|
|
51
|
+
"or-gemini-3-pro-image",
|
|
52
|
+
"or-gpt-image-2"
|
|
53
|
+
],
|
|
54
|
+
"default_image_model" => "or-gpt-image-2",
|
|
44
55
|
# Provider-level default: the Claude family served here is vision-capable.
|
|
45
56
|
"capabilities" => { "vision" => true }.freeze,
|
|
46
57
|
# Model-level overrides: DeepSeek models routed through this provider
|
|
@@ -123,6 +134,13 @@ module Clacky
|
|
|
123
134
|
/\Aanthropic\// => "anthropic-messages",
|
|
124
135
|
/\Aclaude[-.]/ => "anthropic-messages"
|
|
125
136
|
}.freeze,
|
|
137
|
+
# Image generation via OpenRouter is currently routed through the
|
|
138
|
+
# openclacky platform gateway (see "openclacky" provider above) which
|
|
139
|
+
# handles the OpenRouter chat-completions + modalities translation.
|
|
140
|
+
# Direct OpenRouter image config is not exposed here — leave empty
|
|
141
|
+
# until we ship a dedicated client-side adapter for that protocol.
|
|
142
|
+
"image_models" => [],
|
|
143
|
+
"default_image_model" => nil,
|
|
126
144
|
"website_url" => "https://openrouter.ai/keys"
|
|
127
145
|
}.freeze,
|
|
128
146
|
|
|
@@ -305,6 +323,12 @@ module Clacky
|
|
|
305
323
|
"gpt-5.5" => "gpt-5.4-mini",
|
|
306
324
|
"gpt-5.4" => "gpt-5.4-mini"
|
|
307
325
|
},
|
|
326
|
+
# OpenAI's image generation model — same /v1/images/generations
|
|
327
|
+
# endpoint, so the OpenAICompat image provider handles it.
|
|
328
|
+
"image_models" => [
|
|
329
|
+
"gpt-image-2"
|
|
330
|
+
],
|
|
331
|
+
"default_image_model" => "gpt-image-2",
|
|
308
332
|
"website_url" => "https://platform.openai.com/api-keys"
|
|
309
333
|
}.freeze,
|
|
310
334
|
|
|
@@ -342,6 +366,8 @@ module Clacky
|
|
|
342
366
|
|
|
343
367
|
}.freeze
|
|
344
368
|
|
|
369
|
+
MEDIA_KINDS = %w[image video audio].freeze
|
|
370
|
+
|
|
345
371
|
class << self
|
|
346
372
|
# Check if a provider preset exists
|
|
347
373
|
# @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
|
|
@@ -446,6 +472,62 @@ module Clacky
|
|
|
446
472
|
preset&.dig("models") || []
|
|
447
473
|
end
|
|
448
474
|
|
|
475
|
+
# Get available image generation models for a provider.
|
|
476
|
+
# Returns an empty array when the provider doesn't declare any —
|
|
477
|
+
# callers should treat that as "image generation not supported by this provider".
|
|
478
|
+
# @param provider_id [String] The provider identifier
|
|
479
|
+
# @return [Array<String>] List of image model names
|
|
480
|
+
def image_models(provider_id)
|
|
481
|
+
preset = PRESETS[provider_id]
|
|
482
|
+
preset&.dig("image_models") || []
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Video generation models — placeholder. No provider supports video
|
|
486
|
+
# via Clacky yet; once they do, declare "video_models" alongside
|
|
487
|
+
# "image_models" in the relevant PRESETS entry and this returns it.
|
|
488
|
+
def video_models(provider_id)
|
|
489
|
+
preset = PRESETS[provider_id]
|
|
490
|
+
preset&.dig("video_models") || []
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Audio generation models — same placeholder pattern as video_models.
|
|
494
|
+
def audio_models(provider_id)
|
|
495
|
+
preset = PRESETS[provider_id]
|
|
496
|
+
preset&.dig("audio_models") || []
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Unified entry for media model lookup by kind.
|
|
500
|
+
# @param provider_id [String]
|
|
501
|
+
# @param kind [String] one of "image" / "video" / "audio"
|
|
502
|
+
# @return [Array<String>]
|
|
503
|
+
def media_models(provider_id, kind)
|
|
504
|
+
case kind.to_s
|
|
505
|
+
when "image" then image_models(provider_id)
|
|
506
|
+
when "video" then video_models(provider_id)
|
|
507
|
+
when "audio" then audio_models(provider_id)
|
|
508
|
+
else []
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Default media model for a kind under a provider. Falls back to the
|
|
513
|
+
# first declared model when no explicit default is set in the preset.
|
|
514
|
+
# Used by AgentConfig#derive_media_models! to pick which model to
|
|
515
|
+
# surface when the user is on "auto" mode.
|
|
516
|
+
def default_media_model(provider_id, kind)
|
|
517
|
+
preset = PRESETS[provider_id]
|
|
518
|
+
return nil unless preset
|
|
519
|
+
explicit = preset["default_#{kind}_model"]
|
|
520
|
+
return explicit if explicit
|
|
521
|
+
media_models(provider_id, kind).first
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# The set of media kinds Clacky knows about. Drives UI rendering and
|
|
525
|
+
# derivation loops — adding a new modality means listing it here plus
|
|
526
|
+
# adding the corresponding generator class.
|
|
527
|
+
def media_kinds
|
|
528
|
+
MEDIA_KINDS
|
|
529
|
+
end
|
|
530
|
+
|
|
449
531
|
# Get the lite model for a provider.
|
|
450
532
|
# @param provider_id [String] The provider identifier
|
|
451
533
|
# @param primary_model [String, nil] The currently-selected primary model name.
|
|
@@ -511,7 +511,7 @@ module Clacky
|
|
|
511
511
|
platform = event[:platform].to_s
|
|
512
512
|
count = @mutex.synchronize { @session_counters[platform] += 1 }
|
|
513
513
|
name = "#{platform}-#{count}"
|
|
514
|
-
session_id = @session_builder.call(name: name,
|
|
514
|
+
session_id = @session_builder.call(name: name, source: :channel)
|
|
515
515
|
bind_key_to_session(key, session_id)
|
|
516
516
|
|
|
517
517
|
# Create a long-lived ChannelUIController for this session and subscribe it
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Channel
|
|
7
|
+
module Adapters
|
|
8
|
+
# Loads user-defined channel adapters from ~/.clacky/channels/<name>/adapter.rb.
|
|
9
|
+
#
|
|
10
|
+
# Each adapter file is plain Ruby that defines a subclass of
|
|
11
|
+
# Clacky::Channel::Adapters::Base and self-registers via Adapters.register,
|
|
12
|
+
# exactly like the bundled adapters. This loader only discovers and requires
|
|
13
|
+
# those files after the built-in adapters are loaded — the existing
|
|
14
|
+
# self-registration mechanism then takes over with no further wiring.
|
|
15
|
+
#
|
|
16
|
+
# A broken adapter (syntax error, missing interface methods) is isolated:
|
|
17
|
+
# it is skipped with a logged warning and never aborts the load of others.
|
|
18
|
+
module UserAdapterLoader
|
|
19
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/channels")
|
|
20
|
+
|
|
21
|
+
# Required class/instance methods a user adapter must implement to be usable.
|
|
22
|
+
REQUIRED_CLASS_METHODS = %i[platform_id platform_config].freeze
|
|
23
|
+
REQUIRED_INSTANCE_METHODS = %i[start stop send_text].freeze
|
|
24
|
+
|
|
25
|
+
Result = Struct.new(:loaded, :skipped, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# @param dir [String] directory to scan (override for tests)
|
|
28
|
+
# @return [Result] names loaded and skipped (with reasons)
|
|
29
|
+
def self.load_all(dir: DEFAULT_DIR)
|
|
30
|
+
result = Result.new(loaded: [], skipped: [])
|
|
31
|
+
if Dir.exist?(dir)
|
|
32
|
+
Dir.glob(File.join(dir, "*", "adapter.rb")).sort.each do |path|
|
|
33
|
+
name = File.basename(File.dirname(path))
|
|
34
|
+
load_one(path, name, result)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
@last_result = result
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The result of the most recent load_all (set at startup). Lets `channel_verify`
|
|
42
|
+
# report status without re-requiring files (require is idempotent and would
|
|
43
|
+
# otherwise report already-loaded adapters as "did not register").
|
|
44
|
+
def self.last_result
|
|
45
|
+
@last_result || load_all
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.load_one(path, name, result)
|
|
49
|
+
before = Adapters.all.dup
|
|
50
|
+
|
|
51
|
+
require path
|
|
52
|
+
|
|
53
|
+
newly = Adapters.all - before
|
|
54
|
+
klass = newly.last
|
|
55
|
+
|
|
56
|
+
unless klass
|
|
57
|
+
result.skipped << [name, "did not register an adapter (missing Adapters.register?)"]
|
|
58
|
+
log_skip(name, result.skipped.last[1])
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if (missing = interface_gaps(klass)).any?
|
|
63
|
+
unregister(klass)
|
|
64
|
+
result.skipped << [name, "missing required methods: #{missing.join(", ")}"]
|
|
65
|
+
log_skip(name, result.skipped.last[1])
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result.loaded << name
|
|
70
|
+
Clacky::Logger.info("[UserAdapterLoader] Loaded channel adapter '#{name}' → :#{klass.platform_id}")
|
|
71
|
+
rescue StandardError, ScriptError => e
|
|
72
|
+
result.skipped << [name, e.message]
|
|
73
|
+
log_skip(name, e.message)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.interface_gaps(klass)
|
|
77
|
+
missing = REQUIRED_CLASS_METHODS.reject { |m| klass.respond_to?(m) }
|
|
78
|
+
# Base defines stub instance methods that only raise NotImplementedError,
|
|
79
|
+
# so method_defined? alone passes via inheritance. Require the subclass to
|
|
80
|
+
# actually override them — i.e. the method's owner must not be Base.
|
|
81
|
+
missing += REQUIRED_INSTANCE_METHODS.reject do |m|
|
|
82
|
+
klass.method_defined?(m) && klass.instance_method(m).owner != Base
|
|
83
|
+
end
|
|
84
|
+
missing
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.unregister(klass)
|
|
88
|
+
platform = (klass.platform_id if klass.respond_to?(:platform_id))
|
|
89
|
+
return unless platform
|
|
90
|
+
|
|
91
|
+
Adapters.unregister(platform)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.log_skip(name, reason)
|
|
95
|
+
Clacky::Logger.warn("[UserAdapterLoader] Skipped channel adapter '#{name}': #{reason}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate a ready-to-edit adapter skeleton at ~/.clacky/channels/<name>/adapter.rb.
|
|
99
|
+
# The skeleton already self-registers and implements the full interface with
|
|
100
|
+
# TODO markers — the author only fills in the method bodies.
|
|
101
|
+
# @return [String] path to the generated adapter.rb
|
|
102
|
+
def self.scaffold(name, dir: DEFAULT_DIR)
|
|
103
|
+
slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
104
|
+
raise ArgumentError, "invalid channel name: #{name.inspect}" if slug.empty?
|
|
105
|
+
|
|
106
|
+
target_dir = File.join(dir, slug)
|
|
107
|
+
path = File.join(target_dir, "adapter.rb")
|
|
108
|
+
raise ArgumentError, "adapter already exists: #{path}" if File.exist?(path)
|
|
109
|
+
|
|
110
|
+
FileUtils.mkdir_p(target_dir)
|
|
111
|
+
File.write(path, skeleton(slug))
|
|
112
|
+
path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.skeleton(slug)
|
|
116
|
+
const = slug.split("_").map(&:capitalize).join
|
|
117
|
+
<<~RUBY
|
|
118
|
+
# frozen_string_literal: true
|
|
119
|
+
|
|
120
|
+
# User-defined channel adapter for ":#{slug}".
|
|
121
|
+
# Edit the TODO sections, then it loads automatically on next start.
|
|
122
|
+
# Verify with: clacky channel verify
|
|
123
|
+
|
|
124
|
+
module Clacky
|
|
125
|
+
module Channel
|
|
126
|
+
module Adapters
|
|
127
|
+
class #{const}Adapter < Base
|
|
128
|
+
def self.platform_id
|
|
129
|
+
:#{slug}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Map raw config (channels.yml `#{slug}` section) to a symbol-keyed hash.
|
|
133
|
+
def self.platform_config(data)
|
|
134
|
+
{
|
|
135
|
+
# TODO: pull your credentials out of `data`
|
|
136
|
+
# token: data["IM_#{slug.upcase}_TOKEN"] || data["token"]
|
|
137
|
+
}.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def initialize(config)
|
|
141
|
+
@config = config
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Begin receiving messages. Blocks until #stop — runs inside a Thread.
|
|
145
|
+
# Yield one standardized event Hash per inbound message.
|
|
146
|
+
def start(&on_message)
|
|
147
|
+
# TODO: connect to your platform and loop, calling on_message.call(event)
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def stop
|
|
152
|
+
# TODO: close connections / stop the read loop
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Send a plain text (or Markdown) message to a chat.
|
|
156
|
+
# @return [Hash] { message_id: String }
|
|
157
|
+
def send_text(chat_id, text, reply_to: nil)
|
|
158
|
+
# TODO: call your platform's send API
|
|
159
|
+
raise NotImplementedError
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Optional: validate config; return array of error strings (empty = ok).
|
|
163
|
+
def validate_config(config)
|
|
164
|
+
[]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Adapters.register(platform_id, self)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
RUBY
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -31,3 +31,8 @@ require_relative "channel/adapters/dingtalk/adapter"
|
|
|
31
31
|
require_relative "channel/channel_config"
|
|
32
32
|
require_relative "channel/channel_ui_controller"
|
|
33
33
|
require_relative "channel/channel_manager"
|
|
34
|
+
|
|
35
|
+
# Discover and load user-defined adapters from ~/.clacky/channels/.
|
|
36
|
+
# Must run after the bundled adapters so user adapters can extend or override.
|
|
37
|
+
require_relative "channel/user_adapter_loader"
|
|
38
|
+
Clacky::Channel::Adapters::UserAdapterLoader.load_all
|