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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +398 -0
  4. data/lib/discord_rda/bot.rb +842 -0
  5. data/lib/discord_rda/cache/configurable_cache.rb +283 -0
  6. data/lib/discord_rda/cache/entity_cache.rb +184 -0
  7. data/lib/discord_rda/cache/memory_store.rb +143 -0
  8. data/lib/discord_rda/cache/redis_store.rb +136 -0
  9. data/lib/discord_rda/cache/store.rb +56 -0
  10. data/lib/discord_rda/connection/gateway_client.rb +383 -0
  11. data/lib/discord_rda/connection/invalid_bucket.rb +205 -0
  12. data/lib/discord_rda/connection/rate_limiter.rb +280 -0
  13. data/lib/discord_rda/connection/request_queue.rb +340 -0
  14. data/lib/discord_rda/connection/reshard_manager.rb +328 -0
  15. data/lib/discord_rda/connection/rest_client.rb +316 -0
  16. data/lib/discord_rda/connection/rest_proxy.rb +165 -0
  17. data/lib/discord_rda/connection/scalable_rest_client.rb +526 -0
  18. data/lib/discord_rda/connection/shard_manager.rb +223 -0
  19. data/lib/discord_rda/core/async_runtime.rb +108 -0
  20. data/lib/discord_rda/core/configuration.rb +194 -0
  21. data/lib/discord_rda/core/logger.rb +188 -0
  22. data/lib/discord_rda/core/snowflake.rb +121 -0
  23. data/lib/discord_rda/entity/attachment.rb +88 -0
  24. data/lib/discord_rda/entity/base.rb +103 -0
  25. data/lib/discord_rda/entity/channel.rb +446 -0
  26. data/lib/discord_rda/entity/channel_builder.rb +280 -0
  27. data/lib/discord_rda/entity/color.rb +253 -0
  28. data/lib/discord_rda/entity/embed.rb +221 -0
  29. data/lib/discord_rda/entity/emoji.rb +89 -0
  30. data/lib/discord_rda/entity/factory.rb +99 -0
  31. data/lib/discord_rda/entity/guild.rb +619 -0
  32. data/lib/discord_rda/entity/member.rb +263 -0
  33. data/lib/discord_rda/entity/message.rb +405 -0
  34. data/lib/discord_rda/entity/message_builder.rb +369 -0
  35. data/lib/discord_rda/entity/role.rb +157 -0
  36. data/lib/discord_rda/entity/support.rb +294 -0
  37. data/lib/discord_rda/entity/user.rb +231 -0
  38. data/lib/discord_rda/entity/value_objects.rb +263 -0
  39. data/lib/discord_rda/event/auto_moderation.rb +294 -0
  40. data/lib/discord_rda/event/base.rb +986 -0
  41. data/lib/discord_rda/event/bus.rb +225 -0
  42. data/lib/discord_rda/event/scheduled_event.rb +257 -0
  43. data/lib/discord_rda/hot_reload_manager.rb +303 -0
  44. data/lib/discord_rda/interactions/application_command.rb +436 -0
  45. data/lib/discord_rda/interactions/command_system.rb +484 -0
  46. data/lib/discord_rda/interactions/components.rb +464 -0
  47. data/lib/discord_rda/interactions/interaction.rb +553 -0
  48. data/lib/discord_rda/plugin/analytics_plugin.rb +528 -0
  49. data/lib/discord_rda/plugin/base.rb +190 -0
  50. data/lib/discord_rda/plugin/registry.rb +126 -0
  51. data/lib/discord_rda/version.rb +5 -0
  52. data/lib/discord_rda.rb +70 -0
  53. metadata +302 -0
@@ -0,0 +1,842 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscordRDA
4
+ # Main Bot class for DiscordRDA.
5
+ # Entry point for building Discord bots.
6
+ #
7
+ # @example Basic bot
8
+ # bot = DiscordRDA::Bot.new(token: ENV['DISCORD_TOKEN'])
9
+ # bot.on(:message_create) { |e| puts e.content }
10
+ # bot.run
11
+ #
12
+ class Bot
13
+ # @return [Configuration] Bot configuration
14
+ attr_reader :config
15
+
16
+ # @return [Logger] Logger instance
17
+ attr_reader :logger
18
+
19
+ # @return [EventBus] Event bus
20
+ attr_reader :event_bus
21
+
22
+ # @return [EntityCache] Entity cache
23
+ attr_reader :cache
24
+
25
+ # @return [ShardManager] Shard manager
26
+ attr_reader :shard_manager
27
+
28
+ # @return [RestClient] REST client
29
+ attr_reader :rest
30
+
31
+ # @return [ScalableRestClient] Scalable REST client (if enabled)
32
+ attr_reader :scalable_rest
33
+
34
+ # @return [ReshardManager] Reshard manager
35
+ attr_reader :reshard_manager
36
+
37
+ # @return [HotReloadManager] Hot reload manager
38
+ attr_reader :hot_reload_manager
39
+
40
+ # @return [PluginRegistry] Plugin registry
41
+ attr_reader :plugins
42
+
43
+ # @return [Boolean] Whether bot is running
44
+ attr_reader :running
45
+
46
+ # @return [Hash] Registered slash commands
47
+ attr_reader :slash_commands
48
+
49
+ # Initialize a new bot
50
+ # @param token [String] Bot token
51
+ # @param options [Hash] Configuration options
52
+ def initialize(token:, **options)
53
+ @config = Configuration.new(options.merge(token: token))
54
+ @logger = Logger.new(level: @config.log_level, format: @config.log_format)
55
+ @event_bus = EventBus.new(logger: @logger)
56
+ @cache = build_cache
57
+ @shard_manager = ShardManager.new(@config, @event_bus, @logger)
58
+ @rest = RestClient.new(@config, @logger)
59
+
60
+ # Configure entity API clients
61
+ Message.api = @rest
62
+ Interaction.api = @rest
63
+
64
+ setup_event_handlers
65
+ setup_interaction_handlers
66
+
67
+ # Initialize scalable components
68
+ @scalable_rest = nil
69
+ @reshard_manager = ReshardManager.new(self, @shard_manager, @logger)
70
+ @hot_reload_manager = HotReloadManager.new(self, @logger)
71
+ @plugins = PluginRegistry.new(logger: @logger)
72
+ @slash_commands = {}
73
+ @running = false
74
+ @commands = {}
75
+
76
+ setup_event_handlers
77
+ end
78
+
79
+ # Register a slash command (global or guild-specific)
80
+ # @param name [String] Command name
81
+ # @param description [String] Command description
82
+ # @param options [Hash] Command options
83
+ # @option options [String] :guild_id Guild-specific command (nil for global)
84
+ # @option options [Array<Hash>] :options Command options
85
+ # @option options [Integer] :default_member_permissions Default required permissions
86
+ # @option options [Boolean] :dm_permission Whether works in DMs
87
+ # @yield [CommandBuilder] DSL block for building command
88
+ # @return [ApplicationCommand] Registered command
89
+ def slash(name, description, **options, &block)
90
+ builder = CommandBuilder.new(name, description)
91
+ builder.dm_allowed(options[:dm_permission]) if options.key?(:dm_permission)
92
+ builder.default_permissions(options[:default_member_permissions]) if options[:default_member_permissions]
93
+ builder.nsfw(options[:nsfw]) if options[:nsfw]
94
+
95
+ block.call(builder) if block
96
+
97
+ cmd = builder.build
98
+ cmd.instance_variable_set(:@application_id, me.id.to_s) rescue nil
99
+ cmd.instance_variable_set(:@guild_id, options[:guild_id].to_s) if options[:guild_id]
100
+
101
+ key = options[:guild_id] ? "#{name}:#{options[:guild_id]}" : name
102
+ @slash_commands[key] = cmd
103
+
104
+ # Register with Discord if we have application ID
105
+ if cmd.application_id
106
+ if options[:guild_id]
107
+ cmd.create_guild(self, options[:guild_id])
108
+ else
109
+ cmd.create_global(self)
110
+ end
111
+ end
112
+
113
+ @logger.info('Registered slash command', name: name, guild: options[:guild_id] || 'global')
114
+ cmd
115
+ end
116
+
117
+ # Register a context menu command (user or message)
118
+ # @param type [Symbol] :user or :message
119
+ # @param name [String] Command name
120
+ # @param options [Hash] Command options
121
+ # @yield [Interaction] Handler block
122
+ # @return [ApplicationCommand] Registered command
123
+ def context_menu(type:, name:, **options, &block)
124
+ cmd_type = type == :user ? 2 : 3
125
+ options[:type] = cmd_type
126
+ options[:description] = '' # Context menus don't have descriptions
127
+
128
+ slash(name, '', **options, &block)
129
+ end
130
+
131
+ # Bulk register global commands (replaces existing)
132
+ # @param commands [Array<CommandBuilder>] Commands to register
133
+ # @return [Array<ApplicationCommand>] Registered commands
134
+ def bulk_register_commands(commands)
135
+ return [] unless me
136
+
137
+ app_id = me.id.to_s
138
+ payload = commands.map(&:to_h)
139
+
140
+ data = @rest.put("/applications/#{app_id}/commands", body: payload)
141
+ data.map { |cmd| ApplicationCommand.new(cmd) }
142
+ end
143
+
144
+ # Delete a global command
145
+ # @param command_id [String] Command ID
146
+ # @return [void]
147
+ def delete_global_command(command_id)
148
+ @rest.delete("/applications/#{me.id}/commands/#{command_id}") if me
149
+ end
150
+
151
+ # Delete a guild command
152
+ # @param guild_id [String] Guild ID
153
+ # @param command_id [String] Command ID
154
+ # @return [void]
155
+ def delete_guild_command(guild_id, command_id)
156
+ @rest.delete("/applications/#{me.id}/guilds/#{guild_id}/commands/#{command_id}") if me
157
+ end
158
+
159
+ # Register an event handler
160
+ # @param event [String, Symbol] Event type
161
+ # @yield Event handler block
162
+ # @return [Subscription] Subscription object
163
+ def on(event, &block)
164
+ @event_bus.on(event, &block)
165
+ end
166
+
167
+ # Register a one-time event handler
168
+ # @param event [String, Symbol] Event type
169
+ # @yield Event handler block
170
+ # @return [Subscription] Subscription object
171
+ def once(event, &block)
172
+ @event_bus.once(event, &block)
173
+ end
174
+
175
+ # Wait for an event
176
+ # @param event [String, Symbol] Event type
177
+ # @param timeout [Float] Timeout in seconds
178
+ # @yield Block to match event
179
+ # @return [Event, nil] Event or nil if timeout
180
+ def wait_for(event, timeout: nil, &block)
181
+ @event_bus.wait_for(event, timeout: timeout, &block)
182
+ end
183
+
184
+ # Register a command
185
+ # @param name [String] Command name
186
+ # @param description [String] Command description
187
+ # @param options [Array<Hash>] Command options
188
+ # @yield Command handler
189
+ # @return [void]
190
+ def register_command(name, description = '', options = [], &block)
191
+ @commands[name.to_s] = {
192
+ description: description,
193
+ options: options,
194
+ handler: block
195
+ }
196
+ end
197
+ alias command register_command
198
+
199
+ # Register a plugin
200
+ # @param plugin [Plugin] Plugin to register
201
+ # @return [Boolean] True if registered
202
+ def register_plugin(plugin)
203
+ @plugins.register(plugin, self)
204
+ end
205
+ alias plugin register_plugin
206
+
207
+ # Use middleware
208
+ # @param middleware [Middleware] Middleware to use
209
+ # @return [void]
210
+ def use(middleware)
211
+ @event_bus.use(middleware)
212
+ end
213
+
214
+ # Run the bot
215
+ # @param async [Boolean] Run asynchronously
216
+ # @return [void]
217
+ def run(async: false)
218
+ @running = true
219
+
220
+ @logger.info('Starting DiscordRDA bot', version: VERSION, shards: @config.shards.length)
221
+
222
+ # Start REST client
223
+ @rest.start
224
+
225
+ # Calculate shard count if auto
226
+ shard_count = if @config.shards == [:auto]
227
+ @shard_manager.calculate_shard_count(:auto, @rest)
228
+ else
229
+ @config.shards.length
230
+ end
231
+
232
+ @shard_manager.instance_variable_set(:@shard_count, shard_count)
233
+
234
+ # Start shards
235
+ if async
236
+ Async { start_shards }
237
+ else
238
+ start_shards
239
+ end
240
+ end
241
+
242
+ # Stop the bot
243
+ # @return [void]
244
+ def stop
245
+ @logger.info('Stopping bot')
246
+ @running = false
247
+ @shard_manager.stop
248
+ @rest.stop
249
+ end
250
+
251
+ # Update bot presence
252
+ # @param status [String] online, idle, dnd, invisible
253
+ # @param activity [Hash] Activity data
254
+ # @return [void]
255
+ def update_presence(status: 'online', activity: nil)
256
+ @shard_manager.shards.each do |shard|
257
+ shard.update_presence(status: status, activity: activity)
258
+ end
259
+ end
260
+
261
+ # Get bot status
262
+ # @return [Hash] Status information
263
+ def status
264
+ {
265
+ running: @running,
266
+ shards: @shard_manager.status,
267
+ cache: @cache.stats,
268
+ plugins: @plugins.stats
269
+ }
270
+ end
271
+
272
+ # Fetch current user
273
+ # @return [User] Bot user
274
+ def me
275
+ data = @rest.get('/users/@me')
276
+ User.new(data)
277
+ end
278
+
279
+ # Get a guild by ID
280
+ # @param guild_id [String, Snowflake] Guild ID
281
+ # @return [Guild, nil] Guild or nil
282
+ def guild(guild_id)
283
+ cached = @cache.guild(guild_id)
284
+ return cached if cached
285
+
286
+ data = @rest.get("/guilds/#{guild_id}")
287
+ guild = Guild.new(data)
288
+ @cache.cache_guild(guild)
289
+ guild
290
+ rescue RestClient::NotFoundError
291
+ nil
292
+ end
293
+
294
+ # Get a channel by ID
295
+ # @param channel_id [String, Snowflake] Channel ID
296
+ # @return [Channel, nil] Channel or nil
297
+ def channel(channel_id)
298
+ cached = @cache.channel(channel_id)
299
+ return cached if cached
300
+
301
+ data = @rest.get("/channels/#{channel_id}")
302
+ channel = Channel.new(data)
303
+ @cache.cache_channel(channel)
304
+ channel
305
+ rescue RestClient::NotFoundError
306
+ nil
307
+ end
308
+
309
+ # Send a message to a channel
310
+ # @param channel_id [String, Snowflake] Channel ID
311
+ # @param content [String] Message content
312
+ # @param options [Hash] Message options
313
+ # @return [Message] Sent message
314
+ def send_message(channel_id, content = nil, **options)
315
+ payload = { content: content }.merge(options).compact
316
+ data = @rest.post("/channels/#{channel_id}/messages", body: payload)
317
+ Message.new(data)
318
+ end
319
+
320
+ # Get messages from a channel with pagination (simplified)
321
+ # @param channel_id [String, Snowflake] Channel ID
322
+ # @param limit [Integer] Max messages to fetch (1-100, default 50)
323
+ # @param before [String, Snowflake] Get messages before this ID
324
+ # @param after [String, Snowflake] Get messages after this ID
325
+ # @param around [String, Snowflake] Get messages around this ID
326
+ # @return [Array<Message>] Messages
327
+ def channel_messages(channel_id, limit: 50, before: nil, after: nil, around: nil)
328
+ params = { limit: limit }
329
+ params[:before] = before.to_s if before
330
+ params[:after] = after.to_s if after
331
+ params[:around] = around.to_s if around
332
+
333
+ data = @rest.get("/channels/#{channel_id}/messages", params: params)
334
+ data.map { |msg| Message.new(msg) }
335
+ end
336
+
337
+ # Get a single message from a channel
338
+ # @param channel_id [String, Snowflake] Channel ID
339
+ # @param message_id [String, Snowflake] Message ID
340
+ # @return [Message, nil] Message or nil
341
+ def channel_message(channel_id, message_id)
342
+ data = @rest.get("/channels/#{channel_id}/messages/#{message_id}")
343
+ Message.new(data)
344
+ rescue RestClient::NotFoundError
345
+ nil
346
+ end
347
+
348
+ # Enable scalable REST client (queue-based rate limiting)
349
+ # @param proxy [Hash] Optional proxy configuration
350
+ # @return [void]
351
+ def enable_scalable_rest(proxy: nil)
352
+ @logger.info('Enabling scalable REST client')
353
+ @scalable_rest = ScalableRestClient.new(@config, @logger, proxy: proxy)
354
+ @scalable_rest.start
355
+ end
356
+
357
+ # Enable hot reload for development
358
+ # @param watch_dir [String] Directory to watch
359
+ # @return [void]
360
+ def enable_hot_reload(watch_dir: 'lib')
361
+ @logger.info('Enabling hot reload', watch_dir: watch_dir)
362
+ @hot_reload_manager = HotReloadManager.new(self, @logger, watch_dir: watch_dir)
363
+ @hot_reload_manager.enable
364
+ end
365
+
366
+ # Trigger zero-downtime resharding
367
+ # @param new_shard_count [Integer] New shard count
368
+ # @return [void]
369
+ def reshard_to(new_shard_count)
370
+ @logger.info('Triggering resharding', new_count: new_shard_count)
371
+ @reshard_manager.reshard_to(new_shard_count)
372
+ end
373
+
374
+ # Enable auto-resharding based on guild count
375
+ # @param max_guilds_per_shard [Integer] Max guilds per shard
376
+ # @return [void]
377
+ def enable_auto_reshard(max_guilds_per_shard: 1000)
378
+ @event_bus.on(:guild_create) do |_event|
379
+ guild_count = @shard_manager.total_guilds || 0
380
+ @reshard_manager.auto_reshard_if_needed(guild_count, max_guilds_per_shard: max_guilds_per_shard)
381
+ end
382
+ end
383
+
384
+ # Get invalid request bucket status
385
+ # @return [Hash, nil] Invalid bucket status
386
+ def invalid_bucket_status
387
+ @scalable_rest&.invalid_bucket&.status
388
+ end
389
+
390
+ # Get analytics data (if analytics plugin registered)
391
+ # @return [Hash] Analytics data
392
+ def analytics
393
+ analytics_plugin = @plugins.get(:Analytics)
394
+ analytics_plugin&.summary || {}
395
+ end
396
+
397
+ # === Message Reactions (Simplified) ===
398
+
399
+ # Add a reaction to a message
400
+ # @param channel_id [String, Snowflake] Channel ID
401
+ # @param message_id [String, Snowflake] Message ID
402
+ # @param emoji [String, Emoji] Emoji (unicode or name:id format)
403
+ # @return [void]
404
+ def add_reaction(channel_id, message_id, emoji)
405
+ emoji_str = emoji.respond_to?(:id) ? "#{emoji.name}:#{emoji.id}" : emoji.to_s
406
+ @rest.put("/channels/#{channel_id}/messages/#{message_id}/reactions/#{CGI.escape(emoji_str)}/@me")
407
+ end
408
+
409
+ # Remove a reaction from a message
410
+ # @param channel_id [String, Snowflake] Channel ID
411
+ # @param message_id [String, Snowflake] Message ID
412
+ # @param emoji [String, Emoji] Emoji
413
+ # @param user_id [String, Snowflake] User ID (default: @me)
414
+ # @return [void]
415
+ def remove_reaction(channel_id, message_id, emoji, user_id: '@me')
416
+ emoji_str = emoji.respond_to?(:id) ? "#{emoji.name}:#{emoji.id}" : emoji.to_s
417
+ @rest.delete("/channels/#{channel_id}/messages/#{message_id}/reactions/#{CGI.escape(emoji_str)}/#{user_id}")
418
+ end
419
+
420
+ # Get reactions for a message (simplified - no pagination)
421
+ # @param channel_id [String, Snowflake] Channel ID
422
+ # @param message_id [String, Snowflake] Message ID
423
+ # @param emoji [String, Emoji] Emoji filter
424
+ # @param limit [Integer] Max users to return (1-100, default 25)
425
+ # @return [Array<User>] Users who reacted
426
+ def get_reactions(channel_id, message_id, emoji, limit: 25)
427
+ emoji_str = emoji.respond_to?(:id) ? "#{emoji.name}:#{emoji.id}" : emoji.to_s
428
+ data = @rest.get("/channels/#{channel_id}/messages/#{message_id}/reactions/#{CGI.escape(emoji_str)}", params: { limit: limit })
429
+ data.map { |u| User.new(u) }
430
+ end
431
+
432
+ # Remove all reactions from a message
433
+ # @param channel_id [String, Snowflake] Channel ID
434
+ # @param message_id [String, Snowflake] Message ID
435
+ # @return [void]
436
+ def remove_all_reactions(channel_id, message_id)
437
+ @rest.delete("/channels/#{channel_id}/messages/#{message_id}/reactions")
438
+ end
439
+
440
+ # === Guild Members (Simplified) ===
441
+
442
+ # Get a guild member
443
+ # @param guild_id [String, Snowflake] Guild ID
444
+ # @param user_id [String, Snowflake] User ID
445
+ # @return [Member, nil] Member or nil
446
+ def guild_member(guild_id, user_id)
447
+ data = @rest.get("/guilds/#{guild_id}/members/#{user_id}")
448
+ Member.new(data.merge('guild_id' => guild_id.to_s))
449
+ rescue RestClient::NotFoundError
450
+ nil
451
+ end
452
+
453
+ # List guild members (simplified - basic pagination)
454
+ # @param guild_id [String, Snowflake] Guild ID
455
+ # @param limit [Integer] Max members (1-1000, default 100)
456
+ # @param after [String, Snowflake] Get members after this user ID
457
+ # @return [Array<Member>] Members
458
+ def guild_members(guild_id, limit: 100, after: nil)
459
+ params = { limit: limit }
460
+ params[:after] = after.to_s if after
461
+ data = @rest.get("/guilds/#{guild_id}/members", params: params)
462
+ data.map { |m| Member.new(m.merge('guild_id' => guild_id.to_s)) }
463
+ end
464
+
465
+ # Search guild members by query (simplified)
466
+ # @param guild_id [String, Snowflake] Guild ID
467
+ # @param query [String] Search query (username/nickname prefix)
468
+ # @param limit [Integer] Max results (1-100, default 25)
469
+ # @return [Array<Member>] Matching members
470
+ def search_guild_members(guild_id, query, limit: 25)
471
+ params = { query: query, limit: limit }
472
+ data = @rest.get("/guilds/#{guild_id}/members/search", params: params)
473
+ data.map { |m| Member.new(m.merge('guild_id' => guild_id.to_s)) }
474
+ end
475
+
476
+ # Modify a guild member (simplified)
477
+ # @param guild_id [String, Snowflake] Guild ID
478
+ # @param user_id [String, Snowflake] User ID
479
+ # @param options [Hash] Options to modify (nick, roles, mute, deaf, channel_id)
480
+ # @return [Member] Updated member
481
+ def modify_guild_member(guild_id, user_id, **options)
482
+ payload = options.slice(:nick, :roles, :mute, :deaf, :channel_id, :communication_disabled_until)
483
+ data = @rest.patch("/guilds/#{guild_id}/members/#{user_id}", body: payload)
484
+ Member.new(data.merge('guild_id' => guild_id.to_s))
485
+ end
486
+
487
+ # Add role to guild member
488
+ # @param guild_id [String, Snowflake] Guild ID
489
+ # @param user_id [String, Snowflake] User ID
490
+ # @param role_id [String, Snowflake] Role ID
491
+ # @param reason [String] Audit log reason
492
+ # @return [void]
493
+ def add_guild_member_role(guild_id, user_id, role_id, reason: nil)
494
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
495
+ @rest.put("/guilds/#{guild_id}/members/#{user_id}/roles/#{role_id}", headers: headers)
496
+ end
497
+
498
+ # Remove role from guild member
499
+ # @param guild_id [String, Snowflake] Guild ID
500
+ # @param user_id [String, Snowflake] User ID
501
+ # @param role_id [String, Snowflake] Role ID
502
+ # @param reason [String] Audit log reason
503
+ # @return [void]
504
+ def remove_guild_member_role(guild_id, user_id, role_id, reason: nil)
505
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
506
+ @rest.delete("/guilds/#{guild_id}/members/#{user_id}/roles/#{role_id}", headers: headers)
507
+ end
508
+
509
+ # Remove guild member (kick)
510
+ # @param guild_id [String, Snowflake] Guild ID
511
+ # @param user_id [String, Snowflake] User ID
512
+ # @param reason [String] Audit log reason
513
+ # @return [void]
514
+ def remove_guild_member(guild_id, user_id, reason: nil)
515
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
516
+ @rest.delete("/guilds/#{guild_id}/members/#{user_id}", headers: headers)
517
+ end
518
+
519
+ # === Guild Roles (Simplified) ===
520
+
521
+ # Get guild roles
522
+ # @param guild_id [String, Snowflake] Guild ID
523
+ # @return [Array<Role>] Roles
524
+ def guild_roles(guild_id)
525
+ data = @rest.get("/guilds/#{guild_id}/roles")
526
+ data.map { |r| Role.new(r.merge('guild_id' => guild_id.to_s)) }
527
+ end
528
+
529
+ # Create guild role (simplified)
530
+ # @param guild_id [String, Snowflake] Guild ID
531
+ # @param name [String] Role name
532
+ # @param options [Hash] Optional settings (permissions, color, hoist, mentionable)
533
+ # @return [Role] Created role
534
+ def create_guild_role(guild_id, name:, **options)
535
+ payload = { name: name }.merge(options.slice(:permissions, :color, :hoist, :mentionable, :icon, :unicode_emoji))
536
+ data = @rest.post("/guilds/#{guild_id}/roles", body: payload)
537
+ Role.new(data.merge('guild_id' => guild_id.to_s))
538
+ end
539
+
540
+ # Modify guild role
541
+ # @param guild_id [String, Snowflake] Guild ID
542
+ # @param role_id [String, Snowflake] Role ID
543
+ # @param options [Hash] Settings to modify
544
+ # @return [Role] Updated role
545
+ def modify_guild_role(guild_id, role_id, **options)
546
+ payload = options.slice(:name, :permissions, :color, :hoist, :mentionable, :icon, :unicode_emoji)
547
+ data = @rest.patch("/guilds/#{guild_id}/roles/#{role_id}", body: payload)
548
+ Role.new(data.merge('guild_id' => guild_id.to_s))
549
+ end
550
+
551
+ # Delete guild role
552
+ # @param guild_id [String, Snowflake] Guild ID
553
+ # @param role_id [String, Snowflake] Role ID
554
+ # @param reason [String] Audit log reason
555
+ # @return [void]
556
+ def delete_guild_role(guild_id, role_id, reason: nil)
557
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
558
+ @rest.delete("/guilds/#{guild_id}/roles/#{role_id}", headers: headers)
559
+ end
560
+
561
+ # === Guild Bans (Simplified) ===
562
+
563
+ # Get guild bans (simplified - no pagination)
564
+ # @param guild_id [String, Snowflake] Guild ID
565
+ # @param limit [Integer] Max bans (1-1000, default 100)
566
+ # @return [Array<Hash>] Bans (user + reason data)
567
+ def guild_bans(guild_id, limit: 100)
568
+ data = @rest.get("/guilds/#{guild_id}/bans", params: { limit: limit })
569
+ data.map { |b| { user: User.new(b['user']), reason: b['reason'] } }
570
+ end
571
+
572
+ # Get a specific guild ban
573
+ # @param guild_id [String, Snowflake] Guild ID
574
+ # @param user_id [String, Snowflake] User ID
575
+ # @return [Hash, nil] Ban data or nil
576
+ def guild_ban(guild_id, user_id)
577
+ data = @rest.get("/guilds/#{guild_id}/bans/#{user_id}")
578
+ { user: User.new(data['user']), reason: data['reason'] }
579
+ rescue RestClient::NotFoundError
580
+ nil
581
+ end
582
+
583
+ # Create guild ban
584
+ # @param guild_id [String, Snowflake] Guild ID
585
+ # @param user_id [String, Snowflake] User ID
586
+ # @param delete_message_days [Integer] Days of messages to delete (0-7)
587
+ # @param reason [String] Audit log reason
588
+ # @return [void]
589
+ def create_guild_ban(guild_id, user_id, delete_message_days: nil, reason: nil)
590
+ payload = {}
591
+ payload[:delete_message_days] = delete_message_days if delete_message_days
592
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
593
+ @rest.put("/guilds/#{guild_id}/bans/#{user_id}", body: payload, headers: headers)
594
+ end
595
+
596
+ # Remove guild ban (unban)
597
+ # @param guild_id [String, Snowflake] Guild ID
598
+ # @param user_id [String, Snowflake] User ID
599
+ # @param reason [String] Audit log reason
600
+ # @return [void]
601
+ def remove_guild_ban(guild_id, user_id, reason: nil)
602
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
603
+ @rest.delete("/guilds/#{guild_id}/bans/#{user_id}", headers: headers)
604
+ end
605
+
606
+ # === Webhooks (Simplified) ===
607
+
608
+ # Create a webhook
609
+ # @param channel_id [String, Snowflake] Channel ID
610
+ # @param name [String] Webhook name
611
+ # @param avatar [String] Base64-encoded avatar image (optional)
612
+ # @return [Hash] Webhook data
613
+ def create_webhook(channel_id, name:, avatar: nil)
614
+ payload = { name: name }
615
+ payload[:avatar] = avatar if avatar
616
+ @rest.post("/channels/#{channel_id}/webhooks", body: payload)
617
+ end
618
+
619
+ # Get channel webhooks
620
+ # @param channel_id [String, Snowflake] Channel ID
621
+ # @return [Array<Hash>] Webhooks
622
+ def channel_webhooks(channel_id)
623
+ @rest.get("/channels/#{channel_id}/webhooks")
624
+ end
625
+
626
+ # Get guild webhooks
627
+ # @param guild_id [String, Snowflake] Guild ID
628
+ # @return [Array<Hash>] Webhooks
629
+ def guild_webhooks(guild_id)
630
+ @rest.get("/guilds/#{guild_id}/webhooks")
631
+ end
632
+
633
+ # Execute webhook (simplified)
634
+ # @param webhook_id [String, Snowflake] Webhook ID
635
+ # @param token [String] Webhook token
636
+ # @param content [String] Message content
637
+ # @param options [Hash] Options (username, avatar_url, embeds, etc.)
638
+ # @return [void]
639
+ def execute_webhook(webhook_id, token, content = nil, **options)
640
+ payload = { content: content }.merge(options.slice(:username, :avatar_url, :embeds, :components, :allowed_mentions))
641
+ @rest.post("/webhooks/#{webhook_id}/#{token}", body: payload)
642
+ end
643
+
644
+ # Delete a webhook
645
+ # @param webhook_id [String, Snowflake] Webhook ID
646
+ # @param token [String] Webhook token (optional, for webhook-owned deletes)
647
+ # @return [void]
648
+ def delete_webhook(webhook_id, token: nil)
649
+ path = token ? "/webhooks/#{webhook_id}/#{token}" : "/webhooks/#{webhook_id}"
650
+ @rest.delete(path)
651
+ end
652
+
653
+ # === Channel Management (Simplified) ===
654
+
655
+ # Get guild channels
656
+ # @param guild_id [String, Snowflake] Guild ID
657
+ # @return [Array<Channel>] Channels
658
+ def guild_channels(guild_id)
659
+ data = @rest.get("/guilds/#{guild_id}/channels")
660
+ data.map { |c| Channel.new(c) }
661
+ end
662
+
663
+ # Create guild channel (simplified)
664
+ # @param guild_id [String, Snowflake] Guild ID
665
+ # @param name [String] Channel name
666
+ # @param type [Integer] Channel type (0=text, 2=voice, 4=category, etc.)
667
+ # @param options [Hash] Optional settings
668
+ # @return [Channel] Created channel
669
+ def create_guild_channel(guild_id, name:, type: 0, **options)
670
+ payload = { name: name, type: type }.merge(options.slice(:topic, :bitrate, :user_limit, :parent_id, :nsfw, :permission_overwrites, :rate_limit_per_user))
671
+ data = @rest.post("/guilds/#{guild_id}/channels", body: payload)
672
+ Channel.new(data)
673
+ end
674
+
675
+ # Modify channel
676
+ # @param channel_id [String, Snowflake] Channel ID
677
+ # @param options [Hash] Settings to modify
678
+ # @return [Channel] Updated channel
679
+ def modify_channel(channel_id, **options)
680
+ payload = options.slice(:name, :type, :position, :topic, :nsfw, :rate_limit_per_user, :bitrate, :user_limit, :parent_id, :default_auto_archive_duration)
681
+ data = @rest.patch("/channels/#{channel_id}", body: payload)
682
+ Channel.new(data)
683
+ end
684
+
685
+ # Delete channel
686
+ # @param channel_id [String, Snowflake] Channel ID
687
+ # @param reason [String] Audit log reason
688
+ # @return [Channel] Deleted channel
689
+ def delete_channel(channel_id, reason: nil)
690
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
691
+ data = @rest.delete("/channels/#{channel_id}", headers: headers)
692
+ Channel.new(data)
693
+ end
694
+
695
+ # Bulk delete messages
696
+ # @param channel_id [String, Snowflake] Channel ID
697
+ # @param message_ids [Array<String, Snowflake>] Message IDs to delete (2-100)
698
+ # @param reason [String] Audit log reason
699
+ # @return [void]
700
+ def bulk_delete_messages(channel_id, message_ids, reason: nil)
701
+ headers = reason ? { 'X-Audit-Log-Reason' => CGI.escape(reason) } : {}
702
+ @rest.post("/channels/#{channel_id}/messages/bulk-delete", body: { messages: message_ids.map(&:to_s) }, headers: headers)
703
+ end
704
+
705
+ def setup_interaction_handlers
706
+ # Handle slash commands
707
+ @event_bus.on(:interaction_create) do |event|
708
+ interaction = event.interaction
709
+
710
+ if interaction.command?
711
+ handle_slash_command(interaction)
712
+ elsif interaction.component?
713
+ handle_component(interaction)
714
+ elsif interaction.autocomplete?
715
+ handle_autocomplete(interaction)
716
+ elsif interaction.modal_submit?
717
+ handle_modal_submit(interaction)
718
+ end
719
+ end
720
+ end
721
+
722
+ def handle_slash_command(interaction)
723
+ cmd_name = interaction.command_name
724
+ guild_id = interaction.guild_id
725
+
726
+ # Try guild-specific command first, then global
727
+ key = guild_id ? "#{cmd_name}:#{guild_id}" : cmd_name
728
+ cmd = @slash_commands[key] || @slash_commands[cmd_name]
729
+
730
+ if cmd && cmd.handler
731
+ @logger.debug('Executing slash command', name: cmd_name, user: interaction.user&.id)
732
+ begin
733
+ cmd.handler.call(interaction)
734
+ rescue => e
735
+ @logger.error('Slash command error', command: cmd_name, error: e)
736
+ # Send error response
737
+ interaction.respond(content: "An error occurred while executing this command.", ephemeral: true) rescue nil
738
+ end
739
+ else
740
+ @logger.warn('Unknown slash command', name: cmd_name)
741
+ interaction.respond(content: "Unknown command: #{cmd_name}", ephemeral: true) rescue nil
742
+ end
743
+ end
744
+
745
+ def handle_component(interaction)
746
+ # Component interactions are handled by custom_id patterns or specific handlers
747
+ custom_id = interaction.custom_id
748
+ @logger.debug('Component interaction', custom_id: custom_id, user: interaction.user&.id)
749
+
750
+ # Emit specific event for this component type
751
+ event_type = case interaction.component_type
752
+ when 2 then :button_click
753
+ when 3 then :string_select
754
+ when 5 then :user_select
755
+ when 6 then :role_select
756
+ when 7 then :mentionable_select
757
+ when 8 then :channel_select
758
+ else :component_interaction
759
+ end
760
+
761
+ @event_bus.emit(event_type, interaction)
762
+ end
763
+
764
+ def handle_autocomplete(interaction)
765
+ # Autocomplete needs to be handled by the command that registered it
766
+ cmd_name = interaction.command_name
767
+ focused = interaction.focused_option
768
+
769
+ @logger.debug('Autocomplete', command: cmd_name, option: focused&.dig('name'))
770
+
771
+ # Emit autocomplete event
772
+ @event_bus.emit(:autocomplete, interaction)
773
+ end
774
+
775
+ def handle_modal_submit(interaction)
776
+ modal_id = interaction.custom_id
777
+ values = interaction.modal_values
778
+
779
+ @logger.debug('Modal submit', modal_id: modal_id, values: values.keys)
780
+
781
+ # Emit modal submit event
782
+ @event_bus.emit(:modal_submit, interaction)
783
+ end
784
+
785
+ private
786
+
787
+ def build_cache
788
+ store = case @config.cache
789
+ when :redis
790
+ RedisStore.new
791
+ else
792
+ MemoryStore.new
793
+ end
794
+
795
+ EntityCache.new(store, logger: @logger)
796
+ end
797
+
798
+ def setup_event_handlers
799
+ # Cache entities on relevant events
800
+ @event_bus.on(:guild_create) do |event|
801
+ @cache.cache_guild(event.guild) if event.available?
802
+ end
803
+
804
+ @event_bus.on(:guild_update) do |event|
805
+ @cache.cache_guild(event.guild)
806
+ end
807
+
808
+ @event_bus.on(:channel_create) do |event|
809
+ @cache.cache_channel(event.channel)
810
+ end
811
+
812
+ @event_bus.on(:channel_update) do |event|
813
+ @cache.cache_channel(event.channel)
814
+ end
815
+
816
+ @event_bus.on(:message_create) do |event|
817
+ @cache.cache_message(event.message)
818
+ end
819
+
820
+ # Track ready state
821
+ @event_bus.on(:ready) do |event|
822
+ @logger.info('Bot ready', user: event.user&.username, guilds: event.guilds.length)
823
+ @plugins.all.each { |p| p.ready(self) if p.enabled? }
824
+ end
825
+ end
826
+
827
+ def start_shards
828
+ if @config.shards == [:auto]
829
+ @shard_manager.start
830
+ else
831
+ shard_ids = @config.shards.map(&:first)
832
+ @shard_manager.start(shard_ids)
833
+ end
834
+
835
+ # Keep running
836
+ sleep(1) while @running
837
+ rescue Interrupt
838
+ @logger.info('Interrupted')
839
+ stop
840
+ end
841
+ end
842
+ end