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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +99 -356
  3. data/.clacky/skills/gem-release/scripts/release.sh +304 -0
  4. data/CHANGELOG.md +42 -0
  5. data/docs/system-skill-authoring-guide.md +1 -1
  6. data/lib/clacky/agent/tool_executor.rb +3 -1
  7. data/lib/clacky/agent.rb +12 -7
  8. data/lib/clacky/agent_config.rb +9 -3
  9. data/lib/clacky/brand_config.rb +19 -4
  10. data/lib/clacky/cli.rb +1 -1
  11. data/lib/clacky/default_skills/{channel-setup → channel-manager}/SKILL.md +180 -18
  12. data/lib/clacky/default_skills/channel-manager/dingtalk_setup.rb +191 -0
  13. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +199 -0
  14. data/lib/clacky/default_skills/channel-manager/install_feishu_skills.rb +105 -0
  15. data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
  16. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +2 -4
  17. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +18 -96
  18. data/lib/clacky/default_skills/product-help/SKILL.md +10 -2
  19. data/lib/clacky/message_history.rb +26 -1
  20. data/lib/clacky/providers.rb +29 -4
  21. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +177 -0
  22. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +82 -0
  23. data/lib/clacky/server/channel/adapters/dingtalk/stream_client.rb +205 -0
  24. data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
  25. data/lib/clacky/server/channel/adapters/discord/api_client.rb +108 -0
  26. data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
  27. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
  28. data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
  29. data/lib/clacky/server/channel/channel_config.rb +26 -0
  30. data/lib/clacky/server/channel.rb +3 -0
  31. data/lib/clacky/server/http_server.rb +75 -4
  32. data/lib/clacky/server/server_master.rb +35 -13
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +7 -1
  35. data/lib/clacky/telemetry.rb +1 -16
  36. data/lib/clacky/tools/browser.rb +8 -5
  37. data/lib/clacky/tools/glob.rb +11 -38
  38. data/lib/clacky/tools/grep.rb +7 -16
  39. data/lib/clacky/ui2/markdown_renderer.rb +1 -1
  40. data/lib/clacky/ui2/ui_controller.rb +2 -1
  41. data/lib/clacky/utils/file_ignore_helper.rb +49 -0
  42. data/lib/clacky/utils/gitignore_parser.rb +27 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +248 -31
  45. data/lib/clacky/web/app.js +51 -1
  46. data/lib/clacky/web/channels.js +98 -28
  47. data/lib/clacky/web/datepicker.js +205 -0
  48. data/lib/clacky/web/i18n.js +48 -9
  49. data/lib/clacky/web/index.html +33 -6
  50. data/lib/clacky/web/onboard.js +46 -4
  51. data/lib/clacky/web/sessions.js +33 -72
  52. data/lib/clacky/web/settings.js +42 -4
  53. data/lib/clacky/web/version.js +52 -1
  54. metadata +21 -10
  55. data/docs/proposals/2026-05-11-system-prompt-alignment.md +0 -325
  56. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +0 -89
  57. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/feishu_setup.rb +0 -0
  58. /data/lib/clacky/default_skills/{channel-setup → channel-manager}/import_lark_skills.rb +0 -0
  59. /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