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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +134 -12
- data/lib/clacky/default_skills/channel-setup/discord_setup.rb +199 -0
- data/lib/clacky/providers.rb +29 -4
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +107 -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 +11 -0
- data/lib/clacky/server/channel.rb +2 -0
- data/lib/clacky/server/http_server.rb +28 -2
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +44 -0
- data/lib/clacky/web/channels.js +16 -0
- data/lib/clacky/web/i18n.js +24 -2
- data/lib/clacky/web/index.html +6 -1
- data/lib/clacky/web/settings.js +4 -0
- data/lib/clacky/web/version.js +52 -1
- metadata +8 -2
|
@@ -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
|