discord_rda 0.1.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +398 -0
- data/lib/discord_rda/bot.rb +842 -0
- data/lib/discord_rda/cache/configurable_cache.rb +283 -0
- data/lib/discord_rda/cache/entity_cache.rb +184 -0
- data/lib/discord_rda/cache/memory_store.rb +143 -0
- data/lib/discord_rda/cache/redis_store.rb +136 -0
- data/lib/discord_rda/cache/store.rb +56 -0
- data/lib/discord_rda/connection/gateway_client.rb +383 -0
- data/lib/discord_rda/connection/invalid_bucket.rb +205 -0
- data/lib/discord_rda/connection/rate_limiter.rb +280 -0
- data/lib/discord_rda/connection/request_queue.rb +340 -0
- data/lib/discord_rda/connection/reshard_manager.rb +328 -0
- data/lib/discord_rda/connection/rest_client.rb +316 -0
- data/lib/discord_rda/connection/rest_proxy.rb +165 -0
- data/lib/discord_rda/connection/scalable_rest_client.rb +526 -0
- data/lib/discord_rda/connection/shard_manager.rb +223 -0
- data/lib/discord_rda/core/async_runtime.rb +108 -0
- data/lib/discord_rda/core/configuration.rb +194 -0
- data/lib/discord_rda/core/logger.rb +188 -0
- data/lib/discord_rda/core/snowflake.rb +121 -0
- data/lib/discord_rda/entity/attachment.rb +88 -0
- data/lib/discord_rda/entity/base.rb +103 -0
- data/lib/discord_rda/entity/channel.rb +446 -0
- data/lib/discord_rda/entity/channel_builder.rb +280 -0
- data/lib/discord_rda/entity/color.rb +253 -0
- data/lib/discord_rda/entity/embed.rb +221 -0
- data/lib/discord_rda/entity/emoji.rb +89 -0
- data/lib/discord_rda/entity/factory.rb +99 -0
- data/lib/discord_rda/entity/guild.rb +619 -0
- data/lib/discord_rda/entity/member.rb +263 -0
- data/lib/discord_rda/entity/message.rb +405 -0
- data/lib/discord_rda/entity/message_builder.rb +369 -0
- data/lib/discord_rda/entity/role.rb +157 -0
- data/lib/discord_rda/entity/support.rb +294 -0
- data/lib/discord_rda/entity/user.rb +231 -0
- data/lib/discord_rda/entity/value_objects.rb +263 -0
- data/lib/discord_rda/event/auto_moderation.rb +294 -0
- data/lib/discord_rda/event/base.rb +986 -0
- data/lib/discord_rda/event/bus.rb +225 -0
- data/lib/discord_rda/event/scheduled_event.rb +257 -0
- data/lib/discord_rda/hot_reload_manager.rb +303 -0
- data/lib/discord_rda/interactions/application_command.rb +436 -0
- data/lib/discord_rda/interactions/command_system.rb +484 -0
- data/lib/discord_rda/interactions/components.rb +464 -0
- data/lib/discord_rda/interactions/interaction.rb +553 -0
- data/lib/discord_rda/plugin/analytics_plugin.rb +528 -0
- data/lib/discord_rda/plugin/base.rb +190 -0
- data/lib/discord_rda/plugin/registry.rb +126 -0
- data/lib/discord_rda/version.rb +5 -0
- data/lib/discord_rda.rb +70 -0
- metadata +302 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordRDA
|
|
4
|
+
# Redis-backed cache store.
|
|
5
|
+
# Provides distributed caching with Redis.
|
|
6
|
+
#
|
|
7
|
+
class RedisStore < CacheStore
|
|
8
|
+
# @return [Redis] Redis client
|
|
9
|
+
attr_reader :redis
|
|
10
|
+
|
|
11
|
+
# @return [String] Key prefix
|
|
12
|
+
attr_reader :prefix
|
|
13
|
+
|
|
14
|
+
# @return [Integer] Default TTL
|
|
15
|
+
attr_reader :default_ttl
|
|
16
|
+
|
|
17
|
+
# Initialize Redis store
|
|
18
|
+
# @param redis [Redis] Redis client or connection options
|
|
19
|
+
# @param prefix [String] Key prefix
|
|
20
|
+
# @param default_ttl [Integer] Default TTL in seconds
|
|
21
|
+
def initialize(redis: nil, prefix: 'discord_rda:', default_ttl: 3600)
|
|
22
|
+
@redis = redis.is_a?(Redis) ? redis : Redis.new(redis || {})
|
|
23
|
+
@prefix = prefix
|
|
24
|
+
@default_ttl = default_ttl
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get a value from cache
|
|
28
|
+
# @param key [String] Cache key
|
|
29
|
+
# @return [Object, nil] Cached value or nil
|
|
30
|
+
def get(key)
|
|
31
|
+
value = @redis.get(prefixed_key(key))
|
|
32
|
+
return nil unless value
|
|
33
|
+
|
|
34
|
+
Marshal.load(value)
|
|
35
|
+
rescue => e
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set a value in cache
|
|
40
|
+
# @param key [String] Cache key
|
|
41
|
+
# @param value [Object] Value to cache
|
|
42
|
+
# @param ttl [Integer] Time to live in seconds
|
|
43
|
+
# @return [void]
|
|
44
|
+
def set(key, value, ttl: nil)
|
|
45
|
+
serialized = Marshal.dump(value)
|
|
46
|
+
ttl ||= @default_ttl
|
|
47
|
+
|
|
48
|
+
if ttl
|
|
49
|
+
@redis.setex(prefixed_key(key), ttl, serialized)
|
|
50
|
+
else
|
|
51
|
+
@redis.set(prefixed_key(key), serialized)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Delete a value from cache
|
|
56
|
+
# @param key [String] Cache key
|
|
57
|
+
# @return [void]
|
|
58
|
+
def delete(key)
|
|
59
|
+
@redis.del(prefixed_key(key))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if key exists
|
|
63
|
+
# @param key [String] Cache key
|
|
64
|
+
# @return [Boolean] True if exists
|
|
65
|
+
def exist?(key)
|
|
66
|
+
@redis.exists?(prefixed_key(key))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Clear all cached values (matching prefix)
|
|
70
|
+
# @return [void]
|
|
71
|
+
def clear
|
|
72
|
+
keys = @redis.keys("#{@prefix}*")
|
|
73
|
+
@redis.del(*keys) unless keys.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get multiple values
|
|
77
|
+
# @param keys [Array<String>] Cache keys
|
|
78
|
+
# @return [Hash] Key-value pairs
|
|
79
|
+
def mget(keys)
|
|
80
|
+
prefixed = keys.map { |k| prefixed_key(k) }
|
|
81
|
+
values = @redis.mget(prefixed)
|
|
82
|
+
|
|
83
|
+
keys.zip(values).to_h do |k, v|
|
|
84
|
+
[k, v ? Marshal.load(v) : nil]
|
|
85
|
+
end
|
|
86
|
+
rescue
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Set multiple values
|
|
91
|
+
# @param pairs [Hash] Key-value pairs
|
|
92
|
+
# @param ttl [Integer] Time to live
|
|
93
|
+
# @return [void]
|
|
94
|
+
def mset(pairs, ttl: nil)
|
|
95
|
+
ttl ||= @default_ttl
|
|
96
|
+
|
|
97
|
+
@redis.multi do |pipeline|
|
|
98
|
+
pairs.each do |k, v|
|
|
99
|
+
serialized = Marshal.dump(v)
|
|
100
|
+
if ttl
|
|
101
|
+
pipeline.setex(prefixed_key(k), ttl, serialized)
|
|
102
|
+
else
|
|
103
|
+
pipeline.set(prefixed_key(k), serialized)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Get keys matching a pattern using SCAN (non-blocking)
|
|
110
|
+
# @param pattern [String, Regexp] Pattern to match
|
|
111
|
+
# @return [Array<String>] Matching keys (without prefix)
|
|
112
|
+
def keys(pattern)
|
|
113
|
+
glob_pattern = pattern.is_a?(Regexp) ? "#{@prefix}*" : "#{@prefix}#{pattern}"
|
|
114
|
+
keys = []
|
|
115
|
+
|
|
116
|
+
# Use SCAN to iterate without blocking Redis
|
|
117
|
+
cursor = '0'
|
|
118
|
+
loop do
|
|
119
|
+
cursor, results = @redis.scan(cursor, match: glob_pattern, count: 100)
|
|
120
|
+
keys.concat(results)
|
|
121
|
+
break if cursor == '0'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Strip prefix from keys
|
|
125
|
+
keys.map { |k| k.delete_prefix(@prefix) }
|
|
126
|
+
rescue => e
|
|
127
|
+
[]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def prefixed_key(key)
|
|
133
|
+
"#{@prefix}#{key}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DiscordRDA
|
|
4
|
+
# Cache store interface.
|
|
5
|
+
# Implementations must provide get, set, delete, and clear methods.
|
|
6
|
+
#
|
|
7
|
+
class CacheStore
|
|
8
|
+
# Get a value from cache
|
|
9
|
+
# @param key [String] Cache key
|
|
10
|
+
# @return [Object, nil] Cached value or nil
|
|
11
|
+
def get(key)
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Set a value in cache
|
|
16
|
+
# @param key [String] Cache key
|
|
17
|
+
# @param value [Object] Value to cache
|
|
18
|
+
# @param ttl [Integer] Time to live in seconds
|
|
19
|
+
# @return [void]
|
|
20
|
+
def set(key, value, ttl: nil)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Delete a value from cache
|
|
25
|
+
# @param key [String] Cache key
|
|
26
|
+
# @return [void]
|
|
27
|
+
def delete(key)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if key exists
|
|
32
|
+
# @param key [String] Cache key
|
|
33
|
+
# @return [Boolean] True if exists
|
|
34
|
+
def exist?(key)
|
|
35
|
+
!get(key).nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clear all cached values
|
|
39
|
+
# @return [void]
|
|
40
|
+
def clear
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get multiple values
|
|
45
|
+
# @param keys [Array<String>] Cache keys
|
|
46
|
+
# @return [Hash] Key-value pairs
|
|
47
|
+
def mget(keys)
|
|
48
|
+
keys.to_h { |k| [k, get(k)] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get keys matching a pattern
|
|
52
|
+
# @param pattern [String, Regexp] Pattern to match
|
|
53
|
+
# @return [Array<String>] Matching keys
|
|
54
|
+
def keys(pattern)
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
end
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async/websocket'
|
|
4
|
+
require 'async/http/internet'
|
|
5
|
+
require 'async/http/endpoint'
|
|
6
|
+
require 'async/io/stream'
|
|
7
|
+
require 'zlib'
|
|
8
|
+
|
|
9
|
+
module DiscordRDA
|
|
10
|
+
# WebSocket client for Discord Gateway.
|
|
11
|
+
# Handles connection, heartbeat, resume, and message dispatch.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# gateway = GatewayClient.new(config, event_bus)
|
|
15
|
+
# gateway.connect
|
|
16
|
+
# gateway.run
|
|
17
|
+
#
|
|
18
|
+
class GatewayClient
|
|
19
|
+
# Gateway versions
|
|
20
|
+
DEFAULT_VERSION = 10
|
|
21
|
+
|
|
22
|
+
# Gateway encoding
|
|
23
|
+
ENCODING = 'json'
|
|
24
|
+
|
|
25
|
+
# Gateway opcodes
|
|
26
|
+
OPCODES = {
|
|
27
|
+
dispatch: 0,
|
|
28
|
+
heartbeat: 1,
|
|
29
|
+
identify: 2,
|
|
30
|
+
presence_update: 3,
|
|
31
|
+
voice_state_update: 4,
|
|
32
|
+
resume: 6,
|
|
33
|
+
reconnect: 7,
|
|
34
|
+
request_guild_members: 8,
|
|
35
|
+
invalid_session: 9,
|
|
36
|
+
hello: 10,
|
|
37
|
+
heartbeat_ack: 11
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# @return [Configuration] Configuration instance
|
|
41
|
+
attr_reader :config
|
|
42
|
+
|
|
43
|
+
# @return [EventBus] Event bus for dispatching events
|
|
44
|
+
attr_reader :event_bus
|
|
45
|
+
|
|
46
|
+
# @return [Logger] Logger instance
|
|
47
|
+
attr_reader :logger
|
|
48
|
+
|
|
49
|
+
# @return [Integer] Current sequence number
|
|
50
|
+
attr_reader :sequence
|
|
51
|
+
|
|
52
|
+
# @return [String] Session ID for resuming
|
|
53
|
+
attr_reader :session_id
|
|
54
|
+
|
|
55
|
+
# @return [Boolean] Whether connected
|
|
56
|
+
attr_reader :connected
|
|
57
|
+
|
|
58
|
+
# Initialize the gateway client
|
|
59
|
+
# @param config [Configuration] Bot configuration
|
|
60
|
+
# @param event_bus [EventBus] Event bus instance
|
|
61
|
+
# @param logger [Logger] Logger instance
|
|
62
|
+
# @param shard_id [Integer] Shard ID
|
|
63
|
+
# @param shard_count [Integer] Total shard count
|
|
64
|
+
def initialize(config, event_bus, logger, shard_id: 0, shard_count: 1)
|
|
65
|
+
@config = config
|
|
66
|
+
@event_bus = event_bus
|
|
67
|
+
@logger = logger
|
|
68
|
+
@shard_id = shard_id
|
|
69
|
+
@shard_count = shard_count
|
|
70
|
+
@sequence = 0
|
|
71
|
+
@session_id = nil
|
|
72
|
+
@connected = false
|
|
73
|
+
@heartbeat_interval = nil
|
|
74
|
+
@heartbeat_task = nil
|
|
75
|
+
@websocket = nil
|
|
76
|
+
@zlib = nil
|
|
77
|
+
@buffer = +'')
|
|
78
|
+
@last_heartbeat_ack = Time.now
|
|
79
|
+
@resume_gateway_url = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Connect to the Discord Gateway
|
|
83
|
+
# @return [Async::Task] Connection task
|
|
84
|
+
def connect
|
|
85
|
+
Async do
|
|
86
|
+
gateway_url = @resume_gateway_url || fetch_gateway_url
|
|
87
|
+
endpoint = build_endpoint(gateway_url)
|
|
88
|
+
|
|
89
|
+
@logger&.info('Connecting to Gateway', shard: @shard_id, url: gateway_url)
|
|
90
|
+
|
|
91
|
+
@zlib = Zlib::Inflate.new(15 + 32) if @config.gateway_compression == :zlib_stream
|
|
92
|
+
@buffer = +''
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
Async::WebSocket::Client.connect(endpoint) do |websocket|
|
|
96
|
+
@websocket = websocket
|
|
97
|
+
@connected = true
|
|
98
|
+
@logger&.info('Gateway connected', shard: @shard_id)
|
|
99
|
+
|
|
100
|
+
handle_messages
|
|
101
|
+
end
|
|
102
|
+
rescue => e
|
|
103
|
+
@logger&.error('Gateway connection error', error: e, shard: @shard_id)
|
|
104
|
+
@connected = false
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Run the gateway event loop
|
|
111
|
+
# @return [void]
|
|
112
|
+
def run
|
|
113
|
+
connect.wait
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Disconnect from the Gateway
|
|
117
|
+
# @return [void]
|
|
118
|
+
def disconnect
|
|
119
|
+
@connected = false
|
|
120
|
+
@heartbeat_task&.stop
|
|
121
|
+
@websocket&.close
|
|
122
|
+
@zlib&.close
|
|
123
|
+
@logger&.info('Gateway disconnected', shard: @shard_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Send an identify payload
|
|
127
|
+
# @return [void]
|
|
128
|
+
def identify
|
|
129
|
+
payload = {
|
|
130
|
+
op: OPCODES[:identify],
|
|
131
|
+
d: {
|
|
132
|
+
token: @config.token,
|
|
133
|
+
properties: {
|
|
134
|
+
os: 'linux',
|
|
135
|
+
browser: 'discord_rda',
|
|
136
|
+
device: 'discord_rda'
|
|
137
|
+
},
|
|
138
|
+
compress: @config.gateway_compression == :zlib_stream,
|
|
139
|
+
large_threshold: 250,
|
|
140
|
+
shard: [@shard_id, @shard_count],
|
|
141
|
+
intents: @config.intents_bitmask
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
send_payload(payload)
|
|
146
|
+
@logger&.info('Sent identify', shard: @shard_id)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Send a resume payload
|
|
150
|
+
# @return [void]
|
|
151
|
+
def resume
|
|
152
|
+
return unless @session_id && @sequence > 0
|
|
153
|
+
|
|
154
|
+
payload = {
|
|
155
|
+
op: OPCODES[:resume],
|
|
156
|
+
d: {
|
|
157
|
+
token: @config.token,
|
|
158
|
+
session_id: @session_id,
|
|
159
|
+
seq: @sequence
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
send_payload(payload)
|
|
164
|
+
@logger&.info('Sent resume', shard: @shard_id, session: @session_id, seq: @sequence)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Update presence
|
|
168
|
+
# @param status [String] online, idle, dnd, invisible
|
|
169
|
+
# @param activity [Hash] Activity data
|
|
170
|
+
# @param afk [Boolean] Whether AFK
|
|
171
|
+
# @return [void]
|
|
172
|
+
def update_presence(status: 'online', activity: nil, afk: false)
|
|
173
|
+
payload = {
|
|
174
|
+
op: OPCODES[:presence_update],
|
|
175
|
+
d: {
|
|
176
|
+
since: afk ? Time.now.to_i * 1000 : nil,
|
|
177
|
+
activities: activity ? [activity] : [],
|
|
178
|
+
status: status,
|
|
179
|
+
afk: afk
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
send_payload(payload)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Request guild members (chunking)
|
|
187
|
+
# @param guild_id [String] Guild ID
|
|
188
|
+
# @param query [String] Query string
|
|
189
|
+
# @param limit [Integer] Member limit
|
|
190
|
+
# @param presences [Boolean] Include presences
|
|
191
|
+
# @param user_ids [Array<String>] Specific user IDs
|
|
192
|
+
# @param nonce [String] Nonce for response
|
|
193
|
+
# @return [void]
|
|
194
|
+
def request_guild_members(guild_id, query: '', limit: 0, presences: false, user_ids: nil, nonce: nil)
|
|
195
|
+
payload = {
|
|
196
|
+
op: OPCODES[:request_guild_members],
|
|
197
|
+
d: {
|
|
198
|
+
guild_id: guild_id,
|
|
199
|
+
query: query,
|
|
200
|
+
limit: limit,
|
|
201
|
+
presences: presences,
|
|
202
|
+
user_ids: user_ids,
|
|
203
|
+
nonce: nonce
|
|
204
|
+
}.compact
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
send_payload(payload)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def fetch_gateway_url
|
|
213
|
+
# In production, fetch from /gateway/bot endpoint
|
|
214
|
+
# For now, use hardcoded URL
|
|
215
|
+
"wss://gateway.discord.gg/?v=#{DEFAULT_VERSION}&encoding=#{ENCODING}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_endpoint(url)
|
|
219
|
+
Async::HTTP::Endpoint.parse(url)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def handle_messages
|
|
223
|
+
while @connected && (message = @websocket.read)
|
|
224
|
+
process_message(message)
|
|
225
|
+
end
|
|
226
|
+
rescue Async::WebSocket::ConnectionClosed
|
|
227
|
+
@logger&.warn('Gateway connection closed', shard: @shard_id)
|
|
228
|
+
handle_disconnect
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def process_message(message)
|
|
232
|
+
data = decompress_if_needed(message)
|
|
233
|
+
return unless data
|
|
234
|
+
|
|
235
|
+
payload = Oj.load(data)
|
|
236
|
+
handle_payload(payload)
|
|
237
|
+
rescue Oj::ParseError => e
|
|
238
|
+
@logger&.error('Failed to parse Gateway message', error: e, shard: @shard_id)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def decompress_if_needed(message)
|
|
242
|
+
if @config.gateway_compression == :zlib_stream && @zlib
|
|
243
|
+
chunk = message.to_str
|
|
244
|
+
@buffer << chunk
|
|
245
|
+
|
|
246
|
+
# Check for zlib suffix
|
|
247
|
+
if chunk.byteslice(-4, 4) == "\x00\x00\xff\xff"
|
|
248
|
+
decompressed = @zlib.inflate(@buffer)
|
|
249
|
+
@buffer = +''
|
|
250
|
+
decompressed
|
|
251
|
+
else
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
message.to_str
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def handle_payload(payload)
|
|
260
|
+
op = payload['op']
|
|
261
|
+
data = payload['d']
|
|
262
|
+
seq = payload['s']
|
|
263
|
+
event_type = payload['t']
|
|
264
|
+
|
|
265
|
+
# Update sequence number
|
|
266
|
+
@sequence = seq if seq
|
|
267
|
+
|
|
268
|
+
case op
|
|
269
|
+
when OPCODES[:dispatch]
|
|
270
|
+
handle_dispatch(event_type, data)
|
|
271
|
+
when OPCODES[:hello]
|
|
272
|
+
handle_hello(data)
|
|
273
|
+
when OPCODES[:heartbeat_ack]
|
|
274
|
+
handle_heartbeat_ack
|
|
275
|
+
when OPCODES[:reconnect]
|
|
276
|
+
handle_reconnect
|
|
277
|
+
when OPCODES[:invalid_session]
|
|
278
|
+
handle_invalid_session(data)
|
|
279
|
+
else
|
|
280
|
+
@logger&.debug('Unhandled Gateway opcode', op: op, shard: @shard_id)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def handle_dispatch(event_type, data)
|
|
285
|
+
return unless event_type
|
|
286
|
+
|
|
287
|
+
# Store session ID for resume
|
|
288
|
+
if event_type == 'READY'
|
|
289
|
+
@session_id = data['session_id']
|
|
290
|
+
@resume_gateway_url = data['resume_gateway_url']
|
|
291
|
+
@logger&.info('Received READY', shard: @shard_id, session: @session_id)
|
|
292
|
+
elsif event_type == 'RESUMED'
|
|
293
|
+
@logger&.info('Session resumed', shard: @shard_id)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Create and dispatch event
|
|
297
|
+
event = EventFactory.create(event_type, data, @shard_id)
|
|
298
|
+
@event_bus&.publish(event_type, event)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def handle_hello(data)
|
|
302
|
+
@heartbeat_interval = data['heartbeat_interval']
|
|
303
|
+
@logger&.info('Received hello', interval: @heartbeat_interval, shard: @shard_id)
|
|
304
|
+
|
|
305
|
+
# Start heartbeat task
|
|
306
|
+
start_heartbeat
|
|
307
|
+
|
|
308
|
+
# Identify or resume
|
|
309
|
+
if @config.enable_resume && @session_id && @sequence > 0
|
|
310
|
+
resume
|
|
311
|
+
else
|
|
312
|
+
identify
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def handle_heartbeat_ack
|
|
317
|
+
@last_heartbeat_ack = Time.now
|
|
318
|
+
@logger&.debug('Heartbeat acknowledged', shard: @shard_id)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def handle_reconnect
|
|
322
|
+
@logger&.info('Received reconnect request', shard: @shard_id)
|
|
323
|
+
disconnect
|
|
324
|
+
sleep(@config.initial_reconnect_delay)
|
|
325
|
+
connect
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def handle_invalid_session(resumable)
|
|
329
|
+
@logger&.warn('Invalid session', resumable: resumable, shard: @shard_id)
|
|
330
|
+
|
|
331
|
+
if resumable
|
|
332
|
+
sleep(1..5).to_a.sample
|
|
333
|
+
resume
|
|
334
|
+
else
|
|
335
|
+
@session_id = nil
|
|
336
|
+
@sequence = 0
|
|
337
|
+
sleep(1..5).to_a.sample
|
|
338
|
+
identify
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def handle_disconnect
|
|
343
|
+
@connected = false
|
|
344
|
+
@heartbeat_task&.stop
|
|
345
|
+
|
|
346
|
+
# Attempt to resume if enabled
|
|
347
|
+
if @config.enable_resume && @session_id
|
|
348
|
+
@logger&.info('Attempting to resume', shard: @shard_id)
|
|
349
|
+
sleep(@config.initial_reconnect_delay)
|
|
350
|
+
connect
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def start_heartbeat
|
|
355
|
+
@heartbeat_task = Async do
|
|
356
|
+
loop do
|
|
357
|
+
sleep(@heartbeat_interval * @config.heartbeat_interval_buffer / 1000.0)
|
|
358
|
+
send_heartbeat
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def send_heartbeat
|
|
364
|
+
if Time.now - @last_heartbeat_ack > (@heartbeat_interval * 2 / 1000.0)
|
|
365
|
+
@logger&.warn('Heartbeat timeout, reconnecting', shard: @shard_id)
|
|
366
|
+
handle_disconnect
|
|
367
|
+
return
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
payload = { op: OPCODES[:heartbeat], d: @sequence }
|
|
371
|
+
send_payload(payload)
|
|
372
|
+
@logger&.debug('Sent heartbeat', seq: @sequence, shard: @shard_id)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def send_payload(payload)
|
|
376
|
+
return unless @websocket && @connected
|
|
377
|
+
|
|
378
|
+
json = Oj.dump(payload, mode: :compat)
|
|
379
|
+
@websocket.send(json)
|
|
380
|
+
@websocket.flush
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|