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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../adapters/base"
4
+ require_relative "stream_client"
5
+ require_relative "api_client"
6
+
7
+ module Clacky
8
+ module Channel
9
+ module Adapters
10
+ module DingTalk
11
+ class Adapter < Base
12
+ def self.platform_id
13
+ :dingtalk
14
+ end
15
+
16
+ def self.env_keys
17
+ %w[IM_DINGTALK_CLIENT_ID IM_DINGTALK_CLIENT_SECRET IM_DINGTALK_ALLOWED_USERS]
18
+ end
19
+
20
+ def self.platform_config(data)
21
+ {
22
+ client_id: data["IM_DINGTALK_CLIENT_ID"],
23
+ client_secret: data["IM_DINGTALK_CLIENT_SECRET"],
24
+ allowed_users: data["IM_DINGTALK_ALLOWED_USERS"]&.split(",")&.map(&:strip)&.reject(&:empty?)
25
+ }
26
+ end
27
+
28
+ def self.set_env_data(data, config)
29
+ data["IM_DINGTALK_CLIENT_ID"] = config[:client_id]
30
+ data["IM_DINGTALK_CLIENT_SECRET"] = config[:client_secret]
31
+ data["IM_DINGTALK_ALLOWED_USERS"] = Array(config[:allowed_users]).join(",")
32
+ end
33
+
34
+ def self.test_connection(fields)
35
+ client = ApiClient.new(
36
+ client_id: fields[:client_id].to_s.strip,
37
+ client_secret: fields[:client_secret].to_s.strip
38
+ )
39
+ client.test_connection
40
+ rescue => e
41
+ { ok: false, error: e.message }
42
+ end
43
+
44
+ def initialize(config)
45
+ @config = config
46
+ @api_client = ApiClient.new(
47
+ client_id: config[:client_id],
48
+ client_secret: config[:client_secret]
49
+ )
50
+ @stream_client = nil
51
+ @running = false
52
+ # chat_id => { url:, expires_at_ms: } — sessionWebhook is per-message
53
+ # and expires (~2h). We cache it from inbound events and validate on send.
54
+ @webhook_urls = {}
55
+ @webhook_mutex = Mutex.new
56
+ end
57
+
58
+ WEBHOOK_SAFETY_MARGIN_MS = 5 * 60 * 1000
59
+
60
+ def start(&on_message)
61
+ @running = true
62
+ @on_message = on_message
63
+
64
+ @stream_client = StreamClient.new(
65
+ client_id: @config[:client_id],
66
+ client_secret: @config[:client_secret]
67
+ )
68
+ @stream_client.start { |frame| handle_frame(frame) }
69
+ end
70
+
71
+ def stop
72
+ @running = false
73
+ @stream_client&.stop
74
+ end
75
+
76
+ # @param chat_id [String] — for DingTalk Stream Mode, chat_id == webhook URL
77
+ def send_text(chat_id, text, reply_to: nil)
78
+ webhook_url = resolve_webhook(chat_id)
79
+ unless webhook_url
80
+ Clacky::Logger.warn("[dingtalk] no valid sessionWebhook for chat #{chat_id} (expired or never received)")
81
+ return { ok: false, error: "session_webhook_expired" }
82
+ end
83
+ @api_client.send_via_webhook(webhook_url, text)
84
+ end
85
+
86
+ def validate_config(config)
87
+ errors = []
88
+ errors << "client_id is required" if config[:client_id].to_s.strip.empty?
89
+ errors << "client_secret is required" if config[:client_secret].to_s.strip.empty?
90
+ errors
91
+ end
92
+
93
+ private def handle_frame(frame)
94
+ topic = frame.dig("headers", "topic").to_s
95
+ return unless topic == "/v1.0/im/bot/messages/get"
96
+
97
+ data = begin
98
+ raw = frame["data"]
99
+ raw.is_a?(String) ? JSON.parse(raw) : raw
100
+ rescue JSON::ParserError
101
+ Clacky::Logger.warn("[dingtalk] failed to parse event data")
102
+ return
103
+ end
104
+
105
+ sender_id = data.dig("senderStaffId") || data.dig("senderId") || ""
106
+ chat_id = data.dig("conversationId") || sender_id
107
+ webhook_url = data.dig("sessionWebhook") || ""
108
+ expired_ms = (data.dig("sessionWebhookExpiredTime") || 0).to_i
109
+ text = extract_text(data)
110
+ conv_type = data.dig("conversationType").to_s # "1"=DM, "2"=group
111
+
112
+ cache_webhook(chat_id, webhook_url, expired_ms) unless webhook_url.empty?
113
+
114
+ return if sender_id.empty?
115
+
116
+ # Group chats: only respond when @-mentioned
117
+ if conv_type == "2"
118
+ content = data.dig("text", "content").to_s
119
+ at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
120
+ bot_id = data.dig("chatbotUserId").to_s
121
+ unless at_users.include?(bot_id) || content.include?("@")
122
+ return
123
+ end
124
+ end
125
+
126
+ allowed = @config[:allowed_users]
127
+ return if allowed && !allowed.empty? && !allowed.include?(sender_id)
128
+
129
+ event = {
130
+ platform: :dingtalk,
131
+ user_id: sender_id,
132
+ chat_id: chat_id,
133
+ message_id: data.dig("msgId") || "",
134
+ text: text,
135
+ files: [],
136
+ chat_type: conv_type == "2" ? :group : :direct
137
+ }
138
+
139
+ Clacky::Logger.info("[dingtalk] message from #{sender_id}: #{text.to_s[0, 80]}")
140
+ @on_message&.call(event)
141
+ rescue => e
142
+ Clacky::Logger.warn("[dingtalk] handle_frame error: #{e.message}")
143
+ end
144
+
145
+ private def extract_text(data)
146
+ content = data.dig("text", "content").to_s.strip
147
+ # Strip leading @bot mention if present
148
+ content.gsub(/^@\S+\s*/, "").strip
149
+ end
150
+
151
+ private def cache_webhook(chat_id, url, expired_ms)
152
+ @webhook_mutex.synchronize do
153
+ @webhook_urls[chat_id] = { url: url, expires_at_ms: expired_ms }
154
+ end
155
+ end
156
+
157
+ private def resolve_webhook(chat_id)
158
+ entry = @webhook_mutex.synchronize { @webhook_urls[chat_id] }
159
+ return nil unless entry
160
+
161
+ expires_at = entry[:expires_at_ms].to_i
162
+ if expires_at > 0
163
+ now_ms = (Time.now.to_f * 1000).to_i
164
+ if now_ms + WEBHOOK_SAFETY_MARGIN_MS >= expires_at
165
+ @webhook_mutex.synchronize { @webhook_urls.delete(chat_id) }
166
+ return nil
167
+ end
168
+ end
169
+ entry[:url]
170
+ end
171
+ end
172
+
173
+ Adapters.register(:dingtalk, Adapter)
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Clacky
8
+ module Channel
9
+ module Adapters
10
+ module DingTalk
11
+ # DingTalk Bot API client — sends messages via session webhook (Stream Mode).
12
+ class ApiClient
13
+ OPENAPI_BASE = "https://api.dingtalk.com"
14
+
15
+ def initialize(client_id:, client_secret:)
16
+ @client_id = client_id
17
+ @client_secret = client_secret
18
+ @token = nil
19
+ @token_expires_at = 0
20
+ end
21
+
22
+ # Send a text (or Markdown) message via the session webhook URL.
23
+ # In Stream Mode, inbound events carry a `sessionWebhook` — use that directly.
24
+ # @param webhook_url [String]
25
+ # @param text [String]
26
+ # @param msg_type [:text, :markdown] (default :text)
27
+ def send_via_webhook(webhook_url, text, msg_type: :text)
28
+ body = if msg_type == :markdown
29
+ { msgtype: "markdown", markdown: { title: "Reply", text: text } }
30
+ else
31
+ { msgtype: "text", text: { content: text } }
32
+ end
33
+
34
+ uri = URI.parse(webhook_url)
35
+ http = Net::HTTP.new(uri.host, uri.port)
36
+ http.use_ssl = uri.scheme == "https"
37
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
38
+ req.body = JSON.generate(body)
39
+ resp = http.request(req)
40
+ data = JSON.parse(resp.body) rescue {}
41
+ if resp.code.to_i != 200 || (data["errcode"] && data["errcode"] != 0)
42
+ Clacky::Logger.warn("[dingtalk] webhook send rejected (#{resp.code}): #{resp.body}")
43
+ end
44
+ data
45
+ rescue => e
46
+ Clacky::Logger.warn("[dingtalk] webhook send failed: #{e.message}")
47
+ {}
48
+ end
49
+
50
+ # Fetch a short-lived access token (cached for its lifetime).
51
+ def access_token
52
+ return @token if @token && Time.now.to_i < @token_expires_at - 60
53
+
54
+ uri = URI.parse("#{OPENAPI_BASE}/v1.0/oauth2/accessToken")
55
+ http = Net::HTTP.new(uri.host, uri.port)
56
+ http.use_ssl = true
57
+ req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
58
+ req.body = JSON.generate({ appKey: @client_id, appSecret: @client_secret })
59
+
60
+ resp = http.request(req)
61
+ data = JSON.parse(resp.body)
62
+
63
+ raise "DingTalk token error (#{resp.code}): #{data["message"] || resp.body}" unless resp.code.to_i == 200
64
+
65
+ @token = data["accessToken"] || raise("Missing accessToken in response")
66
+ @token_expires_at = Time.now.to_i + (data["expireIn"] || 7200).to_i
67
+ @token
68
+ end
69
+
70
+ # Validate credentials by fetching a token.
71
+ # @return [Hash] { ok: Boolean, error: String? }
72
+ def test_connection
73
+ access_token
74
+ { ok: true, message: "DingTalk access token obtained" }
75
+ rescue => e
76
+ { ok: false, error: e.message }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Clacky
9
+ module Channel
10
+ module Adapters
11
+ module DingTalk
12
+ DINGTALK_API_BASE = "https://api.dingtalk.com"
13
+ RECONNECT_DELAY = 5
14
+
15
+ # WebSocket Stream Mode client for DingTalk.
16
+ # DingTalk Stream Mode uses JSON frames (unlike Feishu's protobuf).
17
+ # Frame shape:
18
+ # { "specVersion": "1.0", "type": "SYSTEM"|"EVENT"|"CALLBACK",
19
+ # "headers": { "messageId": "...", "time": "...", "topic": "...", ... },
20
+ # "data": "..." }
21
+ class StreamClient
22
+ def initialize(client_id:, client_secret:)
23
+ @client_id = client_id
24
+ @client_secret = client_secret
25
+ @running = false
26
+ end
27
+
28
+ def start(&on_event)
29
+ @running = true
30
+ @on_event = on_event
31
+ Clacky::Logger.info("[dingtalk-ws] Starting stream client (client_id=#{@client_id})")
32
+
33
+ while @running
34
+ begin
35
+ connect_and_listen
36
+ rescue => e
37
+ Clacky::Logger.warn("[dingtalk-ws] Connection error: #{e.class}: #{e.message}")
38
+ sleep RECONNECT_DELAY if @running
39
+ end
40
+ end
41
+ end
42
+
43
+ def stop
44
+ @running = false
45
+ @ws_socket&.close rescue nil
46
+ end
47
+
48
+ private def connect_and_listen
49
+ Clacky::Logger.info("[dingtalk-ws] Fetching stream endpoint...")
50
+ endpoint, ticket = fetch_stream_endpoint
51
+ full_url = "#{endpoint}?ticket=#{ticket}"
52
+ Clacky::Logger.info("[dingtalk-ws] Connecting to #{endpoint}")
53
+
54
+ uri = URI.parse(full_url)
55
+ port = uri.port || 443
56
+ tcp = TCPSocket.new(uri.host, port)
57
+
58
+ socket = begin
59
+ require "openssl"
60
+ ctx = OpenSSL::SSL::SSLContext.new
61
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
62
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
63
+ ssl.hostname = uri.host
64
+ ssl.sync_close = true
65
+ ssl.connect
66
+ ssl
67
+ end
68
+
69
+ handshake = WebSocket::Handshake::Client.new(url: full_url)
70
+ socket.write(handshake.to_s)
71
+ until handshake.finished?
72
+ handshake << socket.readpartial(4096)
73
+ end
74
+ raise "WebSocket handshake failed" unless handshake.valid?
75
+
76
+ Clacky::Logger.info("[dingtalk-ws] WebSocket connected")
77
+ @ws_version = handshake.version
78
+ @ws_socket = socket
79
+ @incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
80
+
81
+ loop do
82
+ break unless @running
83
+
84
+ ready = IO.select([socket], nil, nil, 120)
85
+ unless ready
86
+ Clacky::Logger.warn("[dingtalk-ws] read timeout, reconnecting...")
87
+ return
88
+ end
89
+
90
+ data = socket.read_nonblock(4096)
91
+ @incoming << data
92
+ while (frame = @incoming.next)
93
+ case frame.type
94
+ when :text then handle_frame(frame.data)
95
+ when :ping then send_frame(:pong, frame.data)
96
+ when :close
97
+ Clacky::Logger.info("[dingtalk-ws] Server closed connection, will reconnect")
98
+ return
99
+ end
100
+ end
101
+ end
102
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
103
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
104
+ raise
105
+ ensure
106
+ @ws_socket = nil
107
+ socket&.close rescue nil
108
+ end
109
+
110
+ private def fetch_stream_endpoint
111
+ uri = URI.parse("#{DINGTALK_API_BASE}/v1.0/gateway/connections/open")
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = true
114
+
115
+ req = Net::HTTP::Post.new(uri.path)
116
+ req["Content-Type"] = "application/json"
117
+ req["Accept"] = "application/json"
118
+ req.body = JSON.generate({
119
+ clientId: @client_id,
120
+ clientSecret: @client_secret,
121
+ subscriptions: [
122
+ { type: "CALLBACK", topic: "/v1.0/im/bot/messages/get" }
123
+ ],
124
+ ua: self.class.client_identifier,
125
+ localIp: "127.0.0.1"
126
+ })
127
+
128
+ resp = http.request(req)
129
+ data = JSON.parse(resp.body)
130
+
131
+ unless resp.code.to_i == 200
132
+ raise "Stream endpoint error (#{resp.code}): #{data["message"] || resp.body}"
133
+ end
134
+
135
+ endpoint = data.dig("endpoint") || raise("Missing endpoint in response")
136
+ ticket = data.dig("ticket") || ""
137
+ [endpoint, ticket]
138
+ end
139
+
140
+ def self.client_identifier
141
+ name = Clacky::BrandConfig.load.product_name
142
+ name = "OpenClacky" if name.nil? || name.strip.empty?
143
+ "#{name.strip}/#{Clacky::VERSION}"
144
+ end
145
+
146
+ private def handle_frame(json_text)
147
+ frame = JSON.parse(json_text)
148
+ type = frame["type"]
149
+
150
+ case type
151
+ when "SYSTEM"
152
+ handle_system(frame)
153
+ when "EVENT", "CALLBACK"
154
+ send_ack(frame)
155
+ @on_event&.call(frame)
156
+ end
157
+ rescue JSON::ParserError => e
158
+ Clacky::Logger.warn("[dingtalk-ws] JSON parse error: #{e.message}")
159
+ end
160
+
161
+ private def handle_system(frame)
162
+ topic = frame.dig("headers", "topic").to_s
163
+ case topic
164
+ when "ping"
165
+ pong = frame.merge("type" => "SYSTEM",
166
+ "headers" => frame["headers"].merge("topic" => "pong"))
167
+ send_text(JSON.generate(pong))
168
+ Clacky::Logger.info("[dingtalk-ws] pong sent")
169
+ when "disconnect"
170
+ Clacky::Logger.warn("[dingtalk-ws] Server requested disconnect, will reconnect")
171
+ @ws_socket&.close rescue nil
172
+ end
173
+ end
174
+
175
+ private def send_ack(frame)
176
+ ack = {
177
+ "code" => 200,
178
+ "headers" => {
179
+ "messageId" => frame.dig("headers", "messageId"),
180
+ "topic" => "ack"
181
+ },
182
+ "message" => "OK",
183
+ "data" => ""
184
+ }
185
+ send_text(JSON.generate(ack))
186
+ end
187
+
188
+ private def send_frame(type, data)
189
+ return unless @ws_socket
190
+ outgoing = WebSocket::Frame::Outgoing::Client.new(
191
+ version: @ws_version || 13,
192
+ data: data,
193
+ type: type
194
+ )
195
+ @ws_socket.write(outgoing.to_s)
196
+ end
197
+
198
+ private def send_text(text)
199
+ send_frame(:text, text)
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../adapters/base"
4
+ require_relative "api_client"
5
+ require_relative "gateway_client"
6
+ require_relative "../feishu/file_processor"
7
+ require "time"
8
+
9
+ module Clacky
10
+ module Channel
11
+ module Adapters
12
+ module Discord
13
+ # Discord adapter (bot mode).
14
+ # Receives messages via the Gateway WebSocket and sends via the REST API.
15
+ class Adapter < Base
16
+ MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
17
+
18
+ def self.platform_id
19
+ :discord
20
+ end
21
+
22
+ def self.env_keys
23
+ %w[IM_DISCORD_BOT_TOKEN]
24
+ end
25
+
26
+ def self.platform_config(data)
27
+ {
28
+ bot_token: data["IM_DISCORD_BOT_TOKEN"]
29
+ }
30
+ end
31
+
32
+ def self.set_env_data(data, config)
33
+ data["IM_DISCORD_BOT_TOKEN"] = config[:bot_token]
34
+ end
35
+
36
+ def self.test_connection(fields)
37
+ bot_token = fields[:bot_token].to_s.strip
38
+ return { ok: false, error: "bot_token is required" } if bot_token.empty?
39
+
40
+ client = ApiClient.new(bot_token: bot_token)
41
+ me = client.me
42
+ if me["id"]
43
+ { ok: true, message: "Connected as #{me["username"]}##{me["discriminator"]} (id=#{me["id"]})" }
44
+ else
45
+ { ok: false, error: "Empty response from /users/@me" }
46
+ end
47
+ rescue ApiClient::ApiError => e
48
+ { ok: false, error: e.message }
49
+ rescue StandardError => e
50
+ { ok: false, error: e.message }
51
+ end
52
+
53
+ def initialize(config)
54
+ @config = config
55
+ @bot_token = config[:bot_token]
56
+ @api = ApiClient.new(bot_token: @bot_token)
57
+ @gateway = GatewayClient.new(bot_token: @bot_token)
58
+ @bot_user_id = nil
59
+ @running = false
60
+ @on_message = nil
61
+ end
62
+
63
+ def start(&on_message)
64
+ @running = true
65
+ @on_message = on_message
66
+
67
+ begin
68
+ me = @api.me
69
+ @bot_user_id = me["id"]
70
+ Clacky::Logger.info("[DiscordAdapter] authenticated as #{me["username"]} (id=#{@bot_user_id})")
71
+ rescue ApiClient::ApiError => e
72
+ Clacky::Logger.error("[DiscordAdapter] /users/@me failed, not retrying: #{e.message}")
73
+ return
74
+ end
75
+
76
+ @gateway.start do |evt|
77
+ handle_gateway_event(evt)
78
+ end
79
+ rescue GatewayClient::AuthError => e
80
+ Clacky::Logger.error("[DiscordAdapter] Authentication failed, not retrying: #{e.message}")
81
+ end
82
+
83
+ def stop
84
+ @running = false
85
+ @gateway.stop
86
+ end
87
+
88
+ def send_text(chat_id, text, reply_to: nil)
89
+ res = @api.send_message(chat_id, text, reply_to: reply_to)
90
+ { message_id: res["id"] }
91
+ rescue ApiClient::ApiError => e
92
+ Clacky::Logger.error("[DiscordAdapter] send_text failed: #{e.message}")
93
+ { message_id: nil }
94
+ end
95
+
96
+ def update_message(chat_id, message_id, text)
97
+ @api.edit_message(chat_id, message_id, text)
98
+ true
99
+ rescue ApiClient::ApiError => e
100
+ Clacky::Logger.warn("[DiscordAdapter] update_message failed: #{e.message}")
101
+ false
102
+ end
103
+
104
+ def supports_message_updates?
105
+ true
106
+ end
107
+
108
+ def send_file(chat_id, path, name: nil)
109
+ @api.send_file(chat_id, path, name: name)
110
+ rescue ApiClient::ApiError => e
111
+ Clacky::Logger.error("[DiscordAdapter] send_file failed: #{e.message}")
112
+ nil
113
+ end
114
+
115
+ def validate_config(config)
116
+ errors = []
117
+ errors << "bot_token is required" if config[:bot_token].nil? || config[:bot_token].empty?
118
+ errors
119
+ end
120
+
121
+ private def handle_gateway_event(evt)
122
+ return unless evt[:type] == :message
123
+ handle_message(evt[:data])
124
+ end
125
+
126
+ private def handle_message(msg)
127
+ return if msg.nil?
128
+ author = msg["author"] || {}
129
+
130
+ return if author["bot"] == true
131
+ return if @bot_user_id && author["id"] == @bot_user_id
132
+
133
+ chat_id = msg["channel_id"]
134
+ return unless chat_id
135
+
136
+ user_id = author["id"]
137
+ chat_type = msg["guild_id"] ? :group : :direct
138
+ mentioned_ids = Array(msg["mentions"]).map { |m| m["id"] }
139
+
140
+ if chat_type == :group
141
+ if @bot_user_id.nil?
142
+ Clacky::Logger.warn("[DiscordAdapter] bot_user_id unavailable; dropping group message")
143
+ return
144
+ end
145
+ return unless mentioned_ids.include?(@bot_user_id)
146
+ end
147
+
148
+ allowed_users = @config[:allowed_users]
149
+ if allowed_users && !allowed_users.empty?
150
+ return unless allowed_users.include?(user_id)
151
+ end
152
+
153
+ text = strip_bot_mention(msg["content"].to_s, @bot_user_id)
154
+ files = process_attachments(Array(msg["attachments"]), chat_id)
155
+
156
+ return if text.strip.empty? && files.empty?
157
+
158
+ event = {
159
+ type: :message,
160
+ platform: :discord,
161
+ chat_id: chat_id,
162
+ user_id: user_id,
163
+ text: text,
164
+ files: files,
165
+ message_id: msg["id"],
166
+ timestamp: parse_timestamp(msg["timestamp"]),
167
+ chat_type: chat_type,
168
+ mentioned_user_ids: mentioned_ids,
169
+ raw: msg
170
+ }
171
+
172
+ @on_message&.call(event)
173
+ rescue => e
174
+ Clacky::Logger.error("[DiscordAdapter] handle_message error: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
175
+ begin
176
+ chat_id ||= msg && msg["channel_id"]
177
+ @api.send_message(chat_id, "Error processing message: #{e.message}") if chat_id
178
+ rescue
179
+ nil
180
+ end
181
+ end
182
+
183
+ private def strip_bot_mention(text, bot_id)
184
+ return text if bot_id.nil? || text.empty?
185
+ text.gsub(/<@!?#{Regexp.escape(bot_id)}>/, "").strip
186
+ end
187
+
188
+ private def process_attachments(attachments, chat_id)
189
+ files = []
190
+ attachments.each do |att|
191
+ url = att["url"]
192
+ filename = att["filename"] || "attachment"
193
+ next unless url
194
+
195
+ result = @api.download(url)
196
+ body = result[:body]
197
+ mime = att["content_type"] || result[:content_type]
198
+
199
+ if mime && mime.start_with?("image/")
200
+ if body.bytesize > MAX_IMAGE_BYTES
201
+ @api.send_message(chat_id, "Image too large (#{(body.bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
202
+ next
203
+ end
204
+ require "base64"
205
+ data_url = "data:#{mime};base64,#{Base64.strict_encode64(body)}"
206
+ files << { name: filename, mime_type: mime, data_url: data_url }
207
+ else
208
+ files << Clacky::Utils::FileProcessor.save(body: body, filename: filename)
209
+ end
210
+ end
211
+ files
212
+ rescue => e
213
+ Clacky::Logger.warn("[DiscordAdapter] process_attachments error: #{e.message}")
214
+ files
215
+ end
216
+
217
+ private def parse_timestamp(iso)
218
+ return Time.now if iso.nil? || iso.empty?
219
+ Time.iso8601(iso)
220
+ rescue ArgumentError
221
+ Time.now
222
+ end
223
+ end
224
+
225
+ Adapters.register(:discord, Adapter)
226
+ end
227
+ end
228
+ end
229
+ end