openclacky 1.0.4 → 1.1.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/.clacky/skills/gem-release/SKILL.md +99 -356
- data/.clacky/skills/gem-release/scripts/release.sh +304 -0
- data/CHANGELOG.md +42 -0
- data/docs/system-skill-authoring-guide.md +1 -1
- data/lib/clacky/agent/tool_executor.rb +3 -1
- data/lib/clacky/agent.rb +12 -7
- data/lib/clacky/agent_config.rb +9 -3
- data/lib/clacky/brand_config.rb +19 -4
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
- data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
- data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
- data/lib/clacky/message_history.rb +26 -1
- data/lib/clacky/providers.rb +29 -4
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
- data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
- data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/clacky/server/channel/channel_config.rb +26 -0
- data/lib/clacky/server/channel.rb +3 -0
- data/lib/clacky/server/http_server.rb +75 -4
- data/lib/clacky/server/server_master.rb +35 -13
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +7 -1
- data/lib/clacky/telemetry.rb +1 -16
- data/lib/clacky/tools/browser.rb +8 -5
- data/lib/clacky/tools/glob.rb +11 -38
- data/lib/clacky/tools/grep.rb +7 -16
- data/lib/clacky/ui2/markdown_renderer.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_ignore_helper.rb +49 -0
- data/lib/clacky/utils/gitignore_parser.rb +27 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +248 -31
- data/lib/clacky/web/app.js +51 -1
- data/lib/clacky/web/channels.js +98 -28
- data/lib/clacky/web/datepicker.js +205 -0
- data/lib/clacky/web/i18n.js +48 -9
- data/lib/clacky/web/index.html +33 -6
- data/lib/clacky/web/onboard.js +46 -4
- data/lib/clacky/web/sessions.js +33 -72
- data/lib/clacky/web/settings.js +42 -4
- data/lib/clacky/web/version.js +52 -1
- metadata +21 -10
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
- /data/lib/clacky/default_skills/{channel-setup → channel-manager}/weixin_setup.rb +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module Channel
|
|
8
|
+
module Adapters
|
|
9
|
+
module Discord
|
|
10
|
+
class ApiClient
|
|
11
|
+
DEFAULT_HOMEPAGE_URL = "https://discord.com"
|
|
12
|
+
BASE_URL = "#{DEFAULT_HOMEPAGE_URL}/api/v10/"
|
|
13
|
+
|
|
14
|
+
class ApiError < StandardError; end
|
|
15
|
+
|
|
16
|
+
def initialize(bot_token:)
|
|
17
|
+
@bot_token = bot_token
|
|
18
|
+
@conn = Faraday.new(url: BASE_URL) do |f|
|
|
19
|
+
f.headers["Authorization"] = "Bot #{@bot_token}"
|
|
20
|
+
f.headers["User-Agent"] = self.class.user_agent
|
|
21
|
+
f.request :multipart
|
|
22
|
+
f.response :raise_error
|
|
23
|
+
f.adapter Faraday.default_adapter
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def me
|
|
28
|
+
request(:get, "users/@me")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def send_message(channel_id, content, reply_to: nil)
|
|
32
|
+
payload = { content: content.to_s }
|
|
33
|
+
payload[:message_reference] = { message_id: reply_to.to_s } if reply_to
|
|
34
|
+
request(:post, "channels/#{channel_id}/messages", payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def edit_message(channel_id, message_id, content)
|
|
38
|
+
request(:patch, "channels/#{channel_id}/messages/#{message_id}", { content: content.to_s })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def send_file(channel_id, path, name: nil)
|
|
42
|
+
raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
|
|
43
|
+
filename = name || File.basename(path)
|
|
44
|
+
payload = { attachments: [{ id: 0, filename: filename }] }
|
|
45
|
+
io = Faraday::UploadIO.new(path, detect_mime(path), filename)
|
|
46
|
+
|
|
47
|
+
res = @conn.post("channels/#{channel_id}/messages") do |req|
|
|
48
|
+
req.body = { "payload_json" => JSON.generate(payload), "files[0]" => io }
|
|
49
|
+
end
|
|
50
|
+
parse_json(res.body)
|
|
51
|
+
rescue Faraday::Error => e
|
|
52
|
+
raise_api_error(e)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def download(url)
|
|
56
|
+
res = Faraday.get(url)
|
|
57
|
+
{ body: res.body, content_type: res.headers["content-type"] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Discord requires User-Agent of the form "DiscordBot ($url, $versionNumber)".
|
|
61
|
+
# Requests with an invalid UA may be blocked at Cloudflare.
|
|
62
|
+
def self.user_agent
|
|
63
|
+
url = (Clacky::BrandConfig.load.homepage_url rescue nil) || DEFAULT_HOMEPAGE_URL
|
|
64
|
+
"DiscordBot (#{url}, #{Clacky::VERSION})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private def request(verb, path, body = nil)
|
|
68
|
+
res = @conn.run_request(verb, path, body ? JSON.generate(body) : nil,
|
|
69
|
+
{ "Content-Type" => "application/json" })
|
|
70
|
+
parse_json(res.body)
|
|
71
|
+
rescue Faraday::Error => e
|
|
72
|
+
raise_api_error(e)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private def raise_api_error(err)
|
|
76
|
+
status = err.response&.dig(:status)
|
|
77
|
+
body = err.response&.dig(:body).to_s
|
|
78
|
+
parsed = (JSON.parse(body) rescue nil)
|
|
79
|
+
msg = (parsed.is_a?(Hash) && parsed["message"]) || err.message
|
|
80
|
+
raise ApiError, "Discord API #{status}: #{msg}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private def parse_json(body)
|
|
84
|
+
return {} if body.to_s.empty?
|
|
85
|
+
JSON.parse(body)
|
|
86
|
+
rescue JSON::ParserError => e
|
|
87
|
+
raise ApiError, "Invalid JSON response: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private def detect_mime(path)
|
|
91
|
+
case File.extname(path).downcase
|
|
92
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
93
|
+
when ".png" then "image/png"
|
|
94
|
+
when ".gif" then "image/gif"
|
|
95
|
+
when ".webp" then "image/webp"
|
|
96
|
+
when ".mp4" then "video/mp4"
|
|
97
|
+
when ".mp3" then "audio/mpeg"
|
|
98
|
+
when ".pdf" then "application/pdf"
|
|
99
|
+
when ".txt" then "text/plain"
|
|
100
|
+
when ".json" then "application/json"
|
|
101
|
+
else "application/octet-stream"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "socket"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module Channel
|
|
11
|
+
module Adapters
|
|
12
|
+
module Discord
|
|
13
|
+
# WebSocket client for the Discord Gateway (v10, JSON, no compression).
|
|
14
|
+
# Implements identify, heartbeat, resume, and intent-aware error handling.
|
|
15
|
+
class GatewayClient
|
|
16
|
+
GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
17
|
+
|
|
18
|
+
# GUILDS | GUILD_MESSAGES | GUILD_MESSAGE_REACTIONS | DIRECT_MESSAGES | MESSAGE_CONTENT
|
|
19
|
+
INTENTS = (1 << 0) | (1 << 9) | (1 << 10) | (1 << 12) | (1 << 15)
|
|
20
|
+
|
|
21
|
+
FATAL_CLOSE_CODES = [4004, 4010, 4011, 4012, 4013, 4014].freeze
|
|
22
|
+
RECONNECT_DELAY = 5
|
|
23
|
+
READ_TIMEOUT_S = 90
|
|
24
|
+
|
|
25
|
+
class AuthError < StandardError; end
|
|
26
|
+
|
|
27
|
+
def initialize(bot_token:)
|
|
28
|
+
@bot_token = bot_token
|
|
29
|
+
@running = false
|
|
30
|
+
@on_event = nil
|
|
31
|
+
|
|
32
|
+
@session_id = nil
|
|
33
|
+
@resume_gateway_url = nil
|
|
34
|
+
@last_seq = nil
|
|
35
|
+
@heartbeat_interval = nil
|
|
36
|
+
@heartbeat_acked = true
|
|
37
|
+
@heartbeat_thread = nil
|
|
38
|
+
|
|
39
|
+
@ws_socket = nil
|
|
40
|
+
@ws_open = false
|
|
41
|
+
@ws_version = nil
|
|
42
|
+
@incoming = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start(&on_event)
|
|
46
|
+
@running = true
|
|
47
|
+
@on_event = on_event
|
|
48
|
+
|
|
49
|
+
while @running
|
|
50
|
+
begin
|
|
51
|
+
connect_and_listen
|
|
52
|
+
rescue AuthError
|
|
53
|
+
@running = false
|
|
54
|
+
raise
|
|
55
|
+
rescue => e
|
|
56
|
+
Clacky::Logger.error("[DiscordGW] error: #{e.message}")
|
|
57
|
+
sleep RECONNECT_DELAY if @running
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop
|
|
63
|
+
@running = false
|
|
64
|
+
@heartbeat_thread&.kill
|
|
65
|
+
send_raw_frame(:close, "") rescue nil
|
|
66
|
+
@ws_socket&.close rescue nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def connect_and_listen
|
|
70
|
+
ssl = nil
|
|
71
|
+
url = @resume_gateway_url ? "#{@resume_gateway_url}/?v=10&encoding=json" : GATEWAY_URL
|
|
72
|
+
uri = URI.parse(url)
|
|
73
|
+
port = uri.port || 443
|
|
74
|
+
|
|
75
|
+
Clacky::Logger.info("[DiscordGW] connecting to #{uri.host}:#{port} (resume=#{!@session_id.nil?})")
|
|
76
|
+
|
|
77
|
+
tcp = TCPSocket.new(uri.host, port)
|
|
78
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
79
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
80
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
81
|
+
ssl.hostname = uri.host
|
|
82
|
+
ssl.sync_close = true
|
|
83
|
+
ssl.connect
|
|
84
|
+
|
|
85
|
+
handshake = WebSocket::Handshake::Client.new(url: url)
|
|
86
|
+
ssl.write(handshake.to_s)
|
|
87
|
+
handshake << ssl.readpartial(4096) until handshake.finished?
|
|
88
|
+
raise "Gateway WebSocket handshake failed" unless handshake.valid?
|
|
89
|
+
|
|
90
|
+
@ws_version = handshake.version
|
|
91
|
+
@ws_socket = ssl
|
|
92
|
+
@ws_open = true
|
|
93
|
+
@incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
|
|
94
|
+
@heartbeat_acked = true
|
|
95
|
+
|
|
96
|
+
loop do
|
|
97
|
+
break unless @running
|
|
98
|
+
ready = IO.select([ssl], nil, nil, READ_TIMEOUT_S)
|
|
99
|
+
unless ready
|
|
100
|
+
Clacky::Logger.warn("[DiscordGW] read timeout, reconnecting")
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
data = ssl.read_nonblock(4096)
|
|
105
|
+
@incoming << data
|
|
106
|
+
while (frame = @incoming.next)
|
|
107
|
+
case frame.type
|
|
108
|
+
when :text
|
|
109
|
+
handle_payload(JSON.parse(frame.data))
|
|
110
|
+
when :ping
|
|
111
|
+
send_raw_frame(:pong, frame.data)
|
|
112
|
+
when :close
|
|
113
|
+
handle_close_frame(frame.data)
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
|
|
119
|
+
Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
|
|
120
|
+
Clacky::Logger.info("[DiscordGW] connection lost (#{e.class}: #{e.message})")
|
|
121
|
+
ensure
|
|
122
|
+
@ws_open = false
|
|
123
|
+
@ws_socket = nil
|
|
124
|
+
@heartbeat_thread&.kill
|
|
125
|
+
ssl&.close rescue nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def handle_payload(payload)
|
|
129
|
+
op = payload["op"]
|
|
130
|
+
data = payload["d"]
|
|
131
|
+
seq = payload["s"]
|
|
132
|
+
type = payload["t"]
|
|
133
|
+
|
|
134
|
+
@last_seq = seq if seq
|
|
135
|
+
|
|
136
|
+
case op
|
|
137
|
+
when 10
|
|
138
|
+
@heartbeat_interval = data["heartbeat_interval"]
|
|
139
|
+
Clacky::Logger.info("[DiscordGW] hello, heartbeat_interval=#{@heartbeat_interval}ms")
|
|
140
|
+
start_heartbeat_thread
|
|
141
|
+
if @session_id && @resume_gateway_url
|
|
142
|
+
send_resume
|
|
143
|
+
else
|
|
144
|
+
send_identify
|
|
145
|
+
end
|
|
146
|
+
when 0
|
|
147
|
+
handle_dispatch(type, data)
|
|
148
|
+
when 1
|
|
149
|
+
send_heartbeat
|
|
150
|
+
when 7
|
|
151
|
+
Clacky::Logger.info("[DiscordGW] server requested reconnect")
|
|
152
|
+
@ws_socket&.close rescue nil
|
|
153
|
+
when 9
|
|
154
|
+
resumable = data == true
|
|
155
|
+
Clacky::Logger.warn("[DiscordGW] invalid session (resumable=#{resumable})")
|
|
156
|
+
unless resumable
|
|
157
|
+
@session_id = nil
|
|
158
|
+
@resume_gateway_url = nil
|
|
159
|
+
@last_seq = nil
|
|
160
|
+
end
|
|
161
|
+
sleep(rand(1..5))
|
|
162
|
+
@ws_socket&.close rescue nil
|
|
163
|
+
when 11
|
|
164
|
+
@heartbeat_acked = true
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private def handle_dispatch(type, data)
|
|
169
|
+
case type
|
|
170
|
+
when "READY"
|
|
171
|
+
@session_id = data["session_id"]
|
|
172
|
+
@resume_gateway_url = data["resume_gateway_url"]
|
|
173
|
+
user = data["user"] || {}
|
|
174
|
+
Clacky::Logger.info("[DiscordGW] READY as #{user["username"]} (id=#{user["id"]}), session=#{@session_id}")
|
|
175
|
+
when "RESUMED"
|
|
176
|
+
Clacky::Logger.info("[DiscordGW] RESUMED session=#{@session_id}")
|
|
177
|
+
when "MESSAGE_CREATE"
|
|
178
|
+
@on_event&.call(type: :message, data: data)
|
|
179
|
+
end
|
|
180
|
+
rescue => e
|
|
181
|
+
Clacky::Logger.error("[DiscordGW] dispatch handler error (#{type}): #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private def handle_close_frame(data)
|
|
185
|
+
code = data.respond_to?(:code) ? data.code : nil
|
|
186
|
+
reason = data.respond_to?(:data) ? data.data : data.to_s
|
|
187
|
+
Clacky::Logger.warn("[DiscordGW] close frame code=#{code} reason=#{reason}")
|
|
188
|
+
if code && FATAL_CLOSE_CODES.include?(code)
|
|
189
|
+
@running = false
|
|
190
|
+
raise AuthError, "Discord rejected connection (code=#{code}): #{reason}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private def send_identify
|
|
195
|
+
Clacky::Logger.info("[DiscordGW] sending Identify (intents=#{INTENTS})")
|
|
196
|
+
client_id = self.class.client_identifier
|
|
197
|
+
send_payload(
|
|
198
|
+
op: 2,
|
|
199
|
+
d: {
|
|
200
|
+
token: @bot_token,
|
|
201
|
+
intents: INTENTS,
|
|
202
|
+
properties: {
|
|
203
|
+
os: RUBY_PLATFORM,
|
|
204
|
+
browser: client_id,
|
|
205
|
+
device: client_id
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.client_identifier
|
|
212
|
+
name = Clacky::BrandConfig.load.product_name
|
|
213
|
+
name = "OpenClacky" if name.nil? || name.strip.empty?
|
|
214
|
+
"#{name.strip}/#{Clacky::VERSION}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private def send_resume
|
|
218
|
+
Clacky::Logger.info("[DiscordGW] sending Resume (session=#{@session_id} seq=#{@last_seq})")
|
|
219
|
+
send_payload(
|
|
220
|
+
op: 6,
|
|
221
|
+
d: { token: @bot_token, session_id: @session_id, seq: @last_seq }
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private def send_heartbeat
|
|
226
|
+
unless @heartbeat_acked
|
|
227
|
+
Clacky::Logger.warn("[DiscordGW] missed heartbeat ack, forcing reconnect")
|
|
228
|
+
@ws_socket&.close rescue nil
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
@heartbeat_acked = false
|
|
232
|
+
send_payload(op: 1, d: @last_seq)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private def start_heartbeat_thread
|
|
236
|
+
@heartbeat_thread&.kill
|
|
237
|
+
interval_s = @heartbeat_interval.to_f / 1000.0
|
|
238
|
+
jitter = rand
|
|
239
|
+
@heartbeat_thread = Thread.new do
|
|
240
|
+
sleep(interval_s * jitter)
|
|
241
|
+
loop do
|
|
242
|
+
break unless @running && @ws_open
|
|
243
|
+
begin
|
|
244
|
+
send_heartbeat
|
|
245
|
+
rescue => e
|
|
246
|
+
Clacky::Logger.warn("[DiscordGW] heartbeat send failed: #{e.message}")
|
|
247
|
+
@ws_socket&.close rescue nil
|
|
248
|
+
break
|
|
249
|
+
end
|
|
250
|
+
sleep interval_s
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private def send_payload(payload)
|
|
256
|
+
send_raw_frame(:text, JSON.generate(payload))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private def send_raw_frame(type, data)
|
|
260
|
+
return unless @ws_socket && @ws_open
|
|
261
|
+
outgoing = WebSocket::Frame::Outgoing::Client.new(
|
|
262
|
+
version: @ws_version || 13,
|
|
263
|
+
data: data,
|
|
264
|
+
type: type
|
|
265
|
+
)
|
|
266
|
+
@ws_socket.write(outgoing.to_s)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|