openclacky 1.0.4 → 1.0.5

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.
@@ -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
@@ -0,0 +1,107 @@
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.response :raise_error
22
+ f.adapter Faraday.default_adapter
23
+ end
24
+ end
25
+
26
+ def me
27
+ request(:get, "users/@me")
28
+ end
29
+
30
+ def send_message(channel_id, content, reply_to: nil)
31
+ payload = { content: content.to_s }
32
+ payload[:message_reference] = { message_id: reply_to.to_s } if reply_to
33
+ request(:post, "channels/#{channel_id}/messages", payload)
34
+ end
35
+
36
+ def edit_message(channel_id, message_id, content)
37
+ request(:patch, "channels/#{channel_id}/messages/#{message_id}", { content: content.to_s })
38
+ end
39
+
40
+ def send_file(channel_id, path, name: nil)
41
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(path)
42
+ filename = name || File.basename(path)
43
+ payload = { attachments: [{ id: 0, filename: filename }] }
44
+ io = Faraday::UploadIO.new(path, detect_mime(path), filename)
45
+
46
+ res = @conn.post("channels/#{channel_id}/messages") do |req|
47
+ req.body = { "payload_json" => JSON.generate(payload), "files[0]" => io }
48
+ end
49
+ parse_json(res.body)
50
+ rescue Faraday::Error => e
51
+ raise_api_error(e)
52
+ end
53
+
54
+ def download(url)
55
+ res = Faraday.get(url)
56
+ { body: res.body, content_type: res.headers["content-type"] }
57
+ end
58
+
59
+ # Discord requires User-Agent of the form "DiscordBot ($url, $versionNumber)".
60
+ # Requests with an invalid UA may be blocked at Cloudflare.
61
+ def self.user_agent
62
+ url = (Clacky::BrandConfig.load.homepage_url rescue nil) || DEFAULT_HOMEPAGE_URL
63
+ "DiscordBot (#{url}, #{Clacky::VERSION})"
64
+ end
65
+
66
+ private def request(verb, path, body = nil)
67
+ res = @conn.run_request(verb, path, body ? JSON.generate(body) : nil,
68
+ { "Content-Type" => "application/json" })
69
+ parse_json(res.body)
70
+ rescue Faraday::Error => e
71
+ raise_api_error(e)
72
+ end
73
+
74
+ private def raise_api_error(err)
75
+ status = err.response&.dig(:status)
76
+ body = err.response&.dig(:body).to_s
77
+ parsed = (JSON.parse(body) rescue nil)
78
+ msg = (parsed.is_a?(Hash) && parsed["message"]) || err.message
79
+ raise ApiError, "Discord API #{status}: #{msg}"
80
+ end
81
+
82
+ private def parse_json(body)
83
+ return {} if body.to_s.empty?
84
+ JSON.parse(body)
85
+ rescue JSON::ParserError => e
86
+ raise ApiError, "Invalid JSON response: #{e.message}"
87
+ end
88
+
89
+ private def detect_mime(path)
90
+ case File.extname(path).downcase
91
+ when ".jpg", ".jpeg" then "image/jpeg"
92
+ when ".png" then "image/png"
93
+ when ".gif" then "image/gif"
94
+ when ".webp" then "image/webp"
95
+ when ".mp4" then "video/mp4"
96
+ when ".mp3" then "audio/mpeg"
97
+ when ".pdf" then "application/pdf"
98
+ when ".txt" then "text/plain"
99
+ when ".json" then "application/json"
100
+ else "application/octet-stream"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ 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